353 lines
15 KiB
C#
353 lines
15 KiB
C#
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<ToolResult> 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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
/// </summary>
|
|
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<ToolResult> 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
|
|
<IfModule mod_mime.c>
|
|
AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt
|
|
</IfModule>
|
|
""";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private async Task<bool> 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<IfModule mod_mime.c>\n AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt\n</IfModule>\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<ToolResult> 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<ToolResult> 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<ToolResult> 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}");
|
|
}
|
|
}
|