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 };
+87
View File
@@ -0,0 +1,87 @@
// src/features/appPrefs.js
// Allgemeine App-Einstellungen, die keine Geheimnisse enthalten (Tokens liegen verschlüsselt in
// den Zugangsdaten). Gespeichert als userData/app-settings.json.
const fs = require('fs');
const path = require('path');
const DEFAULTS = {
// Konten
gitName: '',
gitEmail: '',
// Uploads
uploadExtraExcludes: '', // zusätzliche Muster im .gitignore-Format, eine Zeile je Muster
uploadSecretScan: true, // Dateien mit Zugangsdaten erkennen und vorab abwählen
uploadPreview: 'always', // 'always' | 'warnings' (nur bei Auffälligkeiten anzeigen)
// Darstellung
theme: 'dark', // 'dark' | 'light' | 'system'
zoom: 100, // Prozent
// System
autoUpdateCheck: true,
closeAction: 'auto', // 'auto' (Tray nur mit Autostart) | 'tray' | 'quit'
defaultCloneDir: ''
};
const CHOICES = {
uploadPreview: ['always', 'warnings'],
theme: ['dark', 'light', 'system'],
closeAction: ['auto', 'tray', 'quit']
};
function clean(key, value) {
const def = DEFAULTS[key];
if (CHOICES[key]) return CHOICES[key].includes(value) ? value : def;
if (typeof def === 'boolean') return !!value;
if (key === 'zoom') {
const n = Math.round(Number(value));
return Number.isFinite(n) ? Math.min(150, Math.max(70, n)) : def;
}
if (key === 'gitEmail') {
const v = String(value || '').trim().slice(0, 200);
return v === '' || /^[^\s@<>]+@[^\s@<>]+$/.test(v) ? v : def;
}
if (key === 'gitName') return String(value || '').replace(/[<>\n\r]/g, '').trim().slice(0, 100);
if (key === 'uploadExtraExcludes') return String(value || '').replace(/\r/g, '').slice(0, 20000);
return String(value || '').trim().slice(0, 1000);
}
function createPrefs(file) {
const listeners = new Set();
let cache = null;
function get() {
if (!cache) {
let stored = {};
try { stored = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch (_) {}
cache = {};
for (const key of Object.keys(DEFAULTS)) cache[key] = key in stored ? clean(key, stored[key]) : DEFAULTS[key];
}
return { ...cache };
}
/** Übernimmt nur bekannte Schlüssel; liefert die bereinigten Werte und eine Liste abgelehnter Eingaben. */
function set(patch) {
const next = get();
const rejected = [];
for (const [key, value] of Object.entries(patch || {})) {
if (!(key in DEFAULTS)) continue;
const v = clean(key, value);
if (key === 'gitEmail' && String(value || '').trim() && v !== String(value).trim()) rejected.push(key);
next[key] = v;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(next, null, 2), 'utf8');
cache = next;
for (const fn of listeners) { try { fn(get()); } catch (_) {} }
return { prefs: get(), rejected };
}
function onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
return { get, set, onChange, DEFAULTS };
}
module.exports = { createPrefs, DEFAULTS };
+539
View File
@@ -0,0 +1,539 @@
// src/features/featuresIpc.js
// IPC-Handler für: Upload-Vorschau mit Geheimnis-Warnung, Server-Diagnose,
// lokale Änderungen (Status/Diff/Commit), Pull Requests & Issues und Repo-Backup.
const fs = require('fs');
const os = require('os');
const path = require('path');
const dns = require('dns').promises;
const net = require('net');
const http = require('http');
const https = require('https');
const { spawn } = require('child_process');
const axios = require('axios');
const { normalizeAndValidateBaseUrl, uploadItemsViaGiteaApi } = require('../git/apiHandler.js');
const { scanForSecrets } = require('./secretScan.js');
const { createRepoBackup, authHeaderArgs } = require('../backup/GiteaRepoBackup.js');
const { commitIdentity } = require('../git/gitBinary.js');
const { createRepoRestore } = require('../backup/GiteaRepoRestore.js');
const agentOptions = { keepAlive: true, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250 };
const api = axios.create({
httpAgent: new http.Agent(agentOptions),
httpsAgent: new https.Agent(agentOptions),
timeout: 30000,
validateStatus: () => true
});
const enc = encodeURIComponent;
const repoApiPath = (owner, repo) => `/repos/${enc(owner)}/${enc(repo)}`;
function giteaContext(readCredentials) {
const c = readCredentials() || {};
if (!c.giteaURL || !c.giteaToken) throw new Error('Gitea-Zugangsdaten fehlen. Bitte in den Einstellungen eintragen.');
const base = normalizeAndValidateBaseUrl(c.giteaURL);
return { base, apiBase: `${base}/api/v1`, token: c.giteaToken, headers: { Authorization: `token ${c.giteaToken}` } };
}
async function giteaCall(ctx, method, apiPath, { params, data, timeout } = {}) {
const res = await api.request({ method, url: ctx.apiBase + apiPath, params, data, headers: ctx.headers, timeout });
if (res.status >= 200 && res.status < 300) return res.data;
const err = new Error((res.data && res.data.message) || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
// Git mit Zeitlimit, ohne den Hauptprozess zu blockieren.
function runGit(args, { cwd, timeoutMs = 60000 } = {}) {
return new Promise((resolve) => {
let child;
try {
child = spawn('git', args, { cwd, windowsHide: true, env: process.env });
} catch (e) {
resolve({ status: -1, stdout: '', stderr: String(e && e.message || e) });
return;
}
const out = [];
const err = [];
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; try { child.kill(); } catch (_) {} }, timeoutMs);
child.stdout.on('data', c => out.push(c));
child.stderr.on('data', c => err.push(c));
child.on('error', e => err.push(Buffer.from(String(e && e.message || e))));
child.on('close', (code) => {
clearTimeout(timer);
resolve({
status: timedOut ? -2 : code,
timedOut,
stdout: Buffer.concat(out).toString('utf8'),
stderr: Buffer.concat(err).toString('utf8')
});
});
child.stdin.end();
});
}
// Token nie in Fehlermeldungen oder Logs durchreichen.
function redact(text, token) {
let s = String(text || '');
if (token) s = s.split(token).join('***');
return s.replace(/\/\/[^/@\s]+@/g, '//***@');
}
function isPackFailure(stderr) {
return /early eof|index-pack|bad pack header|unexpected disconnect|remote end hung up|rpc failed/i.test(String(stderr || ''));
}
/* ----------------------------------------------------------------
Upload-Vorschau
---------------------------------------------------------------- */
async function previewUpload({ paths, destPath }, deps) {
const { items, skipped, ignored } = await deps.buildItemsFromPaths(paths, destPath);
const files = [];
let totalBytes = 0;
for (const item of items) {
let size = 0;
try { size = (await fs.promises.stat(item.localFile)).size; } catch (_) {}
totalBytes += size;
files.push({ localFile: item.localFile, targetPath: item.targetPath, size });
}
const prefs = deps.prefs ? deps.prefs.get() : { uploadSecretScan: true, uploadPreview: 'always' };
// Geheimnis-Erkennung lässt sich unter Einstellungen → Uploads abschalten
const secrets = prefs.uploadSecretScan ? await scanForSecrets(files) : new Map();
for (const f of files) {
const reason = secrets.get(f.localFile);
if (reason) f.secret = reason;
}
const ignoredEntries = [
...skipped.filter(s => s.error === 'gitignore').map(s => ({ path: s.path, isDir: isDirSafe(s.path) })),
...(ignored || [])
];
return {
ok: true, files, totalBytes, ignored: ignoredEntries, secretCount: secrets.size,
previewMode: prefs.uploadPreview, secretScan: prefs.uploadSecretScan
};
}
function isDirSafe(p) {
try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
}
/* ----------------------------------------------------------------
Server-Diagnose
---------------------------------------------------------------- */
const SERVER_HINT_COMMANDS = 'docker logs --tail 300 gitea 2>&1 | grep -iE "pack|upload-pack|error|killed"\ndf -h\nfree -m';
async function probeFamily(ctx, family) {
const lib = ctx.base.startsWith('https:') ? https : http;
const agent = new lib.Agent({ family, keepAlive: false });
const started = Date.now();
try {
const res = await axios.get(`${ctx.apiBase}/version`, {
httpAgent: agent, httpsAgent: agent, timeout: 8000, validateStatus: () => true
});
return { ok: res.status < 500, ms: Date.now() - started, status: res.status };
} catch (e) {
return { ok: false, error: e.code || e.message };
} finally {
agent.destroy();
}
}
async function runDiagnosis(data, deps) {
const checks = [];
const add = (label, status, detail, hint = null) => checks.push({ label, status, detail, hint });
let ctx;
try {
ctx = giteaContext(deps.readCredentials);
add('Zugangsdaten', 'ok', `Server: ${ctx.base}`);
} catch (e) {
add('Zugangsdaten', 'fail', e.message, 'In den Einstellungen Gitea-URL und Token eintragen.');
return { ok: true, checks };
}
// Namensauflösung und Erreichbarkeit pro Adressfamilie
const host = new URL(ctx.base).hostname.replace(/^\[|\]$/g, '');
let families = [];
if (net.isIP(host)) {
families = [net.isIP(host)];
add('Namensauflösung', 'ok', `IP-Adresse direkt angegeben (IPv${families[0]})`);
} else {
try {
const addrs = await dns.lookup(host, { all: true });
const v4 = addrs.filter(a => a.family === 4).map(a => a.address);
const v6 = addrs.filter(a => a.family === 6).map(a => a.address);
if (v4.length) families.push(4);
if (v6.length) families.push(6);
add('Namensauflösung', 'ok', [v4.length ? `IPv4: ${v4.join(', ')}` : null, v6.length ? `IPv6: ${v6.join(', ')}` : null].filter(Boolean).join(' · '));
} catch (e) {
add('Namensauflösung', 'fail', `${host} lässt sich nicht auflösen (${e.code || e.message})`,
'DNS-Eintrag (bei ipv64.net: DynDNS-Update) prüfen und ob der Name richtig geschrieben ist.');
return { ok: true, checks };
}
}
const reach = {};
for (const fam of families) reach[fam] = await probeFamily(ctx, fam);
const working = families.filter(f => reach[f].ok);
for (const fam of families) {
const r = reach[fam];
if (r.ok) add(`Erreichbar über IPv${fam}`, 'ok', `Antwort in ${r.ms} ms`);
else add(`Erreichbar über IPv${fam}`, working.length ? 'warn' : 'fail', `Keine Verbindung (${r.error || 'HTTP ' + r.status})`,
working.length
? `Die App weicht automatisch auf IPv${working[0]} aus. Falls IPv${fam} gewollt ist: Firewall/Portfreigabe und ${fam === 6 ? 'AAAA' : 'A'}-Eintrag prüfen.`
: 'Server läuft nicht oder Port/Firewall/Reverse-Proxy blockiert.');
}
if (!working.length) return { ok: true, checks };
// API und Token
let login = null;
try {
const v = await giteaCall(ctx, 'get', '/version');
add('Gitea-API', 'ok', `Gitea ${v && v.version ? v.version : '(Version unbekannt)'}`);
} catch (e) {
add('Gitea-API', 'fail', e.message, 'Gitea-URL prüfen, sie muss auf die Startseite von Gitea zeigen (ohne /api).');
return { ok: true, checks };
}
try {
const u = await giteaCall(ctx, 'get', '/user');
login = u.login;
add('Token', 'ok', `Angemeldet als ${u.login}`);
} catch (e) {
add('Token', 'fail', e.status === 401 ? 'Token ungültig oder abgelaufen' : e.message, 'In Gitea unter Einstellungen → Anwendungen einen neuen Token erzeugen.');
return { ok: true, checks };
}
// Git-Transport mit einem echten Repo testen
let owner = data && data.owner;
let repo = data && data.repo;
if (!owner || !repo) {
try {
const repos = await giteaCall(ctx, 'get', '/user/repos', { params: { limit: 1 } });
if (repos && repos[0]) { owner = repos[0].owner.login; repo = repos[0].name; }
} catch (_) {}
}
if (!owner || !repo) {
add('Git-Transport', 'warn', 'Kein Repository zum Testen gefunden');
} else {
const u = new URL(ctx.base);
const authUrl = `${u.protocol}//${enc(ctx.token)}@${u.host}${u.pathname.replace(/\/$/, '')}/${owner}/${repo}.git`;
const lr = await runGit(['ls-remote', '--heads', authUrl], { timeoutMs: 30000 });
if (lr.status !== 0) {
add('Git-Anmeldung', 'fail', `ls-remote für ${owner}/${repo} fehlgeschlagen: ${redact(lr.stderr, ctx.token).trim().slice(0, 300)}`,
'Token braucht Lese- und Schreibrechte für Repositories. Reverse-Proxy muss /info/refs durchlassen.');
} else {
add('Git-Anmeldung', 'ok', `${owner}/${repo} erreichbar`);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gm-diag-'));
try {
const started = Date.now();
let pk = await runGit(['clone', '--bare', '--depth', '1', '--filter=tree:0', authUrl, path.join(tmp, 'r.git')], { timeoutMs: 90000 });
if (pk.status !== 0 && /filter|not recognized|unsupported/i.test(pk.stderr) && !isPackFailure(pk.stderr)) {
pk = await runGit(['clone', '--bare', '--depth', '1', authUrl, path.join(tmp, 'r2.git')], { timeoutMs: 90000 });
}
if (pk.status === 0) {
add('Git-Pakete', 'ok', `Server liefert Pakete aus (${Date.now() - started} ms)`);
if (deps.clearGitTransportBlock && deps.clearGitTransportBlock(u.host)) {
add('Schneller Git-Weg', 'ok', 'Sperre aufgehoben: Uploads und Löschungen laufen wieder über Git statt über die langsame API.');
}
} else if (pk.timedOut) {
add('Git-Pakete', 'fail', 'Zeitüberschreitung beim Abholen eines Pakets', 'Verbindung oder Server sehr langsam. Reverse-Proxy-Timeouts prüfen.');
} else if (isPackFailure(pk.stderr)) {
add('Git-Pakete', 'fail',
'Der Server bricht beim Ausliefern von Git-Paketen ab (early EOF). Deshalb laufen Uploads und Löschungen über die langsame API.',
'Das liegt am Server. Häufige Ursachen: Datenträger voll, zu wenig RAM für git pack-objects, defektes Repo oder ein Proxy, der die Antwort abschneidet. Auf dem Server prüfen:\n' + SERVER_HINT_COMMANDS);
} else {
add('Git-Pakete', 'fail', redact(pk.stderr, ctx.token).trim().slice(0, 300));
}
} finally {
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {}
}
}
}
add('Speicherplatz & RAM', 'info', 'Lassen sich über die Gitea-API nicht abfragen.', 'Auf dem Server prüfen mit:\ndf -h\nfree -m');
return { ok: true, checks, login };
}
/* ----------------------------------------------------------------
Lokale Änderungen
---------------------------------------------------------------- */
const STATUS_LABELS = { M: 'geändert', A: 'neu', D: 'gelöscht', R: 'umbenannt', C: 'kopiert', U: 'Konflikt', '?': 'neu (nicht verfolgt)', T: 'Typ geändert' };
function parsePorcelainZ(out) {
const parts = out.split('\0');
const files = [];
for (let i = 0; i < parts.length; i++) {
const entry = parts[i];
if (!entry) continue;
const x = entry[0];
const y = entry[1];
const file = entry.slice(3);
let from = null;
if (x === 'R' || x === 'C') { from = parts[i + 1]; i++; }
const code = x === '?' ? '?' : (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) ? 'U' : (y !== ' ' ? y : x);
files.push({ path: file, from, code, label: STATUS_LABELS[code] || code, staged: x !== ' ' && x !== '?' });
}
return files;
}
async function localGit(data, deps = {}) {
const folder = data && data.folder;
if (!folder || !fs.existsSync(folder)) return { ok: false, error: 'Ordner nicht gefunden' };
const top = await runGit(['-C', folder, 'rev-parse', '--show-toplevel']);
if (top.status !== 0) return { ok: false, notRepo: true, error: 'Dieser Ordner ist kein Git-Repository.' };
const root = path.resolve(top.stdout.trim());
switch (data.action) {
case 'status': {
const st = await runGit(['-C', root, 'status', '--porcelain=v1', '-z', '-uall']);
if (st.status !== 0) return { ok: false, error: st.stderr.trim() };
const br = await runGit(['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD']);
const ahead = await runGit(['-C', root, 'rev-list', '--count', '@{u}..HEAD']);
return {
ok: true, root,
branch: br.status === 0 ? br.stdout.trim() : '(noch kein Commit)',
ahead: ahead.status === 0 ? Number(ahead.stdout.trim()) || 0 : null,
files: parsePorcelainZ(st.stdout)
};
}
case 'diff': {
const file = String(data.file || '');
if (!file) return { ok: false, error: 'Keine Datei' };
const abs = path.join(root, file);
if (data.code === '?' || data.code === 'A') {
try {
const st = await fs.promises.stat(abs);
if (st.size > 300 * 1024) return { ok: true, diff: `Neue Datei (${Math.round(st.size / 1024)} KB), zu groß für die Vorschau.` };
const buf = await fs.promises.readFile(abs);
if (buf.subarray(0, 8000).includes(0)) return { ok: true, diff: 'Neue Binärdatei' };
return { ok: true, diff: buf.toString('utf8').split('\n').map(l => '+' + l).join('\n'), allNew: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
const d = await runGit(['-C', root, 'diff', 'HEAD', '--no-color', '--', file]);
if (d.status !== 0) return { ok: false, error: d.stderr.trim() };
return { ok: true, diff: d.stdout.length > 400000 ? d.stdout.slice(0, 400000) + '\n… (gekürzt)' : (d.stdout || 'Keine inhaltlichen Unterschiede (z. B. nur Zeilenenden).') };
}
case 'commit': {
const files = Array.isArray(data.files) ? data.files.filter(Boolean) : [];
const message = String(data.message || '').trim();
if (!files.length) return { ok: false, error: 'Keine Dateien ausgewählt' };
if (!message) return { ok: false, error: 'Commit-Nachricht fehlt' };
// Nur die ausgewählten Dateien in den Commit nehmen.
await runGit(['-C', root, 'reset', '-q']);
for (let i = 0; i < files.length; i += 100) {
const r = await runGit(['-C', root, 'add', '-A', '--', ...files.slice(i, i + 100)]);
if (r.status !== 0) return { ok: false, error: r.stderr.trim() };
}
let c = await runGit(['-C', root, 'commit', '-m', message]);
if (c.status !== 0 && /tell me who you are|user\.email|user\.name/i.test(c.stderr)) {
const id = commitIdentity(deps.prefs && deps.prefs.get());
c = await runGit(['-C', root, '-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-m', message]);
}
if (c.status !== 0) return { ok: false, error: (c.stderr || c.stdout).trim() };
return { ok: true, output: c.stdout.trim() };
}
case 'push': {
const p = await runGit(['-C', root, 'push', 'origin', 'HEAD'], { timeoutMs: 600000 });
if (p.status !== 0) {
return { ok: false, error: redact(p.stderr || p.stdout, null).trim() || 'Push fehlgeschlagen',
hint: isPackFailure(p.stderr) ? 'Der Server bricht die Übertragung ab. Server-Diagnose in den Einstellungen ausführen.' : null };
}
return { ok: true, output: p.stderr.trim() };
}
case 'discard': {
const file = String(data.file || '');
if (!file) return { ok: false, error: 'Keine Datei' };
if (data.code === '?') {
await fs.promises.rm(path.join(root, file), { force: true });
return { ok: true };
}
const r = await runGit(['-C', root, 'checkout', 'HEAD', '--', file]);
return r.status === 0 ? { ok: true } : { ok: false, error: r.stderr.trim() };
}
default:
return { ok: false, error: 'Unbekannte Aktion' };
}
}
/* ----------------------------------------------------------------
Pull Requests & Issues
---------------------------------------------------------------- */
function mapUser(u) { return u ? { login: u.login, avatar: u.avatar_url } : null; }
function mapIssue(i) {
return {
number: i.number, title: i.title, body: i.body || '', state: i.state,
user: mapUser(i.user), comments: i.comments, created: i.created_at, updated: i.updated_at,
labels: (i.labels || []).map(l => ({ name: l.name, color: l.color })), url: i.html_url,
isPull: !!i.pull_request
};
}
function mapPull(p) {
return {
...mapIssue(p), isPull: true,
head: p.head && p.head.label, base: p.base && p.base.label,
mergeable: p.mergeable, merged: !!p.merged
};
}
async function giteaCollab(data, deps) {
const ctx = giteaContext(deps.readCredentials);
const { owner, repo } = data || {};
if (!owner || !repo) return { ok: false, error: 'Repository fehlt' };
const rp = repoApiPath(owner, repo);
const idx = Number(data.index);
const state = ['open', 'closed', 'all'].includes(data.state) ? data.state : 'open';
const page = Math.max(1, Number(data.page) || 1);
switch (data.action) {
case 'listPulls': {
const list = await giteaCall(ctx, 'get', `${rp}/pulls`, { params: { state, page, limit: 30, sort: 'recentupdate' } });
return { ok: true, items: (list || []).map(mapPull) };
}
case 'listIssues': {
const list = await giteaCall(ctx, 'get', `${rp}/issues`, { params: { state, page, limit: 30, type: 'issues' } });
return { ok: true, items: (list || []).map(mapIssue) };
}
case 'get': {
const item = data.isPull
? mapPull(await giteaCall(ctx, 'get', `${rp}/pulls/${idx}`))
: mapIssue(await giteaCall(ctx, 'get', `${rp}/issues/${idx}`));
const comments = await giteaCall(ctx, 'get', `${rp}/issues/${idx}/comments`);
return {
ok: true, item,
comments: (comments || []).map(c => ({ id: c.id, body: c.body || '', user: mapUser(c.user), created: c.created_at }))
};
}
case 'comment': {
const body = String(data.body || '').trim();
if (!body) return { ok: false, error: 'Kommentar ist leer' };
await giteaCall(ctx, 'post', `${rp}/issues/${idx}/comments`, { data: { body } });
return { ok: true };
}
case 'setState': {
if (!['open', 'closed'].includes(data.state)) return { ok: false, error: 'Ungültiger Status' };
await giteaCall(ctx, 'patch', `${rp}/issues/${idx}`, { data: { state: data.state } });
return { ok: true };
}
case 'merge': {
const style = ['merge', 'rebase', 'rebase-merge', 'squash'].includes(data.style) ? data.style : 'merge';
await giteaCall(ctx, 'post', `${rp}/pulls/${idx}/merge`, {
data: { Do: style, delete_branch_after_merge: !!data.deleteBranch },
timeout: 120000
});
return { ok: true };
}
case 'createIssue': {
const title = String(data.title || '').trim();
if (!title) return { ok: false, error: 'Titel fehlt' };
const created = await giteaCall(ctx, 'post', `${rp}/issues`, { data: { title, body: String(data.body || '') } });
return { ok: true, item: mapIssue(created) };
}
default:
return { ok: false, error: 'Unbekannte Aktion' };
}
}
/* ----------------------------------------------------------------
Registrierung
---------------------------------------------------------------- */
function wrap(fn) {
return async (event, data) => {
try {
return await fn(data || {}, event);
} catch (e) {
console.error('[features]', e && e.message ? e.message : e);
return { ok: false, error: e && e.message ? e.message : String(e) };
}
};
}
function registerFeatureIpc(deps) {
const { ipcMain } = deps;
ipcMain.handle('preview-upload-paths', wrap((data) => previewUpload(data, deps)));
ipcMain.handle('run-server-diagnosis', wrap((data) => runDiagnosis(data, deps)));
ipcMain.handle('local-git', wrap((data) => localGit(data, deps)));
// App-Einstellungen (ohne Geheimnisse)
ipcMain.handle('prefs-get', wrap(() => ({ ok: true, prefs: deps.prefs ? deps.prefs.get() : {} })));
ipcMain.handle('prefs-set', wrap((data) => {
if (!deps.prefs) return { ok: false, error: 'Einstellungen nicht verfügbar' };
const r = deps.prefs.set(data);
return { ok: r.rejected.length === 0, prefs: r.prefs, rejected: r.rejected,
error: r.rejected.includes('gitEmail') ? 'Die E-Mail-Adresse ist ungültig und wurde nicht übernommen.' : undefined };
}));
// Gesperrter schneller Git-Weg (nach "early EOF") anzeigen und von Hand freigeben
ipcMain.handle('git-transport-state', wrap(() => {
const blocks = deps.getGitTransportBlocks ? deps.getGitTransportBlocks() : {};
return { ok: true, blocked: Object.entries(blocks).map(([host, since]) => ({ host, since: new Date(since).toISOString() })) };
}));
ipcMain.handle('git-transport-clear', wrap((data) => {
const host = String((data && data.host) || '');
return { ok: !!(deps.clearGitTransportBlock && deps.clearGitTransportBlock(host)) };
}));
ipcMain.handle('gitea-collab', wrap((data) => giteaCollab(data, deps)));
const backup = createRepoBackup({
configPath: path.join(deps.app.getPath('userData'), 'repo-backup.json'),
getContext: () => giteaContext(deps.readCredentials),
giteaCall,
runGit,
isPackFailure,
redact
});
ipcMain.handle('repo-backup-get-config', wrap(() => ({ ok: true, ...backup.getState() })));
ipcMain.handle('repo-backup-set-config', wrap((data) => ({ ok: true, ...backup.setConfig(data) })));
// Fortschritt an alle Fenster melden – auch bei einem geplanten Backup und für später geöffnete Ansichten
backup.onProgress((p) => {
if (deps.broadcast) deps.broadcast('repo-backup-progress', p);
// Windows-Benachrichtigung am Ende – je nach Einstellung immer, nur bei Problemen oder nie
if (!p || (p.phase !== 'done' && p.phase !== 'error') || !deps.notify) return;
const mode = backup.getState().config.notify;
const s = p.summary;
const problem = p.phase === 'error' || (s && (s.failed > 0 || s.cancelled));
if (mode === 'never' || (mode === 'errors' && !problem)) return;
if (p.phase === 'error') deps.notify('Repo-Backup fehlgeschlagen', p.error || 'Unbekannter Fehler');
else if (s.cancelled) deps.notify('Repo-Backup abgebrochen', `${s.ok} von ${s.total} Repos gesichert.`);
else if (s.failed) deps.notify('Repo-Backup mit Fehlern', `${s.ok} von ${s.total} gesichert, ${s.failed} fehlgeschlagen. Details in den Einstellungen unter Backup.`);
else deps.notify('Repo-Backup fertig', `${s.ok} von ${s.total} Repos gesichert.`);
});
// Ordner im Explorer öffnen – nur feste, bekannte Ziele, keine beliebigen Pfade aus dem Renderer
ipcMain.handle('open-known-folder', wrap(async (data) => {
const targets = {
backup: () => backup.getState().config.targetDir,
appData: () => deps.app.getPath('userData'),
logs: () => deps.app.getPath('logs')
};
const resolve = targets[data && data.which];
const dir = resolve && resolve();
if (!dir) return { ok: false, error: 'Kein Ordner festgelegt.' };
if (!fs.existsSync(dir)) return { ok: false, error: `Ordner "${dir}" nicht gefunden.` };
const err = deps.openPath ? await deps.openPath(dir) : 'Öffnen nicht möglich';
return err ? { ok: false, error: err } : { ok: true, path: dir };
}));
ipcMain.handle('repo-backup-run-now', wrap(() => backup.runNow(null)));
ipcMain.handle('repo-backup-cancel', wrap(() => backup.cancel()));
backup.startScheduler();
const restore = createRepoRestore({
getContext: () => giteaContext(deps.readCredentials),
giteaCall, runGit, isPackFailure, redact, uploadItemsViaGiteaApi, authHeaderArgs,
identity: () => commitIdentity(deps.prefs && deps.prefs.get())
});
ipcMain.handle('repo-backup-list', wrap((data) => {
const dir = (data && data.dir) || backup.getState().config.targetDir;
if (!dir) return { ok: false, error: 'Kein Backup-Ordner festgelegt.' };
if (!fs.existsSync(dir)) return { ok: false, error: `Backup-Ordner "${dir}" nicht gefunden.` };
return { ok: true, dir, backups: restore.listBackups(dir) };
}));
ipcMain.handle('repo-restore-local', wrap((data) => restore.restoreLocal(data)));
ipcMain.handle('repo-restore-gitea', wrap((data, event) => restore.restoreToGitea(data, (p) => {
try { event.sender.send('repo-restore-progress', p); } catch (_) {}
})));
return { backup, restore };
}
module.exports = { registerFeatureIpc, parsePorcelainZ };
+70
View File
@@ -0,0 +1,70 @@
// Vorlagen für die .gitignore, die per Knopf in ein Repo geschrieben werden.
const GENERAL = `# Betriebssystem / Editor
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
# Zugangsdaten und Logs
.env
.env.*
*.pem
*.key
*.log
`;
const TEMPLATES = {
node: {
label: 'Node.js',
content: `# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm/
.cache/
`
},
dotnet: {
label: '.NET',
content: `# .NET
bin/
obj/
.vs/
*.user
*.suo
*.nupkg
`
},
electron: {
label: 'Electron',
content: `# Electron
dist/
out/
release/
*.exe
*.blockmap
*.sha256
`
}
};
function listTemplates() {
return Object.entries(TEMPLATES).map(([key, t]) => ({ key, label: t.label }));
}
// Baut die Datei aus dem allgemeinen Teil und den gewählten Vorlagen (unbekannte Schlüssel entfallen).
function buildGitignore(keys) {
const parts = [GENERAL];
const seen = new Set();
for (const key of keys || []) {
if (seen.has(key) || !Object.prototype.hasOwnProperty.call(TEMPLATES, key)) continue;
seen.add(key);
parts.push(TEMPLATES[key].content);
}
return parts.join('\n');
}
module.exports = { listTemplates, buildGitignore };
+75
View File
@@ -0,0 +1,75 @@
// src/features/secretScan.js
// Erkennt Dateien, die Zugangsdaten enthalten oder selbst welche sind, bevor sie hochgeladen werden.
// Geprüft wird der Dateiname und bei kleinen Textdateien der Inhalt.
const fs = require('fs');
const path = require('path');
// Vorlagen wie ".env.example" sind gewollt und werden nicht gemeldet.
const TEMPLATE_SUFFIX = /\.(example|sample|template|dist|defaults?)$/i;
const NAME_RULES = [
[/^\.env(\..+)?$/i, 'Umgebungsvariablen (.env) enthalten meist Passwörter und Tokens'],
[/\.(keystore|jks|p12|pfx)$/i, 'Signatur-Keystore: damit können fremde App-Updates signiert werden'],
[/\.(pem|key|ppk)$/i, 'Privater Schlüssel oder Zertifikat'],
[/^id_(rsa|dsa|ecdsa|ed25519)$/i, 'Privater SSH-Schlüssel'],
[/^google-services\.json$/i, 'Firebase-Konfiguration mit API-Schlüssel'],
[/^GoogleService-Info\.plist$/i, 'Firebase-Konfiguration (iOS) mit API-Schlüssel'],
[/^(credentials|secrets?|service[-_]?account.*)\.json$/i, 'Zugangsdaten-Datei'],
[/^(keystore|signing)\.properties$/i, 'Passwörter für den Signatur-Keystore'],
[/^local\.properties$/i, 'Lokale Einstellungen, oft mit Pfaden und Signatur-Passwörtern'],
[/^(\.npmrc|\.pypirc|\.netrc|\.git-credentials)$/i, 'Zugangsdaten für Paketdienste oder Git']
];
const CONTENT_RULES = [
[/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/, 'enthält einen privaten Schlüssel'],
[/\bgh[pousr]_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/, 'enthält einen GitHub-Token'],
[/\bglpat-[A-Za-z0-9_-]{20,}\b/, 'enthält einen GitLab-Token'],
[/\bAKIA[0-9A-Z]{16}\b/, 'enthält einen AWS-Zugangsschlüssel'],
[/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, 'enthält einen Slack-Token'],
[/\bsk_live_[A-Za-z0-9]{20,}\b/, 'enthält einen Stripe-Live-Schlüssel'],
[/\bAIza[0-9A-Za-z_-]{35}\b/, 'enthält einen Google-API-Schlüssel'],
[/\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/, 'enthält einen Telegram-Bot-Token'],
[/discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]{20,}/i, 'enthält einen Discord-Webhook'],
[/\b[MNO][A-Za-z\d_-]{23,27}\.[\w-]{6}\.[\w-]{27,40}\b/, 'enthält einen Discord-Bot-Token']
];
const MAX_SCAN_BYTES = 512 * 1024;
const MAX_SCAN_FILES = 3000;
function checkName(fileName) {
if (TEMPLATE_SUFFIX.test(fileName)) return null;
for (const [re, reason] of NAME_RULES) if (re.test(fileName)) return reason;
return null;
}
async function checkContent(filePath, size) {
if (size > MAX_SCAN_BYTES || size === 0) return null;
let buf;
try { buf = await fs.promises.readFile(filePath); } catch (_) { return null; }
// Binärdateien überspringen
if (buf.subarray(0, 8000).includes(0)) return null;
const text = buf.toString('utf8');
for (const [re, reason] of CONTENT_RULES) if (re.test(text)) return reason;
return null;
}
/**
* @param {Array<{localFile: string, size: number}>} files
* @returns {Promise<Map<string, string>>} localFile -> Grund
*/
async function scanForSecrets(files) {
const found = new Map();
let scanned = 0;
for (const f of files) {
const byName = checkName(path.basename(f.localFile));
if (byName) { found.set(f.localFile, byName); continue; }
if (scanned >= MAX_SCAN_FILES) continue;
scanned++;
const byContent = await checkContent(f.localFile, f.size);
if (byContent) found.set(f.localFile, byContent);
}
return found;
}
module.exports = { scanForSecrets, checkName };
+156 -12
View File
@@ -7,14 +7,16 @@ const fs = require('fs');
const http = require('http');
const https = require('https');
// IPv4 bevorzugen – verhindert ETIMEDOUT wenn der Hostname nur per IPv6 erreichbar wäre
// oder Node.js fälschlicherweise IPv6 vorranging versucht.
const ipv4HttpAgent = new http.Agent({ family: 4, keepAlive: true });
const ipv4HttpsAgent = new https.Agent({ family: 4, keepAlive: true });
// Dual-Stack: IPv4 und IPv6 werden unterstützt. autoSelectFamily (Happy Eyeballs)
// probiert beide Adressfamilien, damit weder reine IPv6- noch reine IPv4-Server
// in einen Timeout laufen.
const agentOptions = { keepAlive: true, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: 250 };
const dualStackHttpAgent = new http.Agent(agentOptions);
const dualStackHttpsAgent = new https.Agent(agentOptions);
const axiosInstance = axios.create({
httpAgent: ipv4HttpAgent,
httpsAgent: ipv4HttpsAgent,
httpAgent: dualStackHttpAgent,
httpsAgent: dualStackHttpsAgent,
});
// Kurzlebiger Cache für die Default-Branch-Auflösung (HEAD -> z.B. "main").
@@ -2094,7 +2096,34 @@ async function resolveGiteaBranchName({ token, base, owner, repo, branch }) {
// Ermittelt die SHAs bereits vorhandener Dateien. Die Contents-API verlangt bei einem
// "update" die SHA der Vorgängerversion. Abgefragt wird pro Verzeichnis, nicht pro Datei.
// Schnellweg: der komplette Dateibaum kommt über die Git-Trees-API in wenigen Seiten
// statt einer Anfrage pro Verzeichnis. Verzeichnislisten sind außerdem je nach Server
// begrenzt und lieferten bei großen Ordnern unvollständige SHAs ("already exists").
async function fetchShasViaTree({ token, base, owner, repo, branch }) {
const shaByPath = new Map();
const url = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(branch)}`;
for (let page = 1; page <= 200; page++) {
const res = await axiosInstance.get(url, {
params: { recursive: true, per_page: 1000, page },
headers: { Authorization: `token ${token}` },
timeout: 60000
});
const tree = (res.data && res.data.tree) || [];
for (const node of tree) {
if (node.type === 'blob' && node.path && node.sha) shaByPath.set(node.path, node.sha);
}
const total = res.data && res.data.total_count;
if (!tree.length || !res.data.truncated || (total && shaByPath.size >= total)) break;
}
return shaByPath;
}
async function fetchExistingFileShas({ token, base, owner, repo, branch, paths }) {
try {
return await fetchShasViaTree({ token, base, owner, repo, branch });
} catch (e) {
console.warn('[ApiUpload] Trees-API nicht nutzbar, nutze Verzeichnislisten:', e.message || e);
}
const shaByPath = new Map();
const dirs = new Set(paths.map(p => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')));
@@ -2116,12 +2145,28 @@ async function fetchExistingFileShas({ token, base, owner, repo, branch, paths }
}
// Ein Commit mit mehreren Dateiänderungen.
async function changeGiteaFiles({ token, base, owner, repo, branch, files, message }) {
// Fehler, mit dem ein vom Nutzer abgebrochener Upload die Aufrufer-Kette verlaesst.
function createUploadCancelledError() {
const err = new Error('Upload abgebrochen');
err.code = 'UPLOAD_CANCELLED';
return err;
}
function isUploadCancelledError(err) {
return !!err && err.code === 'UPLOAD_CANCELLED';
}
function throwIfUploadCancelled(signal) {
if (signal && signal.aborted) throw createUploadCancelledError();
}
async function changeGiteaFiles({ token, base, owner, repo, branch, files, message, signal }) {
const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`;
const res = await axiosInstance.post(
endpoint,
{ branch, message, files },
{
signal,
headers: { Authorization: `token ${token}` },
timeout: 180000,
maxBodyLength: Infinity,
@@ -2145,10 +2190,11 @@ function isPayloadTooLargeError(err) {
* @param {function(number):void} [onProgress] Fortschritt in Prozent
* @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>}
*/
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress }) {
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress, signal }) {
const base = normalizeBase(url);
if (!base) throw new Error('Invalid Gitea base URL');
if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen');
throwIfUploadCancelled(signal);
const branchName = await resolveGiteaBranchName({ token, base, owner, repo, branch });
const shaByPath = await fetchExistingFileShas({
@@ -2193,7 +2239,8 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items,
if (current.length > 0) batches.push(current);
// Ein Paket absenden; bei zu großem Body rekursiv halbieren.
const sendBatch = async (batch) => {
const sendBatch = async (batch, retried = false) => {
throwIfUploadCancelled(signal);
const files = batch.map(item => {
const existingSha = shaByPath.get(item.targetPath);
const entry = {
@@ -2206,7 +2253,7 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items,
});
try {
await changeGiteaFiles({ token, base, owner, repo, branch: branchName, files, message });
await changeGiteaFiles({ token, base, owner, repo, branch: branchName, files, message, signal });
commits++;
for (const item of batch) {
uploaded.push(item.targetPath);
@@ -2215,6 +2262,30 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items,
if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100)));
return;
} catch (err) {
// Abbruch darf nicht als Dateifehler in `failed` landen.
throwIfUploadCancelled(signal);
const errText = String((err.response && err.response.data && err.response.data.message) || err.message || '');
// Datei existiert schon (SHA fehlte/veraltet): SHAs frisch laden und einmal wiederholen.
if (!retried && /already exists|\[sha\]|does not match|sha/i.test(errText)) {
try {
const fresh = await fetchExistingFileShas({
token, base, owner, repo, branch: branchName, paths: batch.map(i => i.targetPath)
});
shaByPath.clear();
for (const [k, v] of fresh) shaByPath.set(k, v);
} catch (_) {}
return sendBatch(batch, true);
}
// Weiterhin Fehler: Paket halbieren, damit eine einzelne Datei nicht alle anderen blockiert.
if (retried && batch.length > 1 && /already exists|sha/i.test(errText)) {
const half = Math.ceil(batch.length / 2);
await sendBatch(batch.slice(0, half), true);
await sendBatch(batch.slice(half), true);
return;
}
// Body zu groß: aufteilen und erneut versuchen.
if (isPayloadTooLargeError(err) && batch.length > 1) {
const half = Math.ceil(batch.length / 2);
@@ -2235,15 +2306,88 @@ async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items,
}
};
for (const batch of batches) {
await sendBatch(batch);
for (let i = 0; i < batches.length; i++) {
await sendBatch(batches[i]);
console.log(`[ApiUpload] Paket ${i + 1}/${batches.length} fertig (${uploaded.length} hochgeladen, ${failed.length} fehlgeschlagen)`);
}
if (onProgress) onProgress(100);
return { branch: branchName, commits, uploaded, failed };
}
// Alle Dateien eines Branches als Map Pfad -> SHA (über die Git-Trees-API).
async function listGiteaRepoFiles({ token, url, owner, repo, branch = 'HEAD' }) {
const base = normalizeBase(url);
if (!base) throw new Error('Invalid Gitea base URL');
const branchName = await resolveGiteaBranchName({ token, base, owner, repo, branch });
const files = await fetchShasViaTree({ token, base, owner, repo, branch: branchName });
return { branch: branchName, files };
}
// Gemessen auf git.viper.ipv64.net: 100 Dateien ca. 3 s, 1000 Dateien über 2 min pro Commit.
// Gitea verarbeitet die Dateien eines Commits einzeln, große Pakete lohnen sich daher nicht.
const API_DELETE_FILES_PER_COMMIT = 100;
/**
* Löscht Dateien paketweise: ein Commit pro bis zu 100 Dateien statt einem Commit pro Datei.
* Entweder `prefix` (Datei oder Ordner samt Inhalt) oder eine Liste `paths` angeben.
*
* @returns {Promise<{branch: string, deleted: number, commits: number, failed: Array<{path: string, error: string}>}>}
*/
async function deleteGiteaPathsBatched({ token, url, owner, repo, branch = 'HEAD', prefix = null, paths = null, message, onProgress }) {
const base = normalizeBase(url);
if (!base) throw new Error('Invalid Gitea base URL');
const { branch: branchName, files } = await listGiteaRepoFiles({ token, url: base, owner, repo, branch });
let targets;
if (prefix != null) {
const clean = String(prefix).replace(/^\/+|\/+$/g, '');
targets = [...files.keys()].filter(p => p === clean || p.startsWith(`${clean}/`));
} else {
targets = (paths || []).filter(p => files.has(p));
}
const failed = [];
let deleted = 0;
let commits = 0;
const sendBatch = async (batch) => {
const ops = batch.map(p => ({ operation: 'delete', path: p, sha: files.get(p) }));
try {
await changeGiteaFiles({
token, base, owner, repo, branch: branchName, files: ops,
message: message || (batch.length === 1 ? `Delete ${batch[0]} via Git Manager GUI` : `Delete ${batch.length} files via Git Manager GUI`)
});
deleted += batch.length;
commits++;
} catch (err) {
// Eine problematische Datei soll nicht das ganze Paket blockieren.
if (batch.length > 1) {
const half = Math.ceil(batch.length / 2);
await sendBatch(batch.slice(0, half));
await sendBatch(batch.slice(half));
return;
}
const detail = (err.response && err.response.data && err.response.data.message) || err.message || String(err);
failed.push({ path: batch[0], error: String(detail) });
}
};
for (let i = 0; i < targets.length; i += API_DELETE_FILES_PER_COMMIT) {
await sendBatch(targets.slice(i, i + API_DELETE_FILES_PER_COMMIT));
if (onProgress) onProgress(Math.round(Math.min(targets.length, i + API_DELETE_FILES_PER_COMMIT) / targets.length * 100));
console.log(`[ApiDelete] ${deleted}/${targets.length} gelöscht, ${failed.length} fehlgeschlagen`);
}
return { branch: branchName, deleted, commits, failed, total: targets.length };
}
module.exports = {
createUploadCancelledError,
isUploadCancelledError,
throwIfUploadCancelled,
listGiteaRepoFiles,
deleteGiteaPathsBatched,
normalizeAndValidateBaseUrl,
createRepoGitHub,
createRepoGitea,
+76
View File
@@ -0,0 +1,76 @@
// src/git/gitBinary.js
// Die App bringt ihr eigenes Git mit (MinGit unter vendor/mingit bzw. resources/mingit),
// damit auf dem PC kein Git installiert sein muss.
//
// Statt jeden Aufruf umzustellen, wird der mitgelieferte Ordner beim Start vorne in den
// PATH dieses Prozesses gesetzt. spawnSync('git') und simple-git finden dadurch immer das
// eingebaute Git. simple-git akzeptiert als "binary" ohnehin keine Pfade mit Leerzeichen,
// und das Installationsverzeichnis "Git Manager GUI" enthält welche.
const fs = require('fs');
const path = require('path');
function findBundledGitDir() {
if (process.platform !== 'win32') return null;
const candidates = [
// Installierte App: electron-builder legt extraResources neben app.asar ab.
process.resourcesPath && path.join(process.resourcesPath, 'mingit', 'cmd'),
// Entwicklung (npm start)
path.join(__dirname, '..', '..', 'vendor', 'mingit', 'cmd')
].filter(Boolean);
return candidates.find(dir => fs.existsSync(path.join(dir, 'git.exe'))) || null;
}
let bundledGitDir;
function useBundledGit() {
if (bundledGitDir !== undefined) return bundledGitDir;
bundledGitDir = findBundledGitDir();
// Nie auf eine Eingabe im (unsichtbaren) Terminal warten: die App übergibt Tokens selbst.
process.env.GIT_TERMINAL_PROMPT = '0';
process.env.GCM_INTERACTIVE = 'never';
if (bundledGitDir) {
const key = Object.keys(process.env).find(k => k.toUpperCase() === 'PATH') || 'Path';
process.env[key] = `${bundledGitDir}${path.delimiter}${process.env[key] || ''}`;
console.log(`[Git] Nutze eingebautes Git: ${bundledGitDir}`);
} else {
console.warn('[Git] Eingebautes Git nicht gefunden, nutze Git aus dem PATH');
}
return bundledGitDir;
}
// Git-Identität aus den App-Einstellungen als Rückfallwert setzen. Sie landet in der
// System-Ebene (niedrigste Priorität): ein Name/eine E-Mail aus ~/.gitconfig oder aus dem
// Repo selbst gewinnt weiterhin. Dafür zeigt GIT_CONFIG_SYSTEM auf eine eigene Datei, die
// die Standard-Konfiguration des eingebauten Git einbindet.
function applyGitIdentity({ name, email } = {}, userDataDir) {
const dir = bundledGitDir === undefined ? findBundledGitDir() : bundledGitDir;
if (!dir || !userDataDir) return false;
if (!name && !email) {
delete process.env.GIT_CONFIG_SYSTEM;
try { fs.rmSync(path.join(userDataDir, 'git-system.gitconfig'), { force: true }); } catch (_) {}
return false;
}
const quote = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
const baseConfig = path.join(dir, '..', 'etc', 'gitconfig').split(path.sep).join('/');
const lines = ['[include]', `\tpath = ${quote(baseConfig)}`, '[user]'];
if (name) lines.push(`\tname = ${quote(name)}`);
if (email) lines.push(`\temail = ${quote(email)}`);
const file = path.join(userDataDir, 'git-system.gitconfig');
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8');
process.env.GIT_CONFIG_SYSTEM = file;
return true;
}
// Name/E-Mail für Commits, die die App selbst anlegt (Upload-Klone, Wiederherstellung).
function commitIdentity(prefs) {
return {
name: (prefs && prefs.gitName) || 'Git Manager GUI',
email: (prefs && prefs.gitEmail) || 'gui@gitmanager.local'
};
}
module.exports = { useBundledGit, findBundledGitDir, applyGitIdentity, commitIdentity };
+8
View File
@@ -274,8 +274,16 @@ function safeWriteFile(filePath, content) {
}
}
// IPv6-Literale kommen aus new URL().hostname mit eckigen Klammern ("[::1]").
// http.request/net erwarten den Host ohne Klammern.
function bareHostname(hostname) {
const h = String(hostname || '');
return h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;
}
// ===== EXPORTS =====
module.exports = {
bareHostname,
logger,
normalizeBranch,
isSafeBranch,