Host-Routing: Bot-Produktseite und Community-Hub getrennt
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Eine Anwendung, zwei Gesichter — der Server erkennt an der Domain, welche
Seite gefragt ist, und schreibt das als data-site an den <body>. Das
Frontend rendert daraufhin entweder den Community-Hub wie bisher oder die
neue Produktseite.

- Bot-Produktseite (BotApp): Landing mit Feature-Übersicht, Befehls-
  referenz, Dashboard unter /dashboard, Orange als Leitfarbe
- Weiterleitungen: Community-Routen auf der Bot-Domain und umgekehrt
  werden dauerhaft (301) auf die richtige Adresse geschickt — geteilte
  Devlog-Permalinks laufen also nicht ins Leere. Rechtstexte bleiben auf
  beiden erreichbar.
- Open-Graph-Tags je Domain, inklusive eigener Vorschau für frei
  angelegte Seiten (Entwürfe bekommen bewusst keine)
- Login: neue Einstellung für die Cookie-Domain, damit die Anmeldung auf
  beiden Seiten gilt; nach dem Discord-Login landet man wieder auf der
  Seite, von der man gestartet ist (vorher immer auf der Hauptadresse)
- Alles greift erst, wenn beide Adressen im Setup eingetragen sind —
  bis dahin verhält sich die Anwendung unverändert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:20:53 +02:00
