using System.Text; using System.Text.Json; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tools; using FluentFTP; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Tools.FTP; public sealed class FTPTool : IAgentTool { public string Name => "FTP"; /// #715: Brings text from outside into the context — files from outside servers. public bool ReadsForeignContent => true; public string Description => """ Allows file transfer between the local workspace and a remote FTP/FTPS server. For localPath, the same workspace prefixes as in the FileRW tool are supported: - "personal:website/index.html" → file in the agent's personal workspace - "shared:website/index.html" → file in the shared SharedWorkspace - "unit:report.pdf" → your unit's workspace; ":file" → a workspace you are a member of - Without prefix, the personal workspace is used. Uploading from a workspace needs read access to it, downloading into one needs write access. Example upload: { "action": "upload", "localPath": "shared:website/index.html", "remotePath": "/index.html" } """; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "properties": { "action": { "type": "string", "enum": ["upload", "download", "list", "delete"], "description": "The action to perform" }, "localPath": { "type": "string", "description": "Path to local file with optional workspace prefix: 'personal:file.txt' (default), 'shared:file.txt' for the SharedWorkspace, 'unit:file.txt' for your unit's workspace or ':file.txt'. Without prefix, personal workspace is used." }, "remotePath": { "type": "string", "description": "Path on the FTP server" } }, "required": ["action"] } """).RootElement.Clone(); public async Task ExecuteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { var action = input.GetProperty("action").GetString() ?? throw new ArgumentException("'action' is required"); var config = context.ToolConfig; var host = config.TryGetValue("host", out var h) ? h?.ToString() : null; var username = config.TryGetValue("username", out var u) ? u?.ToString() : null; var password = config.TryGetValue("password", out var p) ? p?.ToString() : null; var port = 21; if (config.TryGetValue("port", out var po) && po is JsonElement portElement) port = portElement.TryGetInt32(out var p32) ? p32 : 21; if (string.IsNullOrWhiteSpace(host)) { return ToolResult.Fail("Configuration error: 'host' is required."); } // SSL/TLS-Modus aus Config lesen (Standard: true) var useSsl = true; if (config.TryGetValue("useSsl", out var sslVal)) { if (sslVal is JsonElement sslElem) useSsl = sslElem.GetBoolean(); else if (sslVal is bool sslBool) useSsl = sslBool; } using var ftpClient = new AsyncFtpClient(host, username, password, port); if (useSsl) { ftpClient.Config.EncryptionMode = FtpEncryptionMode.Explicit; ftpClient.Config.ValidateAnyCertificate = true; } try { await ftpClient.Connect(ct); return action switch { "upload" => await HandleUploadAsync(ftpClient, input, context, ct), "download" => await HandleDownloadAsync(ftpClient, input, context, ct), "list" => await HandleListAsync(ftpClient, input, context, ct), "delete" => await HandleDeleteAsync(ftpClient, input, context, ct), _ => ToolResult.Fail($"Unknown action: {action}") }; } catch (Exception ex) { context.Logger.LogError(ex, "Error during FTP action {Action} on {Host}", action, host); return ToolResult.Fail($"Error: {ex.Message}"); } finally { if (ftpClient.IsConnected) await ftpClient.Disconnect(ct); } } /// /// Löst einen localPath mit optionalem Workspace-Prefix auf. /// "shared:website/index.html" → SharedWorkspace + "website/index.html" /// "personal:datei.txt" → persönlicher Workspace + "datei.txt" /// "datei.txt" → persönlicher Workspace + "datei.txt" (Standard) /// /// /// #718: The file is written into the workspace (download). A workspace with a member list /// then needs Write; reading from it (upload) needs Read. /// internal static (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context, bool write) { string basePath; var (label, relativePath) = NamedWorkspaces.Split(localPath); if (label == WorkspaceLayout.Shared) { basePath = context.SharedWorkspacePath ?? throw new ArgumentException("No SharedWorkspace configured."); } else if (label == WorkspaceLayout.Personal) { basePath = context.WorkspacePath ?? throw new ArgumentException("No personal workspace configured."); } else { // #718: "unit:" or a workspace id. Unknown and not allowed answer the same. var named = NamedWorkspaces.Resolve(label, context.AgentId, context.Organisation, context.SharedWorkspacePath); if (named is null || named.Access < (write ? WorkspaceAccess.Write : WorkspaceAccess.Read)) throw new ArgumentException(named is null ? NamedWorkspaces.Unknown(label, context.AgentId, context.Organisation, withShared: true) : $"Access denied: this agent may not {(write ? "write to" : "read from")} workspace '{named.Workspace.Id}'."); basePath = named.Root; label = named.Workspace.Id; if (write) Directory.CreateDirectory(basePath); } // Config-rootPath als Override (nur wenn kein Prefix verwendet) if (label == "personal" && !localPath.Contains(':')) { if (context.ToolConfig.TryGetValue("rootPath", out var rp) && !string.IsNullOrWhiteSpace(rp?.ToString())) { basePath = rp.ToString()!; } } basePath = Path.GetFullPath(basePath); var fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath)); // Sicherheitsprüfung: Kein Ausbruch aus dem Workspace. // // PathBoundary statt eines Zeichenketten-Präfixes: Es vergleicht auf // Verzeichnisgrenzen (sonst gälte "…/Workspace-Backup" als Teil von // "…/Workspace"), berücksichtigt die Groß-/Kleinschreibung des Dateisystems // und löst symbolische Verknüpfungen auf. if (!PathBoundary.IsInside(fullPath, basePath)) { throw new UnauthorizedAccessException( $"Access outside the {label} workspace directory denied."); } return (fullPath, label); } private async Task HandleUploadAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct) { var localRel = input.TryGetProperty("localPath", out var lp) ? lp.GetString() : null; var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null; if (string.IsNullOrWhiteSpace(localRel) || string.IsNullOrWhiteSpace(remote)) { return ToolResult.Fail("'localPath' and 'remotePath' are required for upload."); } try { var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: false); if (!File.Exists(localFull)) return ToolResult.Fail($"Local file not found: {localRel} (resolved: {localFull})"); var status = await client.UploadFile(localFull, remote, FtpRemoteExists.Overwrite, true, FtpVerify.None, null, ct); if (status != FtpStatus.Success) return ToolResult.Fail($"Upload failed: {status}"); // Bei HTML/CSS/JS-Uploads: .htaccess für UTF-8 sicherstellen var extra = ""; if (IsWebFile(remote)) { var htaccessDeployed = await EnsureHtaccessAsync(client, remote, context.Logger, ct); if (htaccessDeployed) extra = " (.htaccess for UTF-8 encoding was automatically created)"; } return ToolResult.Ok($"[{wsLabel} Workspace] File successfully uploaded: {localRel} → {remote}{extra}"); } catch (ArgumentException ex) { return ToolResult.Fail(ex.Message); } catch (UnauthorizedAccessException ex) { return ToolResult.Fail(ex.Message); } } private static bool IsWebFile(string remotePath) { var ext = Path.GetExtension(remotePath).ToLowerInvariant(); return ext is ".html" or ".htm" or ".css" or ".js" or ".json" or ".xml" or ".svg"; } private const string HtaccessCharsetMarker = "# ClawdDotNet-UTF8-Charset"; private const string HtaccessCharsetBlock = $""" {HtaccessCharsetMarker} AddDefaultCharset UTF-8 AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt """; /// /// Stellt sicher, dass im Zielverzeichnis eine .htaccess mit UTF-8 Charset existiert. /// - Existiert keine .htaccess: Neue mit nur den Charset-Regeln erstellen. /// - Existiert eine .htaccess: Charset-Block anhängen, falls der Marker noch nicht drin ist. /// Der bestehende Inhalt (z.B. Zugriffsschutz, Rewrite-Regeln) bleibt IMMER unangetastet. /// private async Task EnsureHtaccessAsync(IAsyncFtpClient client, string remotePath, ILogger logger, CancellationToken ct) { try { var remoteDir = Path.GetDirectoryName(remotePath)?.Replace('\\', '/') ?? "/"; if (string.IsNullOrWhiteSpace(remoteDir)) remoteDir = "/"; var htaccessPath = remoteDir.TrimEnd('/') + "/.htaccess"; if (await client.FileExists(htaccessPath, ct)) { // .htaccess existiert — lesen und prüfen ob UTF-8-Block schon drin ist using var downloadStream = new MemoryStream(); if (!await client.DownloadStream(downloadStream, htaccessPath, token: ct)) return false; // Download fehlgeschlagen → nichts tun downloadStream.Position = 0; var existingContent = Encoding.UTF8.GetString(downloadStream.ToArray()); // Wenn unser Marker bereits enthalten ist → nichts tun if (existingContent.Contains(HtaccessCharsetMarker, StringComparison.Ordinal)) return false; // Wenn bereits AddDefaultCharset gesetzt ist → nicht einmischen if (existingContent.Contains("AddDefaultCharset", StringComparison.OrdinalIgnoreCase)) return false; // Charset-Block an bestehenden Inhalt anhängen var newContent = existingContent.TrimEnd() + "\n" + HtaccessCharsetBlock; var bytes = Encoding.UTF8.GetBytes(newContent); using var uploadStream = new MemoryStream(bytes); var status = await client.UploadStream(uploadStream, htaccessPath, FtpRemoteExists.Overwrite, true, null, ct); if (status == FtpStatus.Success) { logger.LogInformation("UTF-8 charset block appended to existing .htaccess: {Path}", htaccessPath); return true; } } else { // Keine .htaccess vorhanden → neue erstellen (nur Charset-Regeln) var content = HtaccessCharsetMarker + "\nAddDefaultCharset UTF-8\n\n\n AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt\n\n"; var bytes = Encoding.UTF8.GetBytes(content); using var stream = new MemoryStream(bytes); var status = await client.UploadStream(stream, htaccessPath, FtpRemoteExists.Skip, true, null, ct); if (status == FtpStatus.Success) { logger.LogInformation("New UTF-8 .htaccess created: {Path}", htaccessPath); return true; } } } catch (Exception ex) { logger.LogWarning(ex, "Could not automatically adjust .htaccess (non-critical)"); } return false; } private async Task HandleDownloadAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct) { var localRel = input.TryGetProperty("localPath", out var lp) ? lp.GetString() : null; var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null; if (string.IsNullOrWhiteSpace(localRel) || string.IsNullOrWhiteSpace(remote)) { return ToolResult.Fail("'localPath' and 'remotePath' are required for download."); } try { var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: true); var localDir = Path.GetDirectoryName(localFull); if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir); var status = await client.DownloadFile(localFull, remote, FtpLocalExists.Overwrite, FtpVerify.None, null, ct); return status == FtpStatus.Success ? ToolResult.Ok($"[{wsLabel} Workspace] File successfully downloaded: {remote} → {localRel}") : ToolResult.Fail($"Download failed: {status}"); } catch (ArgumentException ex) { return ToolResult.Fail(ex.Message); } catch (UnauthorizedAccessException ex) { return ToolResult.Fail(ex.Message); } } private async Task HandleListAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct) { var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : "/"; var items = await client.GetListing(remote, FtpListOption.Recursive, ct); var result = items.Select(i => new { i.Name, i.FullName, Type = i.Type.ToString(), Size = i.Size, Modified = i.Modified }).ToList(); return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); } private async Task HandleDeleteAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct) { var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null; if (string.IsNullOrWhiteSpace(remote)) return ToolResult.Fail("'remotePath' is required for delete."); await client.DeleteFile(remote, ct); return ToolResult.Ok($"File/directory deleted: {remote}"); } }