// src/backup/GiteaRepoRestore.js // Stellt Sicherungen aus GiteaRepoBackup wieder her: // - Git-Spiegel (//.git): lokal als Arbeitskopie klonen oder per // "push --mirror" auf Gitea zurückspielen (neues Repo oder bestehendes überschreiben). // - ZIP-Stand (//-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 "/". 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 };