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