Willkommens-Karten, Geburtstage, Devlog-Permalinks mit OG-Vorschau, Auto-Publish

- Willkommens-Karten: gerendertes PNG (SVG → sharp) mit Avatar im
  Neon-Ring, Willkommens-Schriftzug und Member-Nummer; Fallback aufs
  bisherige Embed wenn das Rendering scheitert; Dockerfile installiert
  fonts-dejavu-core für die Text-Darstellung
- Geburtstags-System: /geburtstag setzen|entfernen (birthdays-Tabelle),
  tägliche Runde ab 09:00 Europe/Berlin (Doppel-Post-Schutz über
  last_birthday_run), Gratulations-Embed + Tages-Rolle (wird am
  nächsten Morgen wieder abgeräumt); Kanal + Rolle im Community-Tab
- Devlog-Permalinks: /devlogs/:id als eigene Seite (GET /api/devlogs/:id),
  Link-Symbol an jeder Karte, Link-kopieren-Button; der Server injiziert
  Open-Graph-Tags ins SPA-HTML — Devlog-Links zeigen Titel, Anriss und
  Bild, alle anderen Seiten bekommen Default-Tags (auch die Startseite)
- Auto-Publish: maybeCrosspost() veröffentlicht Devlog-, Release-,
  Composer- und geplante Posts automatisch in Ankündigungs-Kanälen
- brandEmbed: leere Avatar-URL crasht nicht mehr die Footer-Validierung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 22:43:15 +02:00
co-authored by Claude Fable 5
parent ef0fb066f7
commit 676fc45bb1
23 changed files with 1080 additions and 21 deletions
+62 -6
View File
@@ -4,12 +4,12 @@ import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import crypto from 'node:crypto';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { config } from '../config.js';
import { saveCommits, saveRelease, takeBugReport } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored } from '../runtime-settings.js';
import { saveCommits, saveRelease, takeBugReport, getDevlog } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName } from '../runtime-settings.js';
import { postPushEmbed } from '../bot/commit-feed.js';
import { postReleaseEmbed } from '../bot/release-feed.js';
import { registerAuthRoutes } from './auth.js';
@@ -90,14 +90,70 @@ export async function startWebServer(client) {
});
if (existsSync(frontendDist)) {
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing)
await app.register(fastifyStatic, { root: frontendDist });
// React-Frontend ausliefern; unbekannte GET-Pfade → index.html (SPA-Routing).
// Dabei werden Open-Graph-Tags injiziert, damit geteilte Links (Discord,
// WhatsApp, …) eine hübsche Vorschau zeigen — Devlog-Links sogar mit Bild.
const indexHtml = readFileSync(join(frontendDist, 'index.html'), 'utf8');
const esc = (s) => String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('"', '&quot;');
function ogTagsFor(url) {
const base = publicUrl();
let title = `${brandName()} // Community-Hub`;
let description = 'Devlogs, Roadmap, Server-Status und alles aus der Community — direkt aus dem Discord.';
let image = null;
const pageTitles = {
'/devlogs': ['Devlog-Archiv', 'Entwicklungs-Updates, automatisch archiviert.'],
'/roadmap': ['Roadmap', 'Meilensteine und Community-Wünsche zum Abstimmen.'],
'/server': ['Server-Status', 'Alle Game-Server live — Spielerzahlen und Verlauf.'],
'/galerie': ['Galerie', 'Screenshots aus der Community.'],
'/level': ['Level', 'Die XP-Bestenliste des Discords.'],
'/events': ['Events', 'Playtests, Streams und was sonst ansteht.'],
'/changelog': ['Changelog', 'Alle Releases mit Notes.'],
};
const path = url.split('?')[0];
const devlogId = path.match(/^\/devlogs\/(\d{15,21})$/)?.[1];
const devlog = devlogId ? getDevlog(devlogId) : null;
if (devlog) {
const prose = devlog.content.replace(/^[-*>]\s+/gm, '').replace(/[#*`_]/g, '').replace(/\s+/g, ' ').trim();
title = `${brandName()} // Devlog — ${new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }).format(new Date(devlog.posted_at))}`;
description = prose.slice(0, 200) + (prose.length > 200 ? ' …' : '');
const firstImage = JSON.parse(devlog.images || '[]')[0];
if (firstImage) image = `${base}/devlog-assets/${firstImage}`;
} else if (pageTitles[path]) {
title = `${brandName()} // ${pageTitles[path][0]}`;
description = pageTitles[path][1];
}
return [
`<meta property="og:site_name" content="${esc(brandName())}">`,
`<meta property="og:title" content="${esc(title)}">`,
`<meta property="og:description" content="${esc(description)}">`,
`<meta property="og:url" content="${esc(base + path)}">`,
`<meta property="og:type" content="website">`,
`<meta name="theme-color" content="#f5c518">`,
`<meta name="description" content="${esc(description)}">`,
image ? `<meta property="og:image" content="${esc(image)}">` : '',
image ? `<meta name="twitter:card" content="summary_large_image">` : '<meta name="twitter:card" content="summary">',
].filter(Boolean).join('\n ');
}
const sendInjected = (request, reply) => {
const html = indexHtml.replace('</head>', ` ${ogTagsFor(request.url)}\n</head>`);
return reply.type('text/html; charset=utf-8').send(html);
};
// index: false → "/" wird nicht statisch bedient, sondern von der eigenen
// Route darunter (sonst gäbe es keine OG-Tags auf der Startseite)
await app.register(fastifyStatic, { root: frontendDist, index: false });
app.get('/', sendInjected);
app.setNotFoundHandler((request, reply) => {
const isApiPath = ['/api', '/auth', '/webhooks'].some((p) =>
request.url.startsWith(p)
);
if (request.method === 'GET' && !isApiPath) {
return reply.sendFile('index.html');
return sendInjected(request, reply);
}
return reply.code(404).send({ error: 'not found' });
});