Upload via GUI (28 Dateien)
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
// src/backup/GiteaRepoBackup.js
|
||||
// Sichert alle Gitea-Repos des Benutzers in einen Ordner (z. B. NAS-Freigabe).
|
||||
//
|
||||
// Bevorzugt als Git-Spiegel (<ziel>/<owner>/<repo>.git): vollständige Historie, alle Branches
|
||||
// und Tags, und ab dem zweiten Lauf werden nur Änderungen geholt. Liefert der Server keine
|
||||
// Git-Pakete (early EOF), wird stattdessen ein ZIP-Archiv des Standard-Branches über die API
|
||||
// geladen (<ziel>/<owner>/<repo>-archiv/<repo>-<zeit>.zip); davon bleiben die neuesten N.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const axios = require('axios');
|
||||
|
||||
const INTERVALS = { off: 0, daily: 24 * 3600 * 1000, weekly: 7 * 24 * 3600 * 1000 };
|
||||
const SCHEDULER_TICK_MS = 15 * 60 * 1000;
|
||||
|
||||
function stamp(d = new Date()) {
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function safeName(s) {
|
||||
return String(s).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
|
||||
}
|
||||
|
||||
// Token nur pro Git-Aufruf mitgeben, nie in einer Repo- oder Spiegel-Konfiguration speichern.
|
||||
function authHeaderArgs(ctx) {
|
||||
const basic = Buffer.from(`${ctx.token}:x-oauth-basic`, 'utf8').toString('base64');
|
||||
return ['-c', `http.extraheader=Authorization: Basic ${basic}`];
|
||||
}
|
||||
|
||||
// Ausschlussmuster: eine Zeile je Muster, * und ? als Platzhalter, ohne "/" passend auf den Repo-Namen.
|
||||
function excludeMatcher(text) {
|
||||
const pats = String(text || '').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
||||
const res = pats.map(p => ({
|
||||
full: p.includes('/'),
|
||||
re: new RegExp('^' + p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i')
|
||||
}));
|
||||
return (fullName) => res.some(({ full, re }) => re.test(full ? fullName : fullName.split('/').pop()));
|
||||
}
|
||||
|
||||
function createRepoBackup({ configPath, getContext, giteaCall, runGit, isPackFailure, redact }) {
|
||||
let running = null;
|
||||
// Laufender Zustand für die Fortschrittsanzeige. Wird bei jeder Änderung an alle Zuhörer gemeldet,
|
||||
// damit auch ein später geöffnetes Fenster (oder ein geplantes Backup) den Stand zeigen kann.
|
||||
let live = null;
|
||||
const listeners = new Set();
|
||||
let lastBytesEmit = 0;
|
||||
|
||||
function snapshot() {
|
||||
if (!live) return null;
|
||||
const { abort, ...rest } = live;
|
||||
return { ...rest, results: live.results.slice() };
|
||||
}
|
||||
function emit(force = true) {
|
||||
if (!live) return;
|
||||
if (!force) {
|
||||
const now = Date.now();
|
||||
if (now - lastBytesEmit < 250) return;
|
||||
lastBytesEmit = now;
|
||||
}
|
||||
const s = snapshot();
|
||||
for (const fn of listeners) { try { fn(s); } catch (_) {} }
|
||||
}
|
||||
function setLive(patch, force = true) {
|
||||
if (!live) return;
|
||||
Object.assign(live, patch);
|
||||
emit(force);
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
targetDir: '', interval: 'off', keep: 5, lastRun: null, lastResult: null,
|
||||
excludeRepos: '', // Muster je Zeile, z. B. "M_Viper/test-*" oder "*-alt"
|
||||
time: '', // frühester Zeitpunkt für automatische Backups ("HH:MM"), leer = jederzeit
|
||||
notify: 'errors' // 'errors' | 'always' | 'never'
|
||||
};
|
||||
|
||||
function readConfig() {
|
||||
try { return { ...defaults, ...JSON.parse(fs.readFileSync(configPath, 'utf8')) }; } catch (_) { return { ...defaults }; }
|
||||
}
|
||||
function writeConfig(cfg) {
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return { config: readConfig(), running: !!running, progress: snapshot() };
|
||||
}
|
||||
|
||||
function setConfig(data) {
|
||||
const cfg = readConfig();
|
||||
if (typeof data.targetDir === 'string') cfg.targetDir = data.targetDir.trim();
|
||||
if (data.interval in INTERVALS) cfg.interval = data.interval;
|
||||
if (data.keep != null) cfg.keep = Math.min(100, Math.max(1, Number(data.keep) || 5));
|
||||
if (typeof data.excludeRepos === 'string') cfg.excludeRepos = data.excludeRepos.replace(/\r/g, '').slice(0, 20000);
|
||||
if (typeof data.time === 'string') cfg.time = /^([01]\d|2[0-3]):[0-5]\d$/.test(data.time) ? data.time : '';
|
||||
if (['errors', 'always', 'never'].includes(data.notify)) cfg.notify = data.notify;
|
||||
writeConfig(cfg);
|
||||
return getState();
|
||||
}
|
||||
|
||||
async function listAllRepos(ctx) {
|
||||
const all = [];
|
||||
for (let page = 1; page <= 100; page++) {
|
||||
const list = await giteaCall(ctx, 'get', '/user/repos', { params: { page, limit: 50 } });
|
||||
if (!list || !list.length) break;
|
||||
all.push(...list);
|
||||
if (list.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function backupAsMirror(ctx, repo, dir) {
|
||||
const auth = authHeaderArgs(ctx);
|
||||
if (fs.existsSync(path.join(dir, 'HEAD'))) {
|
||||
setLive({ step: 'Git-Spiegel wird aktualisiert …', bytes: 0 });
|
||||
return runGit([...auth, '-C', dir, 'remote', 'update', '--prune'], { timeoutMs: 30 * 60000 });
|
||||
}
|
||||
setLive({ step: 'Git-Spiegel wird angelegt …', bytes: 0 });
|
||||
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
||||
const r = await runGit([...auth, 'clone', '--mirror', repo.clone_url, dir], { timeoutMs: 60 * 60000 });
|
||||
if (r.status !== 0) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} }
|
||||
return r;
|
||||
}
|
||||
|
||||
async function backupAsZip(ctx, repo, folder, keep, signal) {
|
||||
const branch = repo.default_branch || 'main';
|
||||
fs.mkdirSync(folder, { recursive: true });
|
||||
const file = path.join(folder, `${safeName(repo.name)}-${stamp()}.zip`);
|
||||
const lib = ctx.base.startsWith('https:') ? https : http;
|
||||
const agent = new lib.Agent({ keepAlive: false, autoSelectFamily: true });
|
||||
const tmp = `${file}.part`;
|
||||
setLive({ step: `ZIP vom Branch ${branch} wird geladen …`, bytes: 0, bytesTotal: null });
|
||||
try {
|
||||
const res = await axios.get(`${ctx.apiBase}/repos/${encodeURIComponent(repo.owner.login)}/${encodeURIComponent(repo.name)}/archive/${encodeURIComponent(branch)}.zip`, {
|
||||
headers: ctx.headers, responseType: 'stream', timeout: 30 * 60000, signal,
|
||||
httpAgent: agent, httpsAgent: agent, validateStatus: () => true, maxRedirects: 5
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
res.data.resume();
|
||||
throw new Error(`Archiv-Download HTTP ${res.status}`);
|
||||
}
|
||||
const total = Number(res.headers['content-length']) || null;
|
||||
if (total) setLive({ bytesTotal: total });
|
||||
let bytes = 0;
|
||||
await new Promise((resolve, reject) => {
|
||||
const out = fs.createWriteStream(tmp);
|
||||
res.data.on('data', (chunk) => { bytes += chunk.length; setLive({ bytes }, false); });
|
||||
res.data.on('error', reject);
|
||||
out.on('error', reject);
|
||||
out.on('finish', resolve);
|
||||
res.data.pipe(out);
|
||||
});
|
||||
setLive({ bytes });
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (e) {
|
||||
try { fs.rmSync(tmp, { force: true }); } catch (_) {}
|
||||
if (signal && signal.aborted) throw new Error('Abgebrochen');
|
||||
throw e;
|
||||
} finally {
|
||||
agent.destroy();
|
||||
}
|
||||
// Ältere Archive über der Aufbewahrungsgrenze entfernen
|
||||
const zips = fs.readdirSync(folder).filter(f => f.endsWith('.zip')).sort();
|
||||
for (const old of zips.slice(0, Math.max(0, zips.length - keep))) {
|
||||
try { fs.rmSync(path.join(folder, old), { force: true }); } catch (_) {}
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const cfg = readConfig();
|
||||
if (!cfg.targetDir) throw new Error('Kein Backup-Ziel festgelegt.');
|
||||
fs.mkdirSync(cfg.targetDir, { recursive: true });
|
||||
const ctx = getContext();
|
||||
setLive({ phase: 'list', step: 'Repos werden abgefragt …' });
|
||||
const all = await listAllRepos(ctx);
|
||||
const excluded = excludeMatcher(cfg.excludeRepos);
|
||||
const repos = all.filter(r => !excluded(`${r.owner.login}/${r.name}`));
|
||||
setLive({ excludedCount: all.length - repos.length });
|
||||
const results = live.results;
|
||||
let mirrorBroken = false;
|
||||
setLive({ phase: 'running', total: repos.length, repos: repos.map(r => `${r.owner.login}/${r.name}`) });
|
||||
|
||||
for (let i = 0; i < repos.length; i++) {
|
||||
if (live.cancelRequested) break;
|
||||
const repo = repos[i];
|
||||
const full = `${repo.owner.login}/${repo.name}`;
|
||||
setLive({ current: i + 1, repo: full, step: 'Wird vorbereitet …', bytes: 0, bytesTotal: null });
|
||||
const ownerDir = path.join(cfg.targetDir, safeName(repo.owner.login));
|
||||
const finish = (r) => { results.push(r); setLive({ done: results.length }); };
|
||||
|
||||
if (repo.empty) { finish({ repo: full, ok: true, method: 'leer' }); continue; }
|
||||
|
||||
// Scheitert der Spiegel einmal am Server (early EOF), nicht für jedes Repo erneut probieren.
|
||||
let mirrorError = null;
|
||||
if (!mirrorBroken) {
|
||||
const r = await backupAsMirror(ctx, repo, path.join(ownerDir, `${safeName(repo.name)}.git`));
|
||||
if (r.status === 0) { finish({ repo: full, ok: true, method: 'Git-Spiegel' }); continue; }
|
||||
if (isPackFailure(r.stderr)) {
|
||||
mirrorBroken = true;
|
||||
setLive({ mirrorBroken: true });
|
||||
} else if (!/could not read|authentication|403|401/i.test(r.stderr)) {
|
||||
mirrorError = redact(r.stderr, ctx.token).trim().slice(0, 200);
|
||||
}
|
||||
}
|
||||
if (live.cancelRequested) break;
|
||||
live.abort = new AbortController();
|
||||
try {
|
||||
const file = await backupAsZip(ctx, repo, path.join(ownerDir, `${safeName(repo.name)}-archiv`), cfg.keep, live.abort.signal);
|
||||
finish({ repo: full, ok: true, method: 'ZIP', file: path.basename(file), bytes: live.bytes });
|
||||
} catch (e) {
|
||||
if (live.cancelRequested) break;
|
||||
finish({ repo: full, ok: false, method: 'ZIP', error: redact(e.message, ctx.token) + (mirrorError ? ` (Git-Spiegel vorher: ${mirrorError})` : '') });
|
||||
} finally {
|
||||
live.abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => !r.ok);
|
||||
const summary = {
|
||||
at: new Date().toISOString(),
|
||||
total: repos.length,
|
||||
excluded: all.length - repos.length,
|
||||
ok: results.length - failed.length,
|
||||
failed: failed.length,
|
||||
skipped: repos.length - results.length,
|
||||
cancelled: !!live.cancelRequested,
|
||||
durationMs: Date.now() - live.startedAt,
|
||||
mirror: results.filter(r => r.method === 'Git-Spiegel').length,
|
||||
zip: results.filter(r => r.method === 'ZIP' && r.ok).length,
|
||||
mirrorBroken,
|
||||
errors: failed.slice(0, 10)
|
||||
};
|
||||
const after = readConfig();
|
||||
after.lastRun = summary.at;
|
||||
after.lastResult = summary;
|
||||
writeConfig(after);
|
||||
try {
|
||||
fs.writeFileSync(path.join(cfg.targetDir, 'backup-protokoll.json'), JSON.stringify({ ...summary, results }, null, 2), 'utf8');
|
||||
} catch (_) {}
|
||||
console.log(`[RepoBackup] ${summary.cancelled ? 'abgebrochen' : 'fertig'}: ${summary.ok}/${summary.total} gesichert (${summary.mirror} Spiegel, ${summary.zip} ZIP), ${summary.failed} Fehler`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function runNow(onProgress) {
|
||||
if (running) return { ok: false, error: 'Ein Backup läuft bereits.', running: true };
|
||||
live = {
|
||||
phase: 'start', startedAt: Date.now(), total: 0, current: 0, done: 0, repo: null, repos: [],
|
||||
step: 'Startet …', bytes: 0, bytesTotal: null, results: [], mirrorBroken: false,
|
||||
cancelRequested: false, abort: null, targetDir: readConfig().targetDir
|
||||
};
|
||||
if (onProgress) listeners.add(onProgress);
|
||||
emit();
|
||||
running = run();
|
||||
try {
|
||||
const summary = await running;
|
||||
setLive({ phase: 'done', summary, step: summary.cancelled ? 'Abgebrochen' : 'Fertig' });
|
||||
return { ok: summary.failed === 0 && !summary.cancelled, summary };
|
||||
} catch (e) {
|
||||
setLive({ phase: 'error', error: e.message, step: 'Fehlgeschlagen' });
|
||||
return { ok: false, error: e.message };
|
||||
} finally {
|
||||
if (onProgress) listeners.delete(onProgress);
|
||||
running = null;
|
||||
live = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Bricht nach dem aktuellen Schritt ab; ein laufender ZIP-Download wird sofort beendet.
|
||||
function cancel() {
|
||||
if (!running || !live) return { ok: false, error: 'Es läuft kein Backup.' };
|
||||
live.cancelRequested = true;
|
||||
if (live.abort) { try { live.abort.abort(); } catch (_) {} }
|
||||
setLive({ step: 'Wird abgebrochen …' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function onProgress(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
function isDue(now = new Date()) {
|
||||
const cfg = readConfig();
|
||||
const every = INTERVALS[cfg.interval] || 0;
|
||||
if (!every || !cfg.targetDir) return false;
|
||||
const last = cfg.lastRun ? new Date(cfg.lastRun).getTime() : 0;
|
||||
if (!cfg.time) return !last || now.getTime() - last >= every;
|
||||
// Mit Uhrzeit: erst ab HH:MM, und nur wenn seit dem letzten Lauf (fast) ein Intervall vergangen ist.
|
||||
// 12 Stunden Spielraum, damit sich der Zeitpunkt nicht durch späte Läufe immer weiter verschiebt.
|
||||
const [h, m] = cfg.time.split(':').map(Number);
|
||||
const slot = new Date(now); slot.setHours(h, m, 0, 0);
|
||||
if (slot > now) slot.setDate(slot.getDate() - 1);
|
||||
return last < slot.getTime() && now.getTime() - last >= every - 12 * 3600 * 1000;
|
||||
}
|
||||
|
||||
function startScheduler() {
|
||||
const tick = async () => {
|
||||
if (running || !isDue()) return;
|
||||
console.log('[RepoBackup] Geplantes Backup startet');
|
||||
try { await runNow(null); } catch (e) { console.warn('[RepoBackup] Geplantes Backup fehlgeschlagen:', e.message); }
|
||||
};
|
||||
// unref: Der Zeitplaner allein hält den Prozess nicht am Leben.
|
||||
setTimeout(tick, 2 * 60 * 1000).unref?.();
|
||||
setInterval(tick, SCHEDULER_TICK_MS).unref?.();
|
||||
}
|
||||
|
||||
return { getState, setConfig, runNow, cancel, onProgress, startScheduler, isDue };
|
||||
}
|
||||
|
||||
module.exports = { createRepoBackup, authHeaderArgs, excludeMatcher };
|
||||
@@ -0,0 +1,224 @@
|
||||
// src/backup/GiteaRepoRestore.js
|
||||
// Stellt Sicherungen aus GiteaRepoBackup wieder her:
|
||||
// - Git-Spiegel (<ziel>/<owner>/<repo>.git): lokal als Arbeitskopie klonen oder per
|
||||
// "push --mirror" auf Gitea zurückspielen (neues Repo oder bestehendes überschreiben).
|
||||
// - ZIP-Stand (<ziel>/<owner>/<repo>-archiv/*.zip): lokal entpacken oder als neues Repo
|
||||
// auf Gitea anlegen (per Git-Push, bei Serverproblemen über die Contents-API).
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const unzipper = require('unzipper');
|
||||
|
||||
function isEmptyOrMissing(dir) {
|
||||
try { return fs.readdirSync(dir).length === 0; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
// Sicherungen im Backup-Ordner finden
|
||||
function listBackups(targetDir) {
|
||||
const repos = new Map();
|
||||
if (!targetDir || !fs.existsSync(targetDir)) return [];
|
||||
for (const owner of fs.readdirSync(targetDir)) {
|
||||
const ownerDir = path.join(targetDir, owner);
|
||||
let entries;
|
||||
try {
|
||||
if (!fs.statSync(ownerDir).isDirectory()) continue;
|
||||
entries = fs.readdirSync(ownerDir);
|
||||
} catch (_) { continue; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(ownerDir, entry);
|
||||
let st;
|
||||
try { st = fs.statSync(full); } catch (_) { continue; }
|
||||
if (!st.isDirectory()) continue;
|
||||
if (entry.endsWith('.git') && fs.existsSync(path.join(full, 'HEAD'))) {
|
||||
const name = entry.slice(0, -4);
|
||||
const key = `${owner}/${name}`;
|
||||
const rec = repos.get(key) || { owner, repo: name, mirror: null, zips: [] };
|
||||
let updated = st.mtime;
|
||||
for (const marker of ['FETCH_HEAD', 'packed-refs', 'refs']) {
|
||||
try { const m = fs.statSync(path.join(full, marker)).mtime; if (m > updated) updated = m; } catch (_) {}
|
||||
}
|
||||
rec.mirror = { path: full, updated: updated.toISOString() };
|
||||
repos.set(key, rec);
|
||||
} else if (entry.endsWith('-archiv')) {
|
||||
const name = entry.slice(0, -'-archiv'.length);
|
||||
const key = `${owner}/${name}`;
|
||||
const rec = repos.get(key) || { owner, repo: name, mirror: null, zips: [] };
|
||||
for (const f of fs.readdirSync(full).filter(f => f.endsWith('.zip'))) {
|
||||
try {
|
||||
const zst = fs.statSync(path.join(full, f));
|
||||
rec.zips.push({ path: path.join(full, f), file: f, size: zst.size, date: zst.mtime.toISOString() });
|
||||
} catch (_) {}
|
||||
}
|
||||
rec.zips.sort((a, b) => b.date.localeCompare(a.date));
|
||||
repos.set(key, rec);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...repos.values()].sort((a, b) => `${a.owner}/${a.repo}`.localeCompare(`${b.owner}/${b.repo}`));
|
||||
}
|
||||
|
||||
// Gitea-Archive enthalten einen Oberordner "<repo>/". Er wird beim Entpacken entfernt.
|
||||
async function extractZip(zipPath, dest) {
|
||||
const dir = await unzipper.Open.file(zipPath);
|
||||
const files = dir.files.filter(f => f.type === 'File');
|
||||
const tops = new Set(files.map(f => f.path.split('/')[0]));
|
||||
const strip = tops.size === 1 && files.every(f => f.path.includes('/')) ? [...tops][0] + '/' : '';
|
||||
const root = path.resolve(dest);
|
||||
// Erst alle Pfade prüfen, dann schreiben: ein manipuliertes Archiv soll gar nichts entpacken.
|
||||
// Schutz vor Pfaden, die aus dem Zielordner herausführen ("zip slip").
|
||||
const plan = [];
|
||||
for (const f of files) {
|
||||
const rel = strip ? f.path.slice(strip.length) : f.path;
|
||||
if (!rel) continue;
|
||||
const out = path.resolve(root, rel);
|
||||
if (out === root || !out.startsWith(root + path.sep)) throw new Error(`Unsicherer Pfad im Archiv: ${f.path}`);
|
||||
plan.push({ f, out });
|
||||
}
|
||||
let count = 0;
|
||||
for (const { f, out } of plan) {
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
await new Promise((resolve, reject) => {
|
||||
f.stream().on('error', reject).pipe(fs.createWriteStream(out)).on('error', reject).on('finish', resolve);
|
||||
});
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function collectFiles(root) {
|
||||
const items = [];
|
||||
(function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
if (entry === '.git') continue;
|
||||
const full = path.join(dir, entry);
|
||||
const st = fs.statSync(full);
|
||||
if (st.isDirectory()) walk(full);
|
||||
else if (st.isFile()) items.push({ localFile: full, targetPath: path.relative(root, full).split(path.sep).join('/') });
|
||||
}
|
||||
})(root);
|
||||
return items;
|
||||
}
|
||||
|
||||
function createRepoRestore({ getContext, giteaCall, runGit, isPackFailure, redact, uploadItemsViaGiteaApi, authHeaderArgs, identity }) {
|
||||
|
||||
async function restoreLocal({ kind, source, destParent, repo }) {
|
||||
if (!destParent || !fs.existsSync(destParent)) throw new Error('Zielordner nicht gefunden');
|
||||
const dest = path.join(destParent, repo);
|
||||
if (!isEmptyOrMissing(dest)) throw new Error(`"${dest}" existiert bereits und ist nicht leer. Bitte einen anderen Ordner wählen.`);
|
||||
|
||||
if (kind === 'mirror') {
|
||||
const r = await runGit(['clone', source, dest], { timeoutMs: 30 * 60000 });
|
||||
if (r.status !== 0) throw new Error(r.stderr.trim() || 'Klonen fehlgeschlagen');
|
||||
// origin soll wieder auf Gitea zeigen, nicht auf den Backup-Ordner.
|
||||
// Der Spiegel kennt die ursprüngliche Adresse (ohne Token, der wird nur pro Aufruf übergeben).
|
||||
const remote = await runGit(['-C', source, 'config', '--get', 'remote.origin.url']);
|
||||
const url = remote.stdout.trim();
|
||||
if (/^https?:/i.test(url)) await runGit(['-C', dest, 'remote', 'set-url', 'origin', url]);
|
||||
const br = await runGit(['-C', dest, 'branch', '-r']);
|
||||
return { ok: true, path: dest, branches: br.stdout.split('\n').filter(l => l.trim() && !l.includes('->')).length };
|
||||
}
|
||||
|
||||
if (kind === 'zip') {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
const count = await extractZip(source, dest);
|
||||
return { ok: true, path: dest, files: count };
|
||||
}
|
||||
throw new Error('Unbekannte Sicherungsart');
|
||||
}
|
||||
|
||||
async function createGiteaRepo(ctx, owner, name, isPrivate) {
|
||||
const me = await giteaCall(ctx, 'get', '/user');
|
||||
const body = { name, private: !!isPrivate, auto_init: false, description: `Wiederhergestellt aus Backup (${new Date().toLocaleDateString('de-DE')})` };
|
||||
try {
|
||||
return owner && owner !== me.login
|
||||
? await giteaCall(ctx, 'post', `/orgs/${encodeURIComponent(owner)}/repos`, { data: body })
|
||||
: await giteaCall(ctx, 'post', '/user/repos', { data: body });
|
||||
} catch (e) {
|
||||
if (e.status === 409) throw new Error(`Ein Repo "${owner || me.login}/${name}" existiert bereits. Bitte einen anderen Namen wählen.`);
|
||||
if (e.status === 404 || e.status === 403) throw new Error(`Repo kann unter "${owner}" nicht angelegt werden (keine Rechte oder Organisation fehlt): ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreToGitea({ kind, source, owner, repo, mode, newName, isPrivate }, onProgress) {
|
||||
const ctx = getContext();
|
||||
const progress = (text) => { if (onProgress) onProgress({ text }); };
|
||||
|
||||
let target;
|
||||
let created = false;
|
||||
if (mode === 'overwrite') {
|
||||
if (kind !== 'mirror') throw new Error('Überschreiben ist nur mit einem Git-Spiegel möglich, weil ein ZIP-Stand keine Historie enthält.');
|
||||
progress('Prüfe Ziel-Repo ...');
|
||||
target = await giteaCall(ctx, 'get', `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
|
||||
} else {
|
||||
const name = String(newName || '').trim();
|
||||
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('Ungültiger Repo-Name. Erlaubt sind Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich.');
|
||||
progress(`Lege Repo "${name}" an ...`);
|
||||
target = await createGiteaRepo(ctx, owner, name, isPrivate);
|
||||
created = true;
|
||||
}
|
||||
const full = target.full_name || `${target.owner.login}/${target.name}`;
|
||||
|
||||
if (kind === 'mirror') {
|
||||
progress('Übertrage Branches und Tags ...');
|
||||
const r = await runGit([...authHeaderArgs(ctx), '-C', source, 'push', '--mirror', target.clone_url], { timeoutMs: 60 * 60000 });
|
||||
if (r.status !== 0) {
|
||||
const msg = redact(r.stderr, ctx.token).trim();
|
||||
throw new Error(isPackFailure(msg)
|
||||
? `Der Server hat die Übertragung abgebrochen (${msg.split('\n').pop()}). Die Server-Diagnose zeigt die Ursache.${created ? ` Das leere Repo "${full}" wurde bereits angelegt.` : ''}`
|
||||
: msg || 'Push fehlgeschlagen');
|
||||
}
|
||||
// Standard-Branch wie im Backup setzen
|
||||
const head = await runGit(['-C', source, 'symbolic-ref', '--short', 'HEAD']);
|
||||
const branch = head.status === 0 ? head.stdout.trim() : null;
|
||||
if (branch && branch !== target.default_branch) {
|
||||
try { await giteaCall(ctx, 'patch', `/repos/${encodeURIComponent(target.owner.login)}/${encodeURIComponent(target.name)}`, { data: { default_branch: branch } }); } catch (_) {}
|
||||
}
|
||||
return { ok: true, repo: full, url: target.html_url, method: 'Git-Spiegel' };
|
||||
}
|
||||
|
||||
if (kind === 'zip') {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-restore-'));
|
||||
try {
|
||||
progress('Entpacke Archiv ...');
|
||||
const count = await extractZip(source, tmp);
|
||||
if (!count) throw new Error('Das Archiv ist leer.');
|
||||
const branch = target.default_branch || 'main';
|
||||
|
||||
progress(`Übertrage ${count} Dateien per Git ...`);
|
||||
const who = identity ? identity() : { name: 'Git Manager GUI', email: 'gui@gitmanager.local' };
|
||||
const id = ['-c', `user.name=${who.name}`, '-c', `user.email=${who.email}`];
|
||||
let pushed = false;
|
||||
let pushErr = '';
|
||||
if ((await runGit(['-C', tmp, 'init', '-q'])).status === 0) {
|
||||
await runGit(['-C', tmp, 'add', '-A']);
|
||||
await runGit([...id, '-C', tmp, 'commit', '-q', '-m', `Wiederhergestellt aus Backup ${path.basename(source)}`]);
|
||||
const p = await runGit([...authHeaderArgs(ctx), '-C', tmp, 'push', target.clone_url, `HEAD:refs/heads/${branch}`], { timeoutMs: 30 * 60000 });
|
||||
pushed = p.status === 0;
|
||||
pushErr = redact(p.stderr, ctx.token).trim();
|
||||
}
|
||||
if (pushed) return { ok: true, repo: full, url: target.html_url, method: 'Git', files: count };
|
||||
|
||||
// Git-Weg nicht nutzbar: Dateien über die API hochladen
|
||||
progress(`Git-Push nicht möglich, lade ${count} Dateien über die API hoch ...`);
|
||||
const result = await uploadItemsViaGiteaApi({
|
||||
token: ctx.token, url: ctx.base, owner: target.owner.login, repo: target.name, branch,
|
||||
items: collectFiles(tmp), message: `Wiederhergestellt aus Backup ${path.basename(source)}`,
|
||||
onProgress: (pct) => progress(`Lade über die API hoch ... ${pct} %`)
|
||||
});
|
||||
if (result.failed.length) {
|
||||
throw new Error(`${result.failed.length} von ${count} Dateien konnten nicht hochgeladen werden (z. B. ${result.failed[0].path}: ${result.failed[0].error}). Git-Fehler vorher: ${pushErr.slice(0, 200)}`);
|
||||
}
|
||||
return { ok: true, repo: full, url: target.html_url, method: 'API', files: count };
|
||||
} finally {
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
throw new Error('Unbekannte Sicherungsart');
|
||||
}
|
||||
|
||||
return { listBackups, restoreLocal, restoreToGitea };
|
||||
}
|
||||
|
||||
module.exports = { createRepoRestore, listBackups, extractZip };
|
||||
Reference in New Issue
Block a user