Host-Routing: Bot-Produktseite und Community-Hub getrennt
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 })} />
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(/\/$/, '');
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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('&', '&').replaceAll('<', '<').replaceAll('"', '"');
|
const esc = (s) => String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"');
|
||||||
|
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user