import { createHash, randomBytes, timingSafeEqual } from 'crypto'; import { db } from './db.mjs'; export const PERMISSION_CATALOG = Object.freeze([ { key: 'dashboard.view', label: 'Dashboard ansehen', group: 'Allgemein' }, { key: 'listings.view', label: 'Artikel ansehen', group: 'Artikel' }, { key: 'listings.edit', label: 'Artikel erstellen und bearbeiten', group: 'Artikel' }, { key: 'listings.delete', label: 'Artikel in den Papierkorb verschieben', group: 'Artikel' }, { key: 'listings.permanent_delete', label: 'Artikel endgültig löschen', group: 'Artikel' }, { key: 'media.view', label: 'Bilder ansehen und herunterladen', group: 'Bilder' }, { key: 'media.edit', label: 'Bilder hochladen und bearbeiten', group: 'Bilder' }, { key: 'media.delete', label: 'Bilder löschen', group: 'Bilder' }, { key: 'generation.execute', label: 'AI- und FLUX-Generierung starten', group: 'AI & FLUX' }, { key: 'batch.execute', label: 'Bildproduktion ausführen', group: 'AI & FLUX' }, { key: 'quality.execute', label: 'Qualitätsanalysen und AI-Verbesserungen', group: 'Qualität' }, { key: 'publishing.view', label: 'Publishing ansehen', group: 'Publishing' }, { key: 'publishing.execute', label: 'Veröffentlichungen starten und wiederholen', group: 'Publishing' }, { key: 'publishing.manage', label: 'Veröffentlichungen beenden und endgültig löschen', group: 'Publishing' }, { key: 'inventory.edit', label: 'Lagerorte und Bestände ändern', group: 'Organisation' }, { key: 'templates.edit', label: 'Vorlagen verwalten', group: 'Organisation' }, { key: 'settings.manage', label: 'Globale Einstellungen ändern', group: 'Administration' }, { key: 'users.manage', label: 'Benutzer und Rollen verwalten', group: 'Administration' }, { key: 'sessions.manage', label: 'Fremde Sitzungen beenden', group: 'Administration' }, { key: 'audit.view', label: 'Audit-Protokoll ansehen und exportieren', group: 'Administration' }, { key: 'operations.manage', label: 'Backups, Restore und lokale Updates verwalten', group: 'Administration' }, { key: 'updates.manage', label: 'GitHub-/Coolify-Deployments auslösen', group: 'Administration' }, { key: 'security.manage', label: 'Server-Sicherheit konfigurieren', group: 'Administration' }, { key: 'modules.manage', label: 'Module installieren, aktivieren und entfernen', group: 'Administration' }, ]); const ALL_PERMISSIONS = PERMISSION_CATALOG.map(item => item.key); export const ROLE_DEFINITIONS = Object.freeze({ admin: { key: 'admin', label: 'Administrator', description: 'Vollzugriff auf Vendoo, Benutzer, Backups, Updates und Server-Sicherheit.', permissions: ALL_PERMISSIONS, }, manager: { key: 'manager', label: 'Manager', description: 'Operative Leitung mit vollständiger Artikel-, Medien- und Publishing-Verwaltung, ohne System- und Benutzeradministration.', permissions: ALL_PERMISSIONS.filter(key => ![ 'users.manage', 'sessions.manage', 'operations.manage', 'updates.manage', 'security.manage', 'settings.manage', 'modules.manage', ].includes(key)), }, editor: { key: 'editor', label: 'Editor', description: 'Erstellt und bearbeitet Artikel, Bilder und AI-Inhalte. Keine endgültigen Löschungen oder Systemverwaltung.', permissions: [ 'dashboard.view', 'listings.view', 'listings.edit', 'listings.delete', 'media.view', 'media.edit', 'generation.execute', 'batch.execute', 'quality.execute', 'publishing.view', 'publishing.execute', 'inventory.edit', 'templates.edit', ], }, publisher: { key: 'publisher', label: 'Publisher', description: 'Prüft Artikel und führt Veröffentlichungen aus, ohne Artikelstammdaten oder Systemeinstellungen zu ändern.', permissions: [ 'dashboard.view', 'listings.view', 'media.view', 'quality.execute', 'publishing.view', 'publishing.execute', ], }, viewer: { key: 'viewer', label: 'Leser', description: 'Reiner Lesezugriff auf Artikel, Bilder, Qualität und Publishing-Status.', permissions: ['dashboard.view', 'listings.view', 'media.view', 'publishing.view'], }, }); const MUTATION_DEFAULT_PERMISSION = 'listings.edit'; export function normalizeRole(role) { return ROLE_DEFINITIONS[role] ? role : 'viewer'; } export function getRoleDefinitions() { return Object.values(ROLE_DEFINITIONS).map(role => ({ ...role, permissions: [...role.permissions] })); } export function getPermissionCatalog() { return PERMISSION_CATALOG.map(item => ({ ...item })); } export function permissionsForRole(role) { return new Set(ROLE_DEFINITIONS[normalizeRole(role)].permissions); } export function hasPermission(role, permission) { if (!permission) return true; return permissionsForRole(role).has(permission); } export function requiredPermissionForApi(method, path) { const verb = String(method || 'GET').toUpperCase(); const route = String(path || '').split('?')[0]; if (route === '/api/me' || route.startsWith('/api/my/')) return null; if (route === '/api/extensions/heartbeat' || route === '/api/extensions/prepare') return null; if (route === '/api/backup' || route === '/api/restore') return 'operations.manage'; if (route.startsWith('/api/admin/users')) return 'users.manage'; if (route.startsWith('/api/admin/sessions')) return 'sessions.manage'; if (route.startsWith('/api/admin/login-history')) return 'users.manage'; if (route.startsWith('/api/admin/smtp')) return 'settings.manage'; if (route.startsWith('/api/admin/modules')) return 'modules.manage'; if (route.startsWith('/api/admin/deployment')) return 'updates.manage'; if (route.startsWith('/api/admin/audit')) return 'audit.view'; if (route.startsWith('/api/admin/') || route.startsWith('/api/operations/')) return 'operations.manage'; if (route.startsWith('/api/settings')) return verb === 'GET' ? null : 'settings.manage'; if (route.startsWith('/api/themes')) return null; // sichere Detail-Policies übernimmt der Platform Kernel if (route.startsWith('/api/extensions/diagnostics')) return 'security.manage'; if (route.startsWith('/api/security/')) return verb === 'GET' ? 'security.manage' : 'security.manage'; if (route.startsWith('/api/locks/admin/')) return 'sessions.manage'; if (route.startsWith('/api/trash')) { if (verb === 'GET') return 'listings.view'; if (verb === 'DELETE') return 'listings.permanent_delete'; return 'listings.edit'; } if (route.startsWith('/api/listings')) { if (verb === 'GET') return 'listings.view'; if (verb === 'DELETE') return 'listings.delete'; return 'listings.edit'; } if (route.startsWith('/api/templates')) return verb === 'GET' ? null : 'templates.edit'; if (route.startsWith('/api/storage') || route.startsWith('/api/inventory')) return verb === 'GET' ? null : 'inventory.edit'; if (route.startsWith('/api/media') || route.startsWith('/api/gallery')) { if (verb === 'GET') return 'media.view'; if (verb === 'DELETE') return 'media.delete'; return 'media.edit'; } if (route.startsWith('/api/upload') || route.startsWith('/api/images') || route.startsWith('/api/image-editor') || route.startsWith('/api/mobile-upload-sessions')) { if (verb === 'GET') return 'media.view'; if (verb === 'DELETE') return 'media.delete'; return 'media.edit'; } if (route === '/api/html-render' || route === '/api/html-wrap') return 'listings.view'; if (route === '/api/photos/archive') return 'publishing.execute'; if (route.startsWith('/api/local-image/install') || route.startsWith('/api/local-image/update') || route.startsWith('/api/local-image/uninstall') || route.startsWith('/api/local-image/regenerate-token')) return 'settings.manage'; if (route.startsWith('/api/ebay')) { if (verb === 'GET') return 'publishing.view'; if (verb === 'DELETE') return 'publishing.manage'; return 'publishing.execute'; } if (route.startsWith('/api/flux') || route.startsWith('/api/local-image') || route.startsWith('/api/ai-model') || route === '/api/generate' || route.startsWith('/api/prompt-lab')) { return verb === 'GET' ? 'media.view' : 'generation.execute'; } if (route.startsWith('/api/image-batch')) return verb === 'GET' ? 'media.view' : 'batch.execute'; if (route.startsWith('/api/quality')) return verb === 'GET' ? 'listings.view' : 'quality.execute'; if (route.startsWith('/api/publishing') || route.startsWith('/api/extension/publishing') || route.startsWith('/api/publish') || route.startsWith('/api/schedule')) { if (verb === 'GET') return 'publishing.view'; if (verb === 'DELETE' || route.includes('/end') || route.includes('/delete')) return 'publishing.manage'; return 'publishing.execute'; } if (route.startsWith('/api/extension/queue') || route.startsWith('/api/extension/listing')) return 'publishing.view'; if (['GET', 'HEAD', 'OPTIONS'].includes(verb)) return null; return MUTATION_DEFAULT_PERMISSION; } // --- Resource locks ------------------------------------------------------- db.exec(` CREATE TABLE IF NOT EXISTS resource_locks ( id INTEGER PRIMARY KEY AUTOINCREMENT, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, acquired_at TEXT NOT NULL DEFAULT (datetime('now')), renewed_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL, client_label TEXT, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(resource_type, resource_id) ); CREATE INDEX IF NOT EXISTS idx_resource_locks_expiry ON resource_locks(expires_at); CREATE INDEX IF NOT EXISTS idx_resource_locks_user ON resource_locks(user_id, expires_at); `); try { db.prepare(`INSERT OR IGNORE INTO system_migrations (migration_key, app_version, description) VALUES ('multiuser-security-1.31.0', '1.31.0', 'Rollen, sichere Sitzungen, Bearbeitungssperren und Server-Härtung')`).run(); } catch {} function hashLockToken(token) { return createHash('sha256').update(String(token || '')).digest('hex'); } function safeEqualHex(a, b) { try { const aa = Buffer.from(String(a || ''), 'hex'); const bb = Buffer.from(String(b || ''), 'hex'); return aa.length > 0 && aa.length === bb.length && timingSafeEqual(aa, bb); } catch { return false; } } export function cleanExpiredResourceLocks() { return db.prepare("DELETE FROM resource_locks WHERE expires_at <= datetime('now')").run().changes; } function hydrateLock(row) { if (!row) return null; return { id: row.id, resource_type: row.resource_type, resource_id: row.resource_id, user_id: row.user_id, user_name: row.user_name || '', user_email: row.user_email || '', acquired_at: row.acquired_at, renewed_at: row.renewed_at, expires_at: row.expires_at, client_label: row.client_label || '', }; } export function getResourceLock(resourceType, resourceId) { cleanExpiredResourceLocks(); const row = db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email FROM resource_locks l JOIN users u ON u.id = l.user_id WHERE l.resource_type = ? AND l.resource_id = ? AND l.expires_at > datetime('now')`).get(String(resourceType), String(resourceId)); return hydrateLock(row); } export function listResourceLocks() { cleanExpiredResourceLocks(); return db.prepare(`SELECT l.*, u.name AS user_name, u.email AS user_email FROM resource_locks l JOIN users u ON u.id = l.user_id WHERE l.expires_at > datetime('now') ORDER BY l.renewed_at DESC`).all().map(hydrateLock); } export function acquireResourceLock({ resourceType, resourceId, userId, clientLabel = '', ttlSeconds = 120 }) { cleanExpiredResourceLocks(); const type = String(resourceType || '').trim().slice(0, 40); const id = String(resourceId || '').trim().slice(0, 120); if (!type || !id) throw new Error('Ressourcentyp und ID sind erforderlich'); const existing = getResourceLock(type, id); if (existing && Number(existing.user_id) !== Number(userId)) { const error = new Error(`${existing.user_name || existing.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Datensatz bereits.`); error.code = 'LOCKED'; error.lock = existing; throw error; } const token = randomBytes(32).toString('hex'); const tokenHash = hashLockToken(token); const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120)); const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); const transaction = db.transaction(() => { db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id); db.prepare(`INSERT INTO resource_locks (resource_type, resource_id, user_id, token_hash, expires_at, client_label) VALUES (?, ?, ?, ?, ?, ?)`).run(type, id, userId, tokenHash, expiresAt, String(clientLabel || '').slice(0, 160)); }); transaction(); return { token, lock: getResourceLock(type, id) }; } export function validateResourceLock({ resourceType, resourceId, userId, token }) { const type = String(resourceType || ''); const id = String(resourceId || ''); const row = db.prepare(`SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND expires_at > datetime('now')`).get(type, id); if (!row) return { valid: true, lock: null }; const providedHash = hashLockToken(token); const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, providedHash); return { valid, lock: getResourceLock(type, id) }; } export function renewResourceLock({ resourceType, resourceId, userId, token, ttlSeconds = 120 }) { const validation = validateResourceLock({ resourceType, resourceId, userId, token }); if (!validation.valid || !validation.lock) { const error = new Error('Bearbeitungssperre ist abgelaufen oder gehört zu einer anderen Sitzung.'); error.code = 'LOCK_INVALID'; error.lock = validation.lock; throw error; } const ttl = Math.max(45, Math.min(600, Number(ttlSeconds) || 120)); const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); db.prepare(`UPDATE resource_locks SET renewed_at = datetime('now'), expires_at = ? WHERE resource_type = ? AND resource_id = ?`).run(expiresAt, String(resourceType), String(resourceId)); return getResourceLock(resourceType, resourceId); } export function releaseResourceLock({ resourceType, resourceId, userId, token, force = false }) { const type = String(resourceType || ''); const id = String(resourceId || ''); if (force) return db.prepare('DELETE FROM resource_locks WHERE resource_type = ? AND resource_id = ?').run(type, id).changes; const row = db.prepare('SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ?').get(type, id); if (!row) return 0; const valid = Number(row.user_id) === Number(userId) && safeEqualHex(row.token_hash, hashLockToken(token)); if (!valid) { const error = new Error('Bearbeitungssperre kann nicht durch diese Sitzung aufgehoben werden.'); error.code = 'LOCK_INVALID'; throw error; } return db.prepare('DELETE FROM resource_locks WHERE id = ?').run(row.id).changes; } // --- Rate limiter --------------------------------------------------------- const rateBuckets = new Map(); const rateStats = { allowed: 0, blocked: 0, buckets: 0, last_blocked_at: null }; export function consumeRateLimit(key, { limit = 120, windowMs = 60_000 } = {}) { const now = Date.now(); const normalizedKey = String(key || 'anonymous'); let bucket = rateBuckets.get(normalizedKey); if (!bucket || now >= bucket.resetAt) bucket = { count: 0, resetAt: now + windowMs }; bucket.count += 1; rateBuckets.set(normalizedKey, bucket); rateStats.buckets = rateBuckets.size; const remaining = Math.max(0, limit - bucket.count); const allowed = bucket.count <= limit; if (allowed) rateStats.allowed += 1; else { rateStats.blocked += 1; rateStats.last_blocked_at = new Date().toISOString(); } if (rateBuckets.size > 5000) { for (const [entryKey, entry] of rateBuckets) if (entry.resetAt <= now) rateBuckets.delete(entryKey); } return { allowed, remaining, retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)), resetAt: bucket.resetAt }; } export function getRateLimitStats() { return { ...rateStats, buckets: rateBuckets.size }; } setInterval(cleanExpiredResourceLocks, 60_000).unref?.();