Repo-Backups, Gitea-Theme und Auto-Deploy
- 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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user