Repo-Backups, Gitea-Theme und Auto-Deploy
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

- Repo-Backups: nachts um 04:00 werden alle Gitea-Repos als git-Bundle
  gesichert — eine Datei pro Repo mit kompletter Historie, aus der sich
  direkt wieder klonen lässt. 14 Tage Rotation wie beim DB-Backup,
  Schalter und "Repos jetzt sichern" im System-Tab. Nutzt den bereits
  vorhandenen Gitea-Token; Dockerfile installiert dafür git mit.
- Gitea-Theme im D4RKST3R-Look (docs/gitea-theme/): Neon-Gelb/Orange auf
  Schwarz, gebaut gegen die Variablen von Gitea 1.26, Diff-Farben bleiben
  lesbar. Einbau-Anleitung liegt daneben.
- Auto-Deploy statt manuellem "Pull and redeploy": Workflow für Gitea
  Actions (prüft Server-Syntax und Frontend-Build, bevor deployed wird)
  plus dokumentierter Runner-loser Weg über den Portainer-Webhook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 14:01:31 +02:00
co-authored by Claude Fable 5
parent c73b4e1074
commit b5617a9d18
10 changed files with 567 additions and 4 deletions
+49
View File
@@ -0,0 +1,49 @@
# Auto-Deploy: prüft den Code und stößt danach das Redeploy in Portainer an.
# Braucht einen laufenden Act-Runner und das Repo-Secret PORTAINER_WEBHOOK
# (Portainer → Stack → Webhook-URL). Siehe docs/auto-deploy.md.
name: Deploy
on:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Code holen
uses: actions/checkout@v4
- name: Node einrichten
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Abhängigkeiten installieren
run: npm ci
- name: Server-Code prüfen
run: |
for f in $(find src -name "*.js"); do
node --check "$f" || exit 1
done
- name: Frontend bauen
run: |
cd frontend
npm ci
npm run build
deploy:
needs: check
runs-on: ubuntu-latest
steps:
- name: Portainer-Redeploy auslösen
run: |
if [ -z "${{ secrets.PORTAINER_WEBHOOK }}" ]; then
echo "PORTAINER_WEBHOOK ist nicht gesetzt — übersprungen."
exit 0
fi
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${{ secrets.PORTAINER_WEBHOOK }}")
echo "Portainer antwortete mit HTTP $code"
[ "$code" -lt 400 ] || exit 1
+3 -2
View File
@@ -10,8 +10,9 @@ RUN npm run build
# Stage 2: Runtime (slim statt alpine: better-sqlite3 liefert glibc-Prebuilds) # Stage 2: Runtime (slim statt alpine: better-sqlite3 liefert glibc-Prebuilds)
FROM node:22-slim FROM node:22-slim
# Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften) # Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften),
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \ # git für die Repo-Backups (git clone --mirror + bundle)
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+5 -2
View File
@@ -50,7 +50,9 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence | | 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence |
| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only; **Audit-Log** im Team-Tab zeigt dem Owner die letzten Aktionen | | 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only; **Audit-Log** im Team-Tab zeigt dem Owner die letzten Aktionen |
| 🔑 **Single Sign-On** | Andere Dienste (Kanban, Platform …) nutzen den Discord-Login des Bots mit und bekommen die Discord-Rollen gleich mitgeliefert — [Anleitung](docs/sso.md) | | 🔑 **Single Sign-On** | Andere Dienste (Kanban, Platform …) nutzen den Discord-Login des Bots mit und bekommen die Discord-Rollen gleich mitgeliefert — [Anleitung](docs/sso.md) |
| 🎨 **Geteiltes Design** | `/brand.css` + `/brand-nav.js` geben jeder anderen App den D4RKST3R-Look samt Navigation — Farben kommen live aus dem Brand-Tab | | 🎨 **Geteiltes Design** | `/brand.css` + `/brand-nav.js` geben jeder anderen App den D4RKST3R-Look samt Navigation — Farben kommen live aus dem Brand-Tab; dazu ein [Gitea-Theme](docs/gitea-theme/) im selben Look |
| 💾 **Repo-Backups** | Nachts werden alle Gitea-Repos als git-Bundle gesichert (komplette Historie, direkt wieder klonbar) |
| ⚡ **Auto-Deploy** | Push auf `main` → Portainer rollt neu aus; optional mit Code-Prüfung über Gitea Actions — [Anleitung](docs/auto-deploy.md) |
| 🏠 **Portal** | Dienste (Gitea, Kanban, Cloud …) im Setup pflegen → Kacheln auf der Startseite und Links in der geteilten Navigation | | 🏠 **Portal** | Dienste (Gitea, Kanban, Cloud …) im Setup pflegen → Kacheln auf der Startseite und Links in der geteilten Navigation |
| ⚖️ **Rechtstexte** | `/impressum` + `/datenschutz` aus dem Setup gepflegt; Member können ihre Daten selbst löschen (DSGVO Art. 17) | | ⚖️ **Rechtstexte** | `/impressum` + `/datenschutz` aus dem Setup gepflegt; Member können ihre Daten selbst löschen (DSGVO Art. 17) |
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite | | 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
@@ -99,7 +101,8 @@ SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig:
- **Composer:** visueller **Embed-Builder mit Live-Discord-Preview** (Autor, Felder, - **Composer:** visueller **Embed-Builder mit Live-Discord-Preview** (Autor, Felder,
Bilder, Farbe, Footer), Nachrichten senden & nachträglich bearbeiten, speicherbare Vorlagen Bilder, Farbe, Footer), Nachrichten senden & nachträglich bearbeiten, speicherbare Vorlagen
- **Bug-Reports / Roadmap:** Ziel-Repos - **Bug-Reports / Roadmap:** Ziel-Repos
- **Backups:** an/aus, Upload-Kanal, „Backup jetzt" - **Backups:** DB nächtlich (an/aus, Upload-Kanal, „Backup jetzt") + **Repo-Backups**
um 04:00 (alle Gitea-Repos als git-Bundle, 14 Tage Rotation, „Repos jetzt sichern")
- **Watchdog:** überwachte URLs - **Watchdog:** überwachte URLs
- **API-Keys:** erstellen (Name + Scopes), widerrufen, last-used - **API-Keys:** erstellen (Name + Scopes), widerrufen, last-used
- **Status-Panel:** Bot-Account, Uptime, Devlog-/Commit-Zahlen, DB-Größe, letztes Backup - **Status-Panel:** Bot-Account, Uptime, Devlog-/Commit-Zahlen, DB-Größe, letztes Backup
+71
View File
@@ -0,0 +1,71 @@
# Auto-Deploy statt „Pull and redeploy"
Bisher: pushen, in Portainer einloggen, Stack suchen, *Pull and redeploy* klicken.
Das geht automatisch — zwei Wege, je nachdem wie viel du willst.
## Weg 1: Portainer-Webhook (2 Minuten, kein Runner nötig) ← empfohlen
Portainer kann einen Stack per HTTP-Aufruf neu ausrollen. Gitea ruft diese
Adresse nach jedem Push auf, fertig.
**In Portainer:**
1. Stack `ecobot` öffnen → Reiter **Editor**
2. **Automatic updates** einschalten, Methode **Webhook**
3. Die erzeugte Webhook-URL kopieren
(Form: `https://portainer.../api/stacks/webhooks/<uuid>`)
**In Gitea:**
1. Repo `d4rkbot`**Einstellungen → Webhooks → Webhook hinzufügen → Gitea**
2. Ziel-URL: die kopierte Portainer-Adresse
3. HTTP-Methode `POST`, Inhaltstyp `application/json`
4. Trigger: **Push-Ereignisse**, Branch-Filter `main`
5. Speichern, dann **Zustellung testen** — Portainer sollte den Stack neu bauen
> Der Stack muss aus dem Git-Repository deployed sein (nicht aus einem
> hochgeladenen Compose-Text), damit Portainer den neuen Stand ziehen kann.
> „Re-pull image" bleibt **aus** — das Image wird ja lokal gebaut.
## Weg 2: Gitea Actions (prüft den Code vor dem Deploy)
Der Workflow in `.gitea/workflows/deploy.yml` installiert die Abhängigkeiten,
prüft alle Server-Dateien auf Syntaxfehler, baut das Frontend — und stößt erst
dann das Redeploy an. Kaputter Code geht damit gar nicht erst live.
**Voraussetzung: ein Act-Runner.** In deiner Instanz sind Actions aktiviert,
aber ohne Runner passiert nichts. Einrichten als zusätzlicher Container:
```yaml
# docker-compose.yml für den Runner
services:
runner:
image: gitea/act_runner:latest
restart: always
environment:
GITEA_INSTANCE_URL: https://git.d4rkst3r.de
GITEA_RUNNER_REGISTRATION_TOKEN: <Token aus Gitea>
GITEA_RUNNER_NAME: d4rk-runner
GITEA_RUNNER_LABELS: ubuntu-latest:docker://node:22-bookworm
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./runner-data:/data
```
Den Registrierungs-Token findest du unter **Site-Administration → Actions →
Runner → Runner erstellen** (oder pro Repo unter *Einstellungen → Actions*).
**Secret hinterlegen:** Repo → *Einstellungen → Actions → Secrets*
`PORTAINER_WEBHOOK` mit der Portainer-Adresse. Fehlt das Secret, läuft nur die
Prüfung und der Deploy-Schritt wird übersprungen.
## Welchen nehmen?
| | Weg 1 (Webhook) | Weg 2 (Actions) |
|---|---|---|
| Aufwand | 2 Minuten | Runner-Container aufsetzen |
| Prüft den Code | nein | ja (Syntax + Frontend-Build) |
| Läuft ohne Zusatzdienst | ja | nein |
Fang mit Weg 1 an. Weg 2 lohnt sich, sobald du Tests hast oder mehrere Leute
pushen — dann verhindert er, dass ein kaputter Stand live geht.
+60
View File
@@ -0,0 +1,60 @@
# D4RKST3R-Theme für Gitea
Färbt Gitea im Brand-Look ein — Neon-Gelb (`#f5c518`) und Orange (`#ff4d00`)
auf Schwarz, passend zu `bot.d4rkst3r.de` und dem geteilten Design-System.
Gebaut gegen die Variablen von **Gitea 1.26**.
## Einbau
Die Datei muss in den `custom`-Ordner deiner Gitea-Instanz. Bei einer
Docker-Installation ist das üblicherweise das gemountete Daten-Volume:
```bash
# Pfad je nach Setup, im offiziellen Image liegt custom unter /data/gitea
mkdir -p /data/gitea/public/assets/css
cp theme-d4rkst3r.css /data/gitea/public/assets/css/
```
Danach Gitea neu starten:
```bash
docker restart gitea
```
Ab Gitea 1.21 wird jede Datei `theme-*.css` in diesem Ordner automatisch als
Theme erkannt. Auswählen kannst du es dann unter **Einstellungen → Aussehen**.
### Als Standard für alle setzen
In der `app.ini`:
```ini
[ui]
DEFAULT_THEME = d4rkst3r
```
Wer schon ein Theme gewählt hat, behält seins — die Einstellung greift für
neue Konten und alle, die nie umgestellt haben.
## Was das Theme ändert
* **Marke:** Primärfarbe Gelb statt Gitea-Blau, Orange als Akzent;
Buttons bekommen dunklen Text für ordentlichen Kontrast
* **Flächen:** durchgehend Schwarz (`#0a0a0a`) statt Dunkelgrau, Karten und
Menüs eine Stufe heller
* **Diffs:** Grün/Rot bleiben erhalten (Lesbarkeit geht vor Marke), nur
dezenter hinterlegt
* **Feinschliff:** aktiver Navigationspunkt glüht leicht, weichere Radien,
Scrollbar wie im Bot-Webinterface
## Anpassen
Alle Farben stehen als CSS-Variablen oben in der Datei. Willst du eine andere
Primärfarbe, reicht es, den `--color-primary`-Block zu ändern — die
`dark-*`/`light-*`/`alpha-*`-Varianten solltest du dann mit anpassen, sonst
wirken Hover- und Aktiv-Zustände schief.
> Bei einem Gitea-Update kann es passieren, dass neue Variablen dazukommen,
> die das Theme noch nicht kennt. Dann erscheinen einzelne Elemente in den
> Standardfarben — einfach die fehlende Variable hier ergänzen.
+230
View File
@@ -0,0 +1,230 @@
/* ═══════════════════════════════════════════════════
D4RKST3R — Gitea-Theme
Neon-Gelb/Orange auf Schwarz, passend zu bot.d4rkst3r.de
Einbau: custom/public/assets/css/theme-d4rkst3r.css
danach Gitea neu starten und das Theme unter
Einstellungen → Aussehen auswählen.
═══════════════════════════════════════════════════ */
:root {
/* ── Marke ───────────────────────────────────── */
--color-primary: #f5c518;
--color-primary-contrast: #0a0a0a; /* Text auf gelben Flächen */
--color-primary-hover: #ffd633;
--color-primary-active: #e0b40f;
--color-primary-dark-1: #e0b40f;
--color-primary-dark-2: #c9a20c;
--color-primary-dark-3: #b28f0a;
--color-primary-dark-4: #9b7c08;
--color-primary-dark-5: #7d6406;
--color-primary-dark-6: #5e4b05;
--color-primary-dark-7: #3f3203;
--color-primary-light-1: #f7cd35;
--color-primary-light-2: #f9d552;
--color-primary-light-3: #fadd6f;
--color-primary-light-4: #fce58c;
--color-primary-light-5: #fdeda9;
--color-primary-light-6: #fef4c6;
--color-primary-light-7: #fefae3;
--color-primary-alpha-10: #f5c5181a;
--color-primary-alpha-20: #f5c51833;
--color-primary-alpha-30: #f5c5184d;
--color-primary-alpha-40: #f5c51866;
--color-primary-alpha-50: #f5c51880;
--color-primary-alpha-60: #f5c51899;
--color-primary-alpha-70: #f5c518b3;
--color-primary-alpha-80: #f5c518cc;
--color-primary-alpha-90: #f5c518e6;
/* Orange als Akzent (Links, Hervorhebungen) */
--color-accent: #ff4d00;
--color-small-accent: #ff4d0033;
/* ── Flächen ─────────────────────────────────── */
--color-body: #0a0a0a;
--color-box-body: #111111;
--color-box-body-highlight: #161616;
--color-box-header: #141414;
--color-nav-bg: #0d0d0d;
--color-nav-hover-bg: #1a1a1a;
--color-header-wrapper: #0d0d0d;
--color-footer: #0d0d0d;
--color-menu: #111111;
--color-card: #111111;
--color-markup-code-block: #f5c5180f;
--color-code-bg: #111111;
--color-code-sidebar-bg: #141414;
--color-console-bg: #0a0a0a;
--color-console-fg: #e8e0d0;
--color-console-fg-subtle: #8a8378;
--color-console-border: #f5c51826;
--color-console-hover-bg: #f5c5180f;
--color-console-active-bg: #f5c51817;
--color-console-menu-bg: #111111;
--color-console-menu-border: #f5c51826;
/* ── Text ────────────────────────────────────── */
--color-text: #e8e0d0;
--color-text-dark: #ffffff;
--color-text-light: #c9c2b4;
--color-text-light-1: #a8a294;
--color-text-light-2: #8a8378;
--color-text-light-3: #6b665d;
--color-placeholder-text: #6b665d;
/* ── Rahmen & Zustände ───────────────────────── */
--color-secondary: #2a2a2a;
--color-secondary-dark-1: #333333;
--color-secondary-dark-2: #3d3d3d;
--color-secondary-dark-3: #474747;
--color-secondary-dark-4: #525252;
--color-secondary-light-1: #232323;
--color-secondary-light-2: #1c1c1c;
--color-secondary-light-3: #161616;
--color-secondary-light-4: #111111;
--color-secondary-alpha-40: #2a2a2a66;
--color-secondary-alpha-50: #2a2a2a80;
--color-secondary-alpha-60: #2a2a2a99;
--color-secondary-alpha-70: #2a2a2ab3;
--color-secondary-alpha-80: #2a2a2acc;
--color-secondary-alpha-90: #2a2a2ae6;
--color-secondary-button: #f5c5181a;
--color-secondary-hover: #f5c51826;
--color-secondary-active: #f5c51833;
--color-light: #f5c5180d;
--color-light-border: #f5c51826;
--color-light-mimic-enabled: #f5c5181a;
--color-hover: #f5c5180f;
--color-active: #f5c51817;
--color-input-background: #0f0f0f;
--color-input-border: #2a2a2a;
--color-input-border-hover: #f5c51866;
--color-input-text: #e8e0d0;
--color-input-toggle-background: #1a1a1a;
--color-button: #161616;
--color-caret: #f5c518;
--color-shadow: #00000080;
--color-shadow-opaque: #000000;
--color-overlay-backdrop: #0a0a0acc;
--color-border: #2a2a2a;
/* ── Status ──────────────────────────────────── */
--color-red: #f23f43;
--color-orange: #ff4d00;
--color-yellow: #f5c518;
--color-olive: #b7c62b;
--color-green: #00c46a;
--color-teal: #00b5ad;
--color-blue: #4183c4;
--color-violet: #8f6ed5;
--color-purple: #a333c8;
--color-pink: #e03997;
--color-brown: #a5673f;
--color-grey: #6b665d;
--color-gold: #f5c518;
--color-success: #00c46a;
--color-info: #4183c4;
--color-warning: #ff4d00;
--color-error: #f23f43;
--color-danger: #f23f43;
--color-green-badge: #00c46a1a;
--color-green-badge-bg: #00c46a26;
--color-red-badge: #f23f431a;
--color-red-badge-bg: #f23f4326;
--color-yellow-badge: #f5c5181a;
--color-yellow-badge-bg: #f5c51826;
--color-orange-badge: #ff4d001a;
--color-orange-badge-bg: #ff4d0026;
/* ── Diffs ───────────────────────────────────── */
--color-diff-added-fg: #00c46a;
--color-diff-added-row-bg: #00c46a12;
--color-diff-added-row-border: #00c46a26;
--color-diff-added-word-bg: #00c46a33;
--color-diff-added-linenum-bg: #00c46a1a;
--color-diff-removed-fg: #f23f43;
--color-diff-removed-row-bg: #f23f4312;
--color-diff-removed-row-border: #f23f4326;
--color-diff-removed-word-bg: #f23f4333;
--color-diff-removed-linenum-bg: #f23f431a;
--color-diff-inactive: #111111;
--color-diff-moved-row-bg: #f5c51812;
--color-diff-moved-row-border: #f5c51826;
/* ── Sonstiges ───────────────────────────────── */
--color-timeline: #2a2a2a;
--color-editor-line-highlight: #f5c5180d;
--color-project-column-bg: #111111;
--color-reaction-bg: #f5c5181a;
--color-reaction-hover-bg: #f5c51833;
--color-reaction-active-bg: #f5c51826;
--color-tooltip-bg: #1a1a1a;
--color-tooltip-text: #e8e0d0;
--color-nav-text: #c9c2b4;
--color-label-text: #e8e0d0;
--color-label-bg: #f5c5181a;
--color-label-hover-bg: #f5c51833;
--color-label-active-bg: #f5c51826;
}
/* ── Feinschliff im D4RKST3R-Stil ─────────────────── */
/* Kopfleiste mit Neon-Kante */
.page-content,
#navbar {
border-bottom-color: var(--color-light-border) !important;
}
#navbar {
box-shadow: 0 1px 0 0 var(--color-primary-alpha-20);
}
/* Aktive Navigation glüht leicht */
#navbar .item.active {
color: var(--color-primary) !important;
text-shadow: 0 0 12px var(--color-primary-alpha-40);
}
/* Primär-Buttons kräftiger */
.ui.primary.button,
.ui.primary.buttons .button {
font-weight: 600;
letter-spacing: .02em;
}
.ui.primary.button:hover {
box-shadow: 0 0 16px var(--color-primary-alpha-30);
}
/* Repo-Titel in Markenfarbe */
.repository .header-wrapper .repo-title .name {
color: var(--color-primary);
}
/* Code-Blöcke abheben */
.markup pre,
.markup code {
border: 1px solid var(--color-light-border);
border-radius: 6px;
}
/* Weichere Radien wie im Rest der Plattform */
.ui.segment,
.ui.attached.segment,
.ui.card,
.repository .ui.segment,
.ui.menu {
border-radius: 10px;
}
/* Scrollbar passend zum Bot-Webinterface */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--color-body); }
::-webkit-scrollbar-thumb {
background: #262626;
border: 2px solid var(--color-body);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover { background: var(--color-primary); }
+10
View File
@@ -1406,6 +1406,16 @@ export default function Settings({ me }) {
<button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button> <button className="btn" onClick={() => sendTest('backup')}>Backup jetzt</button>
</div> </div>
</div> </div>
<div className="field">
<label className="toggle">
<input type="checkbox" checked={form.repo_backup_enabled} onChange={(e) => setForm({ ...form, repo_backup_enabled: e.target.checked })} />
<span>Repo-Backups (04:00)</span>
<span className="field-hint">alle Gitea-Repos als git-Bundle, 14 Tage Rotation — braucht den Gitea-Token</span>
</label>
<div className="field-row" style={{ marginTop: '.5rem' }}>
<button className="btn" onClick={() => sendTest('repobackup')}>Repos jetzt sichern</button>
</div>
</div>
<div className="field"> <div className="field">
<label>Watchdog-URLs <span className="field-hint">kommagetrennt; DM bei Ausfall — leer = aus</span></label> <label>Watchdog-URLs <span className="field-hint">kommagetrennt; DM bei Ausfall — leer = aus</span></label>
<input type="text" placeholder="https://git.d4rkst3r.de, https://bot.d4rkst3r.de/health" value={form.watchdog_urls} onChange={(e) => setForm({ ...form, watchdog_urls: e.target.value })} /> <input type="text" placeholder="https://git.d4rkst3r.de, https://bot.d4rkst3r.de/health" value={form.watchdog_urls} onChange={(e) => setForm({ ...form, watchdog_urls: e.target.value })} />
+2
View File
@@ -9,6 +9,7 @@ import { startGiveaways } from './bot/giveaways.js';
import { startScheduledPosts } from './web/scheduled-posts.js'; import { startScheduledPosts } from './web/scheduled-posts.js';
import { startSocialNotify } from './bot/social-notify.js'; import { startSocialNotify } from './bot/social-notify.js';
import { startBirthdays } from './bot/birthdays.js'; import { startBirthdays } from './bot/birthdays.js';
import { scheduleRepoBackups } from './repo-backup.js';
process.on('unhandledRejection', (error) => { process.on('unhandledRejection', (error) => {
console.error('[main] Unhandled Rejection:', error); console.error('[main] Unhandled Rejection:', error);
@@ -25,6 +26,7 @@ try {
startScheduledPosts(client); startScheduledPosts(client);
startSocialNotify(client); startSocialNotify(client);
startBirthdays(client); startBirthdays(client);
scheduleRepoBackups();
} catch (error) { } catch (error) {
console.error('[main] Start fehlgeschlagen:', error); console.error('[main] Start fehlgeschlagen:', error);
process.exit(1); process.exit(1);
+120
View File
@@ -0,0 +1,120 @@
// Repo-Backups: sichert alle Gitea-Repositories als git-Bundle.
// Ein Bundle ist eine einzelne Datei mit der kompletten Historie — daraus lässt
// sich jederzeit wieder klonen (`git clone repo.bundle repo`).
//
// Läuft im selben Fenster wie das DB-Backup (nachts) und nutzt denselben
// Aufbewahrungszeitraum. Braucht den Gitea-API-Token (Brand-Tab oder Env).
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdirSync, readdirSync, statSync } from 'node:fs';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getSetting, setSetting } from './db.js';
import { giteaUrl, giteaApiToken } from './runtime-settings.js';
import { backupDir } from './backup.js';
const exec = promisify(execFile);
const KEEP_DAYS = 14;
const CLONE_TIMEOUT_MS = 5 * 60 * 1000;
export const repoBackupDir = join(backupDir, 'repos');
/** Alle Repos holen, auf die der Token Zugriff hat */
async function listRepos() {
const token = giteaApiToken();
if (!token) throw new Error('kein Gitea-API-Token konfiguriert');
const repos = [];
for (let page = 1; page <= 10; page++) {
const res = await fetch(`${giteaUrl()}/api/v1/user/repos?limit=50&page=${page}`, {
headers: { Authorization: `token ${token}` },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) throw new Error(`Gitea-API ${res.status}`);
const batch = await res.json();
repos.push(...batch);
if (batch.length < 50) break;
}
return repos.map((r) => ({ fullName: r.full_name, cloneUrl: r.clone_url }));
}
/** Ein Repo spiegeln und als Bundle ablegen */
async function bundleRepo(repo, token) {
// Token in die Clone-URL einsetzen (bleibt im Prozess, landet in keiner Datei)
const url = new URL(repo.cloneUrl);
url.username = token;
const safeName = repo.fullName.replace(/[^\w.-]/g, '_');
const workDir = join(repoBackupDir, `${safeName}.tmp`);
const bundle = join(repoBackupDir, `${safeName}.bundle`);
await rm(workDir, { recursive: true, force: true });
try {
await exec('git', ['clone', '--mirror', '--quiet', url.toString(), workDir], { timeout: CLONE_TIMEOUT_MS });
await exec('git', ['-C', workDir, 'bundle', 'create', bundle, '--all'], { timeout: CLONE_TIMEOUT_MS });
return statSync(bundle).size;
} finally {
await rm(workDir, { recursive: true, force: true });
}
}
/** Alle Repos sichern — exportiert für den „Jetzt sichern"-Knopf */
export async function runRepoBackup() {
const token = giteaApiToken();
if (!token) throw new Error('kein Gitea-API-Token konfiguriert');
mkdirSync(repoBackupDir, { recursive: true });
// Ohne git im Image geht nichts — früh und deutlich scheitern
await exec('git', ['--version']).catch(() => {
throw new Error('git ist im Container nicht installiert');
});
const repos = await listRepos();
let ok = 0;
let bytes = 0;
const failed = [];
for (const repo of repos) {
try {
bytes += await bundleRepo(repo, token);
ok += 1;
} catch (error) {
failed.push(repo.fullName);
console.error(`[repo-backup] ${repo.fullName}: ${error.message?.slice(0, 200)}`);
}
}
// Rotation: alte Bundles und liegengebliebene Arbeitsordner aufräumen
const cutoff = Date.now() - KEEP_DAYS * 86400000;
for (const f of readdirSync(repoBackupDir)) {
const path = join(repoBackupDir, f);
if (f.endsWith('.tmp')) {
await rm(path, { recursive: true, force: true });
} else if (f.endsWith('.bundle') && statSync(path).mtimeMs < cutoff) {
await rm(path, { force: true });
}
}
const summary = { repos: repos.length, ok, failed, sizeBytes: bytes };
setSetting('last_repo_backup', new Date().toISOString());
setSetting('last_repo_backup_info', JSON.stringify(summary));
console.log(`[repo-backup] ${ok}/${repos.length} Repos gesichert (${(bytes / 1048576).toFixed(1)} MB)`);
return summary;
}
/** Scheduler: nachts um 04:00, einmal pro Tag (nach dem DB-Backup) */
export function scheduleRepoBackups() {
const check = async () => {
if (getSetting('repo_backup_enabled') !== '1') return;
if (new Date().getHours() !== 4) return;
const today = new Date().toISOString().slice(0, 10);
if ((getSetting('last_repo_backup') ?? '').slice(0, 10) === today) return;
try {
await runRepoBackup();
} catch (error) {
console.error('[repo-backup] Fehlgeschlagen:', error.message);
}
};
setInterval(check, 10 * 60 * 1000);
check();
}
+17
View File
@@ -366,6 +366,7 @@ ${rssItems}
public_url: publicUrl(), public_url: publicUrl(),
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',
backup_channel_id: getSetting('backup_channel_id') ?? '', backup_channel_id: getSetting('backup_channel_id') ?? '',
playtester_role_id: getSetting('playtester_role_id') ?? '', playtester_role_id: getSetting('playtester_role_id') ?? '',
starboard_channel_id: getSetting('starboard_channel_id') ?? '', starboard_channel_id: getSetting('starboard_channel_id') ?? '',
@@ -501,6 +502,9 @@ ${rssItems}
if (body.backup_enabled !== undefined) { if (body.backup_enabled !== undefined) {
setSetting('backup_enabled', body.backup_enabled ? '1' : '0'); setSetting('backup_enabled', body.backup_enabled ? '1' : '0');
} }
if (body.repo_backup_enabled !== undefined) {
setSetting('repo_backup_enabled', body.repo_backup_enabled ? '1' : '0');
}
if (body.sticky_roles_enabled !== undefined) { if (body.sticky_roles_enabled !== undefined) {
setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0'); setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0');
} }
@@ -1449,6 +1453,19 @@ ${rssItems}
return reply.code(502).send({ error: 'Backup fehlgeschlagen' }); return reply.code(502).send({ error: 'Backup fehlgeschlagen' });
} }
} }
// Alle Gitea-Repos als Bundle sichern (kann je nach Größe dauern)
if (target === 'repobackup') {
try {
const { runRepoBackup } = await import('../repo-backup.js');
const result = await runRepoBackup();
logAudit(getSessionUser(request), 'repo-backup gestartet', `${result.ok}/${result.repos} Repos`);
return { ok: true, ...result };
} catch (error) {
request.log.error({ err: error }, 'Repo-Backup fehlgeschlagen');
return reply.code(502).send({ error: `Repo-Backup: ${error.message?.slice(0, 120)}` });
}
}
const channelId = const channelId =
target === 'commit' ? commitChannelId() target === 'commit' ? commitChannelId()
: target === 'devlog' ? devlogChannelId() : target === 'devlog' ? devlogChannelId()