From be29984b25bc20c53b3a561508e9aa6ad1f03bc7 Mon Sep 17 00:00:00 2001 From: Meleki Date: Wed, 4 Mar 2026 23:34:00 +0100 Subject: [PATCH] feat: Einzelne Nachrichten als Link teilen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Endpoints/SharedEndpoint.cs | 180 ++++++++++++++++++++++++++++++++ Middleware/AuthMiddleware.cs | 3 +- Models/SharedMessageRecord.cs | 12 +++ Program.cs | 1 + Services/DatabaseService.cs | 73 +++++++++++++ wwwroot/css/chat.css | 123 +++++++++++++++++++++- wwwroot/index.html | 14 +++ wwwroot/js/api.js | 28 +++++ wwwroot/js/app.js | 13 ++- wwwroot/js/history.js | 2 + wwwroot/js/pages/shared-page.js | 59 +++++++++++ wwwroot/js/ui.js | 91 ++++++++++++++++ 12 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 Endpoints/SharedEndpoint.cs create mode 100644 Models/SharedMessageRecord.cs create mode 100644 wwwroot/js/pages/shared-page.js diff --git a/Endpoints/SharedEndpoint.cs b/Endpoints/SharedEndpoint.cs new file mode 100644 index 0000000..3be7cd0 --- /dev/null +++ b/Endpoints/SharedEndpoint.cs @@ -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(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; + } +} diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs index f3f8fb4..41fef6b 100644 --- a/Middleware/AuthMiddleware.cs +++ b/Middleware/AuthMiddleware.cs @@ -19,7 +19,8 @@ public class AuthMiddleware // Static files, login and telegram webhook don't require auth var isTelegramWebhook = path.StartsWith("/api/telegram/webhook/") && ctx.Request.Method == "POST"; - if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook) + var isSharedGet = path.StartsWith("/api/shared/") && ctx.Request.Method == "GET"; + if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet) { await _next(ctx); return; diff --git a/Models/SharedMessageRecord.cs b/Models/SharedMessageRecord.cs new file mode 100644 index 0000000..5e390d7 --- /dev/null +++ b/Models/SharedMessageRecord.cs @@ -0,0 +1,12 @@ +namespace Gott.Models; + +public class SharedMessageRecord +{ + public long Id { get; set; } + public string ShareToken { get; set; } + public long MessageId { get; set; } + public string UserId { get; set; } + public bool IncludeContext { get; set; } + public string ExpiresAt { get; set; } + public string CreatedAt { get; set; } +} diff --git a/Program.cs b/Program.cs index ad10a97..8a353dc 100644 --- a/Program.cs +++ b/Program.cs @@ -80,5 +80,6 @@ app.MapSettingsEndpoints(); app.MapTelegramEndpoints(); app.MapCronjobEndpoints(); app.MapKreaEndpoints(); +app.MapSharedEndpoints(); app.Run("http://0.0.0.0:19000"); diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index b5f37b8..ccbbcfd 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -228,6 +228,20 @@ public class DatabaseService CREATE INDEX IF NOT EXISTS idx_cronjobs_user ON cronjobs(user_id); CREATE INDEX IF NOT EXISTS idx_cronjobs_next_run ON cronjobs(next_run_at); "); + + conn.Execute(@" + CREATE TABLE IF NOT EXISTS shared_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + share_token TEXT NOT NULL UNIQUE, + message_id INTEGER NOT NULL, + user_id TEXT NOT NULL, + include_context INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_shared_messages_token ON shared_messages(share_token); + CREATE INDEX IF NOT EXISTS idx_shared_messages_message ON shared_messages(message_id); + "); } private void TryAddColumn(SqliteConnection conn, string table, string column, string type) @@ -236,6 +250,65 @@ public class DatabaseService catch { /* column already exists */ } } + // ── Shared Messages ── + + public async Task CreateSharedMessage(long messageId, string userId, bool includeContext, string expiresAt = null) + { + using var conn = CreateConnection(); + var record = new SharedMessageRecord + { + ShareToken = Guid.NewGuid().ToString("N"), + MessageId = messageId, + UserId = userId, + IncludeContext = includeContext, + ExpiresAt = expiresAt, + CreatedAt = DateTime.UtcNow.ToString("o") + }; + await conn.ExecuteAsync(@" + INSERT INTO shared_messages (share_token, message_id, user_id, include_context, expires_at, created_at) + VALUES (@ShareToken, @MessageId, @UserId, @IncludeContext, @ExpiresAt, @CreatedAt) + ", record); + return record; + } + + public async Task GetSharedMessage(string shareToken) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken }); + } + + public async Task> GetSharedMessagesByUser(string userId) + { + using var conn = CreateConnection(); + return await conn.QueryAsync( + "SELECT * FROM shared_messages WHERE user_id = @userId ORDER BY created_at DESC", new { userId }); + } + + public async Task DeleteSharedMessage(string shareToken, string userId) + { + using var conn = CreateConnection(); + await conn.ExecuteAsync( + "DELETE FROM shared_messages WHERE share_token = @shareToken AND user_id = @userId", + new { shareToken, userId }); + } + + public async Task GetMessage(long messageId) + { + using var conn = CreateConnection(); + return await conn.QuerySingleOrDefaultAsync( + "SELECT * FROM messages WHERE id = @messageId", new { messageId }); + } + + public async Task> GetChainMessagesUpTo(string chainId, long messageId) + { + using var conn = CreateConnection(); + var result = await conn.QueryAsync( + "SELECT * FROM messages WHERE chain_id = @chainId AND id <= @messageId ORDER BY id ASC", + new { chainId, messageId }); + return result.AsList(); + } + // ── Agents ── public async Task CreateAgent(AgentRecord agent) diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 77def5f..25e9087 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -414,7 +414,8 @@ .msg-reply-btn, .msg-delete-btn, -.msg-mark-btn { +.msg-mark-btn, +.msg-share-btn { position: absolute; top: var(--badge-spacing); opacity: 0; @@ -428,20 +429,25 @@ } .msg-reply-btn { - right: calc(var(--badge-spacing) + 4em + var(--badge-spacing)); + right: calc(var(--badge-spacing) + 6em + var(--badge-spacing) * 1.5); } .msg-delete-btn { - right: calc(var(--badge-spacing) + 2em + var(--badge-spacing) / 2); + right: calc(var(--badge-spacing) + 4em + var(--badge-spacing)); } .msg-mark-btn { + right: calc(var(--badge-spacing) + 2em + var(--badge-spacing) / 2); +} + +.msg-share-btn { right: var(--badge-spacing); } .msg:hover .msg-reply-btn, .msg:hover .msg-delete-btn, -.msg:hover .msg-mark-btn { +.msg:hover .msg-mark-btn, +.msg:hover .msg-share-btn { opacity: 1; } @@ -858,3 +864,112 @@ object-fit: contain; border-radius: var(--border-radius); } + +/* Share Menu */ + +.share-menu { + background: var(--f2-bg); + border: var(--border-width) solid var(--f2-border); + border-radius: var(--border-radius); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 16px rgb(0 0 0 / 0.25); + min-width: 12em; +} + +.share-menu-item { + display: flex; + align-items: center; + gap: var(--badge-spacing); + padding: var(--menu-item-padding); + background: none; + border: none; + text-align: left; + white-space: nowrap; + cursor: pointer; + color: var(--text); + font-size: var(--font-size); +} + +.share-menu-item:hover { + background: var(--f3-bg); + border: none; +} + +/* Shared Page (öffentliche Ansicht) */ + +.shared-page { + display: flex; + flex-direction: column; + min-height: 100dvh; + max-width: var(--page-width); + margin: 0 auto; +} + +.shared-header { + display: flex; + align-items: center; + gap: var(--box-spacing); + padding: var(--page-padding); + border-bottom: var(--border-width) solid var(--f2-border); +} + +.shared-logo { + font-weight: bold; + font-size: 1.2em; + letter-spacing: .05em; +} + +.shared-label { + opacity: var(--semi-transparent); + font-size: var(--font-size); +} + +.shared-content { + flex: 1; + padding: var(--page-padding); + display: flex; + flex-direction: column; + gap: var(--badge-spacing); + overflow-y: auto; +} + +.shared-content .msg { + max-width: 100%; +} + +.shared-loading, +.shared-error { + text-align: center; + padding: 3em 1em; + opacity: var(--semi-transparent); +} + +.shared-error { + color: var(--dangerous); + opacity: 1; +} + +.shared-meta { + text-align: center; + padding: var(--box-padding); + opacity: var(--semi-transparent); + font-size: var(--font-size); +} + +.shared-footer { + padding: var(--page-padding); + text-align: center; + border-top: var(--border-width) solid var(--f2-border); +} + +.shared-cta { + color: var(--primary); + text-decoration: none; + font-weight: bold; +} + +.shared-cta:hover { + text-decoration: underline; +} diff --git a/wwwroot/index.html b/wwwroot/index.html index dbcab40..e9f03b4 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -415,6 +415,19 @@ + + @@ -446,6 +459,7 @@ + diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 7bfccdd..0a90c2a 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -374,6 +374,34 @@ async function apiRunCronjob(id) { return resp.json(); } +// ── Shared Message API ── + +async function apiShareMessage(messageId, includeContext = false) { + const resp = await fetch(`/api/messages/${messageId}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ includeContext }) + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || 'Teilen fehlgeschlagen'); + } + return resp.json(); +} + +async function apiGetShared(token) { + const resp = await fetch(`/api/shared/${token}`); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || 'Geteilte Nachricht nicht gefunden'); + } + return resp.json(); +} + +async function apiDeleteShare(token) { + return fetch(`/api/shared/${token}`, { method: 'DELETE' }); +} + // ── Backup API ── async function apiBackupDb() { diff --git a/wwwroot/js/app.js b/wwwroot/js/app.js index de9d383..4e40b7d 100644 --- a/wwwroot/js/app.js +++ b/wwwroot/js/app.js @@ -28,6 +28,14 @@ function applySettings() { document.addEventListener('DOMContentLoaded', async () => { // Auth-Check: Eingeloggt? + const { page, id } = parseHash(); + + // Shared-Page braucht kein Login + if (page === 'shared') { + await navigateTo('shared', { id }, { replace: true }); + return; + } + var user = await apiMe(); if (!user) { await navigateTo('login', {}, { replace: true }); @@ -39,7 +47,6 @@ document.addEventListener('DOMContentLoaded', async () => { applySettings(); loadClaudeStatus(); - const { page, id } = parseHash(); var target = page === 'login' ? 'dashboard' : page; await navigateTo(target, { id }, { replace: true }); }); @@ -84,8 +91,8 @@ window.addEventListener('popstate', async (e) => { id = parsed.id; } - // Login-Seite immer erlauben, alles andere nur wenn eingeloggt - if (page !== 'login') { + // Login- und Shared-Seite immer erlauben, alles andere nur wenn eingeloggt + if (page !== 'login' && page !== 'shared') { var user = await apiMe(); if (!user) { await navigateTo('login', {}, { replace: true }); diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 6cd0edf..33d3b74 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -189,6 +189,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { _addReplyButton(div); _addDeleteButton(div, msg.id, msg.deletedOn); _addMarkButton(div, msg.id, msg.isMarked); + _addShareButton(div, msg.id); fragment.appendChild(div); if (msg.attachmentCount > 0) { // Prüfe ob Bilder dabei sind (Content enthält [Bild: ...]) @@ -217,6 +218,7 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds) { _addReplyButton(div); _addDeleteButton(div, msg.id, msg.deletedOn); _addMarkButton(div, msg.id, msg.isMarked); + _addShareButton(div, msg.id); fragment.appendChild(div); } break; diff --git a/wwwroot/js/pages/shared-page.js b/wwwroot/js/pages/shared-page.js new file mode 100644 index 0000000..0cc9e0f --- /dev/null +++ b/wwwroot/js/pages/shared-page.js @@ -0,0 +1,59 @@ +// ── Shared Message Page (öffentlich, kein Login nötig) ── + +registerPage('shared', { + init: async (params) => { + const token = params.id; + const container = document.getElementById('sharedContent'); + if (!token || !container) { + container.innerHTML = '
Ungültiger Link
'; + return; + } + + try { + const data = await apiGetShared(token); + container.innerHTML = ''; + + if (data.messages && data.messages.length > 0) { + // Mehrere Nachrichten (Thread mit Kontext) + data.messages.forEach(msg => { + const div = document.createElement('div'); + div.className = 'msg ' + (msg.role === 'user' ? 'user' : 'assistant') + ' markdown'; + if (msg.role === 'user') { + renderUserContent(div, msg.content); + } else { + div.innerHTML = renderMarkdown(msg.content); + parseFluentEmojis(div); + wrapKreaGalleries(div); + } + container.appendChild(div); + }); + } else if (data.message) { + // Einzelne Nachricht + const msg = data.message; + const div = document.createElement('div'); + div.className = 'msg ' + (msg.role === 'user' ? 'user' : 'assistant') + ' markdown'; + if (msg.role === 'user') { + renderUserContent(div, msg.content); + } else { + div.innerHTML = renderMarkdown(msg.content); + parseFluentEmojis(div); + wrapKreaGalleries(div); + } + container.appendChild(div); + } + + // Timestamp anzeigen + if (data.createdAt) { + const meta = document.createElement('div'); + meta.className = 'shared-meta'; + const d = new Date(data.createdAt); + meta.innerHTML = ' Geteilt am ' + d.toLocaleDateString('de-DE') + ' um ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + container.appendChild(meta); + } + + } catch (err) { + container.innerHTML = '
' + esc(err.message) + '
'; + } + }, + destroy: () => {} +}); diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 7226aab..7534ddc 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -218,6 +218,95 @@ function _addMarkButton(div, messageId, isMarked) { } } +// ── Share Button ── + +function _addShareButton(div, messageId) { + if (!messageId) return; + const btn = document.createElement('button'); + btn.className = 'msg-share-btn icon-btn'; + btn.innerHTML = ''; + btn.title = 'Nachricht teilen'; + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + // Kontextmenü: Einzelnachricht oder mit Kontext? + const mode = await _showShareMenu(btn); + if (!mode) return; // abgebrochen + + const result = await apiShareMessage(messageId, mode === 'context'); + const url = location.origin + '/#shared/' + result.token; + await navigator.clipboard.writeText(url); + showToast('Link kopiert!', 'success'); + } catch (err) { + showToast('Fehler: ' + err.message, 'error'); + } + }); + // Nach dem Mark-Button einfügen + const markBtn = div.querySelector('.msg-mark-btn'); + if (markBtn) { + markBtn.after(btn); + } else { + const deleteBtn = div.querySelector('.msg-delete-btn'); + if (deleteBtn) { + deleteBtn.after(btn); + } else { + div.prepend(btn); + } + } +} + +function _showShareMenu(anchorBtn) { + return new Promise((resolve) => { + // Wenn schon ein Menü offen, schließen + const existing = document.querySelector('.share-menu'); + if (existing) { existing.remove(); resolve(null); return; } + + const menu = document.createElement('div'); + menu.className = 'share-menu'; + menu.innerHTML = ` + + + `; + + // Positionierung + const rect = anchorBtn.getBoundingClientRect(); + menu.style.position = 'fixed'; + menu.style.top = (rect.bottom + 4) + 'px'; + menu.style.left = rect.left + 'px'; + menu.style.zIndex = '10000'; + + document.body.appendChild(menu); + + function cleanup() { + menu.remove(); + document.removeEventListener('click', outsideClick); + } + + function outsideClick(e) { + if (!menu.contains(e.target)) { + cleanup(); + resolve(null); + } + } + + menu.querySelectorAll('.share-menu-item').forEach(item => { + item.addEventListener('click', (e) => { + e.stopPropagation(); + const mode = item.dataset.mode; + cleanup(); + resolve(mode); + }); + }); + + // Beim nächsten Klick außerhalb schließen + setTimeout(() => document.addEventListener('click', outsideClick), 10); + }); +} + // ── Reply Button ── function _addReplyButton(div) { @@ -354,6 +443,7 @@ function addUserBubble(text, streamed = false, timestamp = '', messageId = null, _addReplyButton(div); _addDeleteButton(div, messageId, deletedOn); _addMarkButton(div, messageId, isMarked); + _addShareButton(div, messageId); chatEl().appendChild(div); scrollDown(); } @@ -371,6 +461,7 @@ function addAssistantBubble(text, timestamp = '', messageId = null, isMarked = f _addReplyButton(div); _addDeleteButton(div, messageId, deletedOn); _addMarkButton(div, messageId, isMarked); + _addShareButton(div, messageId); chatEl().appendChild(div); scrollDown(); return div;