diff --git a/main.js b/main.js index a139971..d2a17d2 100644 --- a/main.js +++ b/main.js @@ -1,12 +1,11 @@ // main.js — Main-Process with concurrent folder upload/download, progress events, and temp-dir cleanup -const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard } = require('electron'); +const { app, BrowserWindow, ipcMain, dialog, Menu, nativeImage, Tray, shell, clipboard, safeStorage } = require('electron'); const ppath = require('path'); const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); -const { execSync } = require('child_process'); +const { execSync, spawnSync } = require('child_process'); const https = require('https'); -const archiver = require('archiver'); const Updater = require('./updater.js'); // Auto-Updater let updater = null; @@ -19,6 +18,7 @@ const { getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile, + getGiteaCurrentUser, getGiteaCommits, getGiteaCommit, getGiteaCommitDiff, @@ -31,20 +31,47 @@ const { editGiteaRelease, deleteGiteaRelease, uploadReleaseAsset, - deleteReleaseAsset + deleteReleaseAsset, + updateGiteaAvatar, + updateGiteaRepoAvatar, + updateGiteaRepoVisibility, + updateGiteaRepoTopics, + migrateRepoToGitea + ,listGiteaTopicsCatalog, + // GitHub + listGithubRepos, + getGithubCurrentUser, + getGithubUserHeatmap, + getGithubRepoContents, + getGithubFileContent, + uploadGithubFile, + deleteGithubFile, + getGithubCommits, + getGithubCommitDiff, + getGithubCommitFiles, + searchGithubCommits, + getGithubBranches, + listGithubReleases, + createGithubRelease, + editGithubRelease, + deleteGithubRelease, + updateGithubRepoVisibility, + updateGithubRepoTopics, + deleteGithubRepo } = require('./src/git/apiHandler.js'); const { initRepo, commitAndPush, getBranches, getCommitLogs } = require('./src/git/gitHandler.js'); -// Backup system -const BackupManager = require('./src/backup/BackupManager.js'); -const LocalProvider = require('./src/backup/LocalProvider.js'); // NOTE: credentials/data location is computed via getDataDir() to avoid calling app.getPath before ready function getCredentialsFilePath() { return ppath.join(app.getPath('userData'), 'credentials.json'); } +function getLegacyWorkspaceCredentialsFilePath() { + return ppath.join(__dirname, 'data', 'credentials.json'); +} + const ALGORITHM = 'aes-256-cbc'; const SECRET_KEY = crypto.scryptSync('SuperSecretKey123!', 'salt', 32); const IV = Buffer.alloc(16, 0); @@ -59,6 +86,201 @@ const RETRY_MAX_ATTEMPTS = 8; let retryQueue = []; let retryQueueRunning = false; let retryQueueTimer = null; +let credentialReadIssueLogged = false; +let lastCredentialReadStatus = { + state: 'idle', + reason: null, + filePath: null, + message: null +}; + +function setCredentialReadStatus(status) { + lastCredentialReadStatus = { + state: status?.state || 'idle', + reason: status?.reason || null, + filePath: status?.filePath || null, + message: status?.message || null + }; +} + +function getCredentialReadStatus() { + return { ...lastCredentialReadStatus }; +} + +function logCredentialIssueOnce(message) { + if (credentialReadIssueLogged) return; + credentialReadIssueLogged = true; + console.warn(message); +} + +function quarantineCredentialsFile(filePath, reason = 'invalid-format') { + try { + if (!fs.existsSync(filePath)) return null; + const backupPath = `${filePath}.corrupt-${Date.now()}`; + fs.renameSync(filePath, backupPath); + return backupPath; + } catch (e) { + console.warn('Could not quarantine credentials file', e && e.message ? e.message : e); + return null; + } +} + +function tryParseJsonCredentials(buffer) { + try { + const parsed = JSON.parse(buffer.toString('utf8')); + if (!parsed || typeof parsed !== 'object') return null; + const hasKnownKey = ['giteaURL', 'giteaToken', 'githubToken', 'githubUsername'].some((k) => Object.prototype.hasOwnProperty.call(parsed, k)); + return hasKnownKey ? parsed : null; + } catch (_) { + return null; + } +} + +function getCredentialFileCandidates() { + return Array.from(new Set([ + getCredentialsFilePath(), + getLegacyWorkspaceCredentialsFilePath() + ])); +} + +function ensureParentDirectory(filePath) { + const dirPath = ppath.dirname(filePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function encryptLegacyCredentials(json) { + const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, IV); + return Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]); +} + +function decryptLegacyCredentials(buffer) { + const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, IV); + const decrypted = Buffer.concat([decipher.update(buffer), decipher.final()]); + return JSON.parse(decrypted.toString('utf8')); +} + +function createCredentialsPayload(data) { + const json = JSON.stringify(data); + + if (safeStorage && safeStorage.isEncryptionAvailable()) { + const encrypted = safeStorage.encryptString(json); + return { + v: 2, + mode: 'safeStorage', + data: encrypted.toString('base64') + }; + } + + return { + v: 1, + mode: 'legacyAesCbc', + data: encryptLegacyCredentials(json).toString('base64') + }; +} + +function writeCredentialsToFile(filePath, data) { + ensureParentDirectory(filePath); + fs.writeFileSync(filePath, JSON.stringify(createCredentialsPayload(data)), 'utf8'); +} + +function persistCredentials(data) { + const primaryPath = getCredentialsFilePath(); + const legacyPath = getLegacyWorkspaceCredentialsFilePath(); + const targets = [primaryPath]; + + if (legacyPath !== primaryPath && fs.existsSync(legacyPath)) { + targets.push(legacyPath); + } + + Array.from(new Set(targets)).forEach((filePath) => { + writeCredentialsToFile(filePath, data); + }); +} + +function tryReadWrappedCredentials(raw) { + try { + const wrapper = JSON.parse(raw.toString('utf8')); + if (!wrapper || typeof wrapper !== 'object' || typeof wrapper.data !== 'string') return null; + + if (wrapper.v === 2 && wrapper.mode === 'safeStorage') { + if (!safeStorage || !safeStorage.isEncryptionAvailable()) { + return { + credentials: null, + needsRewrite: false, + reason: 'safeStorage-unavailable', + message: 'Gespeicherte Zugangsdaten koennen in dieser Sitzung nicht gelesen werden. Bitte neu anmelden.' + }; + } + + try { + return { + credentials: JSON.parse(safeStorage.decryptString(Buffer.from(wrapper.data, 'base64'))), + needsRewrite: false, + reason: null, + message: null + }; + } catch (_) { + return { + credentials: null, + needsRewrite: false, + reason: 'safeStorage-decrypt-failed', + message: 'Gespeicherte Zugangsdaten konnten nicht entschluesselt werden. Bitte Token neu eingeben.' + }; + } + } + + if (wrapper.v === 1 && (wrapper.mode === 'legacyAesCbc' || wrapper.mode === 'legacyAes')) { + return { + credentials: decryptLegacyCredentials(Buffer.from(wrapper.data, 'base64')), + needsRewrite: !!(safeStorage && safeStorage.isEncryptionAvailable()), + reason: null, + message: null + }; + } + } catch (_) { + return null; + } + + return null; +} + +function readCredentialsFromFile(filePath) { + const raw = fs.readFileSync(filePath); + + const wrappedCreds = tryReadWrappedCredentials(raw); + if (wrappedCreds) { + return { + ...wrappedCreds, + filePath + }; + } + + const plainJsonCreds = tryParseJsonCredentials(raw); + if (plainJsonCreds) { + return { credentials: plainJsonCreds, needsRewrite: true, reason: null, message: null, filePath }; + } + + try { + return { + credentials: decryptLegacyCredentials(raw), + needsRewrite: true, + reason: null, + message: null, + filePath + }; + } catch (e) { + quarantineCredentialsFile(filePath, 'legacy-decrypt-failed'); + return { + credentials: null, + needsRewrite: false, + reason: 'legacy-decrypt-failed', + message: 'Gespeicherte Zugangsdaten waren unlesbar und wurden zurueckgesetzt. Bitte Zugangsdaten neu eingeben.', + filePath + }; + } +} function getRetryQueueFilePath() { return ppath.join(app.getPath('userData'), 'retry-queue.json'); @@ -261,6 +483,75 @@ function getSafeTmpDir(baseName) { ----------------------------- */ let tray = null; +function ensureUsableDirectory(dirPath) { + try { + const stat = fs.existsSync(dirPath) ? fs.lstatSync(dirPath) : null; + if (stat && !stat.isDirectory()) { + fs.rmSync(dirPath, { force: true }); + } + fs.mkdirSync(dirPath, { recursive: true }); + return true; + } catch (_) { + return false; + } +} + +function isDirectoryWritable(dirPath) { + try { + if (!ensureUsableDirectory(dirPath)) return false; + const probe = ppath.join(dirPath, `.write-probe-${process.pid}-${Date.now()}.tmp`); + fs.writeFileSync(probe, 'ok', 'utf8'); + fs.rmSync(probe, { force: true }); + return true; + } catch (_) { + return false; + } +} + +function initializeAppStoragePaths() { + try { + // Reduce startup flakiness on Windows when cache folders are locked by stale processes. + app.commandLine.appendSwitch('disable-http-cache'); + app.commandLine.appendSwitch('disable-gpu-shader-disk-cache'); + + const appDataBase = process.env.APPDATA || app.getPath('appData'); + const appName = (app.getName() || 'git-manager-gui').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_'); + const preferredUserData = ppath.join(appDataBase, appName); + const fallbackBase = ppath.join(os.tmpdir(), `${appName}-runtime`); + + const userDataPath = isDirectoryWritable(preferredUserData) ? preferredUserData : fallbackBase; + const usingFallbackStorage = userDataPath === fallbackBase; + const sessionDataPath = ppath.join(userDataPath, 'SessionData'); + const cachePath = ppath.join(userDataPath, 'Cache'); + const gpuCachePath = ppath.join(userDataPath, 'GPUCache'); + + // Ensure key directories are real, writable directories to avoid Chromium cache init failures. + const requiredDirs = usingFallbackStorage + ? [userDataPath, sessionDataPath, cachePath, gpuCachePath] + : [userDataPath]; + requiredDirs.forEach((dir) => { + if (!ensureUsableDirectory(dir)) { + throw new Error(`Storage dir not usable: ${dir}`); + } + }); + + app.setPath('userData', userDataPath); + + // Override Chromium paths only in fallback mode to avoid repeated cache migration attempts. + if (usingFallbackStorage) { + app.setPath('sessionData', sessionDataPath); + app.setPath('cache', cachePath); + app.commandLine.appendSwitch('disk-cache-dir', cachePath); + app.commandLine.appendSwitch('gpu-shader-disk-cache-path', gpuCachePath); + console.warn('Using temporary runtime storage fallback:', fallbackBase); + } + } catch (e) { + console.warn('initializeAppStoragePaths warning', e && e.message ? e.message : e); + } +} + +initializeAppStoragePaths(); + function createTray(win) { const iconPath = ppath.join(__dirname, 'renderer', 'icon.png'); tray = new Tray(nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 })); @@ -288,9 +579,36 @@ function createWindow() { webPreferences: { preload: ppath.join(__dirname, 'preload.js'), nodeIntegration: false, - contextIsolation: true + contextIsolation: true, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false } }); + + // Externe Fenster immer blockieren und nur erlaubte URLs explizit im System-Browser oeffnen. + win.webContents.setWindowOpenHandler(({ url }) => { + if (isAllowedExternalUrl(url)) { + shell.openExternal(url).catch(() => {}); + } + return { action: 'deny' }; + }); + + // Verhindert Navigation auf fremde Seiten im Hauptfenster. + win.webContents.on('will-navigate', (event, url) => { + if (!url.startsWith('file://')) { + event.preventDefault(); + if (isAllowedExternalUrl(url)) { + shell.openExternal(url).catch(() => {}); + } + } + }); + + // Keine Runtime-Berechtigungen aus dem Renderer erteilen (Kamera, Mikrofon, usw.). + win.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); + win.loadFile(ppath.join(__dirname, 'renderer', 'index.html')); // win.webContents.openDevTools(); @@ -328,105 +646,70 @@ app.on('before-quit', () => { ----------------------------- */ function readCredentials() { try { - const CREDENTIALS_FILE = getCredentialsFilePath(); - if (!fs.existsSync(CREDENTIALS_FILE)) return null; - const encrypted = fs.readFileSync(CREDENTIALS_FILE); - const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, IV); - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); - return JSON.parse(decrypted.toString('utf8')); + const primaryPath = getCredentialsFilePath(); + let lastFailure = null; + + setCredentialReadStatus({ state: 'empty', reason: 'no-file', filePath: null, message: null }); + + for (const credentialsFilePath of getCredentialFileCandidates()) { + if (!fs.existsSync(credentialsFilePath)) continue; + + const result = readCredentialsFromFile(credentialsFilePath); + if (!result) continue; + if (!result.credentials) { + lastFailure = result; + if (result.message) { + logCredentialIssueOnce(result.message); + } + continue; + } + + if (result.needsRewrite || credentialsFilePath !== primaryPath) { + try { + persistCredentials(result.credentials); + } catch (_) {} + } + + setCredentialReadStatus({ + state: 'ok', + reason: null, + filePath: credentialsFilePath, + message: null + }); + + return result.credentials; + } + + if (lastFailure) { + setCredentialReadStatus({ + state: 'error', + reason: lastFailure.reason || 'read-failed', + filePath: lastFailure.filePath || null, + message: lastFailure.message || null + }); + } + + return null; } catch (e) { - console.error('readCredentials error', e); + logCredentialIssueOnce('Zugangsdaten konnten nicht gelesen werden. Bitte erneut anmelden.'); + setCredentialReadStatus({ + state: 'error', + reason: 'read-exception', + filePath: null, + message: 'Zugangsdaten konnten nicht gelesen werden. Bitte erneut anmelden.' + }); return null; } } -/* ==================== BACKUP CONFIGURATION ==================== */ -let backupProviders = {}; // { [repoName]: provider instance } -const backupDoneSessions = new Map(); // { [sessionId]: timestamp } - -function getBackupConfigPath() { - return ppath.join(app.getPath('userData'), 'backup-config.json'); +function sanitizeErrorForLog(errorLike) { + const status = errorLike?.response?.status || null; + const message = String( + errorLike?.response?.data?.message || errorLike?.message || errorLike || 'unknown-error' + ); + return { status, message }; } -function readBackupConfig() { - try { - const file = getBackupConfigPath(); - if (!fs.existsSync(file)) return {}; - const encrypted = fs.readFileSync(file); - const decipher = crypto.createDecipheriv(ALGORITHM, SECRET_KEY, IV); - const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); - return JSON.parse(decrypted.toString('utf8')); - } catch (e) { - console.error('readBackupConfig error', e); - return {}; - } -} - -function saveBackupConfig(config) { - try { - const file = getBackupConfigPath(); - const dir = app.getPath('userData'); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, IV); - const encrypted = Buffer.concat([cipher.update(JSON.stringify(config), 'utf8'), cipher.final()]); - fs.writeFileSync(file, encrypted); - } catch (e) { - console.error('saveBackupConfig error', e); - } -} - -function getGlobalBackupKey(provider) { - return `__global__:${provider}`; -} - -async function createProviderInstance(provider, credentials) { - if (provider !== 'local') { - throw new Error('Nur lokaler Backup-Provider wird unterstützt.'); - } - const providerInstance = new LocalProvider(); - await providerInstance.authenticate(credentials); - return providerInstance; -} - -function resolveBackupConfig(config, repoName, providerHint = '') { - if (config[repoName] && config[repoName].provider === 'local') { - return { source: 'repo', entry: config[repoName] }; - } - - if (providerHint === 'local') { - const gk = getGlobalBackupKey(providerHint); - if (config[gk] && config[gk].provider === 'local') { - return { source: 'global', entry: config[gk] }; - } - } - - for (const [key, value] of Object.entries(config)) { - if (key.startsWith('__global__:') && value && value.provider === 'local' && value.credentials) { - return { source: 'global', entry: value }; - } - } - - return { source: 'none', entry: null }; -} - -function normalizeBackupRepoName(inputRepoName, folderPath) { - const raw = String(inputRepoName || '').trim(); - const fromRepo = raw.includes('/') ? raw.split('/').pop() : raw; - const fallback = String(folderPath || '').split(/[\\/]/).pop() || 'project'; - const candidate = (fromRepo || fallback).trim(); - return candidate.replace(/[<>:"/\\|?*]+/g, '-').replace(/\s+/g, '-'); -} - -async function ensureLocalBackupConfigured(repoName, basePath) { - const credentials = { basePath }; - const providerInstance = await createProviderInstance('local', credentials); - backupProviders[repoName] = providerInstance; - - const config = readBackupConfig(); - config[repoName] = { provider: 'local', credentials }; - config[getGlobalBackupKey('local')] = { provider: 'local', credentials }; - saveBackupConfig(config); -} function mapIpcError(errorLike) { const raw = String(errorLike && errorLike.message ? errorLike.message : errorLike || '').toLowerCase(); @@ -452,6 +735,35 @@ function mapIpcError(errorLike) { return String(errorLike && errorLike.message ? errorLike.message : errorLike); } +function isSafeGitRef(ref) { + const value = String(ref || '').trim(); + if (!value) return false; + if (value.startsWith('-')) return false; + if (value.includes('..')) return false; + if (/[\s~^:?*\[\\]/.test(value)) return false; + return /^[A-Za-z0-9._/-]+$/.test(value); +} + +function sanitizeGitRef(ref, fallback = 'main') { + const value = String(ref || '').trim(); + return isSafeGitRef(value) ? value : fallback; +} + +function runGitSync(args, cwd, options = {}) { + const res = spawnSync('git', args, { + cwd, + encoding: 'utf8', + windowsHide: true, + ...options + }); + + if (res.status !== 0) { + throw new Error(String(res.stderr || res.stdout || 'Git-Fehler').trim()); + } + + return String(res.stdout || '').trim(); +} + /* ----------------------------- Generic concurrency runner (worker pool) ----------------------------- */ @@ -484,20 +796,6 @@ async function runLimited(tasks, concurrency = DEFAULT_CONCURRENCY, onProgress = return results; } -async function createZipFromDirectory(sourceDir, zipFilePath) { - return new Promise((resolve, reject) => { - const out = fs.createWriteStream(zipFilePath); - const archive = archiver('zip', { zlib: { level: 5 } }); - - out.on('close', resolve); - out.on('error', reject); - archive.on('error', reject); - - archive.pipe(out); - archive.directory(sourceDir, false); - archive.finalize(); - }); -} /* ----------------------------- Basic IPC handlers @@ -516,16 +814,7 @@ ipcMain.handle('select-file', async () => { ipcMain.handle('save-credentials', async (event, data) => { try { const CREDENTIALS_FILE = getCredentialsFilePath(); - // ✅ Stelle sicher dass das userData Verzeichnis existiert - const userDataDir = app.getPath('userData'); - if (!fs.existsSync(userDataDir)) { - fs.mkdirSync(userDataDir, { recursive: true }); - } - - const json = JSON.stringify(data); - const cipher = crypto.createCipheriv(ALGORITHM, SECRET_KEY, IV); - const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]); - fs.writeFileSync(CREDENTIALS_FILE, encrypted); + persistCredentials(data); console.log('✅ Credentials gespeichert in:', CREDENTIALS_FILE); return { ok: true }; @@ -535,6 +824,18 @@ ipcMain.handle('save-credentials', async (event, data) => { } }); +ipcMain.on('renderer-debug-log', (_event, payload) => { + try { + const level = String(payload?.level || 'log').toLowerCase(); + const message = String(payload?.message || 'renderer-log'); + const details = payload?.payload; + const fn = level === 'error' ? console.error : (level === 'warn' ? console.warn : console.log); + fn('[UPLOAD_DEBUG][renderer->main]', message, details || ''); + } catch (e) { + console.warn('[UPLOAD_DEBUG][renderer->main] logging failed', String(e)); + } +}); + ipcMain.handle('load-credentials', async () => { try { return readCredentials(); @@ -544,6 +845,141 @@ ipcMain.handle('load-credentials', async () => { } }); +ipcMain.handle('get-credentials-status', async () => { + try { + readCredentials(); + } catch (_) {} + return getCredentialReadStatus(); +}); + +ipcMain.handle('get-gitea-current-user', async () => { + try { + const creds = readCredentials(); + if (!creds?.giteaToken || !creds?.giteaURL) return { ok: false, error: 'no-credentials' }; + const user = await getGiteaCurrentUser({ token: creds.giteaToken, url: creds.giteaURL }); + return { ok: true, user }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('get-gitea-current-user error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + +ipcMain.handle('update-gitea-avatar', async (event, { token, url, imageBase64 }) => { + try { + await updateGiteaAvatar({ token, url, imageBase64 }); + console.log('✅ Avatar erfolgreich hochgeladen'); + return { ok: true }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('update-gitea-avatar error', errStatus, errMsg, e.response?.data); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + +ipcMain.handle('update-gitea-repo-avatar', async (event, { token, url, owner, repo, imageBase64 }) => { + try { + await updateGiteaRepoAvatar({ token, url, owner, repo, imageBase64 }); + console.log(`✅ Repo-Avatar für ${owner}/${repo} hochgeladen`); + return { ok: true }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('update-gitea-repo-avatar error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + +ipcMain.handle('update-gitea-repo-visibility', async (event, data) => { + try { + const { token, url, owner, repo, isPrivate, platform } = data || {}; + if (platform === 'github') { + const credentials = readCredentials(); + const githubToken = token || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const updated = await updateGithubRepoVisibility({ token: githubToken, owner, repo, isPrivate }); + return { ok: true, repo: updated }; + } + const updated = await updateGiteaRepoVisibility({ token, url, owner, repo, isPrivate }); + return { ok: true, repo: updated }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('update-gitea-repo-visibility error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + +ipcMain.handle('update-gitea-repo-topics', async (event, data) => { + try { + const { owner, repo, topics, platform } = data || {}; + const creds = readCredentials(); + if (platform === 'github') { + const githubToken = (data.token) || (creds && creds.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const updated = await updateGithubRepoTopics({ + token: githubToken, owner, repo, + topics: Array.isArray(topics) ? topics : [] + }); + return { ok: true, repo: updated }; + } + if (!creds?.giteaToken || !creds?.giteaURL) { + return { ok: false, error: 'Gitea Token oder URL fehlt.' }; + } + const updated = await updateGiteaRepoTopics({ + token: creds.giteaToken, url: creds.giteaURL, owner, repo, + topics: Array.isArray(topics) ? topics : [] + }); + return { ok: true, repo: updated }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('update-gitea-repo-topics error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + +ipcMain.handle('get-gitea-topics-catalog', async () => { + try { + const creds = readCredentials(); + if (!creds?.giteaToken || !creds?.giteaURL) { + return { ok: false, error: 'Gitea Token oder URL fehlt.' }; + } + const topics = await listGiteaTopicsCatalog({ token: creds.giteaToken, url: creds.giteaURL }); + return { ok: true, topics }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('get-gitea-topics-catalog error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + +ipcMain.handle('migrate-repo-to-gitea', async (event, data) => { + try { + const creds = readCredentials(); + if (!creds) return { ok: false, error: 'no-credentials' }; + const result = await migrateRepoToGitea({ + token: creds.giteaToken, + url: creds.giteaURL, + cloneUrl: data.cloneUrl, + repoName: data.repoName, + description: data.description || '', + isPrivate: data.isPrivate || false, + authToken: data.authToken || '', + authUsername: data.authUsername || '' + }); + return { ok: true, repo: result }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('migrate-repo-to-gitea error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + /* ----------------------------- Repo & git handlers ----------------------------- */ @@ -581,46 +1017,13 @@ ipcMain.handle('create-repo', async (event, data) => { ipcMain.handle('push-project', async (event, data) => { try { if (!data.folder || !fs.existsSync(data.folder)) return { ok: false, error: 'folder-not-found' }; - - const emitPrePushStatus = (payload) => { - try { event.sender.send('pre-push-backup-status', payload); } catch (_) {} - }; - - const credentials = readCredentials() || {}; - const autoBackupEnabled = Boolean(data.autoBackup || credentials.autoBackupEnabled); - - if (autoBackupEnabled) { - const backupTarget = String(data.backupTarget || credentials.backupPrefLocalFolder || '').trim(); - if (!backupTarget) { - return { ok: false, error: 'Auto-Backup ist aktiv, aber kein lokaler Backup-Zielordner ist gesetzt.' }; - } - - const backupRepoName = normalizeBackupRepoName(data.repoName, data.folder); - try { - emitPrePushStatus({ stage: 'backup-start', repoName: backupRepoName, target: backupTarget }); - await ensureLocalBackupConfigured(backupRepoName, backupTarget); - const backupResult = await ipcMain._handle_create_cloud_backup(event, { - repoName: backupRepoName, - projectPath: data.folder - }); - - if (!backupResult?.ok) { - emitPrePushStatus({ stage: 'backup-failed', repoName: backupRepoName, error: backupResult?.error || 'Unbekannter Fehler' }); - return { ok: false, error: `Auto-Backup vor Upload fehlgeschlagen: ${backupResult?.error || 'Unbekannter Fehler'}` }; - } - emitPrePushStatus({ stage: 'backup-done', repoName: backupRepoName, filename: backupResult.filename || '' }); - } catch (backupErr) { - emitPrePushStatus({ stage: 'backup-failed', repoName: backupRepoName, error: mapIpcError(backupErr) }); - return { ok: false, error: `Auto-Backup vor Upload fehlgeschlagen: ${mapIpcError(backupErr)}` }; - } - } // Aktuellen Branch ermitteln (NICHT umbenennen!) let currentBranch = data.branch || null; try { - const detected = execSync('git branch --show-current', { cwd: data.folder, stdio: 'pipe', encoding: 'utf-8' }).trim(); + const detected = runGitSync(['branch', '--show-current'], data.folder); if (detected) { - currentBranch = currentBranch || detected; + currentBranch = currentBranch || sanitizeGitRef(detected, 'main'); console.log('Current local branch:', detected); } } catch (e) { @@ -633,7 +1036,19 @@ ipcMain.handle('push-project', async (event, data) => { // 2. Prüfen, ob ein 'origin' Remote existiert let remoteUrl = ''; try { - remoteUrl = execSync('git remote get-url origin', { cwd: data.folder, stdio: 'pipe', encoding: 'utf-8' }).trim(); + remoteUrl = runGitSync(['remote', 'get-url', 'origin'], data.folder); + + // Bereits gespeicherte Credential-URLs in sichere Origin-URLs ohne Userinfo konvertieren. + try { + const remoteObj = new URL(remoteUrl); + if (remoteObj.username || remoteObj.password) { + remoteObj.username = ''; + remoteObj.password = ''; + const sanitizedRemoteUrl = remoteObj.toString(); + runGitSync(['remote', 'set-url', 'origin', sanitizedRemoteUrl], data.folder); + remoteUrl = sanitizedRemoteUrl; + } + } catch (_) {} } catch (e) { if (data.repoName && data.platform) { const creds = readCredentials(); @@ -643,17 +1058,31 @@ ipcMain.handle('push-project', async (event, data) => { const owner = parts[0]; const repo = parts[1]; let constructedUrl = ''; + let authHeader = ''; + let remoteOrigin = ''; if (data.platform === 'gitea' && creds.giteaURL) { try { const urlObj = new URL(creds.giteaURL); - constructedUrl = `${urlObj.protocol}//${creds.giteaToken}@${urlObj.host}/${owner}/${repo}.git`; + constructedUrl = `${urlObj.protocol}//${urlObj.host}/${owner}/${repo}.git`; + remoteOrigin = `${urlObj.protocol}//${urlObj.host}/`; + if (creds.giteaToken) { + authHeader = `AUTHORIZATION: token ${creds.giteaToken}`; + } } catch (err) { console.error('Invalid Gitea URL', err); } } else if (data.platform === 'github' && creds.githubToken) { - constructedUrl = `https://${creds.githubToken}@github.com/${owner}/${repo}.git`; + constructedUrl = `https://github.com/${owner}/${repo}.git`; + remoteOrigin = 'https://github.com/'; + authHeader = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${creds.githubToken}`, 'utf8').toString('base64')}`; } if (constructedUrl) { try { - execSync(`git remote add origin "${constructedUrl}"`, { cwd: data.folder, stdio: 'inherit' }); + runGitSync(['remote', 'add', 'origin', constructedUrl], data.folder, { stdio: 'inherit' }); + + // Auth nie in der Remote-URL speichern; stattdessen Header scoped auf den Remote-Origin. + if (remoteOrigin && authHeader) { + runGitSync(['config', `http.${remoteOrigin}.extraheader`, authHeader], data.folder); + } + console.log(`Remote origin added: ${constructedUrl}`); } catch (err) { return { ok: false, error: 'Failed to add remote origin: ' + String(err) }; @@ -670,10 +1099,7 @@ ipcMain.handle('push-project', async (event, data) => { // 3. Pushen (nutze den tatsächlichen Branch - main ODER master) const progressCb = percent => { try { event.sender.send('push-progress', percent); } catch (_) {} }; const commitMsg = data.commitMessage || 'Update from Git Manager GUI'; - const pushBranch = currentBranch || 'main'; - if (autoBackupEnabled) { - emitPrePushStatus({ stage: 'upload-start' }); - } + const pushBranch = sanitizeGitRef(currentBranch || 'main', 'main'); await commitAndPush(data.folder, pushBranch, commitMsg, progressCb); return { ok: true }; @@ -683,343 +1109,13 @@ ipcMain.handle('push-project', async (event, data) => { } }); -/* ==================== BACKUP IPC HANDLERS ==================== */ - -ipcMain.handle('setup-backup-provider', async (event, { repoName, provider, credentials, applyGlobally = true }) => { - try { - if (!repoName) return { ok: false, error: 'repoName fehlt' }; - const localProvider = 'local'; - const providerInstance = await createProviderInstance(localProvider, credentials); - - // Store provider and config - backupProviders[repoName] = providerInstance; - const config = readBackupConfig(); - config[repoName] = { provider: localProvider, credentials }; - if (applyGlobally) { - config[getGlobalBackupKey(localProvider)] = { provider: localProvider, credentials }; - } - saveBackupConfig(config); - - return { ok: true, message: 'Backup provider configured', provider: localProvider, applyGlobally }; - } catch (e) { - console.error('setup-backup-provider error', e); - return { ok: false, error: mapIpcError(e) }; - } -}); - -ipcMain.handle('test-backup-provider', async (event, { repoName, provider }) => { - try { - if (!backupProviders[repoName]) { - const config = readBackupConfig(); - const resolved = resolveBackupConfig(config, repoName, provider); - if (!resolved.entry) { - return { ok: false, error: 'Backup provider not configured' }; - } - backupProviders[repoName] = await createProviderInstance( - resolved.entry.provider, - resolved.entry.credentials - ); - } - - const result = await backupProviders[repoName].testConnection(); - return result; - } catch (e) { - console.error('test-backup-provider error', e); - return { ok: false, error: mapIpcError(e) }; - } -}); - -ipcMain.handle('create-cloud-backup', async (event, { repoName, projectPath }) => { - return ipcMain._handle_create_cloud_backup(event, { repoName, projectPath }); -}); - -ipcMain._handle_create_cloud_backup = async (event, { repoName, projectPath }) => { - try { - if (!backupProviders[repoName]) { - // Try to load from disk - const config = readBackupConfig(); - const resolved = resolveBackupConfig(config, repoName); - if (!resolved.entry) { - return { ok: false, error: 'Backup not configured' }; - } - - const { provider, credentials } = resolved.entry; - const providerInstance = await createProviderInstance(provider, credentials); - - backupProviders[repoName] = providerInstance; - // Repo-spezifisch merken, damit es beim naechsten Mal ohne globalen Lookup klappt. - config[repoName] = { provider, credentials }; - saveBackupConfig(config); - } - - const manager = new BackupManager(backupProviders[repoName]); - const result = await manager.createBackup(projectPath, repoName); - - try { event?.sender?.send('backup-created', result); } catch (_) {} - return { ok: true, ...result }; - } catch (e) { - console.error('create-cloud-backup error', e); - return { ok: false, error: mapIpcError(e) }; - } -}; - -ipcMain.handle('list-cloud-backups', async (event, { repoName, provider }) => { - try { - if (!backupProviders[repoName]) { - // Try to load from disk - const config = readBackupConfig(); - const resolved = resolveBackupConfig(config, repoName, provider); - if (!resolved.entry) { - return { ok: true, backups: [] }; - } - - const entry = resolved.entry; - const providerInstance = await createProviderInstance(entry.provider, entry.credentials); - - backupProviders[repoName] = providerInstance; - config[repoName] = { provider: entry.provider, credentials: entry.credentials }; - saveBackupConfig(config); - } - - const mapBackupMeta = (items) => { - return (Array.isArray(items) ? items : []).map((b) => ({ - filename: b.filename || b.name || '', - name: b.name || b.filename || '', - size: Number(b.size || 0), - modifiedTime: b.modifiedTime || b.date || null, - date: b.date || b.modifiedTime || null - })); - }; - - const providerInstance = backupProviders[repoName]; - const manager = new BackupManager(providerInstance); - const showAllBackups = !repoName || repoName === 'all-git-projects'; - - if (showAllBackups) { - const backups = await providerInstance.listBackups(); - return { ok: true, backups: mapBackupMeta(backups) }; - } - - const backups = await manager.listBackups(repoName); - return { ok: true, backups: mapBackupMeta(backups) }; - } catch (e) { - console.error('list-cloud-backups error', e); - return { ok: true, backups: [] }; - } -}); - -ipcMain.handle('restore-cloud-backup', async (event, { repoName, filename, targetPath }) => { - try { - if (!backupProviders[repoName]) { - const config = readBackupConfig(); - const resolved = resolveBackupConfig(config, repoName); - if (!resolved.entry) { - return { ok: false, error: 'Backup provider not configured' }; - } - backupProviders[repoName] = await createProviderInstance( - resolved.entry.provider, - resolved.entry.credentials - ); - } - - const manager = new BackupManager(backupProviders[repoName]); - const result = await manager.restoreBackup(repoName, filename, targetPath); - return result; - } catch (e) { - console.error('restore-cloud-backup error', e); - return { ok: false, error: mapIpcError(e) }; - } -}); - -ipcMain.handle('delete-cloud-backup', async (event, { repoName, filename }) => { - try { - if (!backupProviders[repoName]) { - const config = readBackupConfig(); - const resolved = resolveBackupConfig(config, repoName); - if (!resolved.entry) { - return { ok: false, error: 'Backup provider not configured' }; - } - backupProviders[repoName] = await createProviderInstance( - resolved.entry.provider, - resolved.entry.credentials - ); - } - - const manager = new BackupManager(backupProviders[repoName]); - const result = await manager.deleteBackup(filename); - return result; - } catch (e) { - console.error('delete-cloud-backup error', e); - return { ok: false, error: mapIpcError(e) }; - } -}); - -ipcMain.handle('get-backup-auth-status', async (event, { repoName, provider }) => { - try { - if (!repoName) return { ok: true, connected: false, source: 'none' }; - if (backupProviders[repoName]) { - return { ok: true, connected: true, source: 'memory' }; - } - - const config = readBackupConfig(); - const resolved = resolveBackupConfig(config, repoName, provider); - if (!resolved.entry) { - return { ok: true, connected: false, source: 'none' }; - } - - return { - ok: true, - connected: true, - source: resolved.source, - provider: resolved.entry.provider - }; - } catch (e) { - return { ok: false, connected: false, source: 'none', error: mapIpcError(e) }; - } -}); - -ipcMain.handle('export-gitea-projects-to-local', async (event, data) => { - try { - const destination = String(data && data.destination ? data.destination : '').trim(); - const mode = String(data && data.mode ? data.mode : 'single'); - const owner = String(data && data.owner ? data.owner : '').trim(); - const repo = String(data && data.repo ? data.repo : '').trim(); - - if (!destination) return { ok: false, error: 'Zielordner fehlt' }; - - const credentials = readCredentials(); - const token = credentials && credentials.giteaToken; - const url = credentials && credentials.giteaURL; - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; - - fs.mkdirSync(destination, { recursive: true }); - - let repos = []; - if (mode === 'all') { - const allRepos = await listGiteaRepos({ token, url }); - repos = (allRepos || []).map(r => ({ - owner: (r.owner && (r.owner.login || r.owner.username)) || '', - repo: r.name || '' - })).filter(r => r.owner && r.repo); - } else { - if (!owner || !repo) return { ok: false, error: 'owner/repo fehlt' }; - repos = [{ owner, repo }]; - } - - let totalRepos = repos.length; - let totalFiles = 0; - let exportedFiles = 0; - const exportedRepos = []; - const formatStamp = () => { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - return `${year}${month}${day}-${hours}${minutes}${seconds}`; - }; - - for (let i = 0; i < repos.length; i++) { - const pair = repos[i]; - const tempRoot = fs.mkdtempSync(ppath.join(os.tmpdir(), 'git-manager-backup-')); - const targetRepoDir = ppath.join(tempRoot, `${pair.owner}__${pair.repo}`); - fs.mkdirSync(targetRepoDir, { recursive: true }); - - const files = []; - async function gather(pathInRepo) { - const contentRes = await getGiteaRepoContents({ - token, - url, - owner: pair.owner, - repo: pair.repo, - path: pathInRepo || '', - ref: 'HEAD' - }); - const items = contentRes && contentRes.items ? contentRes.items : contentRes; - if (!Array.isArray(items)) return; - for (const item of items) { - if (item.type === 'dir') { - await gather(item.path); - } else if (item.type === 'file') { - files.push(item.path); - } - } - } - - await gather(''); - totalFiles += files.length; - - for (let f = 0; f < files.length; f++) { - const remoteFile = files[f]; - const content = await getGiteaFileContent({ - token, - url, - owner: pair.owner, - repo: pair.repo, - path: remoteFile, - ref: 'HEAD' - }); - const localPath = ppath.join(targetRepoDir, remoteFile); - fs.mkdirSync(ppath.dirname(localPath), { recursive: true }); - fs.writeFileSync(localPath, content, 'utf8'); - - exportedFiles++; - try { - event.sender.send('folder-download-progress', { - processed: exportedFiles, - total: Math.max(totalFiles, exportedFiles), - percent: Math.round((exportedFiles / Math.max(totalFiles, exportedFiles)) * 100) - }); - } catch (_) {} - } - - const stamp = formatStamp(); - const backupFilename = `${pair.repo}-backup-${stamp}-${pair.owner}.zip`; - const backupPath = ppath.join(destination, backupFilename); - - await createZipFromDirectory(targetRepoDir, backupPath); - const zipStat = fs.statSync(backupPath); - fs.rmSync(tempRoot, { recursive: true, force: true }); - - exportedRepos.push({ - owner: pair.owner, - repo: pair.repo, - filename: backupFilename, - path: backupPath, - size: zipStat.size, - files: files.length - }); - try { - event.sender.send('folder-download-progress', { - processed: i + 1, - total: totalRepos, - percent: Math.round(((i + 1) / Math.max(1, totalRepos)) * 100) - }); - } catch (_) {} - } - - return { - ok: true, - destination, - repositories: exportedRepos, - repositoryCount: exportedRepos.length, - fileCount: exportedFiles - }; - } catch (e) { - console.error('export-gitea-projects-to-local error', e); - return { ok: false, error: mapIpcError(e) }; - } -}); - ipcMain.handle('getBranches', async (event, data) => { try { const branches = await getBranches(data.folder); // Aktuellen Branch ermitteln und nach oben sortieren let currentBranch = null; try { - currentBranch = execSync('git branch --show-current', { cwd: data.folder, stdio: 'pipe', encoding: 'utf-8' }).trim(); + currentBranch = runGitSync(['branch', '--show-current'], data.folder); } catch (_) {} branches.sort((a, b) => { if (a === currentBranch) return -1; @@ -1061,9 +1157,25 @@ ipcMain.handle('get-commits', async (event, data) => { return { ok: true, logs }; } - // 2) Gitea-Commits (owner/repo vorhanden) + // 2) Remote-Commits if (data && data.owner && data.repo) { const credentials = readCredentials(); + + // GitHub + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt. Bitte in Settings eintragen.' }; + const commits = await getGithubCommits({ + token: githubToken, + owner: data.owner, + repo: data.repo, + branch: data.sha || data.branch || '', + page: data.page || 1, + limit: data.limit || 50 + }); + return { ok: true, commits }; + } + const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; @@ -1093,6 +1205,58 @@ ipcMain.handle('get-commits', async (event, data) => { } }); +ipcMain.handle('search-commits', async (event, data) => { + try { + if (!data || !data.owner || !data.repo) return { ok: false, error: 'missing-owner-or-repo' }; + const credentials = readCredentials(); + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const commits = await searchGithubCommits({ + token: githubToken, owner: data.owner, repo: data.repo, + query: data.query || '', branch: data.branch || '' + }); + return { ok: true, commits }; + } + const token = (data.token) || (credentials && credentials.giteaToken); + const url = (data.url) || (credentials && credentials.giteaURL); + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + const commits = await searchGiteaCommits({ + token, url, owner: data.owner, repo: data.repo, + query: data.query || '', branch: data.branch || '' + }); + return { ok: true, commits }; + } catch (e) { + console.error('search-commits error', e); + return { ok: false, error: String(e) }; + } +}); + +ipcMain.handle('get-repo-branches', async (event, data) => { + try { + if (!data || !data.owner || !data.repo) return { ok: false, error: 'missing-owner-or-repo' }; + const credentials = readCredentials(); + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const branches = await getGithubBranches({ + token: githubToken, owner: data.owner, repo: data.repo + }); + return { ok: true, branches }; + } + const token = (data.token) || (credentials && credentials.giteaToken); + const url = (data.url) || (credentials && credentials.giteaURL); + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + const branches = await getGiteaBranches({ + token, url, owner: data.owner, repo: data.repo + }); + return { ok: true, branches }; + } catch (e) { + console.error('get-repo-branches error', e); + return { ok: false, error: String(e) }; + } +}); + /* ----------------------------- Local file tree functions ----------------------------- */ @@ -1168,6 +1332,34 @@ ipcMain.handle('writeFile', async (event, data) => { ipcMain.handle('deleteFile', async (event, data) => { try { + // --- GITHUB DELETION --- + if (data && data.isGithub) { + const credentials = readCredentials(); + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const { owner, repo, path: filePath, ref, branch } = data; + if (!owner || !repo || !filePath) return { ok: false, error: 'missing-owner-repo-or-path' }; + const useBranch = (branch && branch !== 'HEAD') ? branch : ((ref && ref !== 'HEAD') ? ref : 'main'); + // GitHub API returns arrays for dirs — handle recursive deletion + async function deleteGithubEntry(entryPath) { + const contents = await getGithubRepoContents({ token: githubToken, owner, repo, path: entryPath, ref: useBranch }); + if (!contents.ok) throw new Error(contents.error || 'Inhalt nicht ladbar'); + if (contents.items && contents.items.length > 1) { + // It's a directory — recurse + for (const item of contents.items) { + if (item.type === 'dir') await deleteGithubEntry(item.path); + else await deleteGithubFile({ token: githubToken, owner, repo, path: item.path, branch: useBranch }); + } + } else if (contents.items && contents.items.length === 1 && contents.items[0].type !== 'dir') { + await deleteGithubFile({ token: githubToken, owner, repo, path: contents.items[0].path, sha: contents.items[0].sha, branch: useBranch }); + } else { + await deleteGithubFile({ token: githubToken, owner, repo, path: entryPath, branch: useBranch }); + } + } + await deleteGithubEntry(filePath); + return { ok: true }; + } + // --- GITEA DELETION --- if (data && data.isGitea) { const credentials = readCredentials(); @@ -1309,6 +1501,33 @@ ipcMain.handle('list-gitea-repos', async (event, data) => { } }); +ipcMain.handle('list-github-repos', async (event, data) => { + try { + const credentials = readCredentials(); + const githubToken = (data && data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt. Bitte Token in Settings eintragen.' }; + const repos = await listGithubRepos({ token: githubToken }); + return { ok: true, repos }; + } catch (e) { + console.error('list-github-repos error', sanitizeErrorForLog(e)); + return { ok: false, error: mapIpcError(e) }; + } +}); + +ipcMain.handle('get-github-current-user', async () => { + try { + const credentials = readCredentials(); + if (!credentials?.githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const user = await getGithubCurrentUser({ token: credentials.githubToken }); + return { ok: true, user }; + } catch (e) { + const errMsg = e.response?.data?.message || e.response?.data || e.message; + const errStatus = e.response?.status; + console.error('get-github-current-user error', errStatus, errMsg); + return { ok: false, error: `(${errStatus || '?'}) ${errMsg}` }; + } +}); + ipcMain.handle('get-gitea-user-heatmap', async (event, data) => { try { const credentials = readCredentials(); @@ -1324,20 +1543,38 @@ ipcMain.handle('get-gitea-user-heatmap', async (event, data) => { } }); +ipcMain.handle('get-github-user-heatmap', async (event, data) => { + try { + const credentials = readCredentials(); + const token = (data && data.token) || (credentials && credentials.githubToken); + if (!token) return { ok: false, error: 'GitHub Token fehlt.' }; + + const result = await getGithubUserHeatmap({ token, monthsBack: data?.monthsBack }); + return { ok: true, ...result }; + } catch (e) { + console.error('get-github-user-heatmap error', sanitizeErrorForLog(e)); + return { ok: false, error: mapIpcError(e) }; + } +}); + ipcMain.handle('get-gitea-repo-contents', async (event, data) => { try { const credentials = readCredentials(); - const token = (data && data.token) || (credentials && credentials.giteaToken); - const url = (data && data.url) || (credentials && credentials.giteaURL); - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; const p = data.path || ''; - - // FIXED: Pass data.ref directly to apiHandler without forcing 'main' - // This allows apiHandler.js to try ['main', 'master'] if no ref is passed - const ref = data.ref; - + const ref = data.ref; + + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const result = await getGithubRepoContents({ token: githubToken, owner, repo, path: p, ref: ref || 'HEAD' }); + return { ok: true, items: result.items || [], empty: result.empty || false }; + } + + const token = (data && data.token) || (credentials && credentials.giteaToken); + const url = (data && data.url) || (credentials && credentials.giteaURL); + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const result = await getGiteaRepoContents({ token, url, owner, repo, path: p, ref }); return { ok: true, items: result.items || result, empty: result.empty || false }; } catch (e) { @@ -1349,16 +1586,21 @@ ipcMain.handle('get-gitea-repo-contents', async (event, data) => { ipcMain.handle('get-gitea-file-content', async (event, data) => { try { const credentials = readCredentials(); - const token = (data && data.token) || (credentials && credentials.giteaToken); - const url = (data && data.url) || (credentials && credentials.giteaURL); - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; const p = data.path; - - // FIXED: Pass data.ref directly const ref = data.ref; - + + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const content = await getGithubFileContent({ token: githubToken, owner, repo, path: p, ref: ref || 'HEAD' }); + return { ok: true, content }; + } + + const token = (data && data.token) || (credentials && credentials.giteaToken); + const url = (data && data.url) || (credentials && credentials.giteaURL); + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const content = await getGiteaFileContent({ token, url, owner, repo, path: p, ref }); return { ok: true, content }; } catch (e) { @@ -1371,6 +1613,25 @@ ipcMain.handle('get-gitea-file-content', async (event, data) => { ipcMain.handle('read-gitea-file', async (event, data) => { try { const credentials = readCredentials(); + const owner = data.owner; + const repo = data.repo; + const p = data.path; + const ref = data.ref || 'HEAD'; + + // GitHub-Pfad + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const isImage = /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(p); + const content = await getGithubFileContent({ token: githubToken, owner, repo, path: p, ref }); + // getGithubFileContent returns utf8 text; for images we re-encode as base64 + if (isImage) { + const base64 = Buffer.from(content, 'utf8').toString('base64'); + return { ok: true, content: base64 }; + } + return { ok: true, content }; + } + const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !url) { @@ -1378,11 +1639,6 @@ ipcMain.handle('read-gitea-file', async (event, data) => { return { ok: false, error: 'missing-token-or-url' }; } - const owner = data.owner; - const repo = data.repo; - const p = data.path; - const ref = data.ref || 'HEAD'; - console.log(`read-gitea-file: ${owner}/${repo}/${p} (ref: ${ref})`); // Prüfe ob es eine Bilddatei ist @@ -1445,17 +1701,56 @@ ipcMain.handle('read-gitea-file', async (event, data) => { }); ipcMain.handle('upload-gitea-file', async (event, data) => { + const uploadDebugId = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { const credentials = readCredentials(); - const token = (data && data.token) || (credentials && credentials.giteaToken); - const url = (data && data.url) || (credentials && credentials.giteaURL); - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const owner = data.owner; const repo = data.repo; + console.log('[UPLOAD_DEBUG][main] upload-gitea-file:start', { + uploadDebugId, + owner, + repo, + platform: data.platform || 'gitea', + destPath: data.destPath || '', + branch: data.branch || 'HEAD', + localPathCount: Array.isArray(data.localPath) ? data.localPath.length : (data.localPath ? 1 : 0) + }); + + // GitHub upload path + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: `GitHub Token fehlt. (${uploadDebugId})` }; + const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); + const branch = (data.branch && data.branch !== 'HEAD') ? data.branch : 'main'; + const message = data.message || 'Upload via Git Manager GUI'; + const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []); + const results = []; + for (const localFile of localFiles) { + if (!fs.existsSync(localFile)) { results.push({ file: localFile, ok: false, error: 'local-file-not-found' }); continue; } + const raw = fs.readFileSync(localFile); + const base64 = raw.toString('base64'); + const fileName = ppath.basename(localFile); + const targetPath = destPath ? `${destPath}/${fileName}` : fileName; + try { + const uploaded = await uploadGithubFile({ token: githubToken, owner, repo, path: targetPath, contentBase64: base64, message: `${message} - ${fileName}`, branch }); + results.push({ file: localFile, ok: true, uploaded }); + } catch (e) { + results.push({ file: localFile, ok: false, error: String(e) }); + } + } + const failedCount = results.filter(r => !r.ok).length; + console.log('[UPLOAD_DEBUG][main] upload-gitea-file:github-done', { uploadDebugId, failedCount, total: results.length }); + return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; + } + + const token = (data && data.token) || (credentials && credentials.giteaToken); + const url = (data && data.url) || (credentials && credentials.giteaURL); + if (!token || !url) return { ok: false, error: `missing-token-or-url (${uploadDebugId})` }; + const owner2 = owner; // destPath is the target folder in the repo const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); // Branch wird unverändert übernommen (main UND master werden unterstützt) - let branch = data.branch || 'HEAD'; + let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const message = data.message || 'Upload via Git Manager GUI'; const localFiles = Array.isArray(data.localPath) ? data.localPath : (data.localPath ? [data.localPath] : []); const results = []; @@ -1480,7 +1775,7 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { const uploaded = await uploadGiteaFile({ token, url, - owner, + owner: owner2, repo, path: targetPath, contentBase64: base64, @@ -1493,10 +1788,12 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { results.push({ file: localFile, ok: false, error: String(e) }); } } - return { ok: true, results }; + const failedCount = results.filter(r => !r.ok).length; + console.log('[UPLOAD_DEBUG][main] upload-gitea-file:gitea-done', { uploadDebugId, failedCount, total: results.length }); + return { ok: failedCount === 0, results, failedCount, debugId: uploadDebugId }; } catch (e) { - console.error('upload-gitea-file error', e); - return { ok: false, error: String(e) }; + console.error('[UPLOAD_DEBUG][main] upload-gitea-file:fatal', { uploadDebugId, error: String(e) }); + return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; } }); @@ -1504,18 +1801,28 @@ ipcMain.handle('upload-gitea-file', async (event, data) => { ipcMain.handle('write-gitea-file', async (event, data) => { try { const credentials = readCredentials(); - const token = (data && data.token) || (credentials && credentials.giteaToken); - const url = (data && data.url) || (credentials && credentials.giteaURL); - if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; - const owner = data.owner; const repo = data.repo; const path = data.path; const content = data.content || ''; const ref = data.ref || 'HEAD'; - - // Konvertiere Content zu Base64 const base64 = Buffer.from(content, 'utf8').toString('base64'); + + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const uploaded = await uploadGithubFile({ + token: githubToken, owner, repo, path, + contentBase64: base64, + message: `Edit ${path} via Git Manager GUI`, + branch: ref === 'HEAD' ? 'main' : ref + }); + return { ok: true, uploaded }; + } + + const token = (data && data.token) || (credentials && credentials.giteaToken); + const url = (data && data.url) || (credentials && credentials.giteaURL); + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; const uploaded = await uploadGiteaFile({ token, @@ -1587,8 +1894,8 @@ ipcMain.handle('upload-local-folder-to-gitea', async (event, data) => { const repo = data.repo; // destPath is the target directory in the repo const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); - // Branch wird unverändert übernommen (main UND master werden unterstützt) - let branch = data.branch || 'HEAD'; + // Branch robust behandeln: HEAD bedeutet Remote-Default-Branch nutzen + let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const messagePrefix = data.messagePrefix || 'Upload folder via GUI'; const concurrency = data.concurrency || DEFAULT_CONCURRENCY; if (!localFolder || !fs.existsSync(localFolder)) return { ok: false, error: 'local-folder-not-found' }; @@ -1864,6 +2171,12 @@ ipcMain.on('ondragstart', async (event, filePath) => { ipcMain.handle('delete-gitea-repo', async (event, data) => { try { const credentials = readCredentials(); + if (data && data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + await deleteGithubRepo({ token: githubToken, owner: data.owner, repo: data.repo }); + return { ok: true }; + } const token = (data && data.token) || (credentials && credentials.giteaToken); const urlStr = (data && data.url) || (credentials && credentials.giteaURL); if (!token || !urlStr) return { ok: false, error: 'missing-token-or-url' }; @@ -1899,18 +2212,31 @@ ipcMain.handle('delete-gitea-repo', async (event, data) => { }); ipcMain.handle('upload-and-push', async (event, data) => { + const uploadDebugId = `u-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; try { - if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: 'local-folder-not-found' }; + if (!data || !data.localFolder || !fs.existsSync(data.localFolder)) return { ok: false, error: `local-folder-not-found (${uploadDebugId})` }; const credentials = readCredentials(); const token = (data && data.token) || (credentials && credentials.giteaToken); const giteaUrl = (data && data.url) || (credentials && credentials.giteaURL); const owner = data.owner; const repo = data.repo; - // Branch wird unverändert übernommen (main UND master werden unterstützt) - let branch = data.branch || 'HEAD'; + // Branch robust behandeln: HEAD nutzt den Remote-Default, sonst nur sichere Refs + let branch = sanitizeGitRef(data.branch || 'HEAD', 'HEAD'); const cloneUrl = data.cloneUrl || null; const destPath = (data.destPath || '').replace(/^\//, '').replace(/\/$/, ''); - if (!owner || !repo) return { ok: false, error: 'missing-owner-or-repo' }; + if (!owner || !repo) return { ok: false, error: `missing-owner-or-repo (${uploadDebugId})` }; + + console.log('[UPLOAD_DEBUG][main] upload-and-push:start', { + uploadDebugId, + localFolder: data.localFolder, + owner, + repo, + branch, + destPath, + hasCloneUrl: !!cloneUrl, + hasToken: !!token, + hasUrl: !!giteaUrl + }); const gitExecOptions = { stdio: 'pipe', @@ -1924,54 +2250,15 @@ ipcMain.handle('upload-and-push', async (event, data) => { maxBuffer: 10 * 1024 * 1024 }; - // Auto-Backup vor Upload (wenn in Einstellungen aktiviert) - const autoBackupEnabled = Boolean(credentials && credentials.autoBackupEnabled); - const uploadSessionId = String(data.uploadSessionId || '').trim(); - const shouldSkipBySession = Boolean(uploadSessionId && backupDoneSessions.has(uploadSessionId)); - if (autoBackupEnabled && !data.skipBackup && !shouldSkipBySession) { - const backupTarget = String((credentials && credentials.backupPrefLocalFolder) || '').trim(); - const emitStatus = (payload) => { - try { event.sender.send('pre-push-backup-status', payload); } catch (_) {} - }; - if (!backupTarget) { - return { ok: false, error: 'Auto-Backup ist aktiv, aber kein lokaler Backup-Zielordner ist gesetzt.' }; - } - // Nur den Repo-Namen ohne Owner verwenden (verhindert "/" im Dateinamen) - const backupRepoName = String(repo || ppath.basename(data.localFolder)).replace(/[<>:"/\\|?*]+/g, '-'); - // Wenn localFolder eine Datei ist, den Elternordner sichern - const statForBackup = fs.statSync(data.localFolder); - const backupProjectPath = statForBackup.isFile() ? ppath.dirname(data.localFolder) : data.localFolder; - try { - emitStatus({ stage: 'backup-start', repoName: backupRepoName, target: backupTarget }); - await ensureLocalBackupConfigured(backupRepoName, backupTarget); - const backupResult = await ipcMain._handle_create_cloud_backup(event, { - repoName: backupRepoName, - projectPath: backupProjectPath - }); - if (!backupResult?.ok) { - emitStatus({ stage: 'backup-failed', repoName: backupRepoName, error: backupResult?.error || 'Unbekannter Fehler' }); - return { ok: false, error: `Auto-Backup vor Upload fehlgeschlagen: ${backupResult?.error || 'Unbekannter Fehler'}` }; - } - if (uploadSessionId) { - backupDoneSessions.set(uploadSessionId, Date.now()); - setTimeout(() => backupDoneSessions.delete(uploadSessionId), 10 * 60 * 1000); - } - emitStatus({ stage: 'backup-done', repoName: backupRepoName, filename: backupResult.filename || '' }); - emitStatus({ stage: 'upload-start' }); - } catch (backupErr) { - emitStatus({ stage: 'backup-failed', repoName: backupRepoName, error: mapIpcError(backupErr) }); - return { ok: false, error: `Auto-Backup vor Upload fehlgeschlagen: ${mapIpcError(backupErr)}` }; - } - } - // Prüfen ob es eine Datei oder ein Ordner ist const stat = fs.statSync(data.localFolder); const isFile = stat.isFile(); const isDirectory = stat.isDirectory(); + console.log('[UPLOAD_DEBUG][main] upload-and-push:path-type', { uploadDebugId, isFile, isDirectory }); // --- FALL 1: Einzelne Datei --- if (isFile) { - console.log('Detected single file. Attempting API upload...'); + console.log('[UPLOAD_DEBUG][main] single-file:api-attempt', { uploadDebugId }); const raw = fs.readFileSync(data.localFolder); const base64 = raw.toString('base64'); const fileName = ppath.basename(data.localFolder); @@ -1996,9 +2283,9 @@ ipcMain.handle('upload-and-push', async (event, data) => { message: `Upload ${fileName} via GUI`, branch }); - return { ok: true, usedGit: false, singleFile: true, uploaded: result }; + return { ok: true, usedGit: false, singleFile: true, uploaded: result, debugId: uploadDebugId }; } catch (e) { - console.error('Single file API upload error:', e.message); + console.error('[UPLOAD_DEBUG][main] single-file:api-error', { uploadDebugId, error: String(e && e.message ? e.message : e) }); // FALLBACK: Wenn API fehlschlägt (z.B. 422 SHA Fehler), nutze Git // Git löst das "Überschreiben"-Problem zuverlässig. @@ -2013,7 +2300,7 @@ ipcMain.handle('upload-and-push', async (event, data) => { } catch (err) { console.error('Invalid URL', err); } } - if (!finalCloneUrl) return { ok: false, error: 'Cannot fallback to Git: No Clone URL' }; + if (!finalCloneUrl) return { ok: false, error: `Cannot fallback to Git: No Clone URL (${uploadDebugId})` }; // Git-Workflow für einzelne Datei simulieren: // 1. Temp Ordner erstellen @@ -2032,7 +2319,10 @@ ipcMain.handle('upload-and-push', async (event, data) => { const tmpDir = getSafeTmpDir(`git-push-file-${owner}-${repo}`); try { - execSync(`git clone --depth 1 --branch ${branch} "${authClone}" "${tmpDir}"`, gitExecOptions); + const cloneArgs = ['clone', '--depth', '1']; + if (branch !== 'HEAD') cloneArgs.push('--branch', branch); + cloneArgs.push(authClone, tmpDir); + runGitSync(cloneArgs, process.cwd(), gitExecOptions); // Zielort im Repo bestimmen let destDirInRepo = tmpDir; @@ -2047,27 +2337,35 @@ ipcMain.handle('upload-and-push', async (event, data) => { fs.copyFileSync(data.localFolder, finalFileDest); // Git Befehle - execSync(`git -C "${tmpDir}" add .`, gitExecOptions); - try { execSync(`git -C "${tmpDir}" commit -m "Upload file ${fileName} via GUI"`, gitExecOptions); } catch (_) {} - execSync(`git -C "${tmpDir}" push origin ${branch}`, gitExecOptions); + runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions); + try { runGitSync(['-C', tmpDir, 'commit', '-m', `Upload file ${fileName} via GUI`], process.cwd(), gitExecOptions); } catch (_) {} + let pushBranch = branch; + if (pushBranch === 'HEAD') { + try { + pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim(); + } catch (_) { + pushBranch = 'main'; + } + } + runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions); setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000); - return { ok: true, usedGit: true, singleFile: true, msg: 'Uploaded via Git (Fallback)' }; + return { ok: true, usedGit: true, singleFile: true, msg: 'Uploaded via Git (Fallback)', debugId: uploadDebugId }; } catch (gitErr) { - console.error('Git Fallback failed:', gitErr); + console.error('[UPLOAD_DEBUG][main] single-file:git-fallback-error', { uploadDebugId, error: String(gitErr) }); if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); - return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)}` }; + return { ok: false, error: `API failed: ${e.message}. Git fallback failed: ${String(gitErr)} (${uploadDebugId})` }; } } } // --- FALL 2: Ordner (Normale Git-Logik) --- if (!isDirectory) { - return { ok: false, error: 'Path is neither file nor directory' }; + return { ok: false, error: `Path is neither file nor directory (${uploadDebugId})` }; } let gitAvailable = true; - try { execSync('git --version', { stdio: 'ignore', env: gitExecOptions.env }); } catch (e) { gitAvailable = false; } + try { runGitSync(['--version'], process.cwd(), { stdio: 'ignore', env: gitExecOptions.env }); } catch (e) { gitAvailable = false; } let finalCloneUrl = cloneUrl; if (!finalCloneUrl && giteaUrl) { @@ -2091,7 +2389,10 @@ ipcMain.handle('upload-and-push', async (event, data) => { const tmpDir = getSafeTmpDir(`gitea-push-${owner}-${repo}`); try { - execSync(`git clone --depth 1 --branch ${branch} "${authClone}" "${tmpDir}"`, gitExecOptions); + const cloneArgs = ['clone', '--depth', '1']; + if (branch !== 'HEAD') cloneArgs.push('--branch', branch); + cloneArgs.push(authClone, tmpDir); + runGitSync(cloneArgs, process.cwd(), gitExecOptions); // FIXED: Respektieren von destPath und Ordnernamen im Git-Workflow const folderName = ppath.basename(data.localFolder); @@ -2117,23 +2418,39 @@ ipcMain.handle('upload-and-push', async (event, data) => { if (fs.cpSync) { fs.cpSync(data.localFolder, finalDest, { recursive: true, force: true }); } else { - if (process.platform === 'win32') { - execSync(`robocopy "${data.localFolder}" "${finalDest}" /E /NFL /NDL /NJH /NJS /nc /ns`, { ...gitExecOptions, shell: true }); - } else { - execSync(`cp -r "${data.localFolder}/." "${finalDest}"`, { ...gitExecOptions, shell: true }); - } + const copyRecursive = (src, dst) => { + const st = fs.statSync(src); + if (st.isDirectory()) { + if (!fs.existsSync(dst)) fs.mkdirSync(dst, { recursive: true }); + const entries = fs.readdirSync(src); + for (const entry of entries) { + copyRecursive(ppath.join(src, entry), ppath.join(dst, entry)); + } + return; + } + fs.copyFileSync(src, dst); + }; + copyRecursive(data.localFolder, finalDest); } try { - execSync(`git -C "${tmpDir}" add .`, gitExecOptions); - try { execSync(`git -C "${tmpDir}" commit -m "Update from Git Manager GUI"`, gitExecOptions); } catch (_) {} - execSync(`git -C "${tmpDir}" push origin ${branch}`, gitExecOptions); + runGitSync(['-C', tmpDir, 'add', '.'], process.cwd(), gitExecOptions); + try { runGitSync(['-C', tmpDir, 'commit', '-m', 'Update from Git Manager GUI'], process.cwd(), gitExecOptions); } catch (_) {} + let pushBranch = branch; + if (pushBranch === 'HEAD') { + try { + pushBranch = runGitSync(['-C', tmpDir, 'rev-parse', '--abbrev-ref', 'HEAD'], process.cwd(), gitExecOptions).trim(); + } catch (_) { + pushBranch = 'main'; + } + } + runGitSync(['-C', tmpDir, 'push', 'origin', pushBranch], process.cwd(), gitExecOptions); } catch (e) { throw e; } setTimeout(() => { try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} }, 5_000); - return { ok: true, usedGit: true, msg: 'Uploaded via git push' }; + return { ok: true, usedGit: true, msg: 'Uploaded via git push', debugId: uploadDebugId }; } catch (e) { - console.error('upload-and-push git-flow error', e); + console.error('[UPLOAD_DEBUG][main] git-flow-error', { uploadDebugId, error: String(e) }); try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} } } @@ -2174,7 +2491,9 @@ ipcMain.handle('upload-and-push', async (event, data) => { const total = items.length; const concurrency = data.concurrency || DEFAULT_CONCURRENCY; - if (total === 0) return { ok: true, usedGit: false, results: [] }; + if (total === 0) return { ok: true, usedGit: false, results: [], debugId: uploadDebugId }; + + console.log('[UPLOAD_DEBUG][main] api-fallback:start', { uploadDebugId, total, concurrency, branch, destPath }); const tasks = items.map(it => async () => { const raw = fs.readFileSync(it.localFile); @@ -2196,9 +2515,28 @@ ipcMain.handle('upload-and-push', async (event, data) => { }; const uploadResults = await runLimited(tasks, concurrency, (proc, t) => onProgress(proc, total)); - return { ok: true, usedGit: false, results: uploadResults }; + const failedCount = uploadResults.filter(r => !r.ok).length; + console.log('[UPLOAD_DEBUG][main] api-fallback:done', { uploadDebugId, failedCount, total: uploadResults.length }); + return { ok: failedCount === 0, usedGit: false, results: uploadResults, debugId: uploadDebugId, failedCount }; + } catch (e) { + console.error('[UPLOAD_DEBUG][main] upload-and-push:fatal', { uploadDebugId, error: String(e) }); + return { ok: false, error: `${String(e)} (${uploadDebugId})`, debugId: uploadDebugId }; + } +}); + +ipcMain.handle('get-path-type', async (_event, filePath) => { + try { + if (!filePath || typeof filePath !== 'string') { + return { ok: false, error: 'invalid-path' }; + } + if (!fs.existsSync(filePath)) { + return { ok: true, type: 'missing' }; + } + const stat = fs.statSync(filePath); + if (stat.isFile()) return { ok: true, type: 'file' }; + if (stat.isDirectory()) return { ok: true, type: 'dir' }; + return { ok: true, type: 'other' }; } catch (e) { - console.error('upload-and-push error', e); return { ok: false, error: String(e) }; } }); @@ -2267,7 +2605,7 @@ ipcMain.handle('run-batch-repo-action', async (event, data) => { } } catch (_) {} - execSync(`git clone --depth 1 \"${authCloneUrl}\" \"${repoDir}\"`, { stdio: 'ignore' }); + runGitSync(['clone', '--depth', '1', authCloneUrl, repoDir], process.cwd(), { stdio: 'ignore' }); results.push({ repo: item.id, ok: true, message: `Geklont nach ${repoDir}` }); } else if (action === 'create-tag') { const tag = String(options.tag || '').trim(); @@ -2560,7 +2898,12 @@ ipcMain.handle('list-releases', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; - + if (data.platform === 'github') { + const githubToken = (data.token) || credentials.githubToken; + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const releases = await listGithubReleases({ token: githubToken, owner: data.owner, repo: data.repo }); + return { ok: true, releases }; + } const releases = await listGiteaReleases({ token: credentials.giteaToken, url: credentials.giteaURL, @@ -2610,6 +2953,13 @@ ipcMain.handle('create-release', async (event, data) => { prerelease: data.prerelease, target_commitish: data.target_commitish }; + + if (data.platform === 'github') { + const githubToken = (data.token) || credentials.githubToken; + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const release = await createGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, data: releaseData }); + return { ok: true, release }; + } const release = await createGiteaRelease({ token: credentials.giteaToken, @@ -2638,6 +2988,13 @@ ipcMain.handle('edit-release', async (event, data) => { if (data.body !== undefined) updateData.body = data.body; if (data.draft !== undefined) updateData.draft = data.draft; if (data.prerelease !== undefined) updateData.prerelease = data.prerelease; + + if (data.platform === 'github') { + const githubToken = (data.token) || credentials.githubToken; + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const release = await editGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, releaseId: data.releaseId, data: updateData }); + return { ok: true, release }; + } const release = await editGiteaRelease({ token: credentials.giteaToken, @@ -2660,6 +3017,13 @@ ipcMain.handle('delete-release', async (event, data) => { try { const credentials = readCredentials(); if (!credentials) return { ok: false, error: 'no-credentials' }; + + if (data.platform === 'github') { + const githubToken = (data.token) || credentials.githubToken; + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + await deleteGithubRelease({ token: githubToken, owner: data.owner, repo: data.repo, releaseId: data.releaseId }); + return { ok: true }; + } await deleteGiteaRelease({ token: credentials.giteaToken, @@ -2817,21 +3181,16 @@ ipcMain.handle('get-local-commit-files', async (event, data) => { ipcMain.handle('get-commit-diff', async (event, data) => { try { const credentials = readCredentials(); + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const diff = await getGithubCommitDiff({ token: githubToken, owner: data.owner, repo: data.repo, sha: data.sha }); + return { ok: true, diff }; + } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); - - if (!token || !url) { - return { ok: false, error: 'missing-token-or-url' }; - } - - const diff = await getGiteaCommitDiff({ - token, - url, - owner: data.owner, - repo: data.repo, - sha: data.sha - }); - + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + const diff = await getGiteaCommitDiff({ token, url, owner: data.owner, repo: data.repo, sha: data.sha }); return { ok: true, diff }; } catch (error) { console.error('get-commit-diff error:', error); @@ -2842,21 +3201,16 @@ ipcMain.handle('get-commit-diff', async (event, data) => { ipcMain.handle('get-commit-files', async (event, data) => { try { const credentials = readCredentials(); + if (data.platform === 'github') { + const githubToken = (data.token) || (credentials && credentials.githubToken); + if (!githubToken) return { ok: false, error: 'GitHub Token fehlt.' }; + const result = await getGithubCommitFiles({ token: githubToken, owner: data.owner, repo: data.repo, sha: data.sha }); + return { ok: true, files: result.files, stats: result.stats }; + } const token = (data && data.token) || (credentials && credentials.giteaToken); const url = (data && data.url) || (credentials && credentials.giteaURL); - - if (!token || !url) { - return { ok: false, error: 'missing-token-or-url' }; - } - - const result = await getGiteaCommitFiles({ - token, - url, - owner: data.owner, - repo: data.repo, - sha: data.sha - }); - + if (!token || !url) return { ok: false, error: 'missing-token-or-url' }; + const result = await getGiteaCommitFiles({ token, url, owner: data.owner, repo: data.repo, sha: data.sha }); return { ok: true, files: result.files, stats: result.stats }; } catch (error) { console.error('get-commit-files error:', error); @@ -2959,10 +3313,28 @@ ipcMain.handle('copy-to-clipboard', async (_event, text) => { } }); +function isAllowedExternalUrl(rawUrl) { + try { + const parsed = new URL(String(rawUrl || '').trim()); + const protocol = parsed.protocol.toLowerCase(); + if (protocol === 'https:' || protocol === 'mailto:') return true; + if (protocol === 'http:') { + const host = String(parsed.hostname || '').toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; + } + return false; + } catch (_) { + return false; + } +} + ipcMain.handle('open-external-url', async (_event, rawUrl) => { try { const url = String(rawUrl || '').trim(); if (!url) return { ok: false, error: 'Leere URL' }; + if (!isAllowedExternalUrl(url)) { + return { ok: false, error: 'Nicht erlaubte URL.' }; + } await shell.openExternal(url); return { ok: true }; } catch (e) { @@ -2976,14 +3348,15 @@ ipcMain.handle('get-app-version', async () => { }); // 2. Suche nach Updates (Manuell oder Automatisch) -ipcMain.handle('check-for-updates', async (event) => { +ipcMain.handle('check-for-updates', async (event, options = {}) => { console.log("[Main] Update-Check angefordert..."); try { + const silent = Boolean(options && options.silent); if (!updater) { const win = BrowserWindow.fromWebContents(event.sender); if (win) updater = new Updater(win); } - if (updater) await updater.checkForUpdates(false); + if (updater) await updater.checkForUpdates(silent); return { ok: true }; } catch (error) { console.error('[Main] Fehler beim Update-Check:', error); @@ -3130,4 +3503,155 @@ ipcMain.handle('check-clone-target-collisions', async (_event, data) => { } catch (e) { return { ok: false, error: String(e) }; } +}); + +ipcMain.handle('sync-repo-to-github', async (event, data) => { + let mirrorDir = null; + try { + const creds = readCredentials(); + if (!creds?.githubToken) { + return { ok: false, error: 'GitHub Token fehlt. Bitte in den Einstellungen eintragen.' }; + } + + const owner = String(data?.owner || '').trim(); + const repo = String(data?.repo || '').trim(); + if (!owner || !repo) { + return { ok: false, error: 'Owner oder Repository fehlt.' }; + } + + let targetOwner = String(data?.targetOwner || '').trim(); + if (!targetOwner) { + const me = await getGithubCurrentUser({ token: creds.githubToken }); + targetOwner = me?.login || ''; + } + if (!targetOwner) { + return { ok: false, error: 'GitHub Benutzer konnte nicht ermittelt werden.' }; + } + + // GitHub repo erstellen (falls noch nicht vorhanden) + let repoCreated = false; + try { + await createRepoGitHub({ + name: repo, + token: creds.githubToken, + auto_init: false, + private: !!data?.isPrivate, + description: String(data?.description || ''), + homepage: String(data?.homepage || '') + }); + repoCreated = true; + } catch (e) { + const msg = String(e?.message || e || ''); + if (!/already exists|existiert bereits|name already exists/i.test(msg)) { + throw e; + } + } + + let sourceCloneUrl = String(data?.cloneUrl || '').trim(); + if (!sourceCloneUrl) { + if (!creds?.giteaURL) return { ok: false, error: 'Clone-URL fehlt und Gitea URL ist nicht gesetzt.' }; + sourceCloneUrl = `${String(creds.giteaURL).replace(/\/$/, '')}/${owner}/${repo}.git`; + } + + // Quelle auf Gitea: URL ohne Userinfo verwenden und Auth nur über temporären Git-Header übergeben. + let cloneNeedsGiteaHeader = false; + let sourceOrigin = ''; + try { + const sourceUrlObj = new URL(sourceCloneUrl); + const giteaHost = creds?.giteaURL ? new URL(creds.giteaURL).host : null; + const isGiteaSource = giteaHost && sourceUrlObj.host === giteaHost; + + // Niemals eingebettete Zugangsdaten in URLs behalten. + if (sourceUrlObj.username || sourceUrlObj.password) { + sourceUrlObj.username = ''; + sourceUrlObj.password = ''; + } + sourceCloneUrl = sourceUrlObj.toString(); + + if (isGiteaSource && creds?.giteaToken) { + sourceOrigin = `${sourceUrlObj.origin}/`; + cloneNeedsGiteaHeader = true; + } + } catch (_) {} + + mirrorDir = fs.mkdtempSync(ppath.join(os.tmpdir(), 'git-manager-sync-')); + const cloneArgs = cloneNeedsGiteaHeader + ? ['-c', `http.${sourceOrigin}.extraheader=AUTHORIZATION: token ${creds.giteaToken}`, 'clone', '--mirror', sourceCloneUrl, mirrorDir] + : ['clone', '--mirror', sourceCloneUrl, mirrorDir]; + + const runGit = (args, cwd) => { + const res = spawnSync('git', args, { + cwd, + encoding: 'utf8', + windowsHide: true + }); + if (res.status !== 0) { + throw new Error((res.stderr || res.stdout || 'Git-Fehler').trim()); + } + return (res.stdout || '').trim(); + }; + + runGit(cloneArgs, process.cwd()); + + const githubRemoteUrl = `https://github.com/${targetOwner}/${repo}.git`; + const githubAuthHeader = `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${creds.githubToken}`, 'utf8').toString('base64')}`; + + runGit(['remote', 'set-url', '--push', 'origin', githubRemoteUrl], mirrorDir); + runGit(['-c', 'credential.helper=', '-c', `http.https://github.com/.extraheader=${githubAuthHeader}`, 'push', '--mirror'], mirrorDir); + + // Optional: Topics auf GitHub angleichen (nur falls übergeben) + const topics = Array.isArray(data?.topics) ? data.topics.filter(Boolean) : []; + if (topics.length > 0) { + try { + await updateGithubRepoTopics({ + token: creds.githubToken, + owner: targetOwner, + repo, + topics + }); + } catch (topicErr) { + console.warn('sync-repo-to-github: topics sync warning', topicErr?.message || topicErr); + } + } + + return { + ok: true, + repoCreated, + githubRepo: `${targetOwner}/${repo}` + }; + } catch (e) { + console.error('sync-repo-to-github error', e); + return { ok: false, error: String(e?.message || e) }; + } finally { + if (mirrorDir) { + try { fs.rmSync(mirrorDir, { recursive: true, force: true }); } catch (_) {} + } + } +}); + +ipcMain.handle('test-github-connection', async (_event, data) => { + try { + const credentials = readCredentials(); + const token = (data && data.token) || (credentials && credentials.githubToken); + if (!token) return { ok: false, error: 'GitHub Token fehlt.' }; + + const user = await getGithubCurrentUser({ token }); + return { + ok: true, + result: { + ok: true, + checks: { + authProvided: true, + authOk: true + }, + user: { + login: user && user.login ? user.login : '', + id: user && user.id ? user.id : null + } + } + }; + } catch (e) { + console.error('test-github-connection error', e); + return { ok: false, error: mapIpcError(e) }; + } }); \ No newline at end of file