Files
Git-Manager-Gui/src/backup/GiteaRepoRestore.js
T
2026-10-22 07:15:59 +00:00

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 };