diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..db5ea77 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 466dc49..cd237cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,9 @@ RUN npm run build # Stage 2: Runtime (slim statt alpine: better-sqlite3 liefert glibc-Prebuilds) FROM node:22-slim -# 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 \ +# Fonts für die Willkommens-Karten (sharp/librsvg braucht installierte Schriften), +# 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/* WORKDIR /app diff --git a/README.md b/README.md index df96640..e760028 100644 --- a/README.md +++ b/README.md @@ -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 | | 👥 **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) | -| 🎨 **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 | | ⚖️ **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 | @@ -99,7 +101,8 @@ SQLite, Env-Variablen sind nur Fallback, kein Redeploy nötig: - **Composer:** visueller **Embed-Builder mit Live-Discord-Preview** (Autor, Felder, Bilder, Farbe, Footer), Nachrichten senden & nachträglich bearbeiten, speicherbare Vorlagen - **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 - **API-Keys:** erstellen (Name + Scopes), widerrufen, last-used - **Status-Panel:** Bot-Account, Uptime, Devlog-/Commit-Zahlen, DB-Größe, letztes Backup diff --git a/docs/auto-deploy.md b/docs/auto-deploy.md new file mode 100644 index 0000000..bf40390 --- /dev/null +++ b/docs/auto-deploy.md @@ -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/`) + +**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: + 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. diff --git a/docs/gitea-theme/README.md b/docs/gitea-theme/README.md new file mode 100644 index 0000000..121d8d5 --- /dev/null +++ b/docs/gitea-theme/README.md @@ -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. diff --git a/docs/gitea-theme/theme-d4rkst3r.css b/docs/gitea-theme/theme-d4rkst3r.css new file mode 100644 index 0000000..99fa0d3 --- /dev/null +++ b/docs/gitea-theme/theme-d4rkst3r.css @@ -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); } diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index e97ae0f..52872b6 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1406,6 +1406,16 @@ export default function Settings({ me }) { +
+ +
+ +
+
setForm({ ...form, watchdog_urls: e.target.value })} /> diff --git a/src/index.js b/src/index.js index 2ef1b43..ec84c79 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import { startGiveaways } from './bot/giveaways.js'; import { startScheduledPosts } from './web/scheduled-posts.js'; import { startSocialNotify } from './bot/social-notify.js'; import { startBirthdays } from './bot/birthdays.js'; +import { scheduleRepoBackups } from './repo-backup.js'; process.on('unhandledRejection', (error) => { console.error('[main] Unhandled Rejection:', error); @@ -25,6 +26,7 @@ try { startScheduledPosts(client); startSocialNotify(client); startBirthdays(client); + scheduleRepoBackups(); } catch (error) { console.error('[main] Start fehlgeschlagen:', error); process.exit(1); diff --git a/src/repo-backup.js b/src/repo-backup.js new file mode 100644 index 0000000..19c1977 --- /dev/null +++ b/src/repo-backup.js @@ -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(); +} diff --git a/src/web/api.js b/src/web/api.js index 78dead9..dad9fe2 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -366,6 +366,7 @@ ${rssItems} public_url: publicUrl(), gitea_url: getSetting('gitea_url') ?? config.giteaUrl, backup_enabled: getSetting('backup_enabled') !== '0', + repo_backup_enabled: getSetting('repo_backup_enabled') === '1', backup_channel_id: getSetting('backup_channel_id') ?? '', playtester_role_id: getSetting('playtester_role_id') ?? '', starboard_channel_id: getSetting('starboard_channel_id') ?? '', @@ -501,6 +502,9 @@ ${rssItems} if (body.backup_enabled !== undefined) { 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) { setSetting('sticky_roles_enabled', body.sticky_roles_enabled ? '1' : '0'); } @@ -1449,6 +1453,19 @@ ${rssItems} 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 = target === 'commit' ? commitChannelId() : target === 'devlog' ? devlogChannelId()