- UserId-Feld zu SessionRecord und AgentRecord hinzugefügt - DB-Migration: user_id Spalte + bestehende Daten dem Admin zuweisen - Alle Endpoints filtern/prüfen nach UserId des eingeloggten Users - HttpContextExtensions.GetUserId() Helper für konsistenten Zugriff - Betrifft: Agent, Session, Chat, History, Workspace, Skill Endpoints - Sessions/Agents anderer User sind nicht sichtbar/zugreifbar
230 lines
8.8 KiB
C#
230 lines
8.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using Gott.Extensions;
|
|
using Gott.Services;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Processing;
|
|
using SixLabors.ImageSharp.Formats.Webp;
|
|
|
|
namespace Gott.Endpoints;
|
|
|
|
public static class SkillEndpoint
|
|
{
|
|
private static readonly Dictionary<string, string> MimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
{ ".png", "image/png" }, { ".jpg", "image/jpeg" }, { ".jpeg", "image/jpeg" },
|
|
{ ".gif", "image/gif" }, { ".webp", "image/webp" }, { ".svg", "image/svg+xml" },
|
|
{ ".bmp", "image/bmp" }, { ".ico", "image/x-icon" },
|
|
{ ".pdf", "application/pdf" },
|
|
{ ".mp4", "video/mp4" }, { ".webm", "video/webm" },
|
|
{ ".mp3", "audio/mpeg" }, { ".wav", "audio/wav" }, { ".ogg", "audio/ogg" },
|
|
};
|
|
|
|
public static void MapSkillEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
// Skills (Dateisystem-basiert)
|
|
app.MapGet("/api/skills", HandleListSkills);
|
|
|
|
// Skill-Output-Dateien (Bilder etc.)
|
|
app.MapGet("/api/skills/{slug}/output/{filename}", HandleSkillOutput);
|
|
|
|
// Thumbnail (gecached)
|
|
app.MapGet("/api/skills/{slug}/output/{filename}/thumb", HandleSkillOutputThumb);
|
|
|
|
// Agent ↔ Skill Zuordnung
|
|
app.MapGet("/api/agents/{agentId}/skills", HandleGetAgentSkills);
|
|
app.MapPut("/api/agents/{agentId}/skills", HandleSetAgentSkills);
|
|
app.MapPost("/api/agents/{agentId}/skills/{slug}", HandleAddAgentSkill);
|
|
app.MapDelete("/api/agents/{agentId}/skills/{slug}", HandleRemoveAgentSkill);
|
|
}
|
|
|
|
private static async Task HandleListSkills(HttpContext ctx, SkillService skillService)
|
|
{
|
|
var skills = skillService.GetAllSkills();
|
|
await ctx.Response.WriteAsJsonAsync(skills);
|
|
}
|
|
|
|
private static async Task<bool> VerifyAgentOwner(HttpContext ctx, DatabaseService db, string agentId)
|
|
{
|
|
if (string.IsNullOrEmpty(agentId)) return false;
|
|
var agent = await db.GetAgent(agentId);
|
|
return agent != null && agent.UserId == ctx.GetUserId();
|
|
}
|
|
|
|
private static async Task HandleGetAgentSkills(HttpContext ctx, DatabaseService db, SkillService skillService)
|
|
{
|
|
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
|
|
if (string.IsNullOrEmpty(agentId) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
|
|
|
|
var slugs = await db.GetAgentSkills(agentId);
|
|
var allSkills = skillService.GetAllSkills();
|
|
|
|
// Nur Skills zurückgeben die auch wirklich existieren
|
|
var result = allSkills
|
|
.Where(s => slugs.Contains(s.Slug))
|
|
.ToList();
|
|
|
|
await ctx.Response.WriteAsJsonAsync(result);
|
|
}
|
|
|
|
private static async Task HandleSetAgentSkills(HttpContext ctx, DatabaseService db)
|
|
{
|
|
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
|
|
if (string.IsNullOrEmpty(agentId) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
|
|
|
|
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
|
|
var slugs = body.TryGetProperty("skills", out var arr)
|
|
? arr.EnumerateArray().Select(e => e.GetString()).Where(s => !string.IsNullOrEmpty(s)).ToList()
|
|
: new System.Collections.Generic.List<string>();
|
|
|
|
await db.SetAgentSkills(agentId, slugs);
|
|
|
|
var updated = await db.GetAgentSkills(agentId);
|
|
await ctx.Response.WriteAsJsonAsync(updated);
|
|
}
|
|
|
|
private static async Task HandleAddAgentSkill(HttpContext ctx, DatabaseService db)
|
|
{
|
|
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
|
|
var slug = ctx.Request.RouteValues["slug"]?.ToString();
|
|
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(slug) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
|
|
|
|
await db.AddAgentSkill(agentId, slug);
|
|
ctx.Response.StatusCode = 204;
|
|
}
|
|
|
|
private static async Task HandleRemoveAgentSkill(HttpContext ctx, DatabaseService db)
|
|
{
|
|
var agentId = ctx.Request.RouteValues["agentId"]?.ToString();
|
|
var slug = ctx.Request.RouteValues["slug"]?.ToString();
|
|
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(slug) || !await VerifyAgentOwner(ctx, db, agentId)) { ctx.Response.StatusCode = 400; return; }
|
|
|
|
await db.RemoveAgentSkill(agentId, slug);
|
|
ctx.Response.StatusCode = 204;
|
|
}
|
|
|
|
private static async Task HandleSkillOutput(HttpContext ctx, SkillService skillService)
|
|
{
|
|
var slug = ctx.Request.RouteValues["slug"]?.ToString();
|
|
var filename = ctx.Request.RouteValues["filename"]?.ToString();
|
|
|
|
if (string.IsNullOrEmpty(slug) || string.IsNullOrEmpty(filename))
|
|
{
|
|
ctx.Response.StatusCode = 400;
|
|
return;
|
|
}
|
|
|
|
// Sicherheit: Keine Pfad-Traversals erlauben
|
|
if (filename.Contains("..") || filename.Contains('/') || filename.Contains('\\'))
|
|
{
|
|
ctx.Response.StatusCode = 400;
|
|
return;
|
|
}
|
|
|
|
var skill = skillService.GetSkill(slug);
|
|
if (skill == null)
|
|
{
|
|
ctx.Response.StatusCode = 404;
|
|
return;
|
|
}
|
|
|
|
var filePath = Path.Combine(skill.Path, "output", filename);
|
|
if (!File.Exists(filePath))
|
|
{
|
|
ctx.Response.StatusCode = 404;
|
|
return;
|
|
}
|
|
|
|
var ext = Path.GetExtension(filename);
|
|
var contentType = MimeTypes.GetValueOrDefault(ext, "application/octet-stream");
|
|
|
|
ctx.Response.ContentType = contentType;
|
|
ctx.Response.Headers["Cache-Control"] = "private, max-age=3600";
|
|
await ctx.Response.SendFileAsync(filePath);
|
|
}
|
|
|
|
private static readonly HashSet<string> ThumbExtensions = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"
|
|
};
|
|
|
|
private static async Task HandleSkillOutputThumb(HttpContext ctx, SkillService skillService)
|
|
{
|
|
var slug = ctx.Request.RouteValues["slug"]?.ToString();
|
|
var filename = ctx.Request.RouteValues["filename"]?.ToString();
|
|
|
|
if (string.IsNullOrEmpty(slug) || string.IsNullOrEmpty(filename))
|
|
{
|
|
ctx.Response.StatusCode = 400;
|
|
return;
|
|
}
|
|
|
|
if (filename.Contains("..") || filename.Contains('/') || filename.Contains('\\'))
|
|
{
|
|
ctx.Response.StatusCode = 400;
|
|
return;
|
|
}
|
|
|
|
var ext = Path.GetExtension(filename);
|
|
if (!ThumbExtensions.Contains(ext))
|
|
{
|
|
ctx.Response.StatusCode = 400;
|
|
await ctx.Response.WriteAsync("Thumbnails nur für Bilder verfügbar");
|
|
return;
|
|
}
|
|
|
|
var skill = skillService.GetSkill(slug);
|
|
if (skill == null) { ctx.Response.StatusCode = 404; return; }
|
|
|
|
var originalPath = Path.Combine(skill.Path, "output", filename);
|
|
if (!File.Exists(originalPath)) { ctx.Response.StatusCode = 404; return; }
|
|
|
|
// Cache-Verzeichnis: data/cache/skills/{slug}/
|
|
var dataDir = Path.GetDirectoryName(skillService.SkillsDir);
|
|
var cacheDir = Path.Combine(dataDir, "cache", "skills", slug);
|
|
Directory.CreateDirectory(cacheDir);
|
|
|
|
var thumbFilename = Path.GetFileNameWithoutExtension(filename) + ".webp";
|
|
var thumbPath = Path.Combine(cacheDir, thumbFilename);
|
|
|
|
// Thumbnail generieren wenn nicht gecacht oder Original neuer
|
|
if (!File.Exists(thumbPath) || File.GetLastWriteTimeUtc(originalPath) > File.GetLastWriteTimeUtc(thumbPath))
|
|
{
|
|
try
|
|
{
|
|
using var image = await Image.LoadAsync(originalPath);
|
|
|
|
// Max 320px auf der längsten Seite
|
|
var maxSize = 320;
|
|
image.Mutate(x => x.Resize(new ResizeOptions
|
|
{
|
|
Size = new Size(maxSize, maxSize),
|
|
Mode = ResizeMode.Max
|
|
}));
|
|
|
|
var encoder = new WebpEncoder { Quality = 75 };
|
|
await image.SaveAsync(thumbPath, encoder);
|
|
}
|
|
catch
|
|
{
|
|
// Fallback: Original ausliefern wenn Thumbnail-Generierung fehlschlägt
|
|
ctx.Response.ContentType = MimeTypes.GetValueOrDefault(ext, "application/octet-stream");
|
|
ctx.Response.Headers["Cache-Control"] = "private, max-age=3600";
|
|
await ctx.Response.SendFileAsync(originalPath);
|
|
return;
|
|
}
|
|
}
|
|
|
|
ctx.Response.ContentType = "image/webp";
|
|
ctx.Response.Headers["Cache-Control"] = "private, max-age=86400";
|
|
await ctx.Response.SendFileAsync(thumbPath);
|
|
}
|
|
}
|