co-authored by Claude Fable 5
parent e8158ce90b
commit 079c75b184
13 changed files with 515 additions and 14 deletions
+18
View File
@@ -16,6 +16,24 @@ export function publicUrl() {
return (getSetting('public_url') || config.publicUrl).replace(/\/$/, '');
}
/**
* Community-Hub — Ziel für Devlog-Buttons, RSS, Vorschaubilder und die
* geteilte Navigation. Ohne eigene Einstellung bleibt alles wie bisher.
*/
export function hubUrl() {
return (getSetting('hub_url') || publicUrl()).replace(/\/$/, '');
}
/** Bot-Produktseite mit Dashboard */
export function botUrl() {
return (getSetting('bot_url') || publicUrl()).replace(/\/$/, '');
}
/** Cookie-Domain für den Login über mehrere Subdomains (leer = nur diese) */
export function cookieDomain() {
return getSetting('cookie_domain') || null;
}
/** Gitea-Basis-URL (API für /bug) */
export function giteaUrl() {
return (getSetting('gitea_url') || config.giteaUrl).replace(/\/$/, '');
+20
View File
@@ -428,6 +428,9 @@ ${rssItems}
roadmap_repo: getSetting('roadmap_repo') ?? 'D4rkst3r/EcoGame',
watchdog_urls: getSetting('watchdog_urls') ?? '',
public_url: publicUrl(),
hub_url: getSetting('hub_url') ?? '',
bot_url: getSetting('bot_url') ?? '',
cookie_domain: getSetting('cookie_domain') ?? '',
gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
backup_enabled: getSetting('backup_enabled') !== '0',
repo_backup_enabled: getSetting('repo_backup_enabled') === '1',
@@ -508,6 +511,7 @@ ${rssItems}
'gitea_api_token', 'twitch_client_id', 'twitch_client_secret',
'discord_guild_id', 'bot_name', 'bot_description', 'public_url', 'gitea_url',
'legal_name', 'legal_address', 'legal_email', 'legal_extra',
'hub_url', 'bot_url', 'cookie_domain',
]) delete body[key];
}
@@ -670,6 +674,22 @@ ${rssItems}
for (const key of ['legal_name', 'legal_address', 'legal_email', 'legal_extra']) {
if (body[key] !== undefined) setSetting(key, String(body[key]).trim().slice(0, 500));
}
// Getrennte Adressen für Hub und Produktseite (leer = beides gleich)
for (const key of ['hub_url', 'bot_url']) {
if (body[key] === undefined) continue;
const value = String(body[key]).trim().replace(/\/$/, '');
if (value && !/^https?:\/\/.+/.test(value)) {
return reply.code(400).send({ error: `${key}: muss mit http(s):// beginnen` });
}
setSetting(key, value);
}
if (body.cookie_domain !== undefined) {
const value = String(body.cookie_domain).trim();
if (value && !/^\.?[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) {
return reply.code(400).send({ error: 'cookie_domain: z. B. .d4rkst3r.de' });
}
setSetting('cookie_domain', value);
}
// URLs: müssen mit http(s) beginnen
for (const key of ['public_url', 'gitea_url']) {
if (body[key] === undefined) continue;
+33 -3
View File
@@ -1,11 +1,12 @@
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
import crypto from 'node:crypto';
import { config } from '../config.js';
import { publicUrl, discordGuildId, memberGateEnabled } from '../runtime-settings.js';
import { publicUrl, discordGuildId, memberGateEnabled, cookieDomain, hubUrl, botUrl } from '../runtime-settings.js';
const DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'd4rkbot_session';
const STATE_COOKIE = 'd4rkbot_oauth_state';
const ORIGIN_COOKIE = 'd4rkbot_origin';
// Dynamisch, damit die Setup-Seite die URL ändern kann (Redirect auch im Dev-Portal eintragen!)
const redirectUri = () => `${publicUrl()}/auth/callback`;
@@ -50,10 +51,20 @@ export function registerAuthRoutes(app, client) {
scope: 'identify',
state,
});
// Bei getrennten Domains merken, von welcher Seite der Login kam —
// Discord schickt immer zur selben Rücksprung-Adresse zurück.
const host = String(request.headers.host ?? '').toLowerCase().split(':')[0];
const origin = [hubUrl(), botUrl()].find((u) => {
try { return new URL(u).hostname === host; } catch { return false; }
});
return reply
.setCookie(STATE_COOKIE, state, {
path: '/auth', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true,
})
.setCookie(ORIGIN_COOKIE, origin ?? '', {
path: '/', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true,
})
.redirect(`https://discord.com/oauth2/authorize?${params}`);
});
@@ -109,15 +120,34 @@ export function registerAuthRoutes(app, client) {
const { takeReturnTo } = await import('./sso.js');
const returnTo = takeReturnTo(request, reply);
// Sonst zurück zu der Seite, von der der Login gestartet wurde
let origin = null;
const rawOrigin = request.cookies[ORIGIN_COOKIE];
if (rawOrigin) {
const unsigned = request.unsignCookie(rawOrigin);
// Nur die beiden bekannten Adressen zulassen
if (unsigned.valid && [hubUrl(), botUrl()].includes(unsigned.value)) {
origin = unsigned.value;
}
}
// Mit gesetzter Cookie-Domain (z. B. .d4rkst3r.de) gilt die Anmeldung
// auf Hub und Bot-Seite gleichzeitig
const domain = cookieDomain();
return reply
.clearCookie(STATE_COOKIE, { path: '/auth' })
.clearCookie(ORIGIN_COOKIE, { path: '/' })
.setCookie(SESSION_COOKIE, JSON.stringify(session), {
path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true,
...(domain ? { domain } : {}),
})
.redirect(returnTo ?? '/');
.redirect(returnTo ?? (origin ? `${origin}/` : '/'));
});
app.get('/auth/logout', async (request, reply) => {
return reply.clearCookie(SESSION_COOKIE, { path: '/' }).redirect('/');
const domain = cookieDomain();
return reply
.clearCookie(SESSION_COOKIE, { path: '/', ...(domain ? { domain } : {}) })
.redirect('/');
});
}
+56 -9
View File
@@ -8,8 +8,8 @@ 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, getDevlog } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName } from '../runtime-settings.js';
import { saveCommits, saveRelease, takeBugReport, getDevlog, getPage } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName, hubUrl, botUrl } from '../runtime-settings.js';
import { postPushEmbed } from '../bot/commit-feed.js';
import { postReleaseEmbed } from '../bot/release-feed.js';
import { registerAuthRoutes } from './auth.js';
@@ -100,13 +100,21 @@ export async function startWebServer(client) {
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.';
function ogTagsFor(url, isBotSite) {
const base = isBotSite ? botUrl() : hubUrl();
let title = isBotSite
? `${brandName()} // Der Bot`
: `${brandName()} // Community-Hub`;
let description = isBotSite
? 'Devlogs, Level, Moderation, Server-Monitoring — ein selbst gebauter Discord-Bot ohne Paywall.'
: 'Devlogs, Roadmap, Server-Status und alles aus der Community — direkt aus dem Discord.';
let image = null;
const pageTitles = {
const pageTitles = isBotSite ? {
'/features': ['Funktionen', 'Alles, was der Bot kann — von Devlogs bis Server-Monitoring.'],
'/commands': ['Befehle', 'Alle Slash-Commands im Überblick.'],
'/dashboard': ['Dashboard', 'Einstellungen des Bots.'],
} : {
'/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.'],
@@ -128,6 +136,14 @@ export async function startWebServer(client) {
} else if (pageTitles[path]) {
title = `${brandName()} // ${pageTitles[path][0]}`;
description = pageTitles[path][1];
} else if (!isBotSite && /^\/[a-z0-9-]+$/.test(path)) {
// Frei angelegte Seite? Titel und Anriss daraus übernehmen
const page = getPage(path.slice(1));
if (page?.published) {
const prose = page.content.replace(/^[-*>#]\s*/gm, '').replace(/[*`_]/g, '').replace(/\s+/g, ' ').trim();
title = `${brandName()} // ${page.title}`;
description = prose.slice(0, 200) + (prose.length > 200 ? ' …' : '');
}
}
return [
@@ -136,15 +152,46 @@ export async function startWebServer(client) {
`<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="theme-color" content="${isBotSite ? '#ff4d00' : '#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 ');
}
// Welche der beiden Seiten ist gemeint? Solange keine getrennten
// Adressen konfiguriert sind, verhält sich alles wie bisher.
function isBotHost(request) {
const bot = botUrl();
const hub = hubUrl();
if (bot === hub) return false; // noch nicht getrennt
const host = String(request.headers.host ?? '').toLowerCase().split(':')[0];
return host === new URL(bot).hostname;
}
// Routen, die nur auf der Bot-Seite existieren — alles andere gehört zum Hub
const BOT_ROUTES = ['/features', '/commands', '/docs', '/dashboard'];
const SHARED_ROUTES = ['/impressum', '/datenschutz'];
const sendInjected = (request, reply) => {
const html = indexHtml.replace('</head>', ` ${ogTagsFor(request.url)}\n</head>`);
const botSite = isBotHost(request);
const path = request.url.split('?')[0];
// Falsche Domain? Dauerhaft auf die richtige umleiten, damit alte
// Links (geteilte Devlog-Permalinks) nicht ins Leere laufen.
if (botUrl() !== hubUrl() && !SHARED_ROUTES.includes(path)) {
const wantsBot = path === '/' ? false : BOT_ROUTES.some((r) => path.startsWith(r));
if (wantsBot && !botSite) {
return reply.redirect(`${botUrl()}${request.url}`, 301);
}
if (!wantsBot && botSite && path !== '/') {
return reply.redirect(`${hubUrl()}${request.url}`, 301);
}
}
const html = indexHtml
.replace('</head>', ` ${ogTagsFor(request.url, botSite)}\n</head>`)
.replace('<body>', `<body data-site="${botSite ? 'bot' : 'hub'}">`);
return reply.type('text/html; charset=utf-8').send(html);
};