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;
}
}
+2 -1
View File
@@ -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;
+12
View File
@@ -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; }
}
+1
View File
@@ -80,5 +80,6 @@ app.MapSettingsEndpoints();
app.MapTelegramEndpoints();
app.MapCronjobEndpoints();
app.MapKreaEndpoints();
app.MapSharedEndpoints();
app.Run("http://0.0.0.0:19000");
+73
View File
@@ -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<SharedMessageRecord> 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<SharedMessageRecord> GetSharedMessage(string shareToken)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<SharedMessageRecord>(
"SELECT * FROM shared_messages WHERE share_token = @shareToken", new { shareToken });
}
public async Task<IEnumerable<SharedMessageRecord>> GetSharedMessagesByUser(string userId)
{
using var conn = CreateConnection();
return await conn.QueryAsync<SharedMessageRecord>(
"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<MessageRecord> GetMessage(long messageId)
{
using var conn = CreateConnection();
return await conn.QuerySingleOrDefaultAsync<MessageRecord>(
"SELECT * FROM messages WHERE id = @messageId", new { messageId });
}
public async Task<List<MessageRecord>> GetChainMessagesUpTo(string chainId, long messageId)
{
using var conn = CreateConnection();
var result = await conn.QueryAsync<MessageRecord>(
"SELECT * FROM messages WHERE chain_id = @chainId AND id <= @messageId ORDER BY id ASC",
new { chainId, messageId });
return result.AsList();
}
// ── Agents ──
public async Task<AgentRecord> CreateAgent(AgentRecord agent)
+119 -4
View File
@@ -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;
}
+14
View File
@@ -415,6 +415,19 @@
</div>
</template>
<template id="tpl-shared">
<div class="shared-page">
<div class="shared-header">
<span class="shared-logo">// gott</span>
<span class="shared-label">Geteilte Nachricht</span>
</div>
<div class="shared-content" id="sharedContent">
<div class="shared-loading"><i class="fa fa-spinner fa-spin"></i> Lade...</div>
</div>
<div class="shared-footer"></div>
</div>
</template>
<script src="js/lib/marked.min.js"></script>
<script src="js/lib/codemirror.min.js"></script>
<script src="js/lib/cm-mode-xml.min.js"></script>
@@ -446,6 +459,7 @@
<script src="js/pages/login-page.js"></script>
<script src="js/pages/settings-page.js"></script>
<script src="js/pages/profile-page.js"></script>
<script src="js/pages/shared-page.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+28
View File
@@ -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() {
+10 -3
View File
@@ -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 });
+2
View File
@@ -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;
+59
View File
@@ -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 = '<div class="shared-error"><i class="fa fa-times-circle"></i> Ungültiger Link</div>';
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 = '<i class="fa fa-clock-o"></i> Geteilt am ' + d.toLocaleDateString('de-DE') + ' um ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
container.appendChild(meta);
}
} catch (err) {
container.innerHTML = '<div class="shared-error"><i class="fa fa-times-circle"></i> ' + esc(err.message) + '</div>';
}
},
destroy: () => {}
});
+91
View File
@@ -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 = '<i class="fa fa-share-alt"></i>';
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 = `
<button class="share-menu-item" data-mode="single">
<i class="fa fa-comment-o"></i> Einzelne Nachricht
</button>
<button class="share-menu-item" data-mode="context">
<i class="fa fa-comments-o"></i> Mit Kontext (Thread)
</button>
`;
// 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;