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
+19 -1
View File
@@ -1,6 +1,24 @@
# Konzept: Bot-Produktseite und Community-Hub trennen # 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 ## Warum
+106
View File
@@ -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 (
<div className="app site-bot">
<header className={`nav ${scrolled ? 'scrolled' : ''}`}>
<NavLink to="/" className="nav-logo">
D4RKBOT<span>.</span>
</NavLink>
<nav className="nav-links">
<NavLink to="/features">Funktionen</NavLink>
<NavLink to="/commands">Befehle</NavLink>
{canDashboard && <NavLink to="/dashboard">Dashboard</NavLink>}
{hub && <a href={hub}>Community</a>}
</nav>
<div className="auth">
{me.loading ? null : me.user ? (
<span className="user-chip">
{me.user.avatar && <img className="avatar" src={me.user.avatar} alt="" />}
<span className="username">{me.user.username}</span>
<a className="logout" href="/auth/logout" title="Abmelden">
<IconX size={13} />
</a>
</span>
) : (
<a className="btn-login" href="/auth/login">Anmelden</a>
)}
</div>
<button className="burger" onClick={() => setDrawer((d) => !d)} aria-label="Menü">
{drawer ? <IconX size={20} /> : <IconMenu size={20} />}
</button>
</header>
{drawer && (
<nav className="drawer" onClick={() => setDrawer(false)}>
<NavLink to="/features">Funktionen</NavLink>
<NavLink to="/commands">Befehle</NavLink>
{canDashboard && <NavLink to="/dashboard">Dashboard</NavLink>}
{hub && <a href={hub}>Community</a>}
{me.user
? <a href="/auth/logout">Abmelden</a>
: <a href="/auth/login">Anmelden</a>}
</nav>
)}
<main className="main">
<Routes>
<Route path="/" element={<BotLanding />} />
<Route path="/features" element={<BotLanding />} />
<Route path="/commands" element={<BotCommands />} />
<Route path="/dashboard" element={<Settings me={me} />} />
<Route path="/impressum" element={<Impressum />} />
<Route path="/datenschutz" element={<Datenschutz />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<footer className="footer">
<span className="footer-logo">D4RKBOT<span>.</span></span>
<span className="footer-sep">//</span>
<a href="https://git.d4rkst3r.de/D4rkst3r/d4rkbot" target="_blank" rel="noreferrer">
Quellcode
</a>
<span className="footer-sep">//</span>
<NavLink to="/impressum">Impressum</NavLink>
<NavLink to="/datenschutz">Datenschutz</NavLink>
</footer>
</div>
);
}
+1
View File
@@ -43,6 +43,7 @@ export const IconBrand = make(Palette);
export const IconFeeds = make(Megaphone); export const IconFeeds = make(Megaphone);
export const IconCommunity = make(Users); export const IconCommunity = make(Users);
export const IconRoles = make(Shield); export const IconRoles = make(Shield);
export const IconShield = make(Shield);
export const IconSupport = make(Ticket); export const IconSupport = make(Ticket);
export const IconApplications = make(FileText); export const IconApplications = make(FileText);
export const IconComposer = make(Pencil); export const IconComposer = make(Pencil);
+5 -1
View File
@@ -2,6 +2,8 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx'; import App from './App.jsx';
import BotApp from './BotApp.jsx';
import { IS_BOT } from './site.js';
import '@fontsource/bebas-neue'; import '@fontsource/bebas-neue';
import '@fontsource/barlow-condensed/300.css'; import '@fontsource/barlow-condensed/300.css';
import '@fontsource/barlow-condensed/400.css'; import '@fontsource/barlow-condensed/400.css';
@@ -9,10 +11,12 @@ import '@fontsource/barlow-condensed/600.css';
import '@fontsource/share-tech-mono'; import '@fontsource/share-tech-mono';
import './style.css'; import './style.css';
// Zwei Gesichter, eine Anwendung: Der Server sagt über data-site am <body>,
// ob gerade die Produktseite oder der Community-Hub gefragt ist.
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<App /> {IS_BOT ? <BotApp /> : <App />}
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
+74
View File
@@ -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 (
<>
<section className="page-header">
<div className="page-bg-text">BEFEHLE</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Referenz</div>
<h1 className="page-title">Befehle</h1>
<p className="page-subtitle">
Alle Slash-Befehle im Überblick. Vieles läuft zusätzlich über Knöpfe
direkt in den Nachrichten.
</p>
</div>
</section>
<section className="content">
{GROUPS.map(([group, commands]) => (
<div className="roadmap-group" key={group}>
<h2 className="settings-title">
// {group}
{group === 'Moderation' && <IconLock size={13} />}
{group === 'Einrichtung' && <IconLock size={13} />}
</h2>
<div className="cmd-list">
{commands.map(([name, desc]) => (
<div className="cmd-row" key={name}>
<code className="cmd-name">{name}</code>
<span className="cmd-desc">{desc}</span>
</div>
))}
</div>
</div>
))}
<p className="notice">
Moderations- und Einrichtungs-Befehle sind Mitgliedern mit den passenden
Discord-Rechten vorbehalten.
</p>
</section>
</>
);
}
+119
View File
@@ -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 (
<>
<section className="page-header bot-hero">
<div className="page-bg-text">BOT</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">// Selbst gebaut, keine Paywall</div>
<h1 className="page-title">
Ein Discord-Bot,<br />der alles macht
</h1>
<p className="page-subtitle">
Devlogs, Level, Moderation, Server-Monitoring, Verlosungen, Tickets
was sonst drei Bots mit Abo-Modell erledigen, läuft hier in einem,
mit eigenem Webinterface.
</p>
<div className="home-cta">
<Link className="btn btn-save with-icon" to="/features">
Alle Funktionen <IconArrowRight size={15} />
</Link>
<Link className="btn btn-ghost with-icon" to="/commands">
<IconBot /> Befehle ansehen
</Link>
</div>
</div>
</section>
<section className="content">
<div className="srv-summary">
<div className="srv-sum-item">
<span className="srv-sum-n">16</span>
<span className="srv-sum-label">Slash-Befehle</span>
</div>
<div className="srv-sum-item">
<span className="srv-sum-n">300<span className="srv-sum-of">+</span></span>
<span className="srv-sum-label">Spiele im Monitor</span>
</div>
<div className="srv-sum-item">
<span className="srv-sum-n">0<span className="srv-sum-of"> </span></span>
<span className="srv-sum-label">Kosten, keine Premium-Stufen</span>
</div>
</div>
<h2 className="settings-title" style={{ marginTop: '2.5rem' }}>// Was er kann</h2>
<div className="home-tiles">
{HIGHLIGHTS.map(([Icon, title, text]) => (
<div className="card home-tile" key={title}>
<span className="home-tile-icon"><Icon size={24} /></span>
<span className="home-tile-title">{title}</span>
<span className="home-tile-desc">{text}</span>
</div>
))}
</div>
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
<h2 className="settings-title">// Für Entwickler</h2>
<div className="home-tiles">
<div className="card home-tile">
<span className="home-tile-icon"><IconKey size={24} /></span>
<span className="home-tile-title">API &amp; Single Sign-On</span>
<span className="home-tile-desc">
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.
</span>
</div>
<div className="card home-tile">
<span className="home-tile-icon"><IconServer size={24} /></span>
<span className="home-tile-title">Läuft überall</span>
<span className="home-tile-desc">
Node und SQLite in einem Container, ein Volume für die Daten.
Nächtliche Sicherungen inklusive von der Datenbank und allen Repositories.
</span>
</div>
</div>
</div>
{hub?.publicUrl && (
<p className="notice" style={{ marginTop: '2rem' }}>
Der Bot läuft für die D4RKST3R-Community
{status?.members ? ` mit ${status.members} Mitgliedern` : ''}
{' '}<a href={hub.publicUrl} style={{ textDecoration: 'underline' }}>zum Community-Hub</a>.
</p>
)}
</section>
</>
);
}
+22
View File
@@ -1311,6 +1311,28 @@ export default function Settings({ me }) {
<label>Gitea-URL</label> <label>Gitea-URL</label>
<input type="text" placeholder="https://git.d4rkst3r.de" value={form.gitea_url} onChange={(e) => setForm({ ...form, gitea_url: e.target.value })} /> <input type="text" placeholder="https://git.d4rkst3r.de" value={form.gitea_url} onChange={(e) => setForm({ ...form, gitea_url: e.target.value })} />
</div> </div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Zwei Seiten</h2>
<p className="section-intro">
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.
</p>
<div className="field">
<label>Community-Hub <span className="field-hint">Devlogs, Roadmap, Server, Profile Ziel für Devlog-Buttons, RSS und Link-Vorschauen</span></label>
<input type="text" placeholder="https://hub.d4rkst3r.de" value={form.hub_url ?? ''} onChange={(e) => setForm({ ...form, hub_url: e.target.value })} />
</div>
<div className="field">
<label>Bot-Produktseite <span className="field-hint">Funktionen, Befehle und dieses Dashboard</span></label>
<input type="text" placeholder="https://bot.d4rkst3r.de" value={form.bot_url ?? ''} onChange={(e) => setForm({ ...form, bot_url: e.target.value })} />
</div>
<div className="field">
<label>Login-Domain <span className="field-hint">z. B. <code>.d4rkst3r.de</code> dann gilt die Anmeldung auf beiden Seiten; leer = getrennte Logins</span></label>
<input type="text" placeholder=".d4rkst3r.de" value={form.cookie_domain ?? ''} onChange={(e) => setForm({ ...form, cookie_domain: e.target.value })} />
</div>
<div className="field"> <div className="field">
<label>Bug-Repo <span className="field-hint">Ziel für /bug-Issues{data.status.giteaTokenConfigured ? '' : ' — ⚠️ kein GITEA_API_TOKEN, /bug ist aus'}</span></label> <label>Bug-Repo <span className="field-hint">Ziel für /bug-Issues{data.status.giteaTokenConfigured ? '' : ' — ⚠️ kein GITEA_API_TOKEN, /bug ist aus'}</span></label>
<input type="text" placeholder="D4rkst3r/EcoGame" value={form.bug_report_repo} onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })} /> <input type="text" placeholder="D4rkst3r/EcoGame" value={form.bug_report_repo} onChange={(e) => setForm({ ...form, bug_report_repo: e.target.value })} />
+8
View File
@@ -0,0 +1,8 @@
// Welche der beiden Seiten läuft gerade?
//
// Der Server schreibt beim Ausliefern ein data-site="bot|hub" an den <body>.
// 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';
+34
View File
@@ -1569,3 +1569,37 @@ button.with-icon, a.with-icon { justify-content: center; }
max-height: 26rem; overflow-y: auto; max-height: 26rem; overflow-y: auto;
background: var(--bg2); 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; }
}
+18
View File
@@ -16,6 +16,24 @@ export function publicUrl() {
return (getSetting('public_url') || config.publicUrl).replace(/\/$/, ''); 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) */ /** Gitea-Basis-URL (API für /bug) */
export function giteaUrl() { export function giteaUrl() {
return (getSetting('gitea_url') || config.giteaUrl).replace(/\/$/, ''); return (getSetting('gitea_url') || config.giteaUrl).replace(/\/$/, '');
+20
View File
@@ -428,6 +428,9 @@ ${rssItems}
roadmap_repo: getSetting('roadmap_repo') ?? 'D4rkst3r/EcoGame', roadmap_repo: getSetting('roadmap_repo') ?? 'D4rkst3r/EcoGame',
watchdog_urls: getSetting('watchdog_urls') ?? '', watchdog_urls: getSetting('watchdog_urls') ?? '',
public_url: publicUrl(), 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, gitea_url: getSetting('gitea_url') ?? config.giteaUrl,
backup_enabled: getSetting('backup_enabled') !== '0', backup_enabled: getSetting('backup_enabled') !== '0',
repo_backup_enabled: getSetting('repo_backup_enabled') === '1', repo_backup_enabled: getSetting('repo_backup_enabled') === '1',
@@ -508,6 +511,7 @@ ${rssItems}
'gitea_api_token', 'twitch_client_id', 'twitch_client_secret', 'gitea_api_token', 'twitch_client_id', 'twitch_client_secret',
'discord_guild_id', 'bot_name', 'bot_description', 'public_url', 'gitea_url', 'discord_guild_id', 'bot_name', 'bot_description', 'public_url', 'gitea_url',
'legal_name', 'legal_address', 'legal_email', 'legal_extra', 'legal_name', 'legal_address', 'legal_email', 'legal_extra',
'hub_url', 'bot_url', 'cookie_domain',
]) delete body[key]; ]) delete body[key];
} }
@@ -670,6 +674,22 @@ ${rssItems}
for (const key of ['legal_name', 'legal_address', 'legal_email', 'legal_extra']) { 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)); 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 // URLs: müssen mit http(s) beginnen
for (const key of ['public_url', 'gitea_url']) { for (const key of ['public_url', 'gitea_url']) {
if (body[key] === undefined) continue; if (body[key] === undefined) continue;
+33 -3
View File
@@ -1,11 +1,12 @@
// Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie // Discord-OAuth2-Login: /auth/login → Discord → /auth/callback → signiertes Session-Cookie
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { config } from '../config.js'; 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 DISCORD_API = 'https://discord.com/api/v10';
const SESSION_COOKIE = 'd4rkbot_session'; const SESSION_COOKIE = 'd4rkbot_session';
const STATE_COOKIE = 'd4rkbot_oauth_state'; 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!) // Dynamisch, damit die Setup-Seite die URL ändern kann (Redirect auch im Dev-Portal eintragen!)
const redirectUri = () => `${publicUrl()}/auth/callback`; const redirectUri = () => `${publicUrl()}/auth/callback`;
@@ -50,10 +51,20 @@ export function registerAuthRoutes(app, client) {
scope: 'identify', scope: 'identify',
state, 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 return reply
.setCookie(STATE_COOKIE, state, { .setCookie(STATE_COOKIE, state, {
path: '/auth', httpOnly: true, sameSite: 'lax', maxAge: 600, signed: true, 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}`); .redirect(`https://discord.com/oauth2/authorize?${params}`);
}); });
@@ -109,15 +120,34 @@ export function registerAuthRoutes(app, client) {
const { takeReturnTo } = await import('./sso.js'); const { takeReturnTo } = await import('./sso.js');
const returnTo = takeReturnTo(request, reply); 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 return reply
.clearCookie(STATE_COOKIE, { path: '/auth' }) .clearCookie(STATE_COOKIE, { path: '/auth' })
.clearCookie(ORIGIN_COOKIE, { path: '/' })
.setCookie(SESSION_COOKIE, JSON.stringify(session), { .setCookie(SESSION_COOKIE, JSON.stringify(session), {
path: '/', httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600, signed: true, 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) => { 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 { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { config } from '../config.js'; import { config } from '../config.js';
import { saveCommits, saveRelease, takeBugReport, getDevlog } from '../db.js'; import { saveCommits, saveRelease, takeBugReport, getDevlog, getPage } from '../db.js';
import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName } from '../runtime-settings.js'; import { commitFeedEnabled, branchAllowed, repoIgnored, publicUrl, brandName, hubUrl, botUrl } from '../runtime-settings.js';
import { postPushEmbed } from '../bot/commit-feed.js'; import { postPushEmbed } from '../bot/commit-feed.js';
import { postReleaseEmbed } from '../bot/release-feed.js'; import { postReleaseEmbed } from '../bot/release-feed.js';
import { registerAuthRoutes } from './auth.js'; import { registerAuthRoutes } from './auth.js';
@@ -100,13 +100,21 @@ export async function startWebServer(client) {
const indexHtml = readFileSync(join(frontendDist, 'index.html'), 'utf8'); const indexHtml = readFileSync(join(frontendDist, 'index.html'), 'utf8');
const esc = (s) => String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('"', '&quot;'); const esc = (s) => String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('"', '&quot;');
function ogTagsFor(url) { function ogTagsFor(url, isBotSite) {
const base = publicUrl(); const base = isBotSite ? botUrl() : hubUrl();
let title = `${brandName()} // Community-Hub`; let title = isBotSite
let description = 'Devlogs, Roadmap, Server-Status und alles aus der Community — direkt aus dem Discord.'; ? `${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; 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.'], '/devlogs': ['Devlog-Archiv', 'Entwicklungs-Updates, automatisch archiviert.'],
'/roadmap': ['Roadmap', 'Meilensteine und Community-Wünsche zum Abstimmen.'], '/roadmap': ['Roadmap', 'Meilensteine und Community-Wünsche zum Abstimmen.'],
'/server': ['Server-Status', 'Alle Game-Server live — Spielerzahlen und Verlauf.'], '/server': ['Server-Status', 'Alle Game-Server live — Spielerzahlen und Verlauf.'],
@@ -128,6 +136,14 @@ export async function startWebServer(client) {
} else if (pageTitles[path]) { } else if (pageTitles[path]) {
title = `${brandName()} // ${pageTitles[path][0]}`; title = `${brandName()} // ${pageTitles[path][0]}`;
description = pageTitles[path][1]; 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 [ return [
@@ -136,15 +152,46 @@ export async function startWebServer(client) {
`<meta property="og:description" content="${esc(description)}">`, `<meta property="og:description" content="${esc(description)}">`,
`<meta property="og:url" content="${esc(base + path)}">`, `<meta property="og:url" content="${esc(base + path)}">`,
`<meta property="og:type" content="website">`, `<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)}">`, `<meta name="description" content="${esc(description)}">`,
image ? `<meta property="og:image" content="${esc(image)}">` : '', image ? `<meta property="og:image" content="${esc(image)}">` : '',
image ? `<meta name="twitter:card" content="summary_large_image">` : '<meta name="twitter:card" content="summary">', image ? `<meta name="twitter:card" content="summary_large_image">` : '<meta name="twitter:card" content="summary">',
].filter(Boolean).join('\n '); ].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 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); return reply.type('text/html; charset=utf-8').send(html);
}; };