Upload via GUI (27 Dateien)

This commit is contained in:
2026-08-13 07:21:45 +00:00
parent 565835418e
commit 5571f225d5
9 changed files with 1244 additions and 711 deletions
+183 -183
View File
@@ -1,183 +1,183 @@
/**
* BackupManager — Orchestrates backup operations
*/
const archiver = require('archiver')
const { createReadStream, createWriteStream } = require('fs')
const { mkdir } = require('fs').promises
const path = require('path')
const { Transform } = require('stream')
class BackupManager {
constructor(provider) {
this.provider = provider
}
/**
* Create a backup from a project folder
* @param {string} projectPath - Path to project folder
* @param {string} repoName - Repository name (used for filenames)
* @returns {Promise<{filename, size, timestamp}>}
*/
async createBackup(projectPath, repoName) {
try {
// Create ZIP buffer
const buffer = await this._createZip(projectPath)
const timestamp = this._getTimestamp()
const filename = `${repoName}-backup-${timestamp}.zip`
// Upload to provider
const result = await this.provider.uploadBackup(buffer, filename)
// Cleanup old backups
await this._cleanupOldBackups(repoName)
return {
filename,
size: result.size || buffer.length,
timestamp
}
} catch (err) {
throw new Error(`Backup creation failed: ${err.message}`)
}
}
/**
* List all backups for a repository
* @param {string} repoName - Repository name
* @returns {Promise<Array>}
*/
async listBackups(repoName) {
try {
const backups = await this.provider.listBackups()
return backups
.filter(b => b.name.startsWith(repoName))
.sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name))
} catch (err) {
throw new Error(`Failed to list backups: ${err.message}`)
}
}
/**
* Restore a backup to a target folder
* @param {string} repoName - Repository name
* @param {string} filename - Backup filename
* @param {string} targetPath - Target folder path
*/
async restoreBackup(repoName, filename, targetPath) {
try {
// Download backup
const buffer = await this.provider.downloadBackup(filename)
// Extract ZIP
await this._extractZip(buffer, targetPath)
return { ok: true, restored: filename }
} catch (err) {
throw new Error(`Restore failed: ${err.message}`)
}
}
/**
* Delete a backup
* @param {string} filename - Backup filename
*/
async deleteBackup(filename) {
try {
await this.provider.deleteBackup(filename)
return { ok: true }
} catch (err) {
throw new Error(`Delete failed: ${err.message}`)
}
}
// ==================== PRIVATE METHODS ====================
/**
* Create ZIP buffer from project folder
* Excludes: .git, node_modules, dist, build, .env
*/
async _createZip(projectPath) {
return new Promise((resolve, reject) => {
const output = []
const archive = archiver('zip', { zlib: { level: 5 } })
archive.on('data', chunk => output.push(chunk))
archive.on('end', () => resolve(Buffer.concat(output)))
archive.on('error', reject)
// Add files with exclusions
archive.glob('**/*', {
cwd: projectPath,
ignore: [
'.git/**',
'.git',
'node_modules/**',
'node_modules',
'dist/**',
'dist',
'build/**',
'build',
'.env',
'.env.local',
'.env.*.local',
'*.log',
'data/backups/**'
],
dot: true
})
archive.finalize()
})
}
/**
* Extract ZIP buffer to target folder
*/
async _extractZip(buffer, targetPath) {
const unzipper = require('unzipper')
return new Promise((resolve, reject) => {
const { Readable } = require('stream')
const stream = Readable.from(buffer)
stream
.pipe(unzipper.Extract({ path: targetPath }))
.on('close', resolve)
.on('error', reject)
})
}
/**
* Get ISO timestamp (YYYYMMDD-HHMMSS format)
*/
_getTimestamp() {
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}`
}
/**
* Cleanup old backups (keep only last N versions)
*/
async _cleanupOldBackups(repoName, maxVersions = 5) {
try {
const backups = await this.listBackups(repoName)
for (let i = maxVersions; i < backups.length; i++) {
await this.provider.deleteBackup(backups[i].name)
}
} catch (err) {
// Silently ignore cleanup errors
console.warn(`Cleanup warning: ${err.message}`)
}
}
}
module.exports = BackupManager
/**
* BackupManager — Orchestrates backup operations
*/
const archiver = require('archiver')
const { createReadStream, createWriteStream } = require('fs')
const { mkdir } = require('fs').promises
const path = require('path')
const { Transform } = require('stream')
class BackupManager {
constructor(provider) {
this.provider = provider
}
/**
* Create a backup from a project folder
* @param {string} projectPath - Path to project folder
* @param {string} repoName - Repository name (used for filenames)
* @returns {Promise<{filename, size, timestamp}>}
*/
async createBackup(projectPath, repoName) {
try {
// Create ZIP buffer
const buffer = await this._createZip(projectPath)
const timestamp = this._getTimestamp()
const filename = `${repoName}-backup-${timestamp}.zip`
// Upload to provider
const result = await this.provider.uploadBackup(buffer, filename)
// Cleanup old backups
await this._cleanupOldBackups(repoName)
return {
filename,
size: result.size || buffer.length,
timestamp
}
} catch (err) {
throw new Error(`Backup creation failed: ${err.message}`)
}
}
/**
* List all backups for a repository
* @param {string} repoName - Repository name
* @returns {Promise<Array>}
*/
async listBackups(repoName) {
try {
const backups = await this.provider.listBackups()
return backups
.filter(b => b.name.startsWith(repoName))
.sort((a, b) => new Date(b.date || b.name) - new Date(a.date || a.name))
} catch (err) {
throw new Error(`Failed to list backups: ${err.message}`)
}
}
/**
* Restore a backup to a target folder
* @param {string} repoName - Repository name
* @param {string} filename - Backup filename
* @param {string} targetPath - Target folder path
*/
async restoreBackup(repoName, filename, targetPath) {
try {
// Download backup
const buffer = await this.provider.downloadBackup(filename)
// Extract ZIP
await this._extractZip(buffer, targetPath)
return { ok: true, restored: filename }
} catch (err) {
throw new Error(`Restore failed: ${err.message}`)
}
}
/**
* Delete a backup
* @param {string} filename - Backup filename
*/
async deleteBackup(filename) {
try {
await this.provider.deleteBackup(filename)
return { ok: true }
} catch (err) {
throw new Error(`Delete failed: ${err.message}`)
}
}
// ==================== PRIVATE METHODS ====================
/**
* Create ZIP buffer from project folder
* Excludes: .git, node_modules, dist, build, .env
*/
async _createZip(projectPath) {
return new Promise((resolve, reject) => {
const output = []
const archive = archiver('zip', { zlib: { level: 5 } })
archive.on('data', chunk => output.push(chunk))
archive.on('end', () => resolve(Buffer.concat(output)))
archive.on('error', reject)
// Add files with exclusions
archive.glob('**/*', {
cwd: projectPath,
ignore: [
'.git/**',
'.git',
'node_modules/**',
'node_modules',
'dist/**',
'dist',
'build/**',
'build',
'.env',
'.env.local',
'.env.*.local',
'*.log',
'data/backups/**'
],
dot: true
})
archive.finalize()
})
}
/**
* Extract ZIP buffer to target folder
*/
async _extractZip(buffer, targetPath) {
const unzipper = require('unzipper')
return new Promise((resolve, reject) => {
const { Readable } = require('stream')
const stream = Readable.from(buffer)
stream
.pipe(unzipper.Extract({ path: targetPath }))
.on('close', resolve)
.on('error', reject)
})
}
/**
* Get ISO timestamp (YYYYMMDD-HHMMSS format)
*/
_getTimestamp() {
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}`
}
/**
* Cleanup old backups (keep only last N versions)
*/
async _cleanupOldBackups(repoName, maxVersions = 5) {
try {
const backups = await this.listBackups(repoName)
for (let i = maxVersions; i < backups.length; i++) {
await this.provider.deleteBackup(backups[i].name)
}
} catch (err) {
// Silently ignore cleanup errors
console.warn(`Cleanup warning: ${err.message}`)
}
}
}
module.exports = BackupManager
+59 -59
View File
@@ -1,59 +1,59 @@
/**
* BackupProvider — Abstract base class for backup providers
* All providers must implement these methods
*/
class BackupProvider {
/**
* Authenticate with the backup service
* @param {Object} credentials - Provider-specific credentials
*/
async authenticate(credentials) {
throw new Error('authenticate() not implemented in ' + this.constructor.name)
}
/**
* Upload a backup file
* @param {Buffer} buffer - File content
* @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip')
* @returns {Promise<{size: number}>}
*/
async uploadBackup(buffer, filename) {
throw new Error('uploadBackup() not implemented in ' + this.constructor.name)
}
/**
* List all backups for a repository
* @returns {Promise<Array>} Array of {name, size, date}
*/
async listBackups() {
throw new Error('listBackups() not implemented in ' + this.constructor.name)
}
/**
* Download a specific backup
* @param {string} filename - Filename to download
* @returns {Promise<Buffer>}
*/
async downloadBackup(filename) {
throw new Error('downloadBackup() not implemented in ' + this.constructor.name)
}
/**
* Delete a backup file
* @param {string} filename - Filename to delete
*/
async deleteBackup(filename) {
throw new Error('deleteBackup() not implemented in ' + this.constructor.name)
}
/**
* Test connection to backup service
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async testConnection() {
throw new Error('testConnection() not implemented in ' + this.constructor.name)
}
}
module.exports = BackupProvider
/**
* BackupProvider — Abstract base class for backup providers
* All providers must implement these methods
*/
class BackupProvider {
/**
* Authenticate with the backup service
* @param {Object} credentials - Provider-specific credentials
*/
async authenticate(credentials) {
throw new Error('authenticate() not implemented in ' + this.constructor.name)
}
/**
* Upload a backup file
* @param {Buffer} buffer - File content
* @param {string} filename - Filename (e.g., 'repo-backup-2025-03-24.zip')
* @returns {Promise<{size: number}>}
*/
async uploadBackup(buffer, filename) {
throw new Error('uploadBackup() not implemented in ' + this.constructor.name)
}
/**
* List all backups for a repository
* @returns {Promise<Array>} Array of {name, size, date}
*/
async listBackups() {
throw new Error('listBackups() not implemented in ' + this.constructor.name)
}
/**
* Download a specific backup
* @param {string} filename - Filename to download
* @returns {Promise<Buffer>}
*/
async downloadBackup(filename) {
throw new Error('downloadBackup() not implemented in ' + this.constructor.name)
}
/**
* Delete a backup file
* @param {string} filename - Filename to delete
*/
async deleteBackup(filename) {
throw new Error('deleteBackup() not implemented in ' + this.constructor.name)
}
/**
* Test connection to backup service
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async testConnection() {
throw new Error('testConnection() not implemented in ' + this.constructor.name)
}
}
module.exports = BackupProvider
+84 -84
View File
@@ -1,84 +1,84 @@
/**
* LocalProvider — Backup provider for local folders
*/
const path = require('path')
const fs = require('fs').promises
const BackupProvider = require('./BackupProvider')
class LocalProvider extends BackupProvider {
constructor() {
super()
this.basePath = null
}
async authenticate(credentials) {
const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim()
if (!basePath) {
throw new Error('Lokaler Backup-Ordner fehlt')
}
this.basePath = path.resolve(basePath)
await fs.mkdir(this.basePath, { recursive: true })
}
async testConnection() {
if (!this.basePath) return { ok: false, error: 'Not authenticated' }
try {
const stat = await fs.stat(this.basePath)
if (!stat.isDirectory()) {
return { ok: false, error: 'Pfad ist kein Ordner' }
}
return { ok: true }
} catch (err) {
return { ok: false, error: err.message }
}
}
async uploadBackup(buffer, filename) {
if (!this.basePath) throw new Error('Not authenticated')
const target = path.join(this.basePath, filename)
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, buffer)
return { size: buffer.length }
}
async listBackups() {
if (!this.basePath) throw new Error('Not authenticated')
const entries = await fs.readdir(this.basePath, { withFileTypes: true })
const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip'))
const backups = []
for (const f of files) {
const full = path.join(this.basePath, f.name)
const stat = await fs.stat(full)
backups.push({
name: f.name,
size: stat.size,
date: stat.mtime.toISOString()
})
}
backups.sort((a, b) => new Date(b.date) - new Date(a.date))
return backups
}
async downloadBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename)
return fs.readFile(file)
}
async deleteBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename)
await fs.unlink(file)
}
}
module.exports = LocalProvider
/**
* LocalProvider — Backup provider for local folders
*/
const path = require('path')
const fs = require('fs').promises
const BackupProvider = require('./BackupProvider')
class LocalProvider extends BackupProvider {
constructor() {
super()
this.basePath = null
}
async authenticate(credentials) {
const basePath = String(credentials && credentials.basePath ? credentials.basePath : '').trim()
if (!basePath) {
throw new Error('Lokaler Backup-Ordner fehlt')
}
this.basePath = path.resolve(basePath)
await fs.mkdir(this.basePath, { recursive: true })
}
async testConnection() {
if (!this.basePath) return { ok: false, error: 'Not authenticated' }
try {
const stat = await fs.stat(this.basePath)
if (!stat.isDirectory()) {
return { ok: false, error: 'Pfad ist kein Ordner' }
}
return { ok: true }
} catch (err) {
return { ok: false, error: err.message }
}
}
async uploadBackup(buffer, filename) {
if (!this.basePath) throw new Error('Not authenticated')
const target = path.join(this.basePath, filename)
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, buffer)
return { size: buffer.length }
}
async listBackups() {
if (!this.basePath) throw new Error('Not authenticated')
const entries = await fs.readdir(this.basePath, { withFileTypes: true })
const files = entries.filter(e => e.isFile() && e.name.endsWith('.zip'))
const backups = []
for (const f of files) {
const full = path.join(this.basePath, f.name)
const stat = await fs.stat(full)
backups.push({
name: f.name,
size: stat.size,
date: stat.mtime.toISOString()
})
}
backups.sort((a, b) => new Date(b.date) - new Date(a.date))
return backups
}
async downloadBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename)
return fs.readFile(file)
}
async deleteBackup(filename) {
if (!this.basePath) throw new Error('Not authenticated')
const file = path.join(this.basePath, filename)
await fs.unlink(file)
}
}
module.exports = LocalProvider
+189
View File
@@ -3,6 +3,7 @@
// getGiteaRepoContents, getGiteaFileContent, uploadGiteaFile
const axios = require('axios');
const fs = require('fs');
const http = require('http');
const https = require('https');
@@ -2055,6 +2056,193 @@ async function updateGiteaAvatar({ token, url, imageBase64 }) {
);
}
/* -----------------------------
Batch-Upload über die Contents-API (Gitea >= 1.20)
Fallback für den Fall, dass der Git-Transport nicht nutzbar ist — etwa wenn
git-upload-pack auf dem Server keine Packfiles erzeugen kann ("early EOF").
Dieser Weg braucht weder Clone noch Push, sondern schreibt die Dateien direkt
über die REST-API. Mehrere Dateien landen dabei in einem gemeinsamen Commit.
----------------------------- */
// Zielgröße einer Anfrage (Rohbytes vor Base64). Bewusst klein gehalten, damit
// ein Reverse-Proxy mit knappem client_max_body_size nicht dazwischenfunkt.
const API_UPLOAD_TARGET_BYTES = 6 * 1024 * 1024;
const API_UPLOAD_MAX_FILES_PER_COMMIT = 50;
// Löst "HEAD" zum tatsächlichen Default-Branch auf.
async function resolveGiteaBranchName({ token, base, owner, repo, branch }) {
const name = branch || 'HEAD';
if (name !== 'HEAD') return name;
const cacheKey = `${base}::${owner}/${repo}`;
const cached = defaultBranchCache.get(cacheKey);
if (cached && (Date.now() - cached.ts) < DEFAULT_BRANCH_TTL_MS) return cached.branch;
try {
const info = await tryRequest(
`${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
token
);
const resolved = (info.ok && info.data && info.data.default_branch) ? info.data.default_branch : 'main';
defaultBranchCache.set(cacheKey, { branch: resolved, ts: Date.now() });
return resolved;
} catch (_) {
return 'main';
}
}
// Ermittelt die SHAs bereits vorhandener Dateien. Die Contents-API verlangt bei einem
// "update" die SHA der Vorgängerversion. Abgefragt wird pro Verzeichnis, nicht pro Datei.
async function fetchExistingFileShas({ token, base, owner, repo, branch, paths }) {
const shaByPath = new Map();
const dirs = new Set(paths.map(p => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')));
for (const dir of dirs) {
try {
const res = await getGiteaRepoContents({ token, url: base, owner, repo, path: dir, ref: branch });
const list = (res && res.items) ? res.items : (Array.isArray(res) ? res : []);
for (const item of list) {
if (item && item.type === 'file' && item.path && item.sha) {
shaByPath.set(item.path, item.sha);
}
}
} catch (_) {
// Verzeichnis existiert noch nicht — dann sind alle Dateien darin neu.
}
}
return shaByPath;
}
// Ein Commit mit mehreren Dateiänderungen.
async function changeGiteaFiles({ token, base, owner, repo, branch, files, message }) {
const endpoint = `${base}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents`;
const res = await axiosInstance.post(
endpoint,
{ branch, message, files },
{
headers: { Authorization: `token ${token}` },
timeout: 180000,
maxBodyLength: Infinity,
maxContentLength: Infinity
}
);
return res.data;
}
function isPayloadTooLargeError(err) {
const status = err && err.response && err.response.status;
if (status === 413) return true;
const msg = String((err && err.message) || '').toLowerCase();
return msg.includes('entity too large') || msg.includes('request body too large');
}
/**
* Lädt Dateien ausschließlich über die REST-API hoch — ohne Clone, ohne Push.
*
* @param {Array<{localFile: string, targetPath: string}>} items
* @param {function(number):void} [onProgress] Fortschritt in Prozent
* @returns {Promise<{branch: string, commits: number, uploaded: string[], failed: Array<{path: string, error: string}>}>}
*/
async function uploadItemsViaGiteaApi({ token, url, owner, repo, branch, items, message, onProgress }) {
const base = normalizeBase(url);
if (!base) throw new Error('Invalid Gitea base URL');
if (!Array.isArray(items) || items.length === 0) throw new Error('Keine Dateien zum Hochladen');
const branchName = await resolveGiteaBranchName({ token, base, owner, repo, branch });
const shaByPath = await fetchExistingFileShas({
token, base, owner, repo, branch: branchName,
paths: items.map(i => i.targetPath)
});
const uploaded = [];
const failed = [];
let commits = 0;
let processedBytes = 0;
// Gesamtgröße für die Fortschrittsanzeige.
let totalBytes = 0;
for (const item of items) {
try { totalBytes += fs.statSync(item.localFile).size; } catch (_) {}
}
if (totalBytes === 0) totalBytes = 1;
// Dateien zu Paketen bündeln, die je einen Commit ergeben.
const batches = [];
let current = [];
let currentBytes = 0;
for (const item of items) {
let size = 0;
try { size = fs.statSync(item.localFile).size; } catch (e) {
failed.push({ path: item.targetPath, error: `Datei nicht lesbar: ${e.message || e}` });
continue;
}
if (current.length > 0 &&
(currentBytes + size > API_UPLOAD_TARGET_BYTES || current.length >= API_UPLOAD_MAX_FILES_PER_COMMIT)) {
batches.push(current);
current = [];
currentBytes = 0;
}
current.push({ ...item, size });
currentBytes += size;
}
if (current.length > 0) batches.push(current);
// Ein Paket absenden; bei zu großem Body rekursiv halbieren.
const sendBatch = async (batch) => {
const files = batch.map(item => {
const existingSha = shaByPath.get(item.targetPath);
const entry = {
operation: existingSha ? 'update' : 'create',
path: item.targetPath,
content: fs.readFileSync(item.localFile).toString('base64')
};
if (existingSha) entry.sha = existingSha;
return entry;
});
try {
await changeGiteaFiles({ token, base, owner, repo, branch: branchName, files, message });
commits++;
for (const item of batch) {
uploaded.push(item.targetPath);
processedBytes += item.size;
}
if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100)));
return;
} catch (err) {
// Body zu groß: aufteilen und erneut versuchen.
if (isPayloadTooLargeError(err) && batch.length > 1) {
const half = Math.ceil(batch.length / 2);
console.warn(`[ApiUpload] Paket zu groß (${batch.length} Dateien) — wird geteilt`);
await sendBatch(batch.slice(0, half));
await sendBatch(batch.slice(half));
return;
}
const detail = (err.response && err.response.data && err.response.data.message)
|| err.message || String(err);
for (const item of batch) {
failed.push({ path: item.targetPath, error: String(detail) });
processedBytes += item.size;
}
if (onProgress) onProgress(Math.min(99, Math.round((processedBytes / totalBytes) * 100)));
}
};
for (const batch of batches) {
await sendBatch(batch);
}
if (onProgress) onProgress(100);
return { branch: branchName, commits, uploaded, failed };
}
module.exports = {
normalizeAndValidateBaseUrl,
createRepoGitHub,
@@ -2073,6 +2261,7 @@ module.exports = {
getGiteaRepoContents,
getGiteaFileContent,
uploadGiteaFile,
uploadItemsViaGiteaApi,
// Commit History
getGiteaCommits,
getGiteaCommit,
+293 -293
View File
@@ -1,293 +1,293 @@
/**
* Gemeinsame Utility-Funktionen für Git Manager GUI
* - Branch Handling
* - API Error Handling
* - Standardisiertes Logging
* - Caching
*/
const fs = require('fs');
const ppath = require('path');
// ===== LOGGING SYSTEM =====
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG;
let logQueue = [];
const MAX_LOG_BUFFER = 2000;
function formatLog(level, context, message, details = null) {
const timestamp = new Date().toISOString();
const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level);
return {
timestamp,
level: levelStr,
context,
message,
details,
pid: process.pid
};
}
function writeLog(logEntry) {
logQueue.push(logEntry);
if (logQueue.length > MAX_LOG_BUFFER) {
logQueue.shift();
}
// Auch in Console schreiben
const { level, timestamp, context, message, details } = logEntry;
const prefix = `[${timestamp}] [${level}] [${context}]`;
if (level === 'ERROR' && details?.error) {
console.error(prefix, message, details.error);
} else if (level === 'WARN') {
console.warn(prefix, message, details ? JSON.stringify(details) : '');
} else if (level !== 'DEBUG' || process.env.DEBUG) {
console.log(prefix, message, details ? JSON.stringify(details) : '');
}
}
const logger = {
debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)),
info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)),
warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)),
error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)),
getRecent: (count = 20) => logQueue.slice(-count),
setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; }
};
// ===== BRANCH HANDLING =====
const BRANCH_DEFAULTS = {
gitea: 'main',
github: 'main'
};
function normalizeBranch(branch = 'HEAD', platform = 'gitea') {
const value = String(branch || '').trim();
// HEAD sollte immer zu Standard konvertiert werden
if (value.toLowerCase() === 'head') {
return BRANCH_DEFAULTS[platform] || 'main';
}
// Validierung: nur sichere Git-Referenzen
if (/^[a-zA-Z0-9._\-/]+$/.test(value)) {
return value;
}
return BRANCH_DEFAULTS[platform] || 'main';
}
function isSafeBranch(branch) {
return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || ''));
}
// ===== ERROR HANDLING =====
const ERROR_CODES = {
NETWORK: 'NETWORK_ERROR',
AUTH_FAILED: 'AUTH_FAILED',
NOT_FOUND: 'NOT_FOUND',
VALIDATION: 'VALIDATION_ERROR',
RATE_LIMIT: 'RATE_LIMIT',
SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN: 'UNKNOWN_ERROR'
};
function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) {
if (!error) {
return { code: defaultCode, message: 'Unknown error', statusCode: null };
}
// Axios-style error
if (error.response) {
const status = error.response.status;
const data = error.response.data;
let code = defaultCode;
if (status === 401 || status === 403) {
code = ERROR_CODES.AUTH_FAILED;
} else if (status === 404) {
code = ERROR_CODES.NOT_FOUND;
} else if (status === 429) {
code = ERROR_CODES.RATE_LIMIT;
} else if (status >= 500) {
code = ERROR_CODES.SERVER_ERROR;
}
return {
code,
message: data?.message || error.message || `HTTP ${status}`,
statusCode: status,
rawMessage: data?.message
};
}
// Network error
if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) {
return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null };
}
if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) {
return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null };
}
return {
code: ERROR_CODES.UNKNOWN,
message: error.message || String(error),
statusCode: null
};
}
function formatErrorForUser(error, context = 'Operation') {
const parsed = parseApiError(error);
const messages = {
[ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`,
[ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`,
[ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`,
[ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`,
[ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`,
[ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.`
};
return {
userMessage: messages[parsed.code],
technicalMessage: parsed.message,
code: parsed.code,
details: parsed
};
}
// ===== CACHING SYSTEM =====
class Cache {
constructor(ttl = 300000) { // 5 min default
this.store = new Map();
this.ttl = ttl;
}
set(key, value, customTtl = null) {
const expiry = Date.now() + (customTtl || this.ttl);
this.store.set(key, { value, expiry });
}
get(key) {
const item = this.store.get(key);
if (!item) return null;
if (Date.now() > item.expiry) {
this.store.delete(key);
return null;
}
return item.value;
}
invalidate(keyPattern) {
for (const [key] of this.store) {
if (key.includes(keyPattern)) {
this.store.delete(key);
}
}
}
clear() {
this.store.clear();
}
size() {
return this.store.size;
}
}
// Standard Caches
const caches = {
repos: new Cache(600000), // 10 min
fileTree: new Cache(300000), // 5 min
api: new Cache(120000) // 2 min
};
// ===== PARALLEL OPERATIONS =====
async function runParallel(operations, concurrency = 4, onProgress = null) {
const results = new Array(operations.length);
let completed = 0;
let index = 0;
async function worker() {
while (index < operations.length) {
const i = index++;
try {
results[i] = { ok: true, result: await operations[i]() };
} catch (e) {
results[i] = { ok: false, error: e };
}
completed++;
if (onProgress) {
try { onProgress(completed, operations.length); } catch (_) {}
}
}
}
const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker());
await Promise.all(workers);
return results;
}
// ===== RETRY LOGIC =====
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
lastError = e;
if (attempt < maxAttempts - 1) {
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
// ===== FILE OPERATIONS =====
function ensureDirectory(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function safeReadFile(filePath, defaultValue = null) {
try {
if (!fs.existsSync(filePath)) return defaultValue;
return fs.readFileSync(filePath, 'utf8');
} catch (e) {
logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message });
return defaultValue;
}
}
function safeWriteFile(filePath, content) {
try {
ensureDirectory(ppath.dirname(filePath));
fs.writeFileSync(filePath, content, 'utf8');
return true;
} catch (e) {
logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message });
return false;
}
}
// ===== EXPORTS =====
module.exports = {
logger,
normalizeBranch,
isSafeBranch,
parseApiError,
formatErrorForUser,
ERROR_CODES,
Cache,
caches,
runParallel,
retryWithBackoff,
ensureDirectory,
safeReadFile,
safeWriteFile,
LOG_LEVELS
};
/**
* Gemeinsame Utility-Funktionen für Git Manager GUI
* - Branch Handling
* - API Error Handling
* - Standardisiertes Logging
* - Caching
*/
const fs = require('fs');
const ppath = require('path');
// ===== LOGGING SYSTEM =====
const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 };
let currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.INFO : LOG_LEVELS.DEBUG;
let logQueue = [];
const MAX_LOG_BUFFER = 2000;
function formatLog(level, context, message, details = null) {
const timestamp = new Date().toISOString();
const levelStr = Object.keys(LOG_LEVELS).find(k => LOG_LEVELS[k] === level);
return {
timestamp,
level: levelStr,
context,
message,
details,
pid: process.pid
};
}
function writeLog(logEntry) {
logQueue.push(logEntry);
if (logQueue.length > MAX_LOG_BUFFER) {
logQueue.shift();
}
// Auch in Console schreiben
const { level, timestamp, context, message, details } = logEntry;
const prefix = `[${timestamp}] [${level}] [${context}]`;
if (level === 'ERROR' && details?.error) {
console.error(prefix, message, details.error);
} else if (level === 'WARN') {
console.warn(prefix, message, details ? JSON.stringify(details) : '');
} else if (level !== 'DEBUG' || process.env.DEBUG) {
console.log(prefix, message, details ? JSON.stringify(details) : '');
}
}
const logger = {
debug: (context, message, details) => writeLog(formatLog(LOG_LEVELS.DEBUG, context, message, details)),
info: (context, message, details) => writeLog(formatLog(LOG_LEVELS.INFO, context, message, details)),
warn: (context, message, details) => writeLog(formatLog(LOG_LEVELS.WARN, context, message, details)),
error: (context, message, details) => writeLog(formatLog(LOG_LEVELS.ERROR, context, message, details)),
getRecent: (count = 20) => logQueue.slice(-count),
setLevel: (level) => { currentLogLevel = LOG_LEVELS[level] || LOG_LEVELS.INFO; }
};
// ===== BRANCH HANDLING =====
const BRANCH_DEFAULTS = {
gitea: 'main',
github: 'main'
};
function normalizeBranch(branch = 'HEAD', platform = 'gitea') {
const value = String(branch || '').trim();
// HEAD sollte immer zu Standard konvertiert werden
if (value.toLowerCase() === 'head') {
return BRANCH_DEFAULTS[platform] || 'main';
}
// Validierung: nur sichere Git-Referenzen
if (/^[a-zA-Z0-9._\-/]+$/.test(value)) {
return value;
}
return BRANCH_DEFAULTS[platform] || 'main';
}
function isSafeBranch(branch) {
return /^[a-zA-Z0-9._\-/]+$/.test(String(branch || ''));
}
// ===== ERROR HANDLING =====
const ERROR_CODES = {
NETWORK: 'NETWORK_ERROR',
AUTH_FAILED: 'AUTH_FAILED',
NOT_FOUND: 'NOT_FOUND',
VALIDATION: 'VALIDATION_ERROR',
RATE_LIMIT: 'RATE_LIMIT',
SERVER_ERROR: 'SERVER_ERROR',
UNKNOWN: 'UNKNOWN_ERROR'
};
function parseApiError(error, defaultCode = ERROR_CODES.UNKNOWN) {
if (!error) {
return { code: defaultCode, message: 'Unknown error', statusCode: null };
}
// Axios-style error
if (error.response) {
const status = error.response.status;
const data = error.response.data;
let code = defaultCode;
if (status === 401 || status === 403) {
code = ERROR_CODES.AUTH_FAILED;
} else if (status === 404) {
code = ERROR_CODES.NOT_FOUND;
} else if (status === 429) {
code = ERROR_CODES.RATE_LIMIT;
} else if (status >= 500) {
code = ERROR_CODES.SERVER_ERROR;
}
return {
code,
message: data?.message || error.message || `HTTP ${status}`,
statusCode: status,
rawMessage: data?.message
};
}
// Network error
if (error.message?.includes('timeout') || error.code?.includes('TIMEOUT')) {
return { code: ERROR_CODES.NETWORK, message: 'Request timeout', statusCode: null };
}
if (error.code?.includes('ECONNREFUSED') || error.message?.includes('ECONNREFUSED')) {
return { code: ERROR_CODES.NETWORK, message: 'Connection refused', statusCode: null };
}
return {
code: ERROR_CODES.UNKNOWN,
message: error.message || String(error),
statusCode: null
};
}
function formatErrorForUser(error, context = 'Operation') {
const parsed = parseApiError(error);
const messages = {
[ERROR_CODES.AUTH_FAILED]: `Authentifizierung fehlgeschlagen. Bitte Token überprüfen.`,
[ERROR_CODES.NOT_FOUND]: `Ressource nicht gefunden.`,
[ERROR_CODES.NETWORK]: `Netzwerkfehler. Bitte Verbindung überprüfen.`,
[ERROR_CODES.RATE_LIMIT]: `Zu viele Anfragen. Bitte später versuchen.`,
[ERROR_CODES.SERVER_ERROR]: `Server-Fehler. Bitte später versuchen.`,
[ERROR_CODES.UNKNOWN]: `${context} fehlgeschlagen.`
};
return {
userMessage: messages[parsed.code],
technicalMessage: parsed.message,
code: parsed.code,
details: parsed
};
}
// ===== CACHING SYSTEM =====
class Cache {
constructor(ttl = 300000) { // 5 min default
this.store = new Map();
this.ttl = ttl;
}
set(key, value, customTtl = null) {
const expiry = Date.now() + (customTtl || this.ttl);
this.store.set(key, { value, expiry });
}
get(key) {
const item = this.store.get(key);
if (!item) return null;
if (Date.now() > item.expiry) {
this.store.delete(key);
return null;
}
return item.value;
}
invalidate(keyPattern) {
for (const [key] of this.store) {
if (key.includes(keyPattern)) {
this.store.delete(key);
}
}
}
clear() {
this.store.clear();
}
size() {
return this.store.size;
}
}
// Standard Caches
const caches = {
repos: new Cache(600000), // 10 min
fileTree: new Cache(300000), // 5 min
api: new Cache(120000) // 2 min
};
// ===== PARALLEL OPERATIONS =====
async function runParallel(operations, concurrency = 4, onProgress = null) {
const results = new Array(operations.length);
let completed = 0;
let index = 0;
async function worker() {
while (index < operations.length) {
const i = index++;
try {
results[i] = { ok: true, result: await operations[i]() };
} catch (e) {
results[i] = { ok: false, error: e };
}
completed++;
if (onProgress) {
try { onProgress(completed, operations.length); } catch (_) {}
}
}
}
const workers = Array.from({ length: Math.min(concurrency, operations.length) }, () => worker());
await Promise.all(workers);
return results;
}
// ===== RETRY LOGIC =====
async function retryWithBackoff(fn, maxAttempts = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
lastError = e;
if (attempt < maxAttempts - 1) {
const delay = baseDelay * Math.pow(2, attempt);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError;
}
// ===== FILE OPERATIONS =====
function ensureDirectory(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function safeReadFile(filePath, defaultValue = null) {
try {
if (!fs.existsSync(filePath)) return defaultValue;
return fs.readFileSync(filePath, 'utf8');
} catch (e) {
logger.warn('safeReadFile', `Failed to read ${filePath}`, { error: e.message });
return defaultValue;
}
}
function safeWriteFile(filePath, content) {
try {
ensureDirectory(ppath.dirname(filePath));
fs.writeFileSync(filePath, content, 'utf8');
return true;
} catch (e) {
logger.error('safeWriteFile', `Failed to write ${filePath}`, { error: e.message });
return false;
}
}
// ===== EXPORTS =====
module.exports = {
logger,
normalizeBranch,
isSafeBranch,
parseApiError,
formatErrorForUser,
ERROR_CODES,
Cache,
caches,
runParallel,
retryWithBackoff,
ensureDirectory,
safeReadFile,
safeWriteFile,
LOG_LEVELS
};