Upload via GUI (28 Dateien)

This commit is contained in:
2026-10-22 07:15:59 +00:00
parent 99d9629e25
commit 9b3aed6f0a
15 changed files with 4286 additions and 222 deletions
+313
View File
@@ -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 };
+224
View File
@@ -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 };