feat: Einzelne Nachrichten als Link teilen

Backend:
- SharedMessageRecord Model + shared_messages DB-Tabelle
- SharedEndpoint: POST /api/messages/{id}/share, GET /api/shared/{token},
  GET /api/shared (Liste), DELETE /api/shared/{token}
- Auth-Ausnahme für GET /api/shared/{token} (öffentlich ohne Login)
- DB-Methoden: CreateSharedMessage, GetSharedMessage, GetChainMessagesUpTo

Frontend:
- Share-Button (fa-share-alt) an jeder Nachricht
- Kontextmenü: Einzelnachricht oder mit Thread-Kontext teilen
- Link wird automatisch in Zwischenablage kopiert
- Öffentliche Shared-Page (#shared/{token}) ohne Login
- Markdown-Rendering in der geteilten Ansicht
- CSS für Share-Button, Share-Menü und Shared-Page
This commit is contained in:
Meleki committed 2026-03-04 23:34:00 +01:00
1 parent 4e915f76af
commit be29984b25
12 files changed
+591 -8

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Gott.Extensions;
using Gott.Models;
using Gott.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Gott.Endpoints;
public static class SharedEndpoint
{
public static void MapSharedEndpoints(this IEndpointRouteBuilder app)
{
// Authentifizierte Endpunkte
app.MapPost("/api/messages/{messageId}/share", HandleShareMessage);
app.MapGet("/api/shared", HandleListShares);
app.MapDelete("/api/shared/{token}", HandleDeleteShare);
// Öffentlicher Endpunkt (Auth-Ausnahme in Middleware)
app.MapGet("/api/shared/{token}", HandleGetShared);
}
private static async Task HandleShareMessage(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var messageIdStr = ctx.Request.RouteValues["messageId"]?.ToString();
if (!long.TryParse(messageIdStr, out var messageId))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new { error = "Invalid messageId" });
return;
}
// Body lesen (optional: includeContext)
bool includeContext = false;
try
{
var body = await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
if (body.TryGetProperty("includeContext", out var ic)) includeContext = ic.GetBoolean();
}
catch { /* kein Body oder ungültig – defaults verwenden */ }
// Nachricht existiert und gehört dem User?
var message = await db.GetMessage(messageId);
if (message == null)
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Message not found" });
return;
}
// Chain holen → Session prüfen
var chain = await db.GetChain(message.ChainId);
if (chain == null)
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Chain not found" });
return;
}
var session = await db.GetSession(chain.SessionId);
if (session == null || session.UserId != userId)
{
ctx.Response.StatusCode = 403;
await ctx.Response.WriteAsJsonAsync(new { error = "Not authorized" });
return;
}
var shared = await db.CreateSharedMessage(messageId, userId, includeContext);
await ctx.Response.WriteAsJsonAsync(new { token = shared.ShareToken });
}
private static async Task HandleGetShared(HttpContext ctx, DatabaseService db)
{
var token = ctx.Request.RouteValues["token"]?.ToString();
if (string.IsNullOrEmpty(token))
{
ctx.Response.StatusCode = 400;
return;
}
var shared = await db.GetSharedMessage(token);
if (shared == null)
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Share not found" });
return;
}
// Ablaufdatum prüfen
if (!string.IsNullOrEmpty(shared.ExpiresAt) &&
DateTime.TryParse(shared.ExpiresAt, out var expires) &&
expires < DateTime.UtcNow)
{
ctx.Response.StatusCode = 410;
await ctx.Response.WriteAsJsonAsync(new { error = "Share expired" });
return;
}
var message = await db.GetMessage(shared.MessageId);
if (message == null)
{
ctx.Response.StatusCode = 404;
await ctx.Response.WriteAsJsonAsync(new { error = "Message not found" });
return;
}
// Chain-Info holen
var chain = await db.GetChain(message.ChainId);
if (shared.IncludeContext)
{
// Alle Messages der Chain bis zu dieser Nachricht laden
var contextMessages = await db.GetChainMessagesUpTo(message.ChainId, shared.MessageId);
// Nur text-Messages zurückgeben (keine tool_use/tool_result)
var textMessages = contextMessages
.Where(m => m.Type == "text" && m.DeletedOn == null)
.Select(m => new
{
m.Id,
m.Role,
m.Content,
m.CreatedAt
})
.ToList();
await ctx.Response.WriteAsJsonAsync(new
{
mode = "context",
messages = textMessages,
model = chain?.Model,
createdAt = shared.CreatedAt
});
}
else
{
// Einzelne Nachricht
await ctx.Response.WriteAsJsonAsync(new
{
mode = "single",
message = new
{
message.Id,
message.Role,
message.Content,
message.CreatedAt
},
model = chain?.Model,
createdAt = shared.CreatedAt
});
}
}
private static async Task HandleListShares(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var shares = await db.GetSharedMessagesByUser(userId);
await ctx.Response.WriteAsJsonAsync(shares);
}
private static async Task HandleDeleteShare(HttpContext ctx, DatabaseService db)
{
var userId = ctx.GetUserId();
var token = ctx.Request.RouteValues["token"]?.ToString();
if (string.IsNullOrEmpty(token))
{
ctx.Response.StatusCode = 400;
return;
}
await db.DeleteSharedMessage(token, userId);
ctx.Response.StatusCode = 204;
}
}