diff --git a/docs/konzept-zwei-seiten.md b/docs/konzept-zwei-seiten.md index ad7208c..510015a 100644 --- a/docs/konzept-zwei-seiten.md +++ b/docs/konzept-zwei-seiten.md @@ -1,6 +1,24 @@ # Konzept: Bot-Produktseite und Community-Hub trennen -**Stand:** 31.07.2026 · Abgestimmt, Umsetzung läuft. +**Stand:** 31.07.2026 · Umgesetzt. Zum Aktivieren müssen nur noch die beiden +Adressen im Setup eingetragen werden (System → Zwei Seiten). + +## Aktivieren + +1. **Setup → System → Zwei Seiten** ausfüllen: + * Community-Hub: `https://hub.d4rkst3r.de` + * Bot-Produktseite: `https://bot.d4rkst3r.de` + * Login-Domain: `.d4rkst3r.de` (damit die Anmeldung auf beiden gilt) +2. **Discord-Portal:** unter *OAuth2 → Redirects* zusätzlich + `https://hub.d4rkst3r.de/auth/callback` eintragen +3. Fertig — ab dem Speichern greift die Trennung sofort. + +Solange die Felder leer bleiben, verhält sich alles wie vorher. Das Umschalten +ist jederzeit reversibel: Felder leeren, und beide Domains zeigen wieder +dieselbe Seite. + +--- + ## Warum diff --git a/frontend/src/BotApp.jsx b/frontend/src/BotApp.jsx new file mode 100644 index 0000000..9a1bc22 --- /dev/null +++ b/frontend/src/BotApp.jsx @@ -0,0 +1,106 @@ +// Rahmen der Bot-Produktseite — eigene Navigation, orange Leitfarbe. +// Das Dashboard ist dieselbe Setup-Seite wie bisher, nur unter /dashboard. +import { useEffect, useState } from 'react'; +import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'; +import { apiGet } from './api.js'; +import BotLanding from './pages/BotLanding.jsx'; +import BotCommands from './pages/BotCommands.jsx'; +import Settings from './pages/Settings.jsx'; +import Impressum from './pages/Impressum.jsx'; +import Datenschutz from './pages/Datenschutz.jsx'; +import { IconMenu, IconX } from './icons.jsx'; + +export default function BotApp() { + const [me, setMe] = useState({ user: null, admin: false, scopes: [], loading: true }); + const [scrolled, setScrolled] = useState(false); + const [drawer, setDrawer] = useState(false); + const [hub, setHub] = useState(null); + const location = useLocation(); + + useEffect(() => { setDrawer(false); }, [location.pathname]); + + useEffect(() => { + apiGet('/api/me') + .then((data) => setMe({ scopes: [], ...data, loading: false })) + .catch(() => setMe({ user: null, admin: false, scopes: [], loading: false })); + apiGet('/api/legal').then((d) => setHub(d.publicUrl)).catch(() => {}); + }, []); + + useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 24); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + const canDashboard = me.admin || me.scopes?.length > 0; + + return ( +
+
+ + D4RKBOT. + + + + +
+ {me.loading ? null : me.user ? ( + + {me.user.avatar && } + {me.user.username} + + + + + ) : ( + Anmelden + )} +
+ + +
+ + {drawer && ( + + )} + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + +
+ ); +} diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index 51c543e..6662865 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -43,6 +43,7 @@ export const IconBrand = make(Palette); export const IconFeeds = make(Megaphone); export const IconCommunity = make(Users); export const IconRoles = make(Shield); +export const IconShield = make(Shield); export const IconSupport = make(Ticket); export const IconApplications = make(FileText); export const IconComposer = make(Pencil); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 988887a..11a6edb 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,6 +2,8 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import App from './App.jsx'; +import BotApp from './BotApp.jsx'; +import { IS_BOT } from './site.js'; import '@fontsource/bebas-neue'; import '@fontsource/barlow-condensed/300.css'; import '@fontsource/barlow-condensed/400.css'; @@ -9,10 +11,12 @@ import '@fontsource/barlow-condensed/600.css'; import '@fontsource/share-tech-mono'; import './style.css'; +// Zwei Gesichter, eine Anwendung: Der Server sagt über data-site am , +// ob gerade die Produktseite oder der Community-Hub gefragt ist. ReactDOM.createRoot(document.getElementById('root')).render( - + {IS_BOT ? : } ); diff --git a/frontend/src/pages/BotCommands.jsx b/frontend/src/pages/BotCommands.jsx new file mode 100644 index 0000000..3885a9b --- /dev/null +++ b/frontend/src/pages/BotCommands.jsx @@ -0,0 +1,74 @@ +// Übersicht aller Slash-Befehle des Bots. +import { useEffect } from 'react'; +import { IconLock } from '../icons.jsx'; + +const GROUPS = [ + ['Für alle', [ + ['/rank', 'Zeigt deinen Rang, deine XP und den Fortschritt zum nächsten Level.'], + ['/wunsch idee:…', 'Reicht einen Feature-Wunsch ein — landet im Voting-Kanal und auf der Roadmap.'], + ['/bug titel:… beschreibung:…', 'Meldet einen Fehler als Issue im Repository. Wird er geschlossen, bekommst du eine Nachricht.'], + ['/geburtstag setzen tag:… monat:…', 'Trägt deinen Geburtstag ein. Der Bot gratuliert morgens und vergibt eine Rolle für den Tag.'], + ['/geburtstag entfernen', 'Trägt ihn wieder aus.'], + ['/remind dauer:… text:…', 'Erinnert dich per Direktnachricht — etwa „2h" oder „30m".'], + ['/tag name:…', 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.'], + ['/ping', 'Lebenszeichen des Bots.'], + ]], + ['Moderation', [ + ['/warn user:… grund:…', 'Verwarnt jemanden, schickt eine Nachricht und schreibt es ins Protokoll.'], + ['/warns user:…', 'Zeigt die Verwarnungs-Historie.'], + ['/timeout user:… dauer:…', 'Setzt eine Auszeit.'], + ['/purge anzahl:…', 'Löscht mehrere Nachrichten auf einmal.'], + ]], + ['Einrichtung', [ + ['/giveaway preis:… dauer:… gewinner:…', 'Startet eine Verlosung mit Teilnahme-Knopf und automatischer Ziehung.'], + ['/ticket-setup', 'Postet den Knopf, über den Support-Tickets als private Threads entstehen.'], + ['/playtester-setup', 'Postet den Bewerbungs-Knopf für das Playtester-Programm.'], + ['/devlog-backfill', 'Holt ältere Devlog-Nachrichten nachträglich ins Archiv.'], + ['/galerie-backfill', 'Dasselbe für den Screenshot-Kanal.'], + ]], +]; + +export default function BotCommands() { + useEffect(() => { window.scrollTo(0, 0); }, []); + + return ( + <> +
+
BEFEHLE
+
+ +
+ {GROUPS.map(([group, commands]) => ( +
+

+ // {group} + {group === 'Moderation' && } + {group === 'Einrichtung' && } +

+
+ {commands.map(([name, desc]) => ( +
+ {name} + {desc} +
+ ))} +
+
+ ))} +

