using System.Text; using System.Text.Json; 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"; public string Description => """ Erlaubt den Dateitransfer zwischen dem lokalen Workspace und einem Remote-FTP/FTPS-Server. Für localPath werden die gleichen Workspace-Prefixe wie beim FileRW-Tool unterstützt: - "personal:website/index.html" → Datei im persönlichen Agent-Workspace - "shared:website/index.html" → Datei im geteilten SharedWorkspace - Ohne Prefix wird der persönliche Workspace verwendet. Beispiel 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": "Die auszuführende Aktion" }, "localPath": { "type": "string", "description": "Pfad zur lokalen Datei mit optionalem Workspace-Prefix: 'personal:datei.txt' (Standard) oder 'shared:datei.txt' für den SharedWorkspace. Ohne Prefix wird der persönliche Workspace verwendet." }, "remotePath": { "type": "string", "description": "Pfad auf dem 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("Konfigurationsfehler: 'host' ist erforderlich."); } // 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($"Unbekannte Aktion: {action}") }; } catch (Exception ex) { context.Logger.LogError(ex, "Fehler bei FTP Aktion {Action} auf {Host}", action, host); return ToolResult.Fail($"Fehler: {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) /// private (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context) { string basePath; string relativePath; string label; if (localPath.StartsWith("shared:", StringComparison.OrdinalIgnoreCase)) { relativePath = localPath[7..]; // nach "shared:" basePath = context.SharedWorkspacePath ?? throw new ArgumentException("Kein SharedWorkspace konfiguriert."); label = "shared"; } else if (localPath.StartsWith("personal:", StringComparison.OrdinalIgnoreCase)) { relativePath = localPath[9..]; // nach "personal:" basePath = context.WorkspacePath ?? throw new ArgumentException("Kein persönlicher Workspace konfiguriert."); label = "personal"; } else { // Kein Prefix → persönlicher Workspace als Standard relativePath = localPath; basePath = context.WorkspacePath ?? throw new ArgumentException("Kein persönlicher Workspace konfiguriert."); label = "personal"; } // 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( $"Zugriff außerhalb des {label} Workspace-Verzeichnisses verweigert."); } 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' und 'remotePath' sind für upload erforderlich."); } try { var (localFull, wsLabel) = ResolveLocalPath(localRel, context); if (!File.Exists(localFull)) return ToolResult.Fail($"Lokale Datei nicht gefunden: {localRel} (aufgelöst: {localFull})"); var status = await client.UploadFile(localFull, remote, FtpRemoteExists.Overwrite, true, FtpVerify.None, null, ct); if (status != FtpStatus.Success) return ToolResult.Fail($"Upload fehlgeschlagen: {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 für UTF-8-Encoding wurde automatisch erstellt)"; } return ToolResult.Ok($"[{wsLabel} Workspace] Datei erfolgreich hochgeladen: {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 an bestehende .htaccess angehängt: {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("Neue UTF-8 .htaccess erstellt: {Path}", htaccessPath); return true; } } } catch (Exception ex) { logger.LogWarning(ex, "Konnte .htaccess nicht automatisch anpassen (nicht kritisch)"); } 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' und 'remotePath' sind für download erforderlich."); } try { var (localFull, wsLabel) = ResolveLocalPath(localRel, context); 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] Datei erfolgreich heruntergeladen: {remote} → {localRel}") : ToolResult.Fail($"Download fehlgeschlagen: {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' ist für delete erforderlich."); await client.DeleteFile(remote, ct); return ToolResult.Ok($"Datei/Verzeichnis gelöscht: {remote}"); } }