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
+2
View File
@@ -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);
+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(),
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()