Files
gott/Endpoints/WorkspaceEndpoint.cs
T
Meleki 5d090e160b feat: Windows-Kompatibilität
- WorkspaceHelper: Zentrale Hilfsmethoden (IsWindows, TempDir, IsSubPathOf,
  GetRelativePath, ShellExecutable, ShellArguments)
- ToolService: execute nutzt cmd.exe auf Windows, GetRelativePath mit
  Forward-Slashes, IsSubPathOf für case-insensitive Pfadprüfung
- SessionEndpoint: /tmp → Path.GetTempPath(), bash → cmd.exe,
  Fire-and-forget via .bat statt systemd-run auf Windows
- ChatService: /tmp → WorkspaceHelper.TempDir
- MemoryService: IsSubPathOf + GetRelativePath
- WorkspaceEndpoint: HandleBrowse zeigt Laufwerke auf Windows,
  IsSubPathOf statt StartsWith
- InfoEndpoint: gott.exe auf Windows, isWindows-Flag in API-Response
- Frontend: File-Explorer mit Laufwerks-Navigation auf Windows
2026-03-09 11:35:04 +01:00

422 lines
15 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Gott.Extensions;
using Gott.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Gott.Services;
namespace Gott.Endpoints;
public static class WorkspaceEndpoint
{
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".cs", ".csproj", ".sln", ".json", ".xml", ".yaml", ".yml", ".toml",
".md", ".txt", ".log", ".csv", ".tsv",
".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx",
".sh", ".bash", ".bat", ".ps1", ".cmd",
".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift",
".c", ".cpp", ".h", ".hpp",
".sql", ".graphql",
".env", ".gitignore", ".dockerignore", ".editorconfig",
".razor", ".cshtml", ".vue", ".svelte",
".ini", ".cfg", ".conf", ".properties",
".dockerfile", ".makefile",
"" // Dateien ohne Extension (Makefile, Dockerfile, etc.)
};
private static readonly HashSet<string> IgnoredDirs = new(StringComparer.OrdinalIgnoreCase)
{
"bin", "obj", "node_modules", ".git", ".vs", ".idea",
"publish", "dist", "out", "build", "target", ".cache"
};
public static void MapWorkspaceEndpoint(this IEndpointRouteBuilder app)
{
// Session-Workspace-Endpunkte
app.MapGet("/api/workspace/tree", async ctx =>
await HandleTreeCore(ctx, await ResolveSessionBasePath(ctx)));
app.MapGet("/api/workspace/file", async ctx =>
await HandleFileCore(ctx, await ResolveSessionBasePath(ctx)));
app.MapPut("/api/workspace/file", async ctx =>
await HandleFileSaveCore(ctx, await ResolveSessionBasePath(ctx), allowCreate: false));
app.MapPost("/api/workspace/mkdir", async ctx =>
await HandleMkdirCore(ctx, await ResolveSessionBasePath(ctx)));
// Agent-Memory-Endpunkte (gleiche Logik, anderer basePath)
app.MapGet("/api/workspace/agent/tree", async ctx =>
await HandleTreeCore(ctx, ResolveAgentBasePath(ctx)));
app.MapGet("/api/workspace/agent/file", async ctx =>
await HandleFileCore(ctx, ResolveAgentBasePath(ctx)));
app.MapPut("/api/workspace/agent/file", async ctx =>
await HandleFileSaveCore(ctx, ResolveAgentBasePath(ctx), allowCreate: true));
app.MapPost("/api/workspace/agent/mkdir", async ctx =>
await HandleMkdirCore(ctx, ResolveAgentBasePath(ctx)));
// Sonstige
app.MapGet("/api/workspace/browse", HandleBrowse);
app.MapPost("/api/workspace/browse/mkdir", HandleBrowseMkdir);
app.MapGet("/api/workspace/raw", HandleRawFile);
}
// ── BasePath-Auflösung ──────────────────────────────────────────────
/// <summary>
/// Löst den Workspace-Pfad einer Session auf. Gibt null zurück + setzt 400 bei Fehler.
/// </summary>
private static async Task<string> ResolveSessionBasePath(HttpContext ctx)
{
var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault();
var db = ctx.RequestServices.GetRequiredService<Gott.Services.DatabaseService>();
var session = await db.GetSession(sessionId ?? "");
if (session == null || session.UserId != ctx.GetUserId() || string.IsNullOrEmpty(session.Workspace))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Kein Workspace konfiguriert" });
return null;
}
var userId = ctx.GetUserId();
var user = await db.GetUser(userId);
var resolved = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(resolved))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Workspace kann nicht aufgelöst werden. Default-Pfad im Profil setzen." });
return null;
}
return Path.GetFullPath(resolved);
}
/// <summary>
/// Löst den Memory-Pfad eines Agents auf. Gibt null zurück + setzt 400 bei Fehler.
/// </summary>
private static string ResolveAgentBasePath(HttpContext ctx)
{
var agentId = ctx.Request.Query["agentId"].FirstOrDefault();
if (string.IsNullOrEmpty(agentId))
{
ctx.Response.StatusCode = 400;
ctx.Response.WriteAsJsonAsync(new { error = "agentId fehlt" }).Wait();
return null;
}
var memoryService = ctx.RequestServices.GetRequiredService<Gott.Services.MemoryService>();
var basePath = memoryService.GetAgentMemoryDir(agentId);
if (!Directory.Exists(basePath))
Directory.CreateDirectory(basePath);
return basePath;
}
// ── Generische Handler ──────────────────────────────────────────────
private static async Task HandleTreeCore(HttpContext ctx, string basePath)
{
if (basePath == null) return;
var subPath = ctx.Request.Query["path"].FirstOrDefault() ?? "";
var targetPath = Path.GetFullPath(Path.Combine(basePath, subPath));
if (!WorkspaceHelper.IsSubPathOf(targetPath, basePath))
{
ctx.Response.StatusCode = 403;
return;
}
if (!Directory.Exists(targetPath))
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Verzeichnis nicht gefunden" });
return;
}
var entries = new List<object>();
foreach (var dir in Directory.GetDirectories(targetPath).OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase))
{
var name = Path.GetFileName(dir);
if (IgnoredDirs.Contains(name)) continue;
var relPath = WorkspaceHelper.GetRelativePath(basePath, dir);
entries.Add(new { name, path = relPath, type = "dir" });
}
foreach (var file in Directory.GetFiles(targetPath).OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase))
{
var name = Path.GetFileName(file);
var ext = Path.GetExtension(file);
var relPath = WorkspaceHelper.GetRelativePath(basePath, file);
var size = new FileInfo(file).Length;
var isText = TextExtensions.Contains(ext) || IsLikelyTextFile(name);
entries.Add(new { name, path = relPath, type = "file", ext, size, isText });
}
await ctx.Response.WriteAsJsonAsync(entries);
}
private static async Task HandleFileCore(HttpContext ctx, string basePath)
{
if (basePath == null) return;
var filePath = ctx.Request.Query["path"].FirstOrDefault();
if (string.IsNullOrEmpty(filePath))
{
ctx.Response.StatusCode = 400;
return;
}
var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath));
if (!WorkspaceHelper.IsSubPathOf(targetPath, basePath))
{
ctx.Response.StatusCode = 403;
return;
}
if (!File.Exists(targetPath))
{
ctx.Response.StatusCode = 404;
return;
}
var info = new FileInfo(targetPath);
if (info.Length > 2 * 1024 * 1024)
{
ctx.Response.StatusCode = 413;
await ctx.Response.WriteAsJsonAsync(new { error = "Datei zu groß (max. 2MB)" });
return;
}
var content = await File.ReadAllTextAsync(targetPath);
var lineCount = content.Split('\n').Length;
await ctx.Response.WriteAsJsonAsync(new
{
path = filePath,
name = info.Name,
size = info.Length,
lineCount,
content
});
}
private static async Task HandleFileSaveCore(HttpContext ctx, string basePath, bool allowCreate)
{
if (basePath == null) return;
var filePath = ctx.Request.Query["path"].FirstOrDefault();
if (string.IsNullOrEmpty(filePath))
{
ctx.Response.StatusCode = 400;
return;
}
var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath));
if (!WorkspaceHelper.IsSubPathOf(targetPath, basePath))
{
ctx.Response.StatusCode = 403;
return;
}
if (allowCreate)
{
// Agent-Memory: Verzeichnis erstellen falls nötig
var dir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
}
else
{
// Session-Workspace: Datei muss existieren
if (!File.Exists(targetPath))
{
ctx.Response.StatusCode = 404;
return;
}
}
using var reader = new StreamReader(ctx.Request.Body);
var content = await reader.ReadToEndAsync();
await File.WriteAllTextAsync(targetPath, content);
var info = new FileInfo(targetPath);
var lineCount = content.Split('\n').Length;
await ctx.Response.WriteAsJsonAsync(new
{
path = filePath,
name = info.Name,
size = info.Length,
lineCount
});
}
private static async Task HandleMkdirCore(HttpContext ctx, string basePath)
{
if (basePath == null) return;
var dirPath = ctx.Request.Query["path"].FirstOrDefault();
if (string.IsNullOrEmpty(dirPath))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" });
return;
}
var targetPath = Path.GetFullPath(Path.Combine(basePath, dirPath));
if (!WorkspaceHelper.IsSubPathOf(targetPath, basePath))
{
ctx.Response.StatusCode = 403;
return;
}
if (Directory.Exists(targetPath))
{
ctx.Response.StatusCode = 409;
await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" });
return;
}
Directory.CreateDirectory(targetPath);
await ctx.Response.WriteAsJsonAsync(new
{
path = WorkspaceHelper.GetRelativePath(basePath, targetPath),
created = true
});
}
// ── Sonstige Endpunkte ──────────────────────────────────────────────
private static async Task HandleBrowse(HttpContext ctx)
{
var path = ctx.Request.Query["path"].FirstOrDefault() ?? (WorkspaceHelper.IsWindows ? "" : "/");
// Windows: Leerer Pfad oder Root → Laufwerke anzeigen
if (WorkspaceHelper.IsWindows && string.IsNullOrEmpty(path))
{
var drives = System.IO.DriveInfo.GetDrives()
.Where(d => d.IsReady)
.Select(d => new { name = d.Name.TrimEnd('\\'), path = d.Name.Replace('\\', '/'), type = "dir" })
.ToList<object>();
await ctx.Response.WriteAsJsonAsync(new { current = "", entries = drives });
return;
}
var targetPath = Path.GetFullPath(path);
if (!Directory.Exists(targetPath))
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Verzeichnis nicht gefunden" });
return;
}
var entries = new List<object>();
try
{
foreach (var dir in Directory.GetDirectories(targetPath).OrderBy(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase))
{
var name = Path.GetFileName(dir);
if (IgnoredDirs.Contains(name)) continue;
entries.Add(new { name, path = dir.Replace('\\', '/'), type = "dir" });
}
}
catch (UnauthorizedAccessException) { }
await ctx.Response.WriteAsJsonAsync(new { current = targetPath.Replace('\\', '/'), entries });
}
private static async Task HandleBrowseMkdir(HttpContext ctx)
{
var path = ctx.Request.Query["path"].FirstOrDefault();
if (string.IsNullOrEmpty(path))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Pfad fehlt" });
return;
}
var targetPath = Path.GetFullPath(path);
if (Directory.Exists(targetPath))
{
ctx.Response.StatusCode = 409;
await ctx.Response.WriteAsJsonAsync(new { error = "Ordner existiert bereits" });
return;
}
var parent = Path.GetDirectoryName(targetPath);
if (string.IsNullOrEmpty(parent) || !Directory.Exists(parent))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Elternverzeichnis nicht gefunden" });
return;
}
Directory.CreateDirectory(targetPath);
await ctx.Response.WriteAsJsonAsync(new { path = targetPath.Replace('\\', '/'), created = true });
}
private static async Task HandleRawFile(HttpContext ctx)
{
var sessionId = ctx.Request.Query["sessionId"].FirstOrDefault();
var filePath = ctx.Request.Query["path"].FirstOrDefault();
if (string.IsNullOrEmpty(filePath))
{
ctx.Response.StatusCode = 400;
return;
}
var basePath = await ResolveSessionBasePath(ctx);
if (basePath == null) return;
var targetPath = Path.GetFullPath(Path.Combine(basePath, filePath));
if (!WorkspaceHelper.IsSubPathOf(targetPath, basePath))
{
ctx.Response.StatusCode = 403;
return;
}
if (!File.Exists(targetPath))
{
ctx.Response.StatusCode = 404;
return;
}
var ext = Path.GetExtension(targetPath).ToLowerInvariant();
ctx.Response.ContentType = ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".mp4" => "video/mp4",
".webm" => "video/webm",
".mp3" => "audio/mpeg",
".wav" => "audio/wav",
_ => "application/octet-stream"
};
await ctx.Response.SendFileAsync(targetPath);
}
private static bool IsLikelyTextFile(string name)
{
var lower = name.ToLowerInvariant();
return lower is "makefile" or "dockerfile" or "rakefile" or "gemfile"
or "procfile" or "vagrantfile" or "license" or "readme"
or "changelog" or "authors" or "contributors";
}
}