+ Moderations- und Einrichtungs-Befehle sind Mitgliedern mit den passenden + Discord-Rechten vorbehalten. +

+
+ + ); +} diff --git a/frontend/src/pages/BotLanding.jsx b/frontend/src/pages/BotLanding.jsx new file mode 100644 index 0000000..3039493 --- /dev/null +++ b/frontend/src/pages/BotLanding.jsx @@ -0,0 +1,119 @@ +// Produktseite des Bots: was er kann, wie er aussieht, wo es weitergeht. +// Bewusst ohne „Einladen"-Button — der Bot ist auf einen Server ausgelegt. +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { apiGet } from '../api.js'; +import { + IconDevlog, IconLevel, IconRoles, IconServer, IconSupport, IconGiveaway, + IconBirthday, IconShield, IconMagic, IconKey, IconArrowRight, IconBot, +} from '../icons.jsx'; + +const HIGHLIGHTS = [ + [IconDevlog, 'Devlogs & Feeds', 'Entwicklungs-Berichte landen als gebrandetes Embed im Discord und werden gleichzeitig durchsuchbar archiviert. Commits und Releases aus Gitea genauso.'], + [IconServer, 'Server-Monitor', 'Über 300 Spiele per gamedig, dazu FiveM und einfache Erreichbarkeits-Checks. Ein Live-Embed pro Server, Alarm bei Ausfall, Spielerzahl in der Bot-Präsenz.'], + [IconLevel, 'Level & Aktivität', 'XP fürs Mitreden mit Rollen-Belohnungen, öffentliche Bestenliste und Aktivitäts-Statistik des Servers.'], + [IconRoles, 'Rollen-Menüs', 'Selbstbedienung per Knopfdruck — im Discord und im Browser. Farben pro Knopf, wahlweise exklusiv.'], + [IconSupport, 'Support & Kontakt', 'Tickets als private Threads mit Gesprächsprotokoll, dazu Modmail: eine Direktnachricht an den Bot landet beim Team.'], + [IconShield, 'Moderation', 'Verwarnungen mit Verlauf, Auszeiten, Aufräumen — alles im Mod-Log, dazu Protokoll für Beitritte, Namens- und Rollenwechsel.'], + [IconGiveaway, 'Verlosungen', 'Teilnahme per Knopf, automatische Ziehung, Neuauslosung wenn sich niemand meldet.'], + [IconBirthday, 'Geburtstage', 'Wer mag, trägt seinen Tag ein — der Bot gratuliert morgens und vergibt eine Rolle für den Tag.'], + [IconMagic, 'Willkommens-Karten', 'Neue Mitglieder bekommen ein gerendertes Bild mit Avatar und Mitglieds-Nummer.'], +]; + +export default function BotLanding() { + const [status, setStatus] = useState(null); + const [hub, setHub] = useState(null); + + useEffect(() => { + apiGet('/api/legal').then((d) => setHub(d)).catch(() => {}); + apiGet('/api/serverstats').then(setStatus).catch(() => {}); + window.scrollTo(0, 0); + }, []); + + return ( + <> +
+
BOT
+
+ +
+
+
+ 16 + Slash-Befehle +
+
+ 300+ + Spiele im Monitor +
+
+ 0 + Kosten, keine Premium-Stufen +
+
+ +

