225 lines
11 KiB
JavaScript
225 lines
11 KiB
JavaScript
// 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 };
|