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
+69
View File
@@ -0,0 +1,69 @@
// Geburtstags-Runde: einmal täglich ab 09:00 (Europe/Berlin) gratulieren
// und die Tages-Rolle umhängen (gestern Geburtstag → Rolle wieder weg).
import { getSetting, setSetting, birthdaysToday } from '../db.js';
import { birthdayChannelId, birthdayRoleId, discordGuildId, brandColor2 } from '../runtime-settings.js';
import { brandEmbed } from '../embeds.js';
const CHECK_INTERVAL_MS = 15 * 60 * 1000;
/** Aktuelles Datum/Stunde in Europe/Berlin */
function berlinNow() {
const parts = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false,
}).formatToParts(new Date());
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
return { day: get('day'), month: get('month'), hour: get('hour'), dateKey: `${get('year')}-${get('month')}-${get('day')}` };
}
export async function birthdayTick(client) {
const { day, month, hour, dateKey } = berlinNow();
if (hour < 9) return; // erst ab 09:00
if (getSetting('last_birthday_run') === dateKey) return; // heute schon gelaufen
setSetting('last_birthday_run', dateKey);
const guildId = discordGuildId() ?? [...client.guilds.cache.keys()][0];
const guild = guildId ? await client.guilds.fetch(guildId).catch(() => null) : null;
const kids = birthdaysToday(day, month);
// Tages-Rolle: erst bei allen abräumen, dann den heutigen geben
const roleId = birthdayRoleId();
if (guild && roleId) {
const role = await guild.roles.fetch(roleId).catch(() => null);
if (role) {
for (const member of role.members.values()) {
await member.roles.remove(roleId).catch(() => {});
}
for (const b of kids) {
const member = await guild.members.fetch(b.user_id).catch(() => null);
await member?.roles.add(roleId).catch(() => {});
}
}
}
if (kids.length === 0) return;
const channelId = birthdayChannelId();
if (!channelId) return;
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel?.isTextBased()) return;
const mentions = kids.map((b) => `<@${b.user_id}>`).join(' ');
await channel.send({
content: mentions,
embeds: [
brandEmbed(client, 'GEBURTSTAG')
.setColor(brandColor2())
.setTitle(kids.length === 1 ? '🎂 Alles Gute zum Geburtstag!' : '🎂 Heute wird gleich mehrfach gefeiert!')
.setDescription(
`${mentions} ${kids.length === 1 ? 'hat' : 'haben'} heute Geburtstag — ` +
'lasst mal ordentlich 🎉 da!'
),
],
allowedMentions: { users: kids.map((b) => b.user_id) },
}).catch(() => {});
console.log(`[birthday] ${kids.length} Gratulation(en) für den ${day}.${month}.`);
}
export function startBirthdays(client) {
setInterval(() => birthdayTick(client).catch((e) => console.error('[birthday]', e)), CHECK_INTERVAL_MS);
birthdayTick(client).catch(() => {});
}
+2 -1
View File
@@ -33,11 +33,12 @@ import * as purge from './commands/purge.js';
import * as rank from './commands/rank.js';
import * as tag from './commands/tag.js';
import * as remind from './commands/remind.js';
import * as geburtstag from './commands/geburtstag.js';
// Alle Commands hier eintragen — jedes Modul exportiert { data, execute }
const commandModules = [
ping, devlogBackfill, bug, playtesterSetup, galerieBackfill,
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind,
wunsch, giveaway, ticketSetup, warn, warns, timeout, purge, rank, tag, remind, geburtstag,
];
export async function startBot() {
+43
View File
@@ -0,0 +1,43 @@
// /geburtstag — eintragen/entfernen; der Bot gratuliert morgens im Geburtstags-Kanal
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
import { setBirthday, deleteBirthday } from '../../db.js';
import { birthdayChannelId } from '../../runtime-settings.js';
const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
export const data = new SlashCommandBuilder()
.setName('geburtstag')
.setDescription('Geburtstag eintragen — der Bot gratuliert dir am großen Tag 🎂')
.addSubcommand((s) =>
s.setName('setzen').setDescription('Deinen Geburtstag eintragen (ohne Jahr)')
.addIntegerOption((o) => o.setName('tag').setDescription('Tag (131)').setRequired(true).setMinValue(1).setMaxValue(31))
.addIntegerOption((o) => o.setName('monat').setDescription('Monat (112)').setRequired(true).setMinValue(1).setMaxValue(12))
)
.addSubcommand((s) =>
s.setName('entfernen').setDescription('Deinen Geburtstag wieder austragen')
);
export async function execute(interaction) {
if (interaction.options.getSubcommand() === 'entfernen') {
const deleted = deleteBirthday(interaction.user.id);
await interaction.reply({
content: deleted ? '🗑️ Geburtstag ausgetragen.' : '❕ Es war kein Geburtstag eingetragen.',
flags: MessageFlags.Ephemeral,
});
return;
}
const day = interaction.options.getInteger('tag');
const month = interaction.options.getInteger('monat');
if (day > DAYS_IN_MONTH[month - 1]) {
await interaction.reply({ content: `❌ Der ${day}.${month}. existiert nicht.`, flags: MessageFlags.Ephemeral });
return;
}
setBirthday(interaction.user.id, interaction.user.username, day, month);
await interaction.reply({
content:
`🎂 Gemerkt: **${day}.${month}.** — ich gratuliere dir dann morgens` +
(birthdayChannelId() ? '!' : '. (Hinweis an die Admins: noch kein Geburtstags-Kanal gesetzt.)'),
flags: MessageFlags.Ephemeral,
});
}
+18 -3
View File
@@ -1,5 +1,5 @@
// Moderation & Kontakt: Modmail (DM ↔ Staff-Thread), Willkommens-Embed, Mod-Log
import { ChannelType, EmbedBuilder, Events } from 'discord.js';
import { AttachmentBuilder, ChannelType, EmbedBuilder, Events } from 'discord.js';
import { saveModmail, modmailByUser, modmailByThread, saveStickyRoles, stickyRolesOf } from '../db.js';
import { modmailChannelId, welcomeChannelId, modlogChannelId, publicUrl, autoroleId, stickyRolesEnabled, brandColor, brandColor2, brandFooter } from '../runtime-settings.js';
@@ -86,7 +86,6 @@ async function handleMemberAdd(member) {
const embed = new EmbedBuilder()
.setColor(brandColor())
.setTitle(`👋 Willkommen, ${member.displayName}!`)
.setThumbnail(member.user.displayAvatarURL({ size: 128 }))
.setDescription(
`Schön, dass du da bist — du bist Mitglied **#${member.guild.memberCount}**.\n\n` +
`📔 Devlogs & Roadmap: ${publicUrl()}\n` +
@@ -96,7 +95,23 @@ async function handleMemberAdd(member) {
.setFooter({ text: brandFooter('COMMUNITY'), iconURL: member.client?.user?.displayAvatarURL?.({ size: 64 }) })
.setTimestamp();
await channel.send({ content: `<@${member.id}>`, embeds: [embed] });
// Gerenderte Willkommens-Karte — wenn das Rendering klemmt, gibt's das Embed pur
let files = [];
try {
const { buildWelcomeCard } = await import('./welcome-card.js');
const png = await buildWelcomeCard({
username: member.displayName,
avatarUrl: member.user.displayAvatarURL({ extension: 'png', size: 128 }),
memberNumber: member.guild.memberCount,
});
files = [new AttachmentBuilder(png, { name: 'welcome.png' })];
embed.setImage('attachment://welcome.png');
} catch (error) {
embed.setThumbnail(member.user.displayAvatarURL({ size: 128 }));
console.error('[welcome] Karte fehlgeschlagen, nutze Embed:', error.message);
}
await channel.send({ content: `<@${member.id}>`, embeds: [embed], files });
}
/* ── Auto- & Sticky-Roles ──────────────────────────── */
+3 -1
View File
@@ -44,6 +44,8 @@ export async function postReleaseEmbed(client, release) {
);
}
await channel.send({ embeds: [embed], components: [buttons] });
const message = await channel.send({ embeds: [embed], components: [buttons] });
const { maybeCrosspost } = await import('../embeds.js');
await maybeCrosspost(message);
return true;
}
+70
View File
@@ -0,0 +1,70 @@
// Willkommens-Karte: gerendertes PNG (SVG → sharp) im D4RKST3R-Look —
// Avatar mit Neon-Ring, großer Willkommens-Schriftzug, Member-Nummer.
import sharp from 'sharp';
import { brandName } from '../runtime-settings.js';
const W = 900;
const H = 300;
/** XML-Sonderzeichen im Usernamen entschärfen */
function esc(s) {
return String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
/**
* Karte rendern.
* @returns {Promise<Buffer>} PNG-Buffer
*/
export async function buildWelcomeCard({ username, avatarUrl, memberNumber }) {
// Avatar holen und als Data-URI einbetten (128px reicht für den 150px-Kreis)
let avatarData = '';
try {
const res = await fetch(avatarUrl, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
avatarData = `data:image/png;base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`;
}
} catch { /* ohne Avatar rendern */ }
const name = esc(username.length > 22 ? `${username.slice(0, 21)}` : username);
const svg = `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="line" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#f5c518" stop-opacity="0"/>
<stop offset=".3" stop-color="#f5c518"/>
<stop offset=".7" stop-color="#ff4d00"/>
<stop offset="1" stop-color="#ff4d00" stop-opacity="0"/>
</linearGradient>
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f5c518"/>
<stop offset="1" stop-color="#ff4d00"/>
</linearGradient>
<pattern id="grid" width="45" height="45" patternUnits="userSpaceOnUse">
<path d="M 45 0 L 0 0 0 45" fill="none" stroke="#f5c518" stroke-opacity=".06" stroke-width="1"/>
</pattern>
<clipPath id="avatar"><circle cx="150" cy="150" r="75"/></clipPath>
</defs>
<rect width="${W}" height="${H}" fill="#0a0a0a"/>
<rect width="${W}" height="${H}" fill="url(#grid)"/>
<text x="${W - 18}" y="${H - 24}" text-anchor="end" font-family="DejaVu Sans, sans-serif" font-weight="bold"
font-size="120" fill="#f5c518" fill-opacity=".05">${esc(brandName())}</text>
<rect x="0" y="0" width="${W}" height="3" fill="url(#line)"/>
<rect x="0" y="${H - 3}" width="${W}" height="3" fill="url(#line)"/>
<circle cx="150" cy="150" r="80" fill="none" stroke="url(#ring)" stroke-width="4"/>
${avatarData
? `<image href="${avatarData}" x="75" y="75" width="150" height="150" clip-path="url(#avatar)" preserveAspectRatio="xMidYMid slice"/>`
: `<circle cx="150" cy="150" r="75" fill="#161616"/>
<text x="150" y="172" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="64" fill="#8a8378">?</text>`}
<text x="270" y="118" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="26"
letter-spacing="10" fill="#8a8378">WILLKOMMEN</text>
<text x="270" y="182" font-family="DejaVu Sans, sans-serif" font-weight="bold" font-size="52"
fill="#e8e0d0">${name}</text>
<text x="270" y="228" font-family="DejaVu Sans, sans-serif" font-size="24"
fill="#f5c518">Member #${Number(memberNumber) || '?'}</text>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
+24
View File
@@ -474,6 +474,25 @@ export const webAdminScopes = (userId) =>
export const listWebAdmins = () => listWebAdminsStmt.all();
export const deleteWebAdmin = (userId) => deleteWebAdminStmt.run(userId).changes > 0;
// Geburtstage: /geburtstag → morgendliche Gratulation + Tages-Rolle
db.exec(`
CREATE TABLE IF NOT EXISTS birthdays (
user_id TEXT PRIMARY KEY,
username TEXT,
day INTEGER NOT NULL,
month INTEGER NOT NULL
);
`);
const upsertBirthday = db.prepare(`
INSERT INTO birthdays (user_id, username, day, month) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET username = excluded.username, day = excluded.day, month = excluded.month
`);
const deleteBirthdayStmt = db.prepare('DELETE FROM birthdays WHERE user_id = ?');
const birthdaysTodayStmt = db.prepare('SELECT * FROM birthdays WHERE day = ? AND month = ?');
export const setBirthday = (userId, username, day, month) => upsertBirthday.run(userId, username, day, month);
export const deleteBirthday = (userId) => deleteBirthdayStmt.run(userId).changes > 0;
export const birthdaysToday = (day, month) => birthdaysTodayStmt.all(day, month);
// Audit-Log: wer (Owner/Team) hat wann was im Webinterface geändert
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
@@ -873,6 +892,11 @@ export function listDevlogs(limit, offset) {
return { items: selectDevlogs.all(limit, offset), total: countDevlogsStmt.get().n };
}
const getDevlogStmt = db.prepare(
'SELECT message_id, content, author_name, posted_at, images FROM devlogs WHERE message_id = ?'
);
export const getDevlog = (messageId) => getDevlogStmt.get(messageId) ?? null;
const searchDevlogsStmt = db.prepare(`
SELECT d.message_id, d.content, d.author_name, d.posted_at, d.images
FROM devlogs_fts f
+10 -2
View File
@@ -1,8 +1,15 @@
// Zentrale Embed-Factory: einheitlicher Brand-Look für alle Bot-Embeds.
// Footer mit Bot-Avatar, Brand-Farbe und Timestamp kommen automatisch.
import { EmbedBuilder } from 'discord.js';
import { ChannelType, EmbedBuilder } from 'discord.js';
import { brandColor, brandColor2, brandFooter } from './runtime-settings.js';
/** Auto-Publish: Posts in Ankündigungs-Kanälen crossposten, damit Follower sie bekommen */
export async function maybeCrosspost(message) {
if (message?.channel?.type === ChannelType.GuildAnnouncement && typeof message.crosspost === 'function') {
await message.crosspost().catch(() => {});
}
}
/**
* Gebrandetes Embed erzeugen.
* @param {import('discord.js').Client|null} client — für das Footer-Icon (Bot-Avatar)
@@ -11,9 +18,10 @@ import { brandColor, brandColor2, brandFooter } from './runtime-settings.js';
*/
export function brandEmbed(client, tag, opts = {}) {
const embed = new EmbedBuilder().setColor(opts.secondary ? brandColor2() : brandColor());
const icon = client?.user?.displayAvatarURL?.({ size: 64 });
embed.setFooter({
text: brandFooter(tag) + (opts.footerSuffix ? `${opts.footerSuffix}` : ''),
iconURL: client?.user?.displayAvatarURL?.({ size: 64 }) ?? undefined,
iconURL: icon || undefined, // leerer String würde die discord.js-Validierung werfen
});
if (opts.timestamp !== false) embed.setTimestamp();
return embed;
+2
View File
@@ -8,6 +8,7 @@ import { startServerMonitor } from './bot/server-monitor.js';
import { startGiveaways } from './bot/giveaways.js';
import { startScheduledPosts } from './web/scheduled-posts.js';
import { startSocialNotify } from './bot/social-notify.js';
import { startBirthdays } from './bot/birthdays.js';
process.on('unhandledRejection', (error) => {
console.error('[main] Unhandled Rejection:', error);
@@ -23,6 +24,7 @@ try {
startGiveaways(client);
startScheduledPosts(client);
startSocialNotify(client);
startBirthdays(client);
} catch (error) {
console.error('[main] Start fehlgeschlagen:', error);
process.exit(1);
+10
View File
@@ -171,6 +171,16 @@ export function discordInviteUrl() {
return getSetting('discord_invite_url') || null;
}
/** Geburtstags-Kanal (morgendliche Gratulation) — leer = Feature aus */
export function birthdayChannelId() {
return getSetting('birthday_channel_id') || null;
}
/** Geburtstags-Rolle für den Tag — leer = keine Rolle */
export function birthdayRoleId() {
return getSetting('birthday_role_id') || null;
}
/** Kanal für Feature-Wünsche (/wunsch) — leer = Feature aus */
export function votingChannelId() {
return getSetting('voting_channel_id') || null;
+19 -2
View File
@@ -1,7 +1,7 @@
// REST-API fürs Webinterface: Devlogs öffentlich, Commits + Settings nur für den Admin
import { EmbedBuilder } from 'discord.js';
import {
listDevlogs, searchDevlogs, listCommits, listReleases, archiveStats,
listDevlogs, searchDevlogs, getDevlog, listCommits, listReleases, archiveStats,
getSetting, setSetting, createApiKey, listApiKeys, deleteApiKey,
listGallery, listPlaytesters, topWishes, commitHeatmap,
createRoleMenu, updateRoleMenu, getRoleMenu, listRoleMenus, deleteRoleMenu,
@@ -89,6 +89,18 @@ export function registerApiRoutes(app, client) {
return { items: mapped, total, page, pageSize: PAGE_SIZE };
});
// Einzelnes Devlog (Permalink-Seite /devlogs/:id)
app.get('/api/devlogs/:id', async (request, reply) => {
const item = getDevlog(String(request.params.id));
if (!item) return reply.code(404).send({ error: 'Devlog nicht gefunden' });
return {
item: {
...item,
images: JSON.parse(item.images || '[]').map((f) => `/devlog-assets/${f}`),
},
};
});
// Changelog — öffentlich (Releases aller Repos)
app.get('/api/releases', async (request) => {
const { limit, offset, page } = paging(request);
@@ -339,6 +351,8 @@ ${rssItems}
server_alert_channel_id: getSetting('server_alert_channel_id') ?? '',
member_gate_enabled: getSetting('member_gate_enabled') !== '0',
discord_invite_url: getSetting('discord_invite_url') ?? '',
birthday_channel_id: getSetting('birthday_channel_id') ?? '',
birthday_role_id: getSetting('birthday_role_id') ?? '',
};
}
@@ -385,6 +399,7 @@ ${rssItems}
'screenshot_channel_id', 'modmail_channel_id', 'welcome_channel_id',
'modlog_channel_id', 'status_channel_id', 'voting_channel_id', 'ticket_channel_id',
'events_announce_channel_id', 'social_announce_channel_id', 'server_alert_channel_id',
'birthday_channel_id',
];
for (const key of ['commit_channel_id', 'devlog_channel_id', ...OPTIONAL_CHANNELS]) {
if (body[key] === undefined) continue;
@@ -400,7 +415,7 @@ ${rssItems}
setSetting(key, value);
}
// Rollen: müssen existieren ('' = Feature aus)
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id']) {
for (const key of ['devlog_ping_role_id', 'playtester_role_id', 'autorole_id', 'birthday_role_id']) {
if (body[key] === undefined) continue;
const value = String(body[key]);
if (value !== '') {
@@ -1005,6 +1020,8 @@ ${rssItems}
}
const message = await channel.send(payload);
const { maybeCrosspost } = await import('../embeds.js');
await maybeCrosspost(message);
request.log.info(`Composer: Nachricht ${message.id} gesendet`);
logAudit(getSessionUser(request), 'composer gesendet', `#${channel.name ?? channelId}`);
return { ok: true, edited: false, message_id: message.id };
+2
View File
@@ -12,6 +12,7 @@ import { config } from '../config.js';
import { saveDevlog } from '../db.js';
import { devlogChannelId, devlogPingRoleId, devlogThreadsEnabled, publicUrl, brandColor, brandFooter } from '../runtime-settings.js';
import { imagesDir } from '../bot/devlog-archive.js';
import { maybeCrosspost } from '../embeds.js';
const MAX_IMAGES = 4;
@@ -153,6 +154,7 @@ export function registerDevlogEndpoint(app, client) {
? { content: `<@&${pingRole}>`, allowedMentions: { roles: [pingRole] } }
: {}),
});
await maybeCrosspost(message);
// Diskussions-Thread unterm Devlog (Fehler nicht fatal — z. B. fehlende Rechte)
if (devlogThreadsEnabled() && typeof message.startThread === 'function') {
+3 -1
View File
@@ -65,7 +65,9 @@ export async function scheduledPostsTick(client) {
try {
const channel = await client.channels.fetch(post.channel_id).catch(() => null);
if (channel?.isTextBased()) {
await channel.send(buildScheduledPayload(post));
const message = await channel.send(buildScheduledPayload(post));
const { maybeCrosspost } = await import('../embeds.js');
await maybeCrosspost(message);
console.log(`[scheduled] Post ${post.id} → #${channel.name ?? post.channel_id}`);
} else {
console.warn(`[scheduled] Post ${post.id}: Kanal ${post.channel_id} nicht erreichbar`);
+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' });
});