// Was er kann

+
+ {HIGHLIGHTS.map(([Icon, title, text]) => ( +
+ + {title} + {text} +
+ ))} +
+ +
+

// Für Entwickler

+
+
+ + API & Single Sign-On + + Eigene Skripte posten über die API v1 mit Bearer-Schlüsseln und Bereichs-Rechten. + Andere Dienste nutzen den Discord-Login des Bots mit und bekommen die Rollen + gleich mitgeliefert. + +
+
+ + Läuft überall + + Node und SQLite in einem Container, ein Volume für die Daten. + Nächtliche Sicherungen inklusive — von der Datenbank und allen Repositories. + +
+
+
+ + {hub?.publicUrl && ( +

+ Der Bot läuft für die D4RKST3R-Community + {status?.members ? ` mit ${status.members} Mitgliedern` : ''} — + {' '}zum Community-Hub. +

+ )} +
+ + ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 441fd27..520dc48 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1311,6 +1311,28 @@ export default function Settings({ me }) { setForm({ ...form, gitea_url: e.target.value })} /> + + +
+

// Zwei Seiten

+

+ Community-Hub und Bot-Produktseite auf getrennte Adressen legen. Solange + beide Felder leer sind, läuft alles unverändert auf der öffentlichen URL. + Sind sie gesetzt, leitet der Server falsch aufgerufene Seiten automatisch + auf die richtige Adresse um. +

+
+ + setForm({ ...form, hub_url: e.target.value })} /> +
+
+ + setForm({ ...form, bot_url: e.target.value })} /> +
+
+ + setForm({ ...form, cookie_domain: e.target.value })} /> +
setForm({ ...form, bug_report_repo: e.target.value })} /> diff --git a/frontend/src/site.js b/frontend/src/site.js new file mode 100644 index 0000000..041c0ff --- /dev/null +++ b/frontend/src/site.js @@ -0,0 +1,8 @@ +// Welche der beiden Seiten läuft gerade? +// +// Der Server schreibt beim Ausliefern ein data-site="bot|hub" an den . +// Fehlt das (alter Build, lokale Entwicklung), fällt es auf den Hub zurück — +// so verhält sich alles wie bisher, solange die Domains nicht getrennt sind. +export const SITE = document.body?.dataset?.site === 'bot' ? 'bot' : 'hub'; +export const IS_BOT = SITE === 'bot'; +export const IS_HUB = SITE === 'hub'; diff --git a/frontend/src/style.css b/frontend/src/style.css index ca9054c..fc0ff23 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1569,3 +1569,37 @@ button.with-icon, a.with-icon { justify-content: center; } max-height: 26rem; overflow-y: auto; background: var(--bg2); } + +/* ── BOT-PRODUKTSEITE ──────────────────────────────── */ +/* Gleiche Marke, aber Orange als Leitfarbe — beim Domainwechsel sofort erkennbar */ +.site-bot { + --neon: #ff4d00; + --neon2: #f5c518; + --border: rgba(255, 77, 0, 0.15); +} +.site-bot .page-bg-text { color: rgba(255, 77, 0, .045); } +.site-bot .page-header-grid { + background-image: + linear-gradient(rgba(255, 77, 0, .12) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 77, 0, .12) 1px, transparent 1px); +} +.site-bot .particle { background: #ff4d00; } +.bot-hero { min-height: 460px; } + +/* Befehlsliste */ +.cmd-list { display: flex; flex-direction: column; gap: .5rem; } +.cmd-row { + display: flex; gap: 1.2rem; align-items: baseline; flex-wrap: wrap; + background: var(--bg2); border: 1px solid rgba(255, 255, 255, .06); + border-radius: 10px; padding: .7rem 1rem; + transition: border-color .2s, background .2s; +} +.cmd-row:hover { border-color: var(--border); background: var(--bg3); } +.cmd-name { + font-family: var(--mono); font-size: .82rem; color: var(--neon); + white-space: nowrap; flex: none; min-width: 15rem; +} +.cmd-desc { color: var(--muted); font-weight: 300; flex: 1; min-width: 15rem; } +@media (max-width: 640px) { + .cmd-name { min-width: 0; } +} diff --git a/src/runtime-settings.js b/src/runtime-settings.js index 32c811c..b448146 100644 --- a/src/runtime-settings.js +++ b/src/runtime-settings.js @@ -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(/\/$/, ''); diff --git a/src/web/api.js b/src/web/api.js index fae0132..a4383f5 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -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; diff --git a/src/web/auth.js b/src/web/auth.js index 95149af..a1e9e60 100644 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -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('/'); }); } diff --git a/src/web/server.js b/src/web/server.js index 9a809dd..da8ce8b 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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('&', '&').replaceAll('<', '<').replaceAll('"', '"'); - 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) { ``, ``, ``, - ``, + ``, ``, image ? `` : '', image ? `` : '', ].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('', ` ${ogTagsFor(request.url)}\n`); + 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('', ` ${ogTagsFor(request.url, botSite)}\n`) + .replace('', ``); return reply.type('text/html; charset=utf-8').send(html); };