import express from 'express'; import multer from 'multer'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import sharp from 'sharp'; import { join, dirname, extname, basename, resolve, sep } from 'path'; import { fileURLToPath } from 'url'; import { randomUUID, randomBytes, createHash, timingSafeEqual } from 'crypto'; import { networkInterfaces } from 'os'; import { spawn } from 'child_process'; import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, unlinkSync, readdirSync, copyFileSync } from 'fs'; import { CONFIG_PATH, readRuntimeConfig, setConfigValue, writeRuntimeConfig, updateRuntimeConfig } from './lib/config-store.mjs'; import { createPlatformKernel } from './app/kernel/platform-kernel.mjs'; import { getThemeContract } from './app/core/themes/theme-contract.mjs'; import { getThemeService } from './app/modules/themes/index.mjs'; import { createUser, getUser, getUserByEmail, getUsers, updateUser, deleteUser, createSession, validateSession, destroySession, destroySessionById, destroyUserSessions, getUserSessions, getAllActiveSessions, createAuthToken, validateAuthToken, checkRateLimit, generateCsrfToken, auditLog, getAuditLog, searchAuditLog, getAuditLogForUser, recordLogin, getLoginHistory, isSetupMode, getUserCount, verifyPassword, setPassword, validatePasswordStrength, registerFailedLogin, clearFailedLogins, isUserLocked, cleanExpiredSessions, } from './lib/auth.mjs'; import { initMailer, sendMagicLink, sendCredentials, isMailerConfigured, reinitMailer, getMailerConfig, testMailer } from './lib/mailer.mjs'; import { createListing, getListing, getListings, updateListing, deleteListing, softDeleteListing, restoreListing, getDeletedListings, permanentlyDeleteListing, cleanupTrash, getTrashCount, duplicateListing, getSettings, updateSettings, createTemplate, getTemplate, getTemplates, updateTemplate, deleteTemplate, getListingsForExport, createPublishLog, updatePublishLog, getPublishLogs, getPublishStatus, addToEbayQueue, getEbayQueue, updateEbayQueue, removeFromEbayQueue, clearEbayQueue, schedulePublish, getScheduledPublishes, getDueScheduled, updateScheduledPublish, removeScheduledPublish, getStorageLocations, getListingsByLocation, getUnassignedListings, bulkUpdateLocation, getDashboardWorkflow, getDashboardAnalytics, createMobileUploadSession, getMobileUploadSessionById, getMobileUploadSessionByTokenHash, appendMobileUploadFiles, updateMobileUploadSession, deleteMobileUploadSession, cleanupMobileUploadSessions, createAiModelGeneration, updateAiModelGeneration, createAiModelPhoto, getAiModelPhotosForListing, getAiModelGenerationsForListing, getAiModelPhotosForGeneration, getAiModelJob, createFluxImage, getFluxImages, paginateFluxImages, getFluxImage, setFluxImageFavorite, deleteFluxImage, getImageEditVersions, getImageEditRoot, createImageEditVersion, listMediaLibraryItems, setMediaLibraryFavorite, getListingReferencedMediaPaths, removeMediaLibraryRecords, db, } from './lib/db.mjs'; import * as etsyApi from './lib/etsy-api.mjs'; import * as ebayApi from './lib/ebay-api.mjs'; import { wrapInTemplate, getHtmlTemplates, sanitizeRichHtml } from './lib/html-templates.mjs'; import { prepareForAI, removeBackground, addWatermark } from './lib/images.mjs'; import { generate, generateText, getProviders } from './lib/ai-router.mjs'; import { calculateFees, getAllFees } from './lib/fees.mjs'; import { createZip } from './lib/zip.mjs'; import { createBackup, listBackups, getBackupPath, verifyStoredBackup, deleteBackup, stageRestore, stageUpdate, getOperationsStatus, cancelPendingOperation, checkForUpdates, updateOperationSettings, recordExtensionHeartbeat, getExtensionDiagnostics, startAutomaticBackupLoop, operationsPaths, } from './lib/operations-center.mjs'; import { analyzeListingQuality, listQualityCenter, getQualityHistory, compareQualityReports, } from './lib/quality-center.mjs'; import { getCategories, getCategoriesFlat, matchCategories, getAllPlatformIds } from './lib/categories.mjs'; import { precheckAiModelPhotos, generateAiModelPhotos, fetchOpenRouterImageModels, getOpenRouterImageDiagnostics } from './lib/ai-model-photos.mjs'; import { getLocalImageStatus, getLocalFluxRuntimeInfo, generateLocalImageToken, ensureLocalFluxBootstrap } from './lib/ai-local-images.mjs'; import { renderImageVersion, resolveEditableImage } from './lib/image-editor.mjs'; import { enqueueAiModelJob, getAiModelJobWithRelations, startAiModelJobLoop } from './lib/ai-model-jobs.mjs'; import { enqueueFluxPromptJob, getFluxPromptJob, listFluxPromptJobs, paginateFluxPromptJobs, getFluxPromptJobSummary, cancelFluxPromptJob, retryFluxPromptJob, clearFluxPromptQueue, archiveFluxPromptJobs, restoreFluxPromptJobs, deleteArchivedFluxPromptJobs, startFluxPromptJobLoop, } from './lib/ai-flux-prompt-jobs.mjs'; import { listImageBatchProfiles, createImageBatchProfile, updateImageBatchProfile, deleteImageBatchProfile, createImageBatchJob, getImageBatchJob, paginateImageBatchJobs, pauseImageBatchJob, resumeImageBatchJob, cancelImageBatchJob, retryImageBatchJob, retryImageBatchItem, deleteImageBatchJob, getImageBatchOutputPaths, startImageBatchJobLoop, } from './lib/image-batch-jobs.mjs'; import { enqueuePublishingJob, getPublishingJob, paginatePublishingJobs, listPublishingEvents, retryPublishingJob, cancelPublishingJob, deletePublishingJobs, claimExtensionJob, completeExtensionJob, configurePublishingProcessors, startPublishingJobLoop, recoverPublishingJobs, migrateLegacyEbayQueue, getPublishCapabilities, } from './lib/publishing-jobs.mjs'; import { getRoleDefinitions, getPermissionCatalog, permissionsForRole, hasPermission, requiredPermissionForApi, acquireResourceLock, getResourceLock, listResourceLocks, renewResourceLock, releaseResourceLock, validateResourceLock, consumeRateLimit, getRateLimitStats, } from './lib/security.mjs'; import { APP_VERSION, DB_PATH, UPLOADS_DIR, OPERATIONS_DIR, LOG_DIR, ensureRuntimeDirectories, verifyRuntimeWritable, runtimePathSummary, } from './lib/runtime-paths.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = Number(process.env.PORT || 8124); ensureRuntimeDirectories(); let shuttingDown = false; const platformKernel = createPlatformKernel({ version: APP_VERSION, hasPermission }); await platformKernel.start(); startAiModelJobLoop(); startFluxPromptJobLoop(); startImageBatchJobLoop(); startAutomaticBackupLoop(); const legacyPublishingMigration = migrateLegacyEbayQueue(); if (legacyPublishingMigration.migrated) console.log(`Publishing: ${legacyPublishingMigration.migrated} alte eBay-Queue-Einträge übernommen`); recoverPublishingJobs(); configurePublishingProcessors({ async process(job, listing) { const payload = job.payload || {}; const previous = db.prepare(`SELECT * FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND id != ? AND status IN ('published','ended') ORDER BY completed_at DESC, created_at DESC LIMIT 1`).get(job.listing_id, job.platform, job.id); const offerId = job.offer_id || payload.offerId || previous?.offer_id || null; const externalId = job.external_id || payload.externalId || previous?.external_id || null; if (job.platform === 'ebay-de') { if (!ebayApi.isConnected()) throw new Error('eBay ist nicht verbunden'); const sku = listing.sku || `AL-${listing.id}`; if (job.action === 'publish') return ebayApi.createAndPublish(listing, sku); if (job.action === 'update') { if (!offerId) throw new Error('Keine eBay Offer-ID für die Aktualisierung vorhanden'); const offer = await ebayApi.updateOffer(listing, offerId, sku); return { offerId, externalId: offer?.listing?.listingId || externalId, url: externalId ? `https://www.ebay.de/itm/${externalId}` : null, status: 'published', offer }; } if (job.action === 'end') { if (!offerId) throw new Error('Keine eBay Offer-ID zum Beenden vorhanden'); const result = await ebayApi.withdrawOffer(offerId); return { offerId, externalId: result?.listingId || externalId, url: externalId ? `https://www.ebay.de/itm/${externalId}` : null, status: 'ended', result }; } if (job.action === 'sync') { if (!offerId) throw new Error('Keine eBay Offer-ID für den Statusabgleich vorhanden'); const offer = await ebayApi.getOffer(offerId); const status = String(offer?.status || '').toUpperCase() === 'PUBLISHED' ? 'published' : 'ended'; const listingId = offer?.listing?.listingId || externalId; return { offerId, externalId: listingId, url: listingId ? `https://www.ebay.de/itm/${listingId}` : null, status, offer }; } } if (job.platform === 'etsy') { if (!etsyApi.isConnected()) throw new Error('Etsy ist nicht verbunden'); if (job.action === 'publish') { const shopId = await etsyApi.getShopId(); const created = await etsyApi.createEtsyListing(listing, shopId); if (listing.photos?.length) { for (const photo of listing.photos) { const photoPath = join(UPLOADS_DIR, photo); if (!existsSync(photoPath)) continue; await etsyApi.uploadEtsyImage(shopId, created.listing_id, readFileSync(photoPath), photo); } } return { externalId: String(created.listing_id), url: `https://www.etsy.com/listing/${created.listing_id}`, status: created.state || 'draft', listing: created }; } if (job.action === 'sync') { if (!externalId) throw new Error('Keine Etsy Listing-ID für den Statusabgleich vorhanden'); const item = await etsyApi.getEtsyListing(externalId); const status = ['active', 'draft'].includes(item?.state) ? 'published' : 'ended'; return { externalId: String(externalId), url: `https://www.etsy.com/listing/${externalId}`, status, listing: item }; } throw new Error(`Aktion ${job.action} wird für Etsy derzeit nicht unterstützt`); } throw new Error(`Keine offizielle API-Verarbeitung für ${job.platform}`); }, }); startPublishingJobLoop(); function plainTextToSafeHtml(text) { const escaped = String(text || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); return escaped.split(/\n{2,}/).map(part => `

${part.replace(/\n/g, '
')}

`).join(''); } function htmlToPlainText(html) { return String(html || '') .replace(/<\s*br\s*\/?>/gi, '\n') .replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/\n{3,}/g, '\n\n') .trim(); } function normalizeListingPayload(payload = {}) { const normalized = { ...payload }; if (normalized.description_html !== undefined) { normalized.description_html = sanitizeRichHtml(normalized.description_html); if (normalized.description === undefined) normalized.description = htmlToPlainText(normalized.description_html); } if (normalized.description !== undefined) normalized.description = String(normalized.description || '').trim(); if (normalized.tags !== undefined && !Array.isArray(normalized.tags)) { normalized.tags = String(normalized.tags || '').split(',').map(tag => tag.trim()).filter(Boolean); } if (normalized.photos !== undefined && !Array.isArray(normalized.photos)) normalized.photos = []; return normalized; } function hashMobileUploadToken(token) { return createHash('sha256').update(String(token || '')).digest('hex'); } function isMobileUploadSessionUsable(session) { if (!session || session.status !== 'active') return false; const expiresAt = Date.parse(String(session.expires_at || '').replace(' ', 'T') + 'Z'); return Number.isFinite(expiresAt) && expiresAt > Date.now(); } function getLanIpv4Addresses() { const addresses = []; const interfaces = networkInterfaces(); for (const [name, entries] of Object.entries(interfaces)) { for (const entry of entries || []) { const family = typeof entry.family === 'string' ? entry.family : (entry.family === 4 ? 'IPv4' : String(entry.family)); if (family !== 'IPv4' || entry.internal) continue; const address = String(entry.address || '').trim(); if (!address || address.startsWith('169.254.')) continue; const isPrivate = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(address); const lowerName = String(name || '').toLowerCase(); const isVirtual = /(virtual|vmware|hyper-v|vethernet|wsl|docker|vpn|tailscale|zerotier|loopback|bluetooth)/i.test(lowerName); const isPrimaryAdapter = /(ethernet|wlan|wi-fi|wifi|lan)/i.test(lowerName); const score = (isPrivate ? 100 : 0) + (isPrimaryAdapter ? 30 : 0) - (isVirtual ? 80 : 0); addresses.push({ name, address, isPrivate, isVirtual, score }); } } addresses.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)); return addresses; } function resolvePublicBaseUrl(req) { const configured = String(process.env.PUBLIC_BASE_URL || '').trim().replace(/\/$/, ''); if (configured) { try { const configuredUrl = new URL(configured); const configuredHost = configuredUrl.hostname.toLowerCase(); if (!['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(configuredHost)) return configured; } catch {} } const forwardedProto = String(req.get('x-forwarded-proto') || '').split(',')[0].trim(); const protocol = forwardedProto || req.protocol || 'http'; const rawHost = String(req.get('x-forwarded-host') || req.get('host') || `localhost:${PORT}`).split(',')[0].trim(); const hostOnly = rawHost.replace(/^\[/, '').replace(/\](:\d+)?$/, '').replace(/:\d+$/, '').toLowerCase(); const isLoopback = ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostOnly); if (!isLoopback) return `${protocol}://${rawHost}`.replace(/\/$/, ''); const lan = getLanIpv4Addresses()[0]; const portMatch = rawHost.match(/:(\d+)$/); const port = portMatch?.[1] || String(PORT); if (lan?.address) return `${protocol}://${lan.address}:${port}`; return `${protocol}://${rawHost}`.replace(/\/$/, ''); } function parseTrustProxy(value) { const raw = String(value || 'false').trim().toLowerCase(); if (['false', '0', 'no', 'off', ''].includes(raw)) return false; if (['true', 'yes', 'on'].includes(raw)) return 1; if (/^\d+$/.test(raw)) return Math.max(0, Number(raw)); if (['loopback', 'linklocal', 'uniquelocal'].includes(raw)) return raw; throw new Error('VENDOO_TRUST_PROXY muss false, true, eine Hop-Anzahl oder loopback/linklocal/uniquelocal sein.'); } const TRUST_PROXY = parseTrustProxy(process.env.VENDOO_TRUST_PROXY); if (TRUST_PROXY !== false) app.set('trust proxy', TRUST_PROXY); const SECURITY_CSP = { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'], imgSrc: ["'self'", 'data:', 'blob:'], fontSrc: ["'self'", 'data:'], connectSrc: ["'self'", 'http://127.0.0.1:*', 'http://localhost:*'], objectSrc: ["'none'"], frameAncestors: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], }; app.use(helmet({ contentSecurityPolicy: { directives: SECURITY_CSP }, crossOriginEmbedderPolicy: false, referrerPolicy: { policy: 'same-origin' }, })); app.use(express.json({ limit: '2mb' })); app.use(cookieParser()); app.use((req, res, next) => { const requestId = String(req.get('x-vendoo-request-id') || randomUUID()).slice(0, 100); req.requestId = requestId; res.setHeader('X-Vendoo-Request-ID', requestId); res.setHeader('Cache-Control', req.path.startsWith('/api/') || req.path.startsWith('/auth/') ? 'no-store' : 'private, max-age=0, must-revalidate'); next(); }); function configuredAllowedOrigins() { return new Set(String(process.env.VENDOO_ALLOWED_ORIGINS || '') .split(',').map(value => value.trim().replace(/\/$/, '')).filter(Boolean)); } function configuredExtensionOrigins() { return new Set(String(process.env.VENDOO_EXTENSION_ORIGINS || '') .split(',').map(value => value.trim().replace(/\/$/, '')).filter(Boolean)); } function isAllowedOrigin(req, origin) { if (!origin) return true; const normalized = origin.replace(/\/$/, ''); if (/^(chrome-extension|moz-extension|safari-web-extension):\/\//i.test(normalized)) { const allowAny = /^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_ANY_EXTENSION_ORIGIN || (process.env.NODE_ENV === 'production' ? 'false' : 'true'))); return allowAny || configuredExtensionOrigins().has(normalized); } const ownOrigin = `${req.protocol}://${req.get('host')}`.replace(/\/$/, ''); if (normalized === ownOrigin) return true; return configuredAllowedOrigins().has(normalized); } function isAllowedHost(hostHeader) { const raw = String(hostHeader || '').trim(); let host = raw; if (raw.startsWith('[')) host = raw.slice(1, raw.indexOf(']') > 0 ? raw.indexOf(']') : undefined); else if ((raw.match(/:/g) || []).length === 1) host = raw.replace(/:\d+$/, ''); host = host.toLowerCase(); if (!host) return false; const configured = new Set(String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean)); if (configured.has(host)) return true; if (['localhost', '127.0.0.1', '::1'].includes(host)) return true; if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return true; try { const publicHost = new URL(String(process.env.PUBLIC_BASE_URL || '')).hostname.toLowerCase(); if (publicHost && host === publicHost) return true; } catch {} return configured.size === 0 && !/^[a-z0-9.-]+$/i.test(host) ? false : configured.has(host); } app.use((req, res, next) => { if (!isAllowedHost(req.get('host'))) return res.status(421).json({ error: 'Host nicht freigegeben. VENDOO_ALLOWED_HOSTS prüfen.' }); const origin = req.get('origin'); if (origin && !isAllowedOrigin(req, origin)) return res.status(403).json({ error: 'Origin nicht freigegeben.' }); if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-CSRF-Token,X-Vendoo-Request-ID,X-Edit-Lock'); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); app.get('/healthz', (_req, res) => { res.setHeader('Cache-Control', 'no-store'); res.json({ ok: true, version: APP_VERSION, uptime_seconds: Math.floor(process.uptime()), time: new Date().toISOString() }); }); app.get('/readyz', async (_req, res) => { res.setHeader('Cache-Control', 'no-store'); const checks = []; try { const dbCheck = db.prepare('SELECT 1 AS ok').get(); checks.push({ name: 'database', ok: Number(dbCheck?.ok) === 1, path: DB_PATH }); } catch (error) { checks.push({ name: 'database', ok: false, path: DB_PATH, error: error.message }); } for (const item of verifyRuntimeWritable()) checks.push({ name: 'filesystem', ok: item.writable, ...item }); try { const kernel = await platformKernel.health(); checks.push({ name: 'platform-kernel', ok: kernel.ok, lifecycle: kernel.lifecycle.state, modules: kernel.modules.length }); } catch (error) { checks.push({ name: 'platform-kernel', ok: false, error: error.message }); } const ok = !shuttingDown && checks.every(item => item.ok !== false); res.status(ok ? 200 : 503).json({ ok, shutting_down: shuttingDown, version: APP_VERSION, checks, paths: runtimePathSummary(), time: new Date().toISOString() }); }); // Init mailer — check DB settings first, fallback to .env function initMailerFromDbOrEnv() { try { const settings = getSettings(); if (settings.smtp_host && settings.smtp_user && settings.smtp_pass) { const ok = reinitMailer({ host: settings.smtp_host, port: settings.smtp_port || '587', user: settings.smtp_user, pass: settings.smtp_pass, }); if (settings.smtp_from) process.env.SMTP_FROM = settings.smtp_from; if (ok) return; } } catch {} initMailer(); } initMailerFromDbOrEnv(); // --- Auth Middleware --- function getClientIp(req) { return TRUST_PROXY ? String(req.ip || '') : String(req.socket?.remoteAddress || ''); } function requireAuth(req, res, next) { if (isSetupMode()) { if (req.originalUrl.startsWith('/api')) return res.status(503).json({ error: 'Vendoo muss zuerst über /login.html eingerichtet werden.', setup_required: true }); return res.redirect('/login.html'); } const token = req.cookies?.auth_token; const session = validateSession(token); if (!session) { if (req.originalUrl.startsWith('/api')) return res.status(401).json({ error: 'Nicht eingeloggt' }); return res.redirect('/login.html'); } req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, avatar_color: session.avatar_color, permissions: [...permissionsForRole(session.role)] }; next(); } function requireRole(...roles) { return (req, res, next) => { if (!req.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Keine Berechtigung' }); next(); }; } function csrfProtect(req, res, next) { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); const token = req.headers['x-csrf-token'] || req.body?._csrf; const cookie = req.cookies?.csrf_token; if (!token || !cookie || token !== cookie) { return res.status(403).json({ error: 'Ungültiger CSRF-Token' }); } next(); } function requirePermission(permission) { return (req, res, next) => { if (!req.user) return res.status(401).json({ error: 'Nicht eingeloggt' }); if (!hasPermission(req.user.role, permission)) { auditLog(req.user.id, req.user.email, 'security.permission_denied', 'route', req.path, JSON.stringify({ permission, method: req.method }), getClientIp(req)); return res.status(403).json({ error: 'Diese Aktion ist für deine Rolle nicht freigegeben.', permission }); } next(); }; } function apiPermissionGuard(req, res, next) { const routePath = String(req.originalUrl || req.path || '').split('?')[0]; const permission = requiredPermissionForApi(req.method, routePath); if (!permission || hasPermission(req.user?.role, permission)) return next(); auditLog(req.user?.id || null, req.user?.email || null, 'security.permission_denied', 'route', routePath, JSON.stringify({ permission, method: req.method }), getClientIp(req)); return res.status(403).json({ error: 'Keine Berechtigung für diese Aktion.', permission }); } function apiRateLimit(req, res, next) { const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(req.method); const limit = mutation ? Number(process.env.VENDOO_MUTATION_RATE_LIMIT || 120) : Number(process.env.VENDOO_READ_RATE_LIMIT || 360); const key = `api:${req.user?.id || getClientIp(req)}:${mutation ? 'write' : 'read'}`; const result = consumeRateLimit(key, { limit: Math.max(20, limit), windowMs: 60_000 }); res.setHeader('X-RateLimit-Remaining', String(result.remaining)); if (!result.allowed) { res.setHeader('Retry-After', String(result.retryAfter)); return res.status(429).json({ error: 'Zu viele Anfragen. Bitte kurz warten.', retry_after: result.retryAfter }); } next(); } function secureCookieOptions(req) { const forwardedHttps = TRUST_PROXY !== false && String(req.get('x-forwarded-proto') || '').split(',')[0].trim() === 'https'; const secureMode = String(process.env.VENDOO_COOKIE_SECURE || 'auto').trim().toLowerCase(); const detectedSecure = Boolean(req.secure || forwardedHttps || /^https:\/\//i.test(String(process.env.PUBLIC_BASE_URL || ''))); const secure = secureMode === 'true' ? true : secureMode === 'false' ? false : detectedSecure; const sameSiteValue = String(process.env.VENDOO_COOKIE_SAMESITE || 'lax').trim().toLowerCase(); const sameSite = ['lax', 'strict', 'none'].includes(sameSiteValue) ? sameSiteValue : 'lax'; if (sameSite === 'none' && !secure) throw new Error('VENDOO_COOKIE_SAMESITE=none erfordert sichere HTTPS-Cookies.'); const maxAge = Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)) * 24 * 60 * 60 * 1000; return { httpOnly: true, secure, sameSite, maxAge, path: '/' }; } function csrfCookieOptions(req) { return { ...secureCookieOptions(req), httpOnly: false }; } const SETUP_TOKEN = String(process.env.VENDOO_SETUP_TOKEN || '').trim(); function setupTokenMatches(value) { if (!SETUP_TOKEN) return true; const provided = Buffer.from(String(value || '')); const expected = Buffer.from(SETUP_TOKEN); return provided.length === expected.length && timingSafeEqual(provided, expected); } // --- Public routes (no auth) --- app.use('/login.html', express.static(join(__dirname, 'public', 'login.html'))); app.get('/auth/setup-check', (_req, res) => { res.json({ setupMode: isSetupMode(), mailConfigured: isMailerConfigured(), setupTokenRequired: Boolean(SETUP_TOKEN) }); }); // Setup: create first admin with password app.post('/auth/setup', (req, res) => { if (!isSetupMode()) return res.status(400).json({ error: 'Setup bereits abgeschlossen' }); const { email, name, password, setup_token: setupToken } = req.body; if (!setupTokenMatches(req.get('x-vendoo-setup-token') || setupToken)) return res.status(403).json({ error: 'Ungültiger Einrichtungs-Code.' }); if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); const passwordCheck = validatePasswordStrength(password); if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); const ip = getClientIp(req); try { const user = createUser(email, name || email.split('@')[0], 'admin', null, password); auditLog(user.id, email, 'user.created', 'user', String(user.id), 'Erster Admin (Setup)', ip); const session = createSession(user.id, ip, req.headers['user-agent'] || ''); recordLogin(user.id, ip, req.headers['user-agent'] || '', true); auditLog(user.id, email, 'login.success', null, null, 'via setup', ip); const csrf = generateCsrfToken(); res.cookie('auth_token', session.token, secureCookieOptions(req)); res.cookie('csrf_token', csrf, csrfCookieOptions(req)); return res.json({ ok: true, redirect: '/' }); } catch (err) { return res.status(500).json({ error: err.message }); } }); // Login with email + password app.post('/auth/login', (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'E-Mail und Passwort erforderlich' }); const ip = getClientIp(req); const rl = checkRateLimit(`login:${ip}`, 5, 15 * 60 * 1000); if (!rl.allowed) return res.status(429).json({ error: `Zu viele Versuche. Bitte warte ${rl.retryAfter} Sekunden.` }); const user = getUserByEmail(email); if (user && isUserLocked(user)) { auditLog(user.id, email, 'login.blocked_locked', null, null, user.locked_until, ip); return res.status(423).json({ error: 'Account nach mehreren Fehlversuchen vorübergehend gesperrt.', locked_until: user.locked_until }); } if (!user || !verifyPassword(password, user.password_hash)) { if (user) { recordLogin(user.id, ip, req.headers['user-agent'] || '', false); const lockResult = registerFailedLogin(user.id); if (lockResult?.locked) { auditLog(user.id, email, 'login.account_locked', 'user', String(user.id), lockResult.lockedUntil, ip); return res.status(423).json({ error: 'Zu viele Fehlversuche. Account für 15 Minuten gesperrt.', locked_until: lockResult.lockedUntil }); } } auditLog(user?.id || null, email, 'login.failed', null, null, null, ip); return res.status(401).json({ error: 'E-Mail oder Passwort falsch' }); } if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); clearFailedLogins(user.id); const session = createSession(user.id, ip, req.headers['user-agent'] || ''); recordLogin(user.id, ip, req.headers['user-agent'] || '', true); auditLog(user.id, email, 'login.success', null, null, 'via password', ip); const csrf = generateCsrfToken(); res.cookie('auth_token', session.token, secureCookieOptions(req)); res.cookie('csrf_token', csrf, csrfCookieOptions(req)); res.json({ ok: true, redirect: '/' }); }); // Invite: admin sends invite link (magic link only for invitations) app.post('/auth/invite', requireAuth, csrfProtect, requireRole('admin'), async (req, res) => { const { email, name, role } = req.body; if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); const baseUrl = `${req.protocol}://${req.get('host')}`; try { const { token } = createAuthToken(email, 'invite', role || 'editor', req.user.id); const result = await sendMagicLink(email, token, baseUrl, 'invite'); auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${role || 'editor'}`, getClientIp(req)); res.json({ ok: true, message: 'Einladung gesendet!', consoleFallback: result.consoleFallback || false }); } catch (err) { return res.status(500).json({ error: err.message }); } }); // Magic link: only for invite — shows password-set form app.get('/auth/magic', (req, res) => { const { token } = req.query; if (!token) return res.redirect('/login.html?error=' + encodeURIComponent('Kein Token angegeben')); res.redirect(`/login.html?invite=${encodeURIComponent(token)}`); }); // Accept invite: set password and activate account app.post('/auth/accept-invite', (req, res) => { const { token, password, name } = req.body; if (!token) return res.status(400).json({ error: 'Kein Token angegeben' }); const passwordCheck = validatePasswordStrength(password); if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); try { const authToken = validateAuthToken(token); if (!authToken) return res.status(400).json({ error: 'Link ungültig oder abgelaufen' }); const ip = getClientIp(req); const ua = req.headers['user-agent'] || ''; let user = getUserByEmail(authToken.email); if (!user) { user = createUser(authToken.email, name || authToken.email.split('@')[0], authToken.role, authToken.created_by, password); auditLog(user.id, user.email, 'user.activated', 'user', String(user.id), null, ip); } else { setPassword(user.id, password); if (name) updateUser(user.id, { name }); } if (!user.active) return res.status(403).json({ error: 'Account deaktiviert' }); const session = createSession(user.id, ip, ua); recordLogin(user.id, ip, ua, true); auditLog(user.id, user.email, 'login.success', null, null, 'via invite', ip); const csrf = generateCsrfToken(); res.cookie('auth_token', session.token, secureCookieOptions(req)); res.cookie('csrf_token', csrf, csrfCookieOptions(req)); res.json({ ok: true, redirect: '/' }); } catch (err) { console.error('Accept invite error:', err); res.status(500).json({ error: err.message }); } }); app.post('/auth/logout', requireAuth, csrfProtect, (req, res) => { const token = req.cookies?.auth_token; if (token) { const session = validateSession(token); if (session) { auditLog(session.user_id, session.email, 'logout', null, null, null, getClientIp(req)); } destroySession(token); } res.clearCookie('auth_token'); res.clearCookie('csrf_token'); res.json({ ok: true }); }); // --- Mobile photo upload (public token page, no account login required) --- app.get('/mobile-upload/:token', (req, res) => { const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); if (!isMobileUploadSessionUsable(session)) return res.status(410).sendFile(join(__dirname, 'public', 'mobile-upload-expired.html')); res.sendFile(join(__dirname, 'public', 'mobile-upload.html')); }); app.get('/mobile-upload-api/:token', (req, res) => { const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); res.json({ id: session.id, context: session.context, listing_id: session.listing_id, files: session.files || [], remaining: Math.max(0, 10 - (session.files || []).length), expires_at: session.expires_at, }); }); // --- Protected routes below --- app.use('/api', requireAuth); app.use('/api', apiRateLimit); app.use('/api', apiPermissionGuard); app.use('/auth/ebay', requireAuth, requirePermission('settings.manage')); app.use('/auth/etsy', requireAuth, requirePermission('settings.manage')); // CSRF for state-changing API calls app.use('/api', csrfProtect); // Platform kernel contracts (new routes must be deny-by-default and explicitly owned) platformKernel.routes.register(app, { id: 'platform.status.read', method: 'GET', path: '/api/security/platform', owner: 'vendoo.system', policy: 'platform.security-admin', audit: 'platform.status.read', rateLimit: 'administration', csrf: false, stability: 'internal', handler: async (_req, res) => res.json(platformKernel.snapshot()), }); platformKernel.routes.register(app, { id: 'platform.modules.read', method: 'GET', path: '/api/security/platform/modules', owner: 'vendoo.system', policy: 'platform.security-admin', audit: 'platform.modules.read', rateLimit: 'administration', csrf: false, stability: 'internal', handler: async (_req, res) => res.json({ modules: platformKernel.modules.snapshot() }), }); platformKernel.routes.register(app, { id: 'themes.contract.read', method: 'GET', path: '/api/security/platform/theme-contract', owner: 'vendoo.themes', policy: 'platform.security-admin', audit: 'themes.contract.read', rateLimit: 'administration', csrf: false, stability: 'internal', handler: async (_req, res) => res.json(getThemeContract()), }); platformKernel.routes.register(app, { id: 'themes.catalog.read', method: 'GET', path: '/api/themes', owner: 'vendoo.themes', policy: 'themes.use', audit: 'themes.catalog.read', rateLimit: 'default', csrf: false, stability: 'stable', handler: async (req, res) => res.json(getThemeService().catalog(req.user.id)), }); platformKernel.routes.register(app, { id: 'themes.validate', method: 'POST', path: '/api/themes/validate', owner: 'vendoo.themes', policy: 'themes.use', audit: 'themes.validated', rateLimit: 'administration', csrf: true, stability: 'stable', handler: async (req, res) => res.json(getThemeService().validate(req.body || {})), }); platformKernel.routes.register(app, { id: 'themes.preference.update', method: 'PUT', path: '/api/themes/preference', owner: 'vendoo.themes', policy: 'themes.use', audit: 'themes.preference.updated', rateLimit: 'administration', csrf: true, stability: 'stable', handler: async (req, res) => { const preference = getThemeService().saveUserPreference(req.user.id, req.body || {}); auditLog(req.user.id, req.user.email, 'theme.preference.updated', 'user', String(req.user.id), JSON.stringify(preference), getClientIp(req)); res.json(preference); }, }); platformKernel.routes.register(app, { id: 'themes.profile.create', method: 'POST', path: '/api/themes', owner: 'vendoo.themes', policy: 'themes.manage', audit: 'themes.profile.created', rateLimit: 'administration', csrf: true, stability: 'stable', handler: async (req, res) => { const theme = getThemeService().create(req.body || {}, req.user.id); auditLog(req.user.id, req.user.email, 'theme.created', 'theme', theme.id, theme.name, getClientIp(req)); res.status(201).json(theme); }, }); platformKernel.routes.register(app, { id: 'themes.system-default.update', method: 'PUT', path: '/api/themes/system-default', owner: 'vendoo.themes', policy: 'themes.manage', audit: 'themes.system-default.updated', rateLimit: 'administration', csrf: true, stability: 'stable', handler: async (req, res) => { const preference = getThemeService().saveSystemDefault(req.body || {}); auditLog(req.user.id, req.user.email, 'theme.system_default.updated', 'system', 'theme', JSON.stringify(preference), getClientIp(req)); res.json(preference); }, }); platformKernel.routes.register(app, { id: 'themes.profile.update', method: 'PUT', path: '/api/themes/:id', owner: 'vendoo.themes', policy: 'themes.manage', audit: 'themes.profile.updated', rateLimit: 'administration', csrf: true, stability: 'stable', handler: async (req, res) => { const theme = getThemeService().update(req.params.id, req.body || {}, req.user.id); auditLog(req.user.id, req.user.email, 'theme.updated', 'theme', theme.id, theme.name, getClientIp(req)); res.json(theme); }, }); platformKernel.routes.register(app, { id: 'themes.profile.delete', method: 'DELETE', path: '/api/themes/:id', owner: 'vendoo.themes', policy: 'themes.manage', audit: 'themes.profile.deleted', rateLimit: 'administration', csrf: true, stability: 'stable', handler: async (req, res) => { const result = getThemeService().remove(req.params.id); auditLog(req.user.id, req.user.email, 'theme.deleted', 'theme', req.params.id, null, getClientIp(req)); res.json(result); }, }); platformKernel.routes.register(app, { id: 'themes.profile.export', method: 'GET', path: '/api/themes/:id/export', owner: 'vendoo.themes', policy: 'themes.manage', audit: 'themes.profile.exported', rateLimit: 'administration', csrf: false, stability: 'stable', handler: async (req, res) => res.json(getThemeService().exportTheme(req.params.id)), }); // Current user info app.get('/api/me', (req, res) => { if (isSetupMode()) return res.json({ setupMode: true, role: 'admin' }); const user = getUser(req.user.id); const initials = (user.name || user.email).split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join(''); res.json({ id: user.id, email: user.email, name: user.name, role: user.role, avatar_color: user.avatar_color, initials, permissions: [...permissionsForRole(user.role)], role_definition: getRoleDefinitions().find(role => role.key === user.role) || null, csrf: req.cookies?.csrf_token, }); }); app.put('/api/me', (req, res) => { if (isSetupMode()) return res.json({ ok: true }); const { name } = req.body; if (name !== undefined) updateUser(req.user.id, { name }); auditLog(req.user.id, req.user.email, 'user.profile_updated', 'user', String(req.user.id), null, getClientIp(req)); res.json(getUser(req.user.id)); }); // --- Roles, permissions and server security --- app.get('/api/security/roles', (_req, res) => { res.json({ roles: getRoleDefinitions(), permissions: getPermissionCatalog() }); }); app.get('/api/security/status', requirePermission('security.manage'), (req, res) => { const bindHost = String(process.env.VENDOO_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'; const publicBaseUrl = String(process.env.PUBLIC_BASE_URL || '').trim(); const remoteBinding = ['0.0.0.0', '::'].includes(bindHost); const publicHttps = /^https:\/\//i.test(publicBaseUrl); const warnings = []; if (remoteBinding && !publicHttps) warnings.push('Vendoo lauscht im Netzwerk, aber PUBLIC_BASE_URL verwendet kein HTTPS. Für öffentlichen Zugriff ist ein HTTPS-Reverse-Proxy zwingend.'); if (remoteBinding && !String(process.env.VENDOO_ALLOWED_HOSTS || '').trim()) warnings.push('VENDOO_ALLOWED_HOSTS ist nicht gesetzt. Private IP-Adressen bleiben erlaubt, öffentliche Hostnamen nicht.'); if (TRUST_PROXY && !publicHttps) warnings.push('VENDOO_TRUST_PROXY ist aktiv, obwohl keine öffentliche HTTPS-Basis-URL erkannt wurde.'); if (String(process.env.VENDOO_ALLOWED_ORIGINS || '').includes('*')) warnings.push('Wildcard-Origin ist unsicher und wird von Vendoo nicht als vertrauenswürdig behandelt.'); if (process.env.NODE_ENV === 'production' && /^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_ANY_EXTENSION_ORIGIN || 'false'))) warnings.push('Alle Browser-Erweiterungs-Origins sind freigegeben. Für Produktion VENDOO_EXTENSION_ORIGINS auf konkrete Extension-IDs begrenzen.'); res.json({ version: APP_VERSION, bind_host: bindHost, port: Number(PORT), trust_proxy: TRUST_PROXY, public_base_url: publicBaseUrl || null, allowed_hosts: String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(v => v.trim()).filter(Boolean), allowed_origins: [...configuredAllowedOrigins()], extension_origins: [...configuredExtensionOrigins()], session_days: Math.max(1, Number(process.env.VENDOO_SESSION_DAYS || 3)), session_idle_minutes: Math.max(10, Number(process.env.VENDOO_SESSION_IDLE_MINUTES || 120)), remote_binding: remoteBinding, secure_public_url: publicHttps, active_locks: listResourceLocks(), rate_limits: getRateLimitStats(), warnings, request_ip: getClientIp(req), request_secure: Boolean(req.secure || (TRUST_PROXY && req.get('x-forwarded-proto') === 'https')), }); }); app.get('/api/locks/:type/:id', (req, res) => { const lock = getResourceLock(req.params.type, req.params.id); res.json({ lock, owned_by_me: Boolean(lock && Number(lock.user_id) === Number(req.user.id)) }); }); app.post('/api/locks/:type/:id/acquire', (req, res) => { try { const result = acquireResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, clientLabel: req.body?.client_label || req.get('user-agent') || '', ttlSeconds: req.body?.ttl_seconds || 120, }); auditLog(req.user.id, req.user.email, 'lock.acquired', req.params.type, req.params.id, null, getClientIp(req)); res.status(201).json(result); } catch (error) { res.status(error.code === 'LOCKED' ? 409 : 400).json({ error: error.message, code: error.code, lock: error.lock || null }); } }); app.post('/api/locks/:type/:id/renew', (req, res) => { try { const lock = renewResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: req.body?.token, ttlSeconds: req.body?.ttl_seconds || 120 }); res.json({ lock }); } catch (error) { res.status(409).json({ error: error.message, code: error.code, lock: error.lock || null }); } }); app.delete('/api/locks/:type/:id', (req, res) => { try { const changes = releaseResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: req.body?.token }); if (changes) auditLog(req.user.id, req.user.email, 'lock.released', req.params.type, req.params.id, null, getClientIp(req)); res.json({ ok: true, changes }); } catch (error) { res.status(409).json({ error: error.message, code: error.code }); } }); app.get('/api/security/locks', requirePermission('security.manage'), (_req, res) => res.json(listResourceLocks())); app.delete('/api/security/locks/:type/:id', requirePermission('security.manage'), (req, res) => { const changes = releaseResourceLock({ resourceType: req.params.type, resourceId: req.params.id, userId: req.user.id, token: '', force: true }); auditLog(req.user.id, req.user.email, 'lock.force_released', req.params.type, req.params.id, null, getClientIp(req)); res.json({ ok: true, changes }); }); // --- Admin: User Management --- app.get('/api/admin/users', requireRole('admin'), (_req, res) => { res.json(getUsers()); }); app.post('/api/admin/users/invite', requireRole('admin'), async (req, res) => { const { email, name, role, method } = req.body; if (!email) return res.status(400).json({ error: 'E-Mail erforderlich' }); const userRole = ['admin', 'manager', 'editor', 'publisher', 'viewer'].includes(role) ? role : 'editor'; const existing = getUserByEmail(email); if (existing) return res.status(400).json({ error: 'User existiert bereits' }); const baseUrl = `${req.protocol}://${req.get('host')}`; const ip = getClientIp(req); try { if (method === 'auto_password') { // Generate random password const password = `Vd!${randomBytes(9).toString('base64url')}9a`; const user = createUser(email, name || email.split('@')[0], userRole, req.user.id, password); const mailResult = await sendCredentials(email, password, name || '', baseUrl); auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}, Auto-Passwort`, ip); res.json({ ok: true, emailSent: mailResult.sent, consoleFallback: mailResult.consoleFallback || false, password }); } else { // Magic link (existing behavior) const { token } = createAuthToken(email, 'invite', userRole, req.user.id); const result = await sendMagicLink(email, token, baseUrl, 'invite'); auditLog(req.user.id, req.user.email, 'user.invited', 'user', email, `Rolle: ${userRole}`, ip); res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); } } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/admin/users/resend-invite', requireRole('admin'), async (req, res) => { const { user_id } = req.body; const user = getUser(user_id); if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); const { token } = createAuthToken(user.email, 'invite', user.role, req.user.id); const baseUrl = `${req.protocol}://${req.get('host')}`; const result = await sendMagicLink(user.email, token, baseUrl, 'invite'); auditLog(req.user.id, req.user.email, 'user.invite_resent', 'user', String(user.id), null, getClientIp(req)); res.json({ ok: true, emailSent: result.sent, consoleFallback: result.consoleFallback || false }); }); app.put('/api/admin/users/:id', requireRole('admin'), (req, res) => { const id = parseInt(req.params.id); const existingUser = getUser(id); if (!existingUser) return res.status(404).json({ error: 'User nicht gefunden' }); if (req.user.id === id && req.body.role && req.body.role !== 'admin') { return res.status(400).json({ error: 'Du kannst dir selbst nicht die Admin-Rolle entziehen' }); } const activeAdmins = getUsers().filter(user => user.role === 'admin' && user.active).length; const removesLastAdmin = existingUser.role === 'admin' && existingUser.active && activeAdmins <= 1 && (req.body.role && req.body.role !== 'admin' || Number(req.body.active) === 0); if (removesLastAdmin) return res.status(400).json({ error: 'Der letzte aktive Administrator darf nicht deaktiviert oder herabgestuft werden.' }); const updated = updateUser(id, req.body); if (!updated) return res.status(404).json({ error: 'User nicht gefunden' }); auditLog(req.user.id, req.user.email, 'user.updated', 'user', String(id), JSON.stringify(req.body), getClientIp(req)); res.json(updated); }); app.delete('/api/admin/users/:id', requireRole('admin'), (req, res) => { const id = parseInt(req.params.id); if (req.user.id === id) return res.status(400).json({ error: 'Du kannst dich nicht selbst löschen' }); const user = getUser(id); if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); if (user.role === 'admin' && user.active && getUsers().filter(entry => entry.role === 'admin' && entry.active).length <= 1) { return res.status(400).json({ error: 'Der letzte aktive Administrator darf nicht gelöscht werden.' }); } deleteUser(id); auditLog(req.user.id, req.user.email, 'user.deleted', 'user', String(id), user.email, getClientIp(req)); res.json({ ok: true }); }); // --- Admin: Sessions --- app.get('/api/admin/sessions', requireRole('admin'), (_req, res) => { res.json(getAllActiveSessions()); }); app.delete('/api/admin/sessions/:id', requireRole('admin'), (req, res) => { destroySessionById(parseInt(req.params.id)); auditLog(req.user.id, req.user.email, 'session.destroyed', 'session', req.params.id, null, getClientIp(req)); res.json({ ok: true }); }); app.delete('/api/admin/users/:id/sessions', requireRole('admin'), (req, res) => { destroyUserSessions(parseInt(req.params.id)); auditLog(req.user.id, req.user.email, 'sessions.destroyed_all', 'user', req.params.id, null, getClientIp(req)); res.json({ ok: true }); }); // --- Admin: Audit Log --- app.get('/api/admin/audit', requireRole('admin', 'manager'), (req, res) => { const result = searchAuditLog({ limit: req.query.limit, offset: req.query.offset, userId: req.query.user_id, action: req.query.action, from: req.query.from, to: req.query.to, query: req.query.q, }); res.json(result); }); app.get('/api/admin/audit/export.csv', requireRole('admin', 'manager'), (req, res) => { const result = searchAuditLog({ limit: 1000, userId: req.query.user_id, action: req.query.action, from: req.query.from, to: req.query.to, query: req.query.q }); const csvEscape = value => `"${String(value ?? '').replace(/"/g, '""')}"`; const lines = [['Zeit', 'Benutzer', 'Aktion', 'Zieltyp', 'Ziel-ID', 'Details', 'IP'].map(csvEscape).join(';')]; for (const row of result.rows) lines.push([row.created_at, row.user_email, row.action, row.target_type, row.target_id, row.details, row.ip].map(csvEscape).join(';')); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="vendoo-audit-${new Date().toISOString().slice(0, 10)}.csv"`); res.send('\uFEFF' + lines.join('\n')); }); // --- Admin: Login History --- app.get('/api/admin/login-history/:userId', requireRole('admin'), (req, res) => { res.json(getLoginHistory(parseInt(req.params.userId))); }); // --- Admin: SMTP Settings --- app.get('/api/admin/smtp', requireRole('admin'), (_req, res) => { const settings = getSettings(); res.json({ smtp_host: settings.smtp_host || process.env.SMTP_HOST || '', smtp_port: settings.smtp_port || process.env.SMTP_PORT || '587', smtp_user: settings.smtp_user || process.env.SMTP_USER || '', smtp_pass: (settings.smtp_pass || process.env.SMTP_PASS) ? '••••••••' : '', smtp_from: settings.smtp_from || process.env.SMTP_FROM || '', configured: isMailerConfigured(), source: settings.smtp_host ? 'db' : (process.env.SMTP_HOST ? 'env' : 'none'), }); }); app.put('/api/admin/smtp', requireRole('admin'), (req, res) => { const { smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from } = req.body; const smtpSettings = {}; if (smtp_host !== undefined) smtpSettings.smtp_host = smtp_host; if (smtp_port !== undefined) smtpSettings.smtp_port = smtp_port; if (smtp_user !== undefined) smtpSettings.smtp_user = smtp_user; if (smtp_pass !== undefined && smtp_pass !== '••••••••') smtpSettings.smtp_pass = smtp_pass; if (smtp_from !== undefined) smtpSettings.smtp_from = smtp_from; updateSettings(smtpSettings); // Reinitialize mailer with new config const allSettings = getSettings(); if (allSettings.smtp_host && allSettings.smtp_user && allSettings.smtp_pass) { const ok = reinitMailer({ host: allSettings.smtp_host, port: allSettings.smtp_port || '587', user: allSettings.smtp_user, pass: allSettings.smtp_pass, }); if (allSettings.smtp_from) process.env.SMTP_FROM = allSettings.smtp_from; } if (req.user) auditLog(req.user.id, req.user.email, 'smtp.updated', null, null, null, getClientIp(req)); res.json({ smtp_host: allSettings.smtp_host || '', smtp_port: allSettings.smtp_port || '587', smtp_user: allSettings.smtp_user || '', smtp_pass: allSettings.smtp_pass ? '••••••••' : '', smtp_from: allSettings.smtp_from || '', configured: isMailerConfigured(), }); }); app.post('/api/admin/smtp/test', requireRole('admin'), async (req, res) => { try { if (!isMailerConfigured()) return res.status(400).json({ error: 'SMTP nicht konfiguriert. Bitte zuerst speichern.' }); const adminEmail = req.user?.email; if (!adminEmail) return res.status(400).json({ error: 'Admin-E-Mail nicht gefunden' }); await testMailer(adminEmail); auditLog(req.user.id, req.user.email, 'smtp.test_sent', null, null, `an ${adminEmail}`, getClientIp(req)); res.json({ ok: true, message: `Testmail an ${adminEmail} gesendet` }); } catch (err) { res.status(500).json({ error: `SMTP-Fehler: ${err.message}` }); } }); // --- Admin: Password Reset --- app.post('/api/admin/users/:id/reset-password', requireRole('admin'), (req, res) => { const id = parseInt(req.params.id); const { password } = req.body; const passwordCheck = validatePasswordStrength(password); if (!passwordCheck.valid) return res.status(400).json({ error: `Passwort benötigt ${passwordCheck.problems.join(', ')}.` }); const user = getUser(id); if (!user) return res.status(404).json({ error: 'User nicht gefunden' }); setPassword(id, password); auditLog(req.user.id, req.user.email, 'user.password_reset', 'user', String(id), `für ${user.email}`, getClientIp(req)); res.json({ ok: true, message: `Passwort für ${user.email} zurückgesetzt` }); }); // --- Admin: User Login History --- app.get('/api/admin/users/:id/login-history', requireRole('admin'), (req, res) => { const id = parseInt(req.params.id); res.json(getLoginHistory(id, 30)); }); // --- Admin: System Diagnostics --- function diagnosticItem(id, label, status, detail, meta = {}) { return { id, label, status, detail, ...meta }; } async function probeComfyUi(baseUrl) { const normalized = String(baseUrl || 'http://127.0.0.1:8188').replace(/\/$/, ''); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 1800); try { const response = await fetch(`${normalized}/system_stats`, { signal: controller.signal }); if (!response.ok) return { ok: false, detail: `HTTP ${response.status}` }; const payload = await response.json().catch(() => ({})); const device = payload?.devices?.[0]; const deviceName = device?.name || device?.type || 'ComfyUI'; return { ok: true, detail: `Erreichbar · ${deviceName}` }; } catch (error) { return { ok: false, detail: error?.name === 'AbortError' ? 'Zeitüberschreitung' : (error?.message || 'Nicht erreichbar') }; } finally { clearTimeout(timer); } } app.get('/api/admin/diagnostics', requireRole('admin'), async (_req, res) => { const startedAt = Date.now(); const checks = []; const root = __dirname; const settings = getSettings(); try { const result = db.pragma('quick_check', { simple: true }); checks.push(diagnosticItem('database', 'Datenbankintegrität', result === 'ok' ? 'ok' : 'error', result === 'ok' ? 'SQLite quick_check: ok' : String(result))); } catch (error) { checks.push(diagnosticItem('database', 'Datenbankintegrität', 'error', error.message)); } const writableTargets = [ ['uploads', 'Upload-Verzeichnis', UPLOADS_DIR], ['db-write', 'Datenbank-Verzeichnis', dirname(DB_PATH)], ]; for (const [id, label, target] of writableTargets) { try { mkdirSync(target, { recursive: true }); const probe = join(target, `.vendoo-write-test-${process.pid}-${Date.now()}.tmp`); writeFileSync(probe, 'ok'); unlinkSync(probe); checks.push(diagnosticItem(id, label, 'ok', 'Schreibzugriff vorhanden')); } catch (error) { checks.push(diagnosticItem(id, label, 'error', error.message)); } } const requiredAssets = [ ['ui-html', 'UI-Dokument', join(root, 'public', 'index.html')], ['ui-js', 'UI-Logik', join(root, 'public', 'app.js')], ['ui-css', 'UI-Styles', join(root, 'public', 'style.css')], ['flux-workflow', 'FLUX-Workflow', join(root, String(settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'))], ]; for (const [id, label, target] of requiredAssets) { const present = existsSync(target); checks.push(diagnosticItem(id, label, present ? 'ok' : 'error', present ? basename(target) : `Fehlt: ${target}`)); } const extensionTargets = [ ['Chrome', join(root, 'extensions', 'chrome', 'manifest.json')], ['Edge', join(root, 'extensions', 'edge', 'manifest.json')], ['Firefox', join(root, 'extensions', 'firefox', 'manifest.json')], ['Safari', join(root, 'extensions', 'safari', 'web-extension', 'manifest.json')], ]; const extensionStates = extensionTargets.map(([name, target]) => ({ name, present: existsSync(target) })); const extensionMissing = extensionStates.filter(item => !item.present).map(item => item.name); checks.push(diagnosticItem('extensions', 'Browser-Extensions', extensionMissing.length ? 'warning' : 'ok', extensionMissing.length ? `Fehlend: ${extensionMissing.join(', ')}` : 'Chrome, Edge, Firefox und Safari vorhanden')); const configuredProviders = getProviders().map(provider => { const configured = !provider.needsKey || Boolean(process.env[provider.needsKey]); return { id: provider.id, configured }; }); const activeProvider = String(settings.ai_provider || settings.default_ai_provider || 'nicht festgelegt'); checks.push(diagnosticItem('ai-provider', 'AI-Konfiguration', configuredProviders.some(item => item.configured) ? 'ok' : 'warning', `Aktiv: ${activeProvider} · verfügbar: ${configuredProviders.filter(item => item.configured).map(item => item.id).join(', ') || 'keiner'}`)); const comfyUrl = settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; const comfy = await probeComfyUi(comfyUrl); checks.push(diagnosticItem('comfyui', 'ComfyUI / FLUX', comfy.ok ? 'ok' : 'optional', `${comfy.detail} · ${comfyUrl}`)); const counts = checks.reduce((acc, check) => { acc[check.status] = (acc[check.status] || 0) + 1; return acc; }, {}); const hardFailures = checks.filter(check => check.status === 'error').length; res.setHeader('Cache-Control', 'no-store'); res.json({ ok: hardFailures === 0, version: APP_VERSION, generated_at: new Date().toISOString(), duration_ms: Date.now() - startedAt, summary: counts, checks, runtime: { node: process.version, platform: process.platform, arch: process.arch, uptime_seconds: Math.floor(process.uptime()), memory_mb: Math.round(process.memoryUsage().rss / 1048576) }, }); }); // --- Admin: System Info --- app.get('/api/admin/system', requireRole('admin'), (_req, res) => { const users = getUsers(); const listings = getListings(); const sessions = getAllActiveSessions(); let dbSize = 'unbekannt'; try { const dbPath = DB_PATH; if (existsSync(dbPath)) { const bytes = statSync(dbPath).size; if (bytes < 1024) dbSize = bytes + ' B'; else if (bytes < 1024 * 1024) dbSize = (bytes / 1024).toFixed(1) + ' KB'; else dbSize = (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } } catch {} const uptimeSeconds = process.uptime(); const hours = Math.floor(uptimeSeconds / 3600); const minutes = Math.floor((uptimeSeconds % 3600) / 60); const uptime = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; res.json({ node_version: process.version, uptime, uptime_seconds: Math.floor(uptimeSeconds), total_users: users.length, total_listings: listings.length, active_sessions: sessions.length, db_size: dbSize, platform: process.platform, memory_mb: Math.round(process.memoryUsage().rss / (1024 * 1024)), }); }); // --- Admin: Session Cleanup --- app.post('/api/admin/sessions/cleanup', requireRole('admin'), (req, res) => { try { cleanExpiredSessions(); auditLog(req.user.id, req.user.email, 'sessions.cleanup', null, null, null, getClientIp(req)); res.json({ ok: true, message: 'Abgelaufene Sessions bereinigt' }); } catch (err) { res.json({ ok: false, error: err.message }); } }); // --- My Sessions --- app.get('/api/my/sessions', (req, res) => { if (isSetupMode()) return res.json([]); res.json(getUserSessions(req.user.id)); }); app.delete('/api/my/sessions/:id', (req, res) => { if (isSetupMode()) return res.json({ ok: true }); destroySessionById(parseInt(req.params.id)); res.json({ ok: true }); }); // Static files served for everyone — auth check happens via /api/me in frontend app.use((req, res, next) => { const path = String(req.path || '').toLowerCase(); if (path === '/' || path.endsWith('.html') || path.endsWith('.js') || path.endsWith('.css')) { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); res.setHeader('Surrogate-Control', 'no-store'); } next(); }); app.use(express.static(join(__dirname, 'public'), { etag: true, maxAge: 0 })); app.use('/uploads', requireAuth, express.static(UPLOADS_DIR, { etag: true, maxAge: '1h', fallthrough: false })); function sanitizeUploadPlatform(value) { const clean = String(value || 'allgemein').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); return clean.slice(0, 48) || 'allgemein'; } const storage = multer.diskStorage({ destination: (req, _file, cb) => { const platform = sanitizeUploadPlatform(req.body?.platform || req.query?.platform || 'allgemein'); const dir = join(UPLOADS_DIR, platform); mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (_req, file, cb) => { const ext = extname(file.originalname).toLowerCase(); cb(null, `${randomUUID()}${ext}`); }, }); const upload = multer({ storage, fileFilter: (_req, file, cb) => { const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']; const ext = extname(file.originalname).toLowerCase(); const isImageMime = String(file.mimetype || '').startsWith('image/'); cb(null, allowed.includes(ext) && isImageMime); }, limits: { fileSize: 20 * 1024 * 1024 }, }); const operationsUpload = multer({ storage: multer.diskStorage({ destination: (_req, _file, cb) => { const dir = join(OPERATIONS_DIR, 'staging'); mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (_req, file, cb) => cb(null, `${Date.now()}-${randomUUID()}${extname(file.originalname || '').toLowerCase() || '.bin'}`), }), fileFilter: (_req, file, cb) => { const ext = extname(file.originalname || '').toLowerCase(); cb(null, ['.zip', '.db', '.sqlite', '.sqlite3'].includes(ext)); }, limits: { fileSize: 2 * 1024 * 1024 * 1024 }, }); async function normalizeMobileUploadFile(file) { const ext = extname(file.originalname || file.filename || '').toLowerCase(); if (!['.heic', '.heif'].includes(ext) && !/hei[cf]/i.test(file.mimetype || '')) { return file; } const outputName = `${randomUUID()}.jpg`; const outputPath = join(dirname(file.path), outputName); try { await sharp(file.path).rotate().jpeg({ quality: 90 }).toFile(outputPath); try { unlinkSync(file.path); } catch {} return { ...file, filename: outputName, path: outputPath, mimetype: 'image/jpeg', originalname: String(file.originalname || 'foto.heic').replace(/\.(heic|heif)$/i, '.jpg'), }; } catch (error) { try { unlinkSync(file.path); } catch {} throw new Error('HEIC/HEIF konnte auf diesem System nicht in JPG umgewandelt werden. Bitte am Smartphone „Am kompatibelsten“ wählen oder als JPG/PNG hochladen.'); } } function validateMobileUploadToken(req, res, next) { const session = getMobileUploadSessionByTokenHash(hashMobileUploadToken(req.params.token)); if (!isMobileUploadSessionUsable(session)) return res.status(410).json({ error: 'Upload-Link ist abgelaufen oder geschlossen.' }); if ((session.files || []).length >= 10) return res.status(400).json({ error: 'Maximal 10 Bilder pro Upload-Sitzung.' }); req.mobileUploadSession = session; next(); } app.post('/mobile-upload-api/:token/photos', validateMobileUploadToken, upload.array('photos', 10), async (req, res) => { const session = req.mobileUploadSession; const remaining = Math.max(0, 10 - (session.files || []).length); const uploadedFiles = req.files || []; const acceptedFiles = uploadedFiles.slice(0, remaining); for (const extraFile of uploadedFiles.slice(remaining)) { try { unlinkSync(extraFile.path); } catch {} } try { const normalizedFiles = []; for (const file of acceptedFiles) normalizedFiles.push(await normalizeMobileUploadFile(file)); const platform = req.body?.platform || req.query?.platform || 'allgemein'; const filenames = normalizedFiles.map(file => `${platform}/${file.filename}`); const updated = appendMobileUploadFiles(session.id, filenames); res.json({ ok: true, files: updated?.files || [], added: filenames.length, remaining: Math.max(0, 10 - (updated?.files || []).length), }); } catch (error) { for (const file of acceptedFiles) { try { if (existsSync(file.path)) unlinkSync(file.path); } catch {} } res.status(400).json({ error: error.message || 'Bilder konnten nicht verarbeitet werden.' }); } }); // --- Platforms --- const platforms = {}; async function loadPlatforms() { for (const f of ['vinted', 'ebay-ka', 'ebay-de', 'etsy']) { const mod = await import(`./platforms/${f}.mjs`); platforms[mod.default.id] = mod.default; } } // --- Upload --- app.post('/api/upload', upload.array('photos', 10), (req, res) => { const platform = sanitizeUploadPlatform(req.body?.platform || req.query?.platform || 'allgemein'); res.json({ filenames: req.files.map(f => `${platform}/${f.filename}`) }); }); app.get('/api/image-editor/versions', async (req, res) => { try { const path = String(req.query?.path || '').trim(); const resolved = resolveEditableImage(UPLOADS_DIR, path); const history = getImageEditVersions(resolved.normalized, req.query?.limit || 50); const original = resolveEditableImage(UPLOADS_DIR, history.root_path || resolved.normalized); const stats = statSync(original.absolute); const metadata = await sharp(original.absolute, { failOn: 'none' }).metadata(); res.json({ root_path: history.root_path || resolved.normalized, current_path: resolved.normalized, original: { id: 'original', output_path: history.root_path || resolved.normalized, root_path: history.root_path || resolved.normalized, source_path: null, context: 'original', operations: {}, width: metadata.width || null, height: metadata.height || null, format: metadata.format || null, file_size: stats.size, created_at: stats.birthtime?.toISOString?.() || stats.mtime?.toISOString?.() || null, }, versions: history.versions, }); } catch (error) { res.status(400).json({ error: error.message || 'Bildversionen konnten nicht geladen werden.' }); } }); app.post('/api/image-editor/render', async (req, res) => { try { const result = await renderImageVersion({ uploadRoot: UPLOADS_DIR, sourcePath: req.body?.source_path, operations: req.body?.operations, context: req.body?.context || 'generator', userId: req.user?.id || null, rootPath: getImageEditRoot(String(req.body?.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim()) || String(req.body?.source_path || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(), recordVersion: createImageEditVersion, }); const history = getImageEditVersions(result.filename, 50); res.status(201).json({ ...result, versions: history.versions }); } catch (error) { res.status(400).json({ error: error.message || 'Bildversion konnte nicht erstellt werden.' }); } }); app.get('/api/network-info', (_req, res) => { const addresses = getLanIpv4Addresses(); res.json({ port: Number(PORT), addresses, preferred_url: addresses[0]?.address ? `http://${addresses[0].address}:${PORT}` : `http://localhost:${PORT}`, public_base_url: String(process.env.PUBLIC_BASE_URL || '').trim() || null, runtime_config_path: CONFIG_PATH, }); }); app.post('/api/mobile-upload-sessions', (req, res) => { cleanupMobileUploadSessions(); const token = randomBytes(32).toString('base64url'); const tokenHash = hashMobileUploadToken(token); const context = ['generator', 'listing'].includes(req.body?.context) ? req.body.context : 'generator'; const listingId = req.body?.listing_id ? Number(req.body.listing_id) : null; if (context === 'listing' && listingId && !getListing(listingId)) return res.status(404).json({ error: 'Artikel nicht gefunden' }); const expires = new Date(Date.now() + 20 * 60 * 1000); const expiresSql = expires.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''); const session = createMobileUploadSession({ token_hash: tokenHash, user_id: req.user?.id || null, listing_id: listingId, context, expires_at: expiresSql, }); const baseUrl = resolvePublicBaseUrl(req); const url = `${baseUrl}/mobile-upload/${encodeURIComponent(token)}`; res.json({ id: session.id, url, base_url: baseUrl, detected_lan_ip: getLanIpv4Addresses()[0]?.address || null, token, context, listing_id: listingId, files: [], expires_at: expires.toISOString(), }); }); app.get('/api/mobile-upload-sessions/:id', (req, res) => { cleanupMobileUploadSessions(); const session = getMobileUploadSessionById(Number(req.params.id), req.user?.id || null); if (!session || !isMobileUploadSessionUsable(session)) return res.status(404).json({ error: 'Upload-Sitzung nicht gefunden oder abgelaufen' }); res.json({ id: session.id, context: session.context, listing_id: session.listing_id, files: session.files || [], expires_at: session.expires_at, status: session.status }); }); app.delete('/api/mobile-upload-sessions/:id', (req, res) => { deleteMobileUploadSession(Number(req.params.id), req.user?.id || null); res.json({ ok: true }); }); // --- Image tools --- app.post('/api/images/remove-bg', async (req, res) => { try { const { filename } = req.body; const newName = await removeBackground(filename); res.json({ filename: newName }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/images/watermark', async (req, res) => { try { const { filename, text } = req.body; const newName = await addWatermark(filename, text); res.json({ filename: newName }); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- AI Model Photos --- app.get('/api/listings/:id/ai-model-generations', (req, res) => { const listing = getListing(req.params.id); if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); const generations = getAiModelGenerationsForListing(req.params.id).map(item => ({ ...item, photos: getAiModelPhotosForGeneration(item.id), })); res.json(generations); }); app.get('/api/ai-model-photos/jobs/:id', (req, res) => { const job = getAiModelJobWithRelations(req.params.id); if (!job) return res.status(404).json({ error: 'Nicht gefunden' }); const response = { ...job }; if (job.generation_id) { response.photos = getAiModelPhotosForGeneration(job.generation_id); } res.json(response); }); app.post('/api/ai-model-photos/jobs', async (req, res) => { try { const settings = getSettings(); if (String(settings.ai_model_photos_enabled || '1') !== '1') { return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); } const payload = normalizeListingPayload(req.body || {}); const precheck = await precheckAiModelPhotos(payload, settings); if (!precheck.allowed) return res.status(400).json(precheck); const job = enqueueAiModelJob({ ...payload, settings, preset: precheck.preset, variants: precheck.variants, mode: precheck.mode }); res.json({ queued: true, job_id: job.id, job, precheck }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/ai-model-photos/providers/openrouter/diagnostics', async (_req, res) => { try { const diagnostics = await getOpenRouterImageDiagnostics(); res.json({ ...diagnostics, has_api_key: !!process.env.OPENROUTER_API_KEY, free_only_enabled: String(getSettings().ai_model_photo_openrouter_free_only || '1') !== '0', }); } catch (err) { res.status(500).json({ error: err.message, has_api_key: !!process.env.OPENROUTER_API_KEY }); } }); app.get('/api/ai-model-photos/providers/openrouter/models', async (req, res) => { try { const settings = getSettings(); const freeOnly = req.query.free_only !== undefined ? String(req.query.free_only) !== '0' : String(settings.ai_model_photo_openrouter_free_only || '1') !== '0'; const models = await fetchOpenRouterImageModels({ freeOnly }); res.json({ provider: 'openrouter', free_only: freeOnly, models }); } catch (err) { res.status(500).json({ error: err.message }); } }); function getLocalFluxFileInfo(pathname) { try { if (!existsSync(pathname)) return { exists: false, size_bytes: 0, size_mb: 0, size_gb: 0 }; const stats = statSync(pathname); return { exists: true, size_bytes: stats.size, size_mb: Math.round((stats.size / 1024 / 1024) * 10) / 10, size_gb: Math.round((stats.size / 1024 / 1024 / 1024) * 100) / 100, modified_at: stats.mtime?.toISOString?.() || null, }; } catch { return { exists: false, size_bytes: 0, size_mb: 0, size_gb: 0 }; } } function getLocalFluxDirectoryMetrics(rootPath) { const result = { exists: false, file_count: 0, size_bytes: 0, size_mb: 0 }; if (!existsSync(rootPath)) return result; result.exists = true; const stack = [rootPath]; while (stack.length) { const current = stack.pop(); let entries = []; try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const full = join(current, entry.name); if (entry.isDirectory()) stack.push(full); else if (entry.isFile()) { result.file_count += 1; try { result.size_bytes += statSync(full).size; } catch {} } } } result.size_mb = Math.round((result.size_bytes / 1024 / 1024) * 10) / 10; return result; } function stripAnsiSequences(value = '') { return String(value || '').replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ''); } function readLocalFluxLogTail(logPath, maxLines = 40) { try { if (!existsSync(logPath)) return []; return readFileSync(logPath, 'utf-8') .split(/\r?\n/) .map(stripAnsiSequences) .filter(Boolean) .slice(-maxLines); } catch { return []; } } function readLocalFluxRuntimeState(includeLogs = false) { const logDir = LOG_DIR; const statePath = join(logDir, 'local-flux-runtime-state.json'); const stdoutPath = join(logDir, 'local-flux-runtime.log'); const stderrPath = join(logDir, 'local-flux-runtime-error.log'); let state = { status: 'stopped', message: 'FLUX / ComfyUI ist nicht gestartet.', pid: null, elapsed_seconds: 0, updated_at: null, error: '', }; if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8').replace(/^\uFEFF/, '')) }; } catch { state = { ...state, status: 'unknown', message: 'FLUX-Runtime-Status konnte nicht gelesen werden.' }; } } const updatedAt = Date.parse(state.updated_at || '') || 0; const ageSeconds = updatedAt ? Math.max(0, Math.round((Date.now() - updatedAt) / 1000)) : null; const stale = state.status === 'starting' && ageSeconds !== null && ageSeconds > 240; if (stale) { state = { ...state, status: 'failed', message: `FLUX-Startstatus ist seit ${ageSeconds} Sekunden veraltet.`, error: state.error || 'Der Startprozess liefert keine Statusaktualisierung mehr.', }; } return { ...state, age_seconds: ageSeconds, stale, state_path: statePath, stdout_log: stdoutPath, stderr_log: stderrPath, ...(includeLogs ? { stdout_tail: readLocalFluxLogTail(stdoutPath, 30), stderr_tail: readLocalFluxLogTail(stderrPath, 30), } : {}), }; } function findLatestLocalFluxArchive(downloadDir) { try { const items = readdirSync(downloadDir, { withFileTypes: true }) .filter(entry => entry.isFile() && /\.7z$/i.test(entry.name)) .map(entry => ({ name: entry.name, path: join(downloadDir, entry.name), info: getLocalFluxFileInfo(join(downloadDir, entry.name)) })) .sort((a, b) => String(b.info.modified_at || '').localeCompare(String(a.info.modified_at || ''))); return items[0] || null; } catch { return null; } } function readLocalFluxInstallState(includeLog = false) { const localRoot = join(__dirname, 'local-ai'); const statePath = join(localRoot, 'install-state.json'); const logPath = join(LOG_DIR, 'local-flux-install.log'); const portableRoot = join(localRoot, 'comfyui', 'ComfyUI_windows_portable'); const comfyMain = join(portableRoot, 'ComfyUI', 'main.py'); const embeddedPython = join(portableRoot, 'python_embeded', 'python.exe'); const modelPath = join(portableRoot, 'ComfyUI', 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors'); const workflowPath = join(localRoot, 'workflows', 'vendoo_flux_schnell_api.json'); const startScript = join(localRoot, 'start-local-flux.ps1'); const stopScript = join(localRoot, 'stop-local-flux.ps1'); const downloadDir = join(localRoot, 'downloads'); const extractTemp = join(localRoot, 'extract-temp'); const sevenZipOutputPath = join(LOG_DIR, 'local-flux-7zip-output.log'); const sevenZipErrorPath = join(LOG_DIR, 'local-flux-7zip-error.log'); const latestArchive = findLatestLocalFluxArchive(downloadDir); const partialFiles = (() => { try { return readdirSync(downloadDir, { withFileTypes: true }) .filter(entry => entry.isFile() && /\.part$/i.test(entry.name)) .map(entry => ({ name: entry.name, ...getLocalFluxFileInfo(join(downloadDir, entry.name)) })); } catch { return []; } })(); let state = { status: 'not-started', phase: 'idle', progress: 0, message: 'Lokale FLUX-Installation wurde noch nicht gestartet.', }; if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf-8').replace(/^\uFEFF/, '')) }; } catch { state = { ...state, status: 'unknown', phase: 'unknown', message: 'Installationsstatus konnte nicht gelesen werden.' }; } } const known7ZipPaths = [ process.env.ProgramFiles ? join(process.env.ProgramFiles, '7-Zip', '7z.exe') : null, process.env['ProgramFiles(x86)'] ? join(process.env['ProgramFiles(x86)'], '7-Zip', '7z.exe') : null, process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', '7-Zip', '7z.exe') : null, ].filter(Boolean); const sevenZipPath = known7ZipPaths.find(pathname => existsSync(pathname)) || null; const checks = { seven_zip: { ok: !!sevenZipPath, path: sevenZipPath }, archive: { ok: !!latestArchive?.info?.exists, name: latestArchive?.name || null, ...(latestArchive?.info || {}) }, embedded_python: { ok: existsSync(embeddedPython), path: embeddedPython }, comfyui: { ok: existsSync(comfyMain), path: comfyMain }, model: { ok: existsSync(modelPath) && getLocalFluxFileInfo(modelPath).size_bytes >= 1024 * 1024 * 1024, path: modelPath, ...getLocalFluxFileInfo(modelPath) }, workflow: { ok: existsSync(workflowPath), path: workflowPath }, start_script: { ok: existsSync(startScript), path: startScript }, stop_script: { ok: existsSync(stopScript), path: stopScript }, }; const completedChecks = Object.values(checks).filter(check => check.ok).length; const totalChecks = Object.keys(checks).length; const readiness = Math.round((completedChecks / totalChecks) * 100); const phaseLabels = { idle: 'Noch nicht gestartet', preflight: 'Vorprüfung', 'download-comfyui': 'ComfyUI wird heruntergeladen', 'dependency-7zip': '7-Zip wird geprüft', 'install-7zip': '7-Zip wird installiert', 'extract-comfyui': 'ComfyUI wird entpackt', 'install-comfyui': 'ComfyUI-Struktur wird installiert', configure: 'Lokale Konfiguration wird erstellt', 'download-model': 'FLUX-Modell wird heruntergeladen', autostart: 'Autostart wird eingerichtet', start: 'Lokaler Bildserver wird gestartet', update: 'ComfyUI wird aktualisiert', done: 'Installation abgeschlossen', error: 'Installation fehlgeschlagen', uninstalled: 'Nicht installiert', }; const extractMetrics = getLocalFluxDirectoryMetrics(extractTemp); const now = Date.now(); const heartbeatAt = Date.parse(state.heartbeat_at || state.updated_at || '') || 0; const heartbeatAgeSeconds = heartbeatAt ? Math.max(0, Math.round((now - heartbeatAt) / 1000)) : null; const staleThresholdSeconds = state.phase === 'extract-comfyui' ? 15 : 120; const stalled = state.status === 'running' && heartbeatAgeSeconds !== null && heartbeatAgeSeconds > staleThresholdSeconds; const installationComplete = checks.embedded_python.ok && checks.comfyui.ok && checks.model.ok && checks.workflow.ok && checks.start_script.ok && checks.stop_script.ok; let effectiveStatus = stalled ? 'stalled' : state.status; let effectivePhase = state.phase; let effectiveMessage = stalled ? `Keine Statusaktualisierung seit ${heartbeatAgeSeconds} Sekunden. Der Installer oder 7-Zip könnte hängen.` : state.message; // Der reale Dateistand hat Vorrang vor einer alten, beschädigten oder fehlenden Statusdatei. if (installationComplete && effectiveStatus !== 'running') { effectiveStatus = 'completed'; effectivePhase = 'done'; effectiveMessage = 'Lokales FLUX ist vollständig installiert.'; } else if (!checks.comfyui.ok && ['completed', 'unknown'].includes(effectiveStatus)) { effectiveStatus = 'not-started'; effectivePhase = 'idle'; effectiveMessage = 'Lokales FLUX ist noch nicht vollständig installiert.'; } const result = { ...state, status: effectiveStatus, original_status: state.status, phase: effectivePhase, message: effectiveMessage, phase_label: stalled ? 'Keine Statusaktualisierung' : (phaseLabels[effectivePhase] || effectivePhase || 'Unbekannt'), checks, readiness, heartbeat_age_seconds: heartbeatAgeSeconds, stale_threshold_seconds: staleThresholdSeconds, stalled, extract_metrics: { file_count: Number(state.extract_files ?? extractMetrics.file_count ?? 0), size_mb: Number(state.extract_mb ?? extractMetrics.size_mb ?? 0), elapsed_seconds: Number(state.extract_elapsed_seconds ?? 0), process_id: state.extract_process_id || null, root: extractTemp, }, installer_process_id: state.process_id || null, completed_checks: completedChecks, total_checks: totalChecks, installation_complete: installationComplete, can_resume: ['failed', 'stalled'].includes(effectiveStatus) || ((checks.archive.ok || checks.comfyui.ok || checks.embedded_python.ok) && (!checks.comfyui.ok || !checks.model.ok)), partial_downloads: partialFiles, paths: { state_file: statePath, log_file: logPath, portable_root: portableRoot, model_path: modelPath, workflow_path: workflowPath, extract_root: extractTemp, seven_zip_output: sevenZipOutputPath, seven_zip_error: sevenZipErrorPath, }, }; if (includeLog) { result.log_tail = readLocalFluxLogTail(logPath, 80); result.seven_zip_output_tail = readLocalFluxLogTail(sevenZipOutputPath, 30); result.seven_zip_error_tail = readLocalFluxLogTail(sevenZipErrorPath, 30); } return result; } function spawnLocalFluxPowerShell(scriptName, args = []) { if (process.platform !== 'win32') { throw new Error('Die automatische lokale FLUX-Installation ist aktuell für Windows vorgesehen.'); } const scriptPath = join(__dirname, 'scripts', scriptName); if (!existsSync(scriptPath)) throw new Error(`Skript fehlt: ${scriptName}`); const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args], { cwd: __dirname, detached: true, stdio: 'ignore', windowsHide: true, }); child.unref(); return child.pid; } app.get('/api/local-image/status', async (_req, res) => { try { const settings = getSettings(); const runtime = await getLocalImageStatus(settings); const install = readLocalFluxInstallState(); const runtimeState = readLocalFluxRuntimeState(true); const engineState = !runtime.enabled ? 'disabled' : runtime.reachable ? 'running' : runtimeState.status === 'starting' ? 'starting' : runtimeState.status === 'failed' ? 'failed' : install.installation_complete ? 'stopped' : 'not-installed'; res.json({ ...runtime, install, runtime_state: runtimeState, engine_state: engineState, engine_label: engineState === 'running' ? 'Aktiv und bereit' : engineState === 'starting' ? 'Wird gestartet' : engineState === 'failed' ? 'Start fehlgeschlagen' : engineState === 'stopped' ? 'Installiert, aber gestoppt' : engineState === 'disabled' ? 'Deaktiviert' : 'Nicht installiert', }); } catch (error) { res.status(500).json({ error: error.message || 'Lokaler Bildstatus konnte nicht geprüft werden.' }); } }); app.get('/api/local-image/install-status', requireRole('admin'), (_req, res) => { res.json(readLocalFluxInstallState(true)); }); app.post('/api/local-image/install', requireRole('admin'), (req, res) => { try { const current = readLocalFluxInstallState(); if (current.status === 'running') { return res.status(409).json({ error: 'Eine lokale FLUX-Installation läuft bereits.', install: current }); } const args = ['-NonInteractive']; if (req.body?.install_model !== false) args.push('-InstallModel'); if (req.body?.enable_autostart !== false) args.push('-EnableAutostart'); if (req.body?.start_now !== false) args.push('-StartNow'); if (req.body?.force === true) args.push('-Force'); const gpu = String(req.body?.gpu || 'auto').toLowerCase(); if (['auto','nvidia','amd','intel','cpu'].includes(gpu)) args.push('-Gpu', gpu); const pid = spawnLocalFluxPowerShell('install-local-flux.ps1', args); res.json({ ok: true, pid, message: 'Lokale FLUX-Installation wurde gestartet.' }); } catch (error) { res.status(500).json({ error: error.message || 'Lokale FLUX-Installation konnte nicht gestartet werden.' }); } }); app.post('/api/local-image/update', requireRole('admin'), (_req, res) => { try { const current = readLocalFluxInstallState(); if (current.status === 'running') { return res.status(409).json({ error: 'Eine lokale FLUX-Aktion läuft bereits.', install: current }); } const pid = spawnLocalFluxPowerShell('install-local-flux.ps1', ['-NonInteractive', '-UpdateOnly', '-StartNow']); res.json({ ok: true, pid, message: 'Lokales FLUX-Update wurde gestartet.' }); } catch (error) { res.status(500).json({ error: error.message || 'Lokales FLUX-Update konnte nicht gestartet werden.' }); } }); app.post('/api/local-image/start', requireRole('admin'), (req, res) => { try { const settings = getSettings(); const requested = String(req.body?.profile || settings.flux_runtime_profile || 'auto').toLowerCase(); const profile = ['auto','fast','balanced','lowvram'].includes(requested) ? requested : 'auto'; const pid = spawnLocalFluxPowerShell('../local-ai/start-local-flux.ps1', ['-Profile', profile]); res.json({ ok: true, pid, message: 'Lokaler FLUX-Server wird gestartet.' }); } catch (error) { res.status(500).json({ error: error.message || 'Lokaler FLUX-Server konnte nicht gestartet werden.' }); } }); app.post('/api/local-image/stop', requireRole('admin'), (_req, res) => { try { const pid = spawnLocalFluxPowerShell('../local-ai/stop-local-flux.ps1', []); res.json({ ok: true, pid, message: 'Lokaler FLUX-Server wird beendet.' }); } catch (error) { res.status(500).json({ error: error.message || 'Lokaler FLUX-Server konnte nicht beendet werden.' }); } }); app.post('/api/local-image/uninstall', requireRole('admin'), (req, res) => { try { const current = readLocalFluxInstallState(); if (current.status === 'running') { return res.status(409).json({ error: 'Während einer laufenden Installation kann FLUX nicht deinstalliert werden.', install: current }); } const args = ['-NonInteractive']; if (req.body?.remove_downloads === true) args.push('-RemoveDownloads'); const pid = spawnLocalFluxPowerShell('uninstall-local-flux.ps1', args); updateSettings({ ai_model_photo_local_enabled: '0' }); process.env.LOCAL_IMAGE_ENABLED = 'false'; updateRuntimeConfig({ LOCAL_IMAGE_ENABLED: 'false' }); res.json({ ok: true, pid, message: req.body?.remove_downloads === true ? 'Lokales FLUX wird vollständig einschließlich Downloads deinstalliert.' : 'Lokales FLUX wird deinstalliert. Installationsarchive bleiben für eine schnelle Neuinstallation erhalten.', }); } catch (error) { res.status(500).json({ error: error.message || 'Lokales FLUX konnte nicht deinstalliert werden.' }); } }); app.post('/api/local-image/regenerate-token', requireRole('admin'), (req, res) => { try { const token = generateLocalImageToken(); updateRuntimeConfig({ LOCAL_IMAGE_TOKEN: token }); res.json({ ok: true, token }); } catch (error) { res.status(500).json({ error: error.message || 'Lokaler Token konnte nicht erneuert werden.' }); } }); app.get('/api/media-library', async (req, res) => { try { const allItems = listMediaLibraryItems({ source: req.query?.source || 'all', search: req.query?.q || '', favoritesOnly: String(req.query?.favorites || '0') === '1', sort: req.query?.sort || 'newest', }); const pageSize = Math.max(12, Math.min(96, Number(req.query?.page_size) || 24)); const total = allItems.length; const totalPages = Math.max(1, Math.ceil(total / pageSize)); const page = Math.min(Math.max(1, Number(req.query?.page) || 1), totalPages); const uploadRoot = resolve(UPLOADS_DIR); const pageItems = allItems.slice((page - 1) * pageSize, page * pageSize); const items = await Promise.all(pageItems.map(async item => { const absolute = resolve(uploadRoot, item.image_path); let fileSize = null; let width = Number(item.width) || null; let height = Number(item.height) || null; let format = item.format || extname(item.image_path).replace('.', '').toLowerCase() || null; if (absolute !== uploadRoot && absolute.startsWith(uploadRoot + sep) && existsSync(absolute)) { try { fileSize = statSync(absolute).size; } catch {} if (!width || !height) { try { const metadata = await sharp(absolute, { failOn: 'none' }).metadata(); width = metadata.width || width; height = metadata.height || height; format = metadata.format || format; } catch {} } } return { ...item, public_url: `/uploads/${item.image_path}`, file_size: fileSize, width, height, format }; })); const summaryItems = listMediaLibraryItems({ source: 'all', search: req.query?.q || '', favoritesOnly: String(req.query?.favorites || '0') === '1', sort: req.query?.sort || 'newest', }); const sourceCounts = summaryItems.reduce((counts, item) => { for (const itemSource of item.sources || []) counts[itemSource] = (counts[itemSource] || 0) + 1; return counts; }, {}); res.json({ items, pagination: { page, page_size: pageSize, total, total_pages: totalPages }, summary: { total: summaryItems.length, sources: sourceCounts } }); } catch (error) { res.status(500).json({ error: error.message || 'Medienbibliothek konnte nicht geladen werden.' }); } }); app.patch('/api/media-library/favorite', (req, res) => { const result = setMediaLibraryFavorite(req.body?.image_path, req.body?.favorite !== false); if (!result.ok) return res.status(400).json({ error: 'Nur FLUX-Bilder können als Favorit markiert werden.' }); res.json(result); }); app.delete('/api/media-library/items', (req, res) => { const requested = [...new Set((Array.isArray(req.body?.image_paths) ? req.body.image_paths : []).map(value => String(value || '').replace(/\\/g, '/').replace(/^\/+/, '').trim()).filter(Boolean))].slice(0, 500); const referenced = getListingReferencedMediaPaths(); const protectedPaths = requested.filter(imagePath => referenced.has(imagePath)); const deletable = requested.filter(imagePath => !referenced.has(imagePath)); const uploadRoot = resolve(UPLOADS_DIR); const deletedFiles = []; for (const imagePath of deletable) { const absolute = resolve(uploadRoot, imagePath); if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep)) continue; try { if (existsSync(absolute) && statSync(absolute).isFile()) { unlinkSync(absolute); deletedFiles.push(imagePath); } } catch {} } removeMediaLibraryRecords(deletable); res.json({ ok: true, deleted: deletable.length, deleted_files: deletedFiles.length, protected: protectedPaths }); }); app.get('/api/image-batch/profiles', (_req, res) => { res.json({ items: listImageBatchProfiles() }); }); app.post('/api/image-batch/profiles', (req, res) => { try { res.status(201).json(createImageBatchProfile(req.body || {})); } catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht erstellt werden.' }); } }); app.patch('/api/image-batch/profiles/:id', (req, res) => { try { res.json(updateImageBatchProfile(req.params.id, req.body || {})); } catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht gespeichert werden.' }); } }); app.delete('/api/image-batch/profiles/:id', (req, res) => { try { res.json(deleteImageBatchProfile(req.params.id)); } catch (error) { res.status(400).json({ error: error.message || 'Produktionsprofil konnte nicht gelöscht werden.' }); } }); app.post('/api/image-batch/jobs', (req, res) => { try { res.status(202).json(createImageBatchJob(req.body || {})); } catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht erstellt werden.' }); } }); app.get('/api/image-batch/jobs', (req, res) => { try { res.json(paginateImageBatchJobs({ page: req.query?.page || 1, pageSize: req.query?.page_size || 10, status: req.query?.status || '' })); } catch (error) { res.status(500).json({ error: error.message || 'Bildaufträge konnten nicht geladen werden.' }); } }); app.get('/api/image-batch/jobs/:id', (req, res) => { const job = getImageBatchJob(req.params.id); if (!job) return res.status(404).json({ error: 'Bildauftrag wurde nicht gefunden.' }); res.json(job); }); app.post('/api/image-batch/jobs/:id/pause', (req, res) => { try { res.json(pauseImageBatchJob(req.params.id)); } catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht pausiert werden.' }); } }); app.post('/api/image-batch/jobs/:id/resume', (req, res) => { try { res.json(resumeImageBatchJob(req.params.id)); } catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht fortgesetzt werden.' }); } }); app.post('/api/image-batch/jobs/:id/cancel', (req, res) => { try { res.json(cancelImageBatchJob(req.params.id)); } catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht abgebrochen werden.' }); } }); app.post('/api/image-batch/jobs/:id/retry', (req, res) => { try { res.json(retryImageBatchJob(req.params.id)); } catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht wiederholt werden.' }); } }); app.post('/api/image-batch/jobs/:id/items/:itemId/retry', (req, res) => { try { res.json(retryImageBatchItem(req.params.id, req.params.itemId)); } catch (error) { res.status(400).json({ error: error.message || 'Bildposition konnte nicht wiederholt werden.' }); } }); app.delete('/api/image-batch/jobs/:id', (req, res) => { try { res.json(deleteImageBatchJob(req.params.id)); } catch (error) { res.status(400).json({ error: error.message || 'Bildauftrag konnte nicht gelöscht werden.' }); } }); app.get('/api/image-batch/jobs/:id/archive', (req, res) => { try { const job = getImageBatchJob(req.params.id); if (!job) return res.status(404).json({ error: 'Bildauftrag wurde nicht gefunden.' }); const outputs = getImageBatchOutputPaths(req.params.id); if (!outputs.length) return res.status(404).json({ error: 'Dieser Auftrag enthält noch keine fertigen Bilder.' }); const uploadRoot = resolve(UPLOADS_DIR); const files = []; for (const [index, relative] of outputs.entries()) { const absolute = resolve(uploadRoot, relative); if (absolute === uploadRoot || !absolute.startsWith(uploadRoot + sep) || !existsSync(absolute)) continue; files.push({ name: `${String(index + 1).padStart(2, '0')}-${basename(relative)}`, data: readFileSync(absolute) }); } if (!files.length) return res.status(404).json({ error: 'Die fertigen Bilddateien wurden nicht gefunden.' }); const archive = createZip(files); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename=vendoo-bildproduktion-${String(job.id).slice(0, 8)}.zip`); res.setHeader('Content-Length', archive.length); res.send(archive); } catch (error) { res.status(500).json({ error: error.message || 'ZIP-Export konnte nicht erstellt werden.' }); } }); app.post('/api/flux-prompt/jobs', (req, res) => { try { const settings = getSettings(); const job = enqueueFluxPromptJob({ prompt: req.body?.prompt, width: req.body?.width, height: req.body?.height, seed: req.body?.seed, steps: req.body?.steps, style: req.body?.style, profile: req.body?.profile || settings.flux_default_profile || 'balanced', promptMode: req.body?.prompt_mode || 'precise', assistant: req.body?.assistant !== false && String(settings.flux_prompt_assistant || '1') !== '0', variants: req.body?.variants || settings.flux_default_variants || 1, seedLock: req.body?.seed_lock === true || String(req.body?.seed_lock || settings.flux_seed_lock || '0') === '1', }); res.status(202).json(job); } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Auftrag konnte nicht erstellt werden.' }); } }); app.get('/api/flux-prompt/jobs', (req, res) => { const pageData = paginateFluxPromptJobs({ page: req.query?.page || 1, pageSize: req.query?.page_size || req.query?.limit || 10, status: req.query?.status || '', archived: String(req.query?.archived || '0') === '1', }); res.json({ ...pageData, summary: getFluxPromptJobSummary() }); }); app.post('/api/flux-prompt/jobs/archive', (req, res) => { res.json(archiveFluxPromptJobs(req.body?.ids || [])); }); app.post('/api/flux-prompt/jobs/restore', (req, res) => { res.json(restoreFluxPromptJobs(req.body?.ids || [])); }); app.delete('/api/flux-prompt/jobs/archive', (req, res) => { res.json(deleteArchivedFluxPromptJobs(req.body?.ids || [], { all: String(req.query?.all || '0') === '1' })); }); app.get('/api/flux-prompt/jobs/:id', (req, res) => { const job = getFluxPromptJob(req.params.id); if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); res.json(job); }); app.post('/api/flux-prompt/jobs/:id/cancel', async (req, res) => { const job = await cancelFluxPromptJob(req.params.id); if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); res.json(job); }); app.post('/api/flux-prompt/jobs/:id/retry', (req, res) => { try { const job = retryFluxPromptJob(req.params.id); if (!job) return res.status(404).json({ error: 'FLUX-Auftrag nicht gefunden.' }); res.status(202).json(job); } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Auftrag konnte nicht wiederholt werden.' }); } }); app.delete('/api/flux-prompt/jobs/queue', (_req, res) => { res.json(clearFluxPromptQueue()); }); app.get('/api/flux-prompt/runtime', async (_req, res) => { try { const settings = getSettings(); const runtime = await getLocalFluxRuntimeInfo(settings, { historyLimit: 20 }); res.json({ ...runtime, vendoo_jobs: getFluxPromptJobSummary() }); } catch (error) { res.status(503).json({ error: error.message || 'ComfyUI-Systemdaten konnten nicht geladen werden.', vendoo_jobs: getFluxPromptJobSummary() }); } }); app.get('/api/flux-studio/images', (req, res) => { const favoritesOnly = String(req.query?.favorites || '0') === '1'; if (req.query?.page !== undefined || req.query?.page_size !== undefined) { const result = paginateFluxImages({ page: req.query.page, pageSize: req.query.page_size, favoritesOnly }); return res.json({ items: result.items.map(item => ({ ...item, public_url: `/uploads/${item.image_path}` })), pagination: result.pagination, }); } const items = getFluxImages(req.query?.limit || 40, { favoritesOnly }).map(item => ({ ...item, public_url: `/uploads/${item.image_path}` })); res.json(items); }); app.post('/api/flux-prompt/improve', async (req, res) => { try { const prompt = String(req.body?.prompt || '').trim().slice(0, 4000); if (prompt.length < 4) return res.status(400).json({ error: 'Bitte zuerst einen aussagekräftigen Prompt eingeben.' }); const settings = getSettings(); const provider = String(req.body?.provider || settings.default_ai || 'openrouter').trim(); const model = String(req.body?.model || '').trim() || undefined; const goal = ['precise','product','creative','cinematic'].includes(req.body?.goal) ? req.body.goal : 'product'; const preserveSubject = req.body?.preserve_subject !== false; const avoidText = req.body?.avoid_text !== false; const goalLabels = { precise: 'präzise und technisch eindeutig', product: 'hochwertige Produkt- und Marketplace-Fotografie', creative: 'kreativ, aufmerksamkeitsstark und visuell eigenständig', cinematic: 'cinematisch, atmosphärisch und fotografisch hochwertig' }; const systemPrompt = `Du bist Vendoo Prompt Director für FLUX-Bildgenerierung. Optimiere einen vorhandenen Bildprompt, ohne die eigentliche Produktidentität oder Nutzerabsicht zu verfälschen. Ziel: ${goalLabels[goal]}. ${preserveSubject ? 'Motiv, Produktmerkmale, Farben, Anzahl und Perspektiven dürfen nicht frei erfunden oder ausgetauscht werden.' : ''} ${avoidText ? 'Vermeide sichtbare Schrift, Logos, Wasserzeichen und erfundene Marken, sofern der Nutzer sie nicht ausdrücklich verlangt.' : ''} Ergänze nur sinnvolle Angaben zu Motiv, Komposition, Kamera, Licht, Materialwirkung, Hintergrund und Qualitätsmerkmalen. Gib ausschließlich valides JSON aus: {"improved_prompt":"...","notes":"..."}. Der verbesserte Prompt soll direkt in FLUX nutzbar sein und maximal 1800 Zeichen enthalten.`; const userPrompt = `Originalprompt: ${prompt} Gewünschtes Profil: ${goal}. Formuliere den verbesserten Prompt bevorzugt in klarem, natürlichem Englisch, behalte aber ausdrücklich verlangte deutsche Texte unverändert bei.`; const raw = await generateText(provider, systemPrompt, userPrompt, model); let parsed = null; try { parsed = JSON.parse(raw); } catch { const match = raw.match(/\{[\s\S]*\}/); if (match) try { parsed = JSON.parse(match[0]); } catch {} } const improved = String(parsed?.improved_prompt || raw || '').trim().slice(0, 2000); if (!improved) throw new Error('Die AI hat keinen verbesserten Prompt zurückgegeben.'); res.json({ improved_prompt: improved, notes: String(parsed?.notes || '').trim().slice(0, 500), provider, model: model || null }); } catch (error) { res.status(500).json({ error: error.message || 'Prompt konnte nicht verbessert werden.' }); } }); app.post('/api/flux-studio/images', async (req, res) => { try { const imagePath = String(req.body?.image_path || '').trim(); if (!/^ai-generated\/[a-z0-9-]+\.(png|jpe?g|webp)$/i.test(imagePath)) { return res.status(400).json({ error: 'Ungültiger FLUX-Bildpfad.' }); } const absolute = resolve(UPLOADS_DIR, imagePath); const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); if (!absolute.startsWith(imageRoot + sep) || !existsSync(absolute)) return res.status(400).json({ error: 'Bilddatei wurde nicht gefunden.' }); const metadata = await sharp(absolute).metadata(); const item = createFluxImage({ prompt: req.body?.prompt || 'Nachbearbeitete FLUX-Ausgabe', image_path: imagePath, width: metadata.width || req.body?.width, height: metadata.height || req.body?.height, seed: req.body?.seed, steps: req.body?.steps, style: req.body?.style, edited_from: req.body?.edited_from, final_prompt: req.body?.final_prompt, profile: req.body?.profile, duration_ms: req.body?.duration_ms, favorite: req.body?.favorite, job_id: req.body?.job_id, variant_index: req.body?.variant_index, prompt_mode: req.body?.prompt_mode, parameters: req.body?.parameters, }); res.status(201).json({ ...item, public_url: `/uploads/${item.image_path}` }); } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht gespeichert werden.' }); } }); app.post('/api/flux-studio/images/:id/copy-to-generator', (req, res) => { try { const item = getFluxImage(req.params.id); if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); const source = resolve(UPLOADS_DIR, item.image_path); const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); if (!source.startsWith(imageRoot + sep) || !existsSync(source)) return res.status(404).json({ error: 'FLUX-Bilddatei wurde nicht gefunden.' }); const targetDir = resolve(UPLOADS_DIR, 'allgemein'); mkdirSync(targetDir, { recursive: true }); const extension = extname(source).toLowerCase() || '.png'; const filename = `${randomUUID()}${extension}`; copyFileSync(source, join(targetDir, filename)); res.status(201).json({ filename: `allgemein/${filename}` }); } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht in den Generator übernommen werden.' }); } }); app.patch('/api/flux-studio/images/:id/favorite', (req, res) => { const item = setFluxImageFavorite(req.params.id, req.body?.favorite !== false); if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); res.json({ ...item, public_url: `/uploads/${item.image_path}` }); }); app.delete('/api/flux-studio/images/:id', (req, res) => { try { const item = deleteFluxImage(req.params.id); if (!item) return res.status(404).json({ error: 'FLUX-Bild nicht gefunden.' }); const absolute = resolve(UPLOADS_DIR, item.image_path); const imageRoot = resolve(UPLOADS_DIR, 'ai-generated'); if (absolute.startsWith(imageRoot + sep)) { try { unlinkSync(absolute); } catch {} } res.json({ ok: true, deleted: item.id }); } catch (error) { res.status(400).json({ error: error.message || 'FLUX-Bild konnte nicht gelöscht werden.' }); } }); app.post('/api/ai-model-photos/precheck', async (req, res) => { try { const settings = getSettings(); const payload = normalizeListingPayload(req.body || {}); const result = await precheckAiModelPhotos(payload, settings); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/ai-model-photos/generate', async (req, res) => { try { const settings = getSettings(); if (String(settings.ai_model_photos_enabled || '1') !== '1') { return res.status(403).json({ error: 'AI-Model-Fotos sind deaktiviert.' }); } const payload = normalizeListingPayload(req.body || {}); const result = await generateAiModelPhotos({ ...payload, settings }); const generation = createAiModelGeneration({ listing_id: payload.listing_id || null, context: payload.listing_id ? 'listing' : 'generator', mode: result.mode || result.precheck.mode || payload.mode || 'model', preset: result.precheck.preset, variants: result.photos.length, source_photos: Array.isArray(payload.photos) ? payload.photos : [], generated_photos: result.photos.map(photo => photo.filename), prompt_summary: result.prompt_summary, provider: result.provider?.provider || null, provider_model: result.provider?.model || null, moderation_notes: result.photos.map(photo => photo.moderation?.notes).filter(Boolean).join(' | ') || null, safety_status: 'safe', status: 'completed', }); const photos = result.photos.map(photo => createAiModelPhoto({ generation_id: generation.id, listing_id: payload.listing_id || null, variant_index: photo.variant_index, mode: photo.mode || result.mode || payload.mode || 'model', preset: photo.preset, image_path: photo.filename, source_photo: photo.source_photo, prompt: photo.prompt, provider: photo.provider || result.provider?.provider || null, provider_model: photo.provider_model || result.provider?.model || null, moderation_result: JSON.stringify(photo.moderation || {}), safety_status: photo.moderation?.safe === false ? 'blocked' : 'safe', })); updateAiModelGeneration(generation.id, { generated_photos: photos.map(photo => photo.image_path), provider: result.provider?.provider || null, provider_model: result.provider?.model || null, }); res.json({ ...result, generation_id: generation.id, photos, provider: result.provider || null }); } catch (err) { if (err.code === 'AI_MODEL_PRECHECK_FAILED') { return res.status(400).json({ error: err.message, precheck: err.precheck || null }); } res.status(500).json({ error: err.message }); } }); app.get('/api/listings/:id/ai-model-photos', (req, res) => { try { res.json(getAiModelPhotosForListing(parseInt(req.params.id))); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- Generate --- app.post('/api/generate', async (req, res) => { try { const { photos, platform: platformId, ai_provider, ai_model, language, variants, template_id, seller_notes } = req.body; if (!photos?.length) return res.status(400).json({ error: 'Keine Fotos angegeben' }); const platform = platforms[platformId]; if (!platform) return res.status(400).json({ error: `Unbekannte Plattform: ${platformId}` }); const settings = getSettings(); const selectedTemplate = template_id ? getTemplate(parseInt(template_id)) : null; const notesParts = [ settings.seller_notes, selectedTemplate?.seller_notes, typeof seller_notes === 'string' ? seller_notes.slice(0, 1000) : '', ].map(value => String(value || '').trim()).filter(Boolean); const sellerNotes = [...new Set(notesParts)].join('\n\n'); const templateTags = Array.isArray(selectedTemplate?.default_tags) ? selectedTemplate.default_tags : []; const lang = language || selectedTemplate?.language || settings.default_language || 'de'; const numVariants = Math.min(parseInt(variants) || 1, 3); const mergeTags = (values) => { const sourceTags = Array.isArray(values) ? values : typeof values === 'string' ? values.split(',') : []; return [...new Set([...sourceTags, ...templateTags].map(tag => String(tag).trim()).filter(Boolean))]; }; const systemPrompt = platform.buildPrompt(sellerNotes, { language: lang, variants: numVariants }); const images = await prepareForAI(photos); const result = await generate(ai_provider || settings.default_ai, images, systemPrompt, ai_model); const meta = { brand: result.brand || null, category: result.category || null, size: result.size || null, color: result.color || null, condition: result.condition || null, suggested_price: result.suggested_price || null, }; const baseData = { platform: platformId, ai_provider: ai_provider || settings.default_ai, photos, seller_notes: sellerNotes, language: lang, }; if (numVariants > 1 && result.variants) { const variantsOut = result.variants.map(v => { const item = { ...baseData, title: v.title || '', description: v.description || '', tags: mergeTags(v.tags || v.hashtags || []), ...meta, price: meta.suggested_price }; item.description_html = wrapInTemplate(item, platformId); return item; }); const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; return res.json({ variants: variantsOut, meta, fees }); } const listing = { ...baseData, title: result.title || '', description: result.description || '', tags: mergeTags(result.tags || result.hashtags || []), ...meta, price: meta.suggested_price }; const descHtml = wrapInTemplate(listing, platformId); const fees = meta.suggested_price ? calculateFees(platformId, meta.suggested_price) : null; res.json({ listing, meta, fees, description_html: descHtml }); } catch (err) { console.error('Generate error:', err); res.status(500).json({ error: err.message }); } }); // --- Fee calculator --- app.get('/api/fees', (req, res) => { const price = parseFloat(req.query.price) || 0; const platform = req.query.platform; if (platform) return res.json(calculateFees(platform, price)); res.json(getAllFees(price)); }); // --- Browser extension center --- const EXTENSION_VERSION = '1.9.0'; const EXTENSION_TARGETS = { chrome: { folder: join(__dirname, 'extensions', 'chrome'), package: `vendoo-link-chrome-${EXTENSION_VERSION}.zip` }, edge: { folder: join(__dirname, 'extensions', 'edge'), package: `vendoo-link-edge-${EXTENSION_VERSION}.zip` }, firefox: { folder: join(__dirname, 'extensions', 'firefox'), package: `vendoo-link-firefox-${EXTENSION_VERSION}.zip` }, safari: { folder: join(__dirname, 'extensions', 'safari'), package: `vendoo-link-safari-${EXTENSION_VERSION}.zip` }, }; function openLocalFolder(folderPath) { try { const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'; const child = spawn(command, [folderPath], { detached: true, stdio: 'ignore', windowsHide: true }); child.unref(); return true; } catch { return false; } } app.get('/api/extensions/info', (_req, res) => { const browsers = {}; for (const [id, target] of Object.entries(EXTENSION_TARGETS)) { browsers[id] = { path: target.folder, available: existsSync(target.folder), package: target.package }; } res.json({ version: EXTENSION_VERSION, platform: process.platform, browsers }); }); app.post('/api/extensions/prepare', (req, res) => { const browser = String(req.body?.browser || '').toLowerCase(); const target = EXTENSION_TARGETS[browser]; if (!target) return res.status(400).json({ error: 'Unbekannter Browser' }); if (!existsSync(target.folder)) return res.status(404).json({ error: 'Extension-Ordner fehlt im Paket' }); res.json({ ok: true, browser, path: target.folder, opened: openLocalFolder(target.folder), version: EXTENSION_VERSION }); }); app.get('/api/extensions/download/:browser', (req, res) => { const browser = String(req.params.browser || '').toLowerCase(); const target = EXTENSION_TARGETS[browser]; if (!target) return res.status(404).json({ error: 'Unbekannter Browser' }); const packagePath = join(__dirname, 'extensions', 'packages', target.package); if (!existsSync(packagePath)) return res.status(404).json({ error: 'Extension-Paket fehlt' }); res.download(packagePath, target.package); }); // --- Article Quality Center --- app.get('/api/quality-center', (req, res) => { try { res.json(listQualityCenter({ query: req.query.q || '', status: req.query.status || 'all', grade: req.query.grade || 'all', page: req.query.page || 1, pageSize: req.query.page_size || 10, })); } catch (error) { res.status(500).json({ error: error.message || 'Quality Center konnte nicht geladen werden.' }); } }); app.get('/api/quality-center/:id', async (req, res) => { try { const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: false }); if (!report) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); report.history = getQualityHistory(req.params.id); report.comparison = compareQualityReports(req.params.id); res.json(report); } catch (error) { res.status(500).json({ error: error.message || 'Artikelqualität konnte nicht analysiert werden.' }); } }); app.post('/api/quality-center/:id/analyze', async (req, res) => { try { const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: true }); if (!report) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); report.history = getQualityHistory(req.params.id); report.comparison = compareQualityReports(req.params.id); if (req.user) auditLog(req.user.id, req.user.email, 'quality.analyzed', 'listing', String(req.params.id), `Score ${report.score}`, getClientIp(req)); res.json(report); } catch (error) { res.status(500).json({ error: error.message || 'Qualitätsanalyse fehlgeschlagen.' }); } }); app.post('/api/quality-center/:id/ai-suggestions', async (req, res) => { try { const listing = getListing(req.params.id); if (!listing) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); const fields = Array.isArray(req.body?.fields) ? req.body.fields.filter(field => ['title','description','tags'].includes(field)) : ['title','description','tags']; if (!fields.length) return res.status(400).json({ error: 'Keine verbesserbaren Felder gewählt.' }); const settings = getSettings(); const provider = String(req.body?.provider || settings.default_ai || 'openrouter').trim(); const model = String(req.body?.model || '').trim() || undefined; const mode = ['balanced','concise','sales','seo'].includes(req.body?.mode) ? req.body.mode : 'balanced'; const fieldContract = { title: fields.includes('title'), description: fields.includes('description'), tags: fields.includes('tags'), }; const systemPrompt = `Du bist das Vendoo Artikel Quality Center. Verbessere ausschließlich die ausdrücklich gewählten Felder eines bestehenden Gebrauchtartikel-Angebots. Erfinde keine Marke, Größe, Farbe, technischen Daten, Schäden, Echtheit, Lieferumfang oder Produkteigenschaften. Bestehende Tatsachen müssen erhalten bleiben. Profil: ${mode}. Plattform: ${listing.platform || 'allgemein'}. Gewählte Felder: ${fields.join(', ')}. Gib ausschließlich valides JSON aus: {"title":null oder "...","description":null oder "...","tags":null oder ["..."],"reasons":["..."]}. Nicht gewählte Felder müssen null sein.`; const userPrompt = JSON.stringify({ title: listing.title || '', description: listing.description || '', tags: listing.tags || [], brand: listing.brand || '', category: listing.category || '', size: listing.size || '', color: listing.color || '', condition: listing.condition || '', seller_notes: listing.seller_notes || '', constraints: fieldContract, }, null, 2); const raw = await generateText(provider, systemPrompt, userPrompt, model); let parsed = null; try { parsed = JSON.parse(raw); } catch { const match = String(raw || '').match(/\{[\s\S]*\}/); if (match) try { parsed = JSON.parse(match[0]); } catch {} } if (!parsed || typeof parsed !== 'object') throw new Error('Die AI hat keinen gültigen Verbesserungsvorschlag geliefert.'); const suggestions = { title: fields.includes('title') ? String(parsed.title || '').trim().slice(0, 180) : null, description: fields.includes('description') ? String(parsed.description || '').trim().slice(0, 6000) : null, tags: fields.includes('tags') ? [...new Set((Array.isArray(parsed.tags) ? parsed.tags : []).map(value => String(value).trim()).filter(Boolean))].slice(0, 20) : null, reasons: (Array.isArray(parsed.reasons) ? parsed.reasons : []).map(value => String(value).trim()).filter(Boolean).slice(0, 8), }; res.json({ listing_id: listing.id, provider, model: model || null, mode, original: { title: listing.title, description: listing.description, tags: listing.tags }, suggestions }); } catch (error) { res.status(500).json({ error: error.message || 'AI-Verbesserung konnte nicht erstellt werden.' }); } }); app.post('/api/quality-center/:id/apply-suggestions', async (req, res) => { try { const current = getListing(req.params.id); if (!current) return res.status(404).json({ error: 'Artikel nicht gefunden.' }); const patch = {}; if (req.body?.title !== undefined) patch.title = String(req.body.title || '').trim().slice(0, 180); if (req.body?.description !== undefined) { patch.description = String(req.body.description || '').trim().slice(0, 6000); patch.description_html = sanitizeRichHtml(plainTextToSafeHtml(patch.description)); } if (req.body?.tags !== undefined) patch.tags = Array.isArray(req.body.tags) ? req.body.tags.map(value => String(value).trim()).filter(Boolean).slice(0, 20) : current.tags; if (!Object.keys(patch).length) return res.status(400).json({ error: 'Keine bestätigten Änderungen vorhanden.' }); const updated = updateListing(req.params.id, normalizeListingPayload(patch)); const report = await analyzeListingQuality(req.params.id, { uploadRoot: resolve(UPLOADS_DIR), persist: true }); if (req.user) auditLog(req.user.id, req.user.email, 'quality.ai_applied', 'listing', String(req.params.id), Object.keys(patch).join(', '), getClientIp(req)); res.json({ listing: updated, report }); } catch (error) { res.status(500).json({ error: error.message || 'Verbesserungen konnten nicht übernommen werden.' }); } }); // --- Listings CRUD --- app.post('/api/listings', (req, res) => { try { const listing = createListing(normalizeListingPayload({ ...req.body, created_by: req.user?.id || null, updated_by: req.user?.id || null })); if (req.user) auditLog(req.user.id, req.user.email, 'listing.created', 'listing', String(listing.id), listing.title, getClientIp(req)); res.json(listing); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/listings', (req, res) => { res.json(getListings(req.query.q, req.query.platform, req.query.status)); }); app.get('/api/listings/:id', (req, res) => { const listing = getListing(req.params.id); if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); res.json(listing); }); app.put('/api/listings/:id', (req, res) => { const lockValidation = validateResourceLock({ resourceType: 'listing', resourceId: req.params.id, userId: req.user.id, token: req.get('x-edit-lock') || req.body?._edit_lock }); if (!lockValidation.valid) return res.status(409).json({ error: `${lockValidation.lock?.user_name || lockValidation.lock?.user_email || 'Ein anderer Benutzer'} bearbeitet diesen Artikel gerade.`, code: 'EDIT_LOCKED', lock: lockValidation.lock }); const payload = { ...req.body }; delete payload._edit_lock; const listing = updateListing(req.params.id, normalizeListingPayload({ ...payload, updated_by: req.user?.id || null })); if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); if (req.user) auditLog(req.user.id, req.user.email, 'listing.updated', 'listing', req.params.id, listing.title, getClientIp(req)); res.json(listing); }); app.delete('/api/listings/:id', (req, res) => { if (req.user) auditLog(req.user.id, req.user.email, 'listing.soft_deleted', 'listing', req.params.id, null, getClientIp(req)); softDeleteListing(req.params.id); res.json({ ok: true }); }); // --- Duplicate --- app.post('/api/listings/:id/duplicate', (req, res) => { const copy = duplicateListing(req.params.id); if (!copy) return res.status(404).json({ error: 'Artikel nicht gefunden' }); if (req.user) auditLog(req.user.id, req.user.email, 'listing.duplicated', 'listing', String(copy.id), copy.title, getClientIp(req)); res.json(copy); }); // --- Trash --- app.get('/api/trash', (_req, res) => { res.json(getDeletedListings()); }); app.get('/api/trash/count', (_req, res) => { res.json({ count: getTrashCount() }); }); app.post('/api/trash/:id/restore', (req, res) => { restoreListing(req.params.id); if (req.user) auditLog(req.user.id, req.user.email, 'listing.restored', 'listing', req.params.id, null, getClientIp(req)); res.json({ ok: true }); }); app.delete('/api/trash/:id', (req, res) => { permanentlyDeleteListing(req.params.id); if (req.user) auditLog(req.user.id, req.user.email, 'listing.permanently_deleted', 'listing', req.params.id, null, getClientIp(req)); res.json({ ok: true }); }); app.delete('/api/trash', (req, res) => { const deleted = getDeletedListings(); for (const l of deleted) permanentlyDeleteListing(l.id); if (req.user) auditLog(req.user.id, req.user.email, 'trash.emptied', null, null, `${deleted.length} Einträge`, getClientIp(req)); res.json({ ok: true, deleted: deleted.length }); }); // --- Templates CRUD --- app.get('/api/templates', (_req, res) => res.json(getTemplates())); app.post('/api/templates', (req, res) => res.json(createTemplate(req.body))); app.put('/api/templates/:id', (req, res) => { const t = updateTemplate(req.params.id, req.body); if (!t) return res.status(404).json({ error: 'Nicht gefunden' }); res.json(t); }); app.delete('/api/templates/:id', (req, res) => { deleteTemplate(req.params.id); res.json({ ok: true }); }); // --- Inventory / Storage --- app.get('/api/inventory/locations', (_req, res) => res.json(getStorageLocations())); app.get('/api/inventory/unassigned', (_req, res) => res.json(getUnassignedListings())); app.get('/api/inventory/location/:loc', (req, res) => res.json(getListingsByLocation(req.params.loc))); app.put('/api/inventory/bulk-location', (req, res) => { const { ids, location } = req.body; if (!ids?.length) return res.status(400).json({ error: 'ids required' }); const count = bulkUpdateLocation(ids, location || ''); res.json({ ok: true, updated: count }); }); // --- CSV Export --- app.get('/api/export/csv', (req, res) => { const listings = getListingsForExport(req.query.platform, req.query.status); const headers = ['ID', 'Plattform', 'Status', 'Titel', 'Beschreibung', 'Tags', 'Marke', 'Kategorie', 'Groesse', 'Farbe', 'Zustand', 'Preis', 'Erstellt']; const escape = v => `"${String(v || '').replace(/"/g, '""')}"`; const rows = listings.map(l => [ l.id, l.platform, l.status || 'active', l.title, l.description, (l.tags || []).join('; '), l.brand, l.category, l.size, l.color, l.condition, l.price, l.created_at, ].map(escape).join(',')); const csv = [headers.join(','), ...rows].join('\n'); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', 'attachment; filename=vendoo-export.csv'); res.send('' + csv); }); // --- Settings --- app.get('/api/settings', (_req, res) => { const settings = getSettings(); settings.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; settings.has_openai_key = !!process.env.OPENAI_API_KEY; settings.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; settings.has_etsy_key = !!process.env.ETSY_API_KEY; settings.etsy_connected = etsyApi.isConnected(); settings.has_ebay_credentials = ebayApi.hasCredentials(); settings.ebay_connected = ebayApi.isConnected(); settings.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; settings.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; settings.public_base_url = process.env.PUBLIC_BASE_URL || ''; settings.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || settings.ai_model_photo_local_url || 'http://127.0.0.1:8188'; settings.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; settings.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || settings.ai_model_photo_local_install_path || 'local-ai/comfyui'; settings.ai_model_photo_local_enabled = settings.ai_model_photo_local_enabled || '1'; settings.ai_model_photo_local_require_token = settings.ai_model_photo_local_require_token || '1'; settings.ai_model_photo_use_source_reference = settings.ai_model_photo_use_source_reference || '1'; settings.ai_model_photo_reference_strength = settings.ai_model_photo_reference_strength || '70'; settings.ai_model_photo_local_resolution = settings.ai_model_photo_local_resolution || '768'; settings.ai_model_photo_preserve_logos = settings.ai_model_photo_preserve_logos || '1'; settings.flux_runtime_profile = settings.flux_runtime_profile || 'auto'; settings.flux_default_profile = settings.flux_default_profile || 'balanced'; settings.flux_prompt_assistant = settings.flux_prompt_assistant || '1'; settings.flux_default_style = settings.flux_default_style || 'photorealistic'; settings.flux_default_format = settings.flux_default_format || '768x768'; settings.flux_default_variants = settings.flux_default_variants || '1'; settings.flux_seed_lock = settings.flux_seed_lock || '0'; settings.flux_max_parallel_jobs = settings.flux_max_parallel_jobs || '1'; settings.local_image_token_set = !!process.env.LOCAL_IMAGE_TOKEN; res.json(settings); }); app.put('/api/settings', requireRole('admin'), (req, res) => { const { anthropic_key, openai_key, openrouter_key, etsy_key, ebay_client_id, ebay_client_secret, ebay_ru_name, ebay_sandbox, ollama_url, public_base_url, ai_model_photo_local_token, ai_model_photo_local_url, ai_model_photo_local_workflow_path, ai_model_photo_local_install_path, ...dbSettings } = req.body; let envContent = readRuntimeConfig(); if (anthropic_key !== undefined) { envContent = setConfigValue(envContent, 'ANTHROPIC_API_KEY', anthropic_key); process.env.ANTHROPIC_API_KEY = anthropic_key; } if (openai_key !== undefined) { envContent = setConfigValue(envContent, 'OPENAI_API_KEY', openai_key); process.env.OPENAI_API_KEY = openai_key; } if (openrouter_key !== undefined) { envContent = setConfigValue(envContent, 'OPENROUTER_API_KEY', openrouter_key); process.env.OPENROUTER_API_KEY = openrouter_key; } if (etsy_key !== undefined) { envContent = setConfigValue(envContent, 'ETSY_API_KEY', etsy_key); process.env.ETSY_API_KEY = etsy_key; } if (ebay_client_id !== undefined) { envContent = setConfigValue(envContent, 'EBAY_CLIENT_ID', ebay_client_id); process.env.EBAY_CLIENT_ID = ebay_client_id; } if (ebay_client_secret !== undefined) { envContent = setConfigValue(envContent, 'EBAY_CLIENT_SECRET', ebay_client_secret); process.env.EBAY_CLIENT_SECRET = ebay_client_secret; } if (ebay_ru_name !== undefined) { envContent = setConfigValue(envContent, 'EBAY_RU_NAME', ebay_ru_name); process.env.EBAY_RU_NAME = ebay_ru_name; } if (ebay_sandbox !== undefined) { envContent = setConfigValue(envContent, 'EBAY_SANDBOX', ebay_sandbox); process.env.EBAY_SANDBOX = ebay_sandbox; } if (ollama_url !== undefined) { envContent = setConfigValue(envContent, 'OLLAMA_URL', ollama_url); process.env.OLLAMA_URL = ollama_url; } if (ai_model_photo_local_token !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_TOKEN', ai_model_photo_local_token); process.env.LOCAL_IMAGE_TOKEN = ai_model_photo_local_token; } if (ai_model_photo_local_url !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_URL', String(ai_model_photo_local_url || '').trim().replace(/\/$/, '')); process.env.LOCAL_IMAGE_URL = String(ai_model_photo_local_url || '').trim().replace(/\/$/, ''); } if (ai_model_photo_local_workflow_path !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_WORKFLOW_PATH', ai_model_photo_local_workflow_path); process.env.LOCAL_IMAGE_WORKFLOW_PATH = ai_model_photo_local_workflow_path; } if (ai_model_photo_local_install_path !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_INSTALL_PATH', ai_model_photo_local_install_path); process.env.LOCAL_IMAGE_INSTALL_PATH = ai_model_photo_local_install_path; } if (dbSettings.ai_model_photo_local_enabled !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_ENABLED', String(dbSettings.ai_model_photo_local_enabled) === '0' ? 'false' : 'true'); process.env.LOCAL_IMAGE_ENABLED = String(dbSettings.ai_model_photo_local_enabled) === '0' ? 'false' : 'true'; } if (dbSettings.ai_model_photo_local_require_token !== undefined) { envContent = setConfigValue(envContent, 'LOCAL_IMAGE_REQUIRE_TOKEN', String(dbSettings.ai_model_photo_local_require_token) === '0' ? 'false' : 'true'); process.env.LOCAL_IMAGE_REQUIRE_TOKEN = String(dbSettings.ai_model_photo_local_require_token) === '0' ? 'false' : 'true'; } if (public_base_url !== undefined) { const cleanPublicBaseUrl = String(public_base_url || '').trim().replace(/\/$/, ''); envContent = setConfigValue(envContent, 'PUBLIC_BASE_URL', cleanPublicBaseUrl); process.env.PUBLIC_BASE_URL = cleanPublicBaseUrl; } writeRuntimeConfig(envContent); if (req.user) auditLog(req.user.id, req.user.email, 'settings.updated', null, null, null, getClientIp(req)); const result = updateSettings(dbSettings); result.has_anthropic_key = !!process.env.ANTHROPIC_API_KEY; result.has_openai_key = !!process.env.OPENAI_API_KEY; result.has_openrouter_key = !!process.env.OPENROUTER_API_KEY; result.has_etsy_key = !!process.env.ETSY_API_KEY; result.etsy_connected = etsyApi.isConnected(); result.has_ebay_credentials = ebayApi.hasCredentials(); result.ebay_connected = ebayApi.isConnected(); result.ebay_sandbox = process.env.EBAY_SANDBOX === 'true'; result.ollama_url = process.env.OLLAMA_URL || 'http://localhost:11434'; result.public_base_url = process.env.PUBLIC_BASE_URL || ''; result.ai_model_photo_local_url = process.env.LOCAL_IMAGE_URL || result.ai_model_photo_local_url || 'http://127.0.0.1:8188'; result.ai_model_photo_local_workflow_path = process.env.LOCAL_IMAGE_WORKFLOW_PATH || result.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json'; result.ai_model_photo_local_install_path = process.env.LOCAL_IMAGE_INSTALL_PATH || result.ai_model_photo_local_install_path || 'local-ai/comfyui'; result.local_image_token_set = !!process.env.LOCAL_IMAGE_TOKEN; res.json(result); }); // --- HTML Templates --- app.get('/api/html-templates', (_req, res) => res.json(getHtmlTemplates())); app.post('/api/html-wrap', (req, res) => { const { listing_id, platform } = req.body; const listing = getListing(listing_id); if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); const html = wrapInTemplate(listing, platform || listing.platform); res.json({ html }); }); // Live rendering for the generator and listing editor. No data is persisted here. app.post('/api/html-render', (req, res) => { try { const listing = normalizeListingPayload(req.body?.listing || req.body || {}); const platform = req.body?.platform || listing.platform || 'vinted'; res.json({ html: wrapInTemplate(listing, platform) }); } catch (err) { res.status(400).json({ error: err.message || 'HTML konnte nicht gerendert werden' }); } }); // --- Publishing Hardening 1.29.0 --- app.get('/api/publishing/capabilities', (_req, res) => { res.json({ ...getPublishCapabilities(), connections: { ebay: { connected: ebayApi.isConnected(), configured: ebayApi.hasCredentials(), sandbox: process.env.EBAY_SANDBOX === 'true' }, etsy: { connected: etsyApi.isConnected(), configured: Boolean(process.env.ETSY_API_KEY) }, extension: { version: '1.9.0', platforms: ['vinted', 'ebay-ka'] }, }, }); }); app.get('/api/publishing/jobs', (req, res) => { res.json(paginatePublishingJobs({ page: req.query.page, pageSize: req.query.page_size, status: req.query.status || '', platform: req.query.platform || '', q: req.query.q || '', })); }); app.get('/api/publishing/jobs/:id', (req, res) => { const job = getPublishingJob(req.params.id); if (!job) return res.status(404).json({ error: 'Publishing-Auftrag nicht gefunden' }); res.json(job); }); app.post('/api/publishing/jobs', (req, res) => { try { const listingIds = Array.isArray(req.body?.listing_ids) ? req.body.listing_ids : [req.body?.listing_id]; const platform = req.body?.platform || null; const action = req.body?.action || 'publish'; const results = []; for (const listingId of listingIds.filter(Boolean)) { results.push(enqueuePublishingJob({ listingId, platform, action, force: Boolean(req.body?.force), payload: req.body?.payload || {} })); } res.status(results.some(item => !item.duplicate) ? 201 : 200).json({ jobs: results.map(item => item.job), duplicates: results.filter(item => item.duplicate).length }); } catch (error) { const status = error.code === 'DUPLICATE_PUBLISH' ? 409 : 400; res.status(status).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); } }); app.post('/api/publishing/jobs/:id/retry', (req, res) => { try { res.json(retryPublishingJob(req.params.id)); } catch (error) { res.status(400).json({ error: error.message }); } }); app.post('/api/publishing/jobs/:id/cancel', (req, res) => { try { res.json(cancelPublishingJob(req.params.id)); } catch (error) { res.status(400).json({ error: error.message }); } }); app.post('/api/publishing/jobs/:id/action', (req, res) => { try { const source = getPublishingJob(req.params.id); if (!source) return res.status(404).json({ error: 'Auftrag nicht gefunden' }); const action = req.body?.action; if (!['sync', 'update', 'end'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion' }); const result = enqueuePublishingJob({ listingId: source.listing_id, platform: source.platform, action, force: true, payload: { offerId: source.offer_id, externalId: source.external_id }, }); res.status(201).json(result.job); } catch (error) { res.status(400).json({ error: error.message }); } }); app.delete('/api/publishing/jobs', (req, res) => { try { const ids = Array.isArray(req.body?.ids) ? req.body.ids : String(req.query.ids || '').split(',').filter(Boolean); res.json({ deleted: deletePublishingJobs(ids) }); } catch (error) { res.status(400).json({ error: error.message }); } }); app.get('/api/publishing/events', (req, res) => { res.json(listPublishingEvents({ limit: req.query.limit, level: req.query.level || '', platform: req.query.platform || '' })); }); app.post('/api/extension/publishing/claim', (req, res) => { const job = claimExtensionJob({ platform: req.body?.platform || 'vinted', clientId: req.body?.client_id || 'vendoo-link' }); if (!job) return res.status(204).end(); res.json(job); }); app.post('/api/extension/publishing/:id/complete', (req, res) => { try { res.json(completeExtensionJob(req.params.id, req.body || {})); } catch (error) { res.status(400).json({ error: error.message }); } }); // --- Publishing --- const PUBLISH_URLS = { vinted: 'https://www.vinted.de/items/new', 'ebay-ka': 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', 'ebay-de': 'https://www.ebay.de/sell/create', }; app.get('/api/publish/info/:id', (req, res) => { const listing = getListing(req.params.id); if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); const logs = getPublishLogs(listing.id); const platform = listing.platform; const publishUrl = PUBLISH_URLS[platform] || null; const canApiPublish = (platform === 'etsy' && etsyApi.isConnected()) || (platform === 'ebay-de' && ebayApi.isConnected()); res.json({ listing, logs, publishUrl, canApiPublish }); }); app.post('/api/publish/smart-copy', (req, res) => { const { listing_id, platform } = req.body; const listing = getListing(listing_id); if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); const targetPlatform = platform || listing.platform; const publishUrl = PUBLISH_URLS[targetPlatform] || null; createPublishLog(listing_id, targetPlatform, 'copied'); const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : listing.tags; res.json({ publishUrl, fields: { title: listing.title, description: listing.description, tags, price: listing.price || '', brand: listing.brand || '', category: listing.category || '', size: listing.size || '', color: listing.color || '', condition: listing.condition || '', }, photos: listing.photos, }); }); app.post('/api/publish/mark', (req, res) => { const { listing_id, platform, status, external_id, external_url } = req.body; const target = platform || 'unknown'; const activeJob = db.prepare(`SELECT id FROM publishing_jobs WHERE listing_id = ? AND platform = ? AND mode = 'extension' AND status IN ('awaiting_user','running') ORDER BY created_at LIMIT 1`).get(listing_id, target); if (activeJob) { const job = completeExtensionJob(activeJob.id, { status: status || 'published', externalId: external_id || null, externalUrl: external_url || null }); return res.json({ ok: true, job }); } createPublishLog(listing_id, target, status || 'published', external_id || null, external_url || null); res.json({ ok: true, job: null }); }); app.get('/api/publish/status', (req, res) => { const ids = (req.query.ids || '').split(',').map(Number).filter(Boolean); res.json(getPublishStatus(ids)); }); app.post('/api/publish/batch-photos', async (req, res) => { const { listing_ids } = req.body; if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Artikel' }); const allPhotos = []; for (const id of listing_ids) { const listing = getListing(id); if (listing?.photos) { for (const p of listing.photos) allPhotos.push(p); } } res.json({ photos: allPhotos }); }); app.post('/api/photos/archive', (req, res) => { const requested = Array.isArray(req.body?.photos) ? req.body.photos : []; const listingIds = Array.isArray(req.body?.listing_ids) ? req.body.listing_ids : []; const photos = [...requested]; for (const id of listingIds) { const listing = getListing(Number(id)); if (listing?.photos?.length) photos.push(...listing.photos); } const unique = [...new Set(photos.map(photo => String(photo || '').replace(/\\/g, '/')).filter(Boolean))]; if (unique.length < 2) return res.status(400).json({ error: 'Für ein ZIP werden mindestens zwei Fotos benötigt' }); const uploadRoot = resolve(UPLOADS_DIR); const files = []; for (const [index, relative] of unique.entries()) { const absolute = resolve(uploadRoot, relative); if (absolute !== uploadRoot && !absolute.startsWith(uploadRoot + sep)) continue; if (!existsSync(absolute) || !statSync(absolute).isFile()) continue; const originalName = basename(relative) || `foto-${index + 1}.jpg`; const prefix = String(index + 1).padStart(2, '0'); files.push({ name: `${prefix}-${originalName}`, data: readFileSync(absolute) }); } if (files.length < 2) return res.status(404).json({ error: 'Nicht genügend Fotodateien gefunden' }); const archive = createZip(files); const date = new Date().toISOString().slice(0, 10); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename=vendoo-fotos-${date}.zip`); res.setHeader('Content-Length', archive.length); res.send(archive); }); // --- Etsy OAuth --- app.get('/auth/etsy', (req, res) => { try { const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; const url = etsyApi.getAuthUrl(redirectUri); res.redirect(url); } catch (err) { res.status(400).send(`Fehler: ${err.message}. Bitte ETSY_API_KEY in Einstellungen setzen.`); } }); app.get('/auth/etsy/callback', async (req, res) => { try { const { code, state } = req.query; const redirectUri = `http://localhost:${PORT}/auth/etsy/callback`; await etsyApi.handleCallback(code, state, redirectUri); res.send('

Etsy verbunden!

Du kannst dieses Fenster schließen.

'); } catch (err) { res.status(500).send(`OAuth Fehler: ${err.message}`); } }); app.get('/api/etsy/status', (_req, res) => { res.json({ connected: etsyApi.isConnected(), hasApiKey: !!process.env.ETSY_API_KEY }); }); app.post('/api/publish/etsy', (req, res) => { try { const result = enqueuePublishingJob({ listingId: req.body?.listing_id, platform: 'etsy', action: 'publish', force: Boolean(req.body?.force) }); res.status(result.duplicate ? 200 : 202).json({ ok: true, queued: true, duplicate: result.duplicate, job: result.job }); } catch (error) { res.status(error.code === 'DUPLICATE_PUBLISH' ? 409 : 400).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); } }); // --- Extension Queue --- app.get('/api/extension/queue', (req, res) => { const platform = req.query.platform || 'vinted'; const jobRows = db.prepare(`SELECT j.id AS publishing_job_id, j.status AS publishing_job_status, l.* FROM publishing_jobs j JOIN listings l ON l.id = j.listing_id WHERE j.platform = ? AND j.mode = 'extension' AND j.status = 'awaiting_user' ORDER BY j.created_at`).all(platform).map(row => { try { row.photos = JSON.parse(row.photos || '[]'); } catch { row.photos = []; } try { row.tags = JSON.parse(row.tags || '[]'); } catch { row.tags = []; } return row; }); const all = getListings(null, platform, 'active'); const queuedIds = new Set(jobRows.map(row => row.id)); const ids = all.map(l => l.id); const pubStatus = getPublishStatus(ids); const fallback = all.filter(l => { if (queuedIds.has(l.id)) return false; const ps = pubStatus[l.id]; return !ps || !ps[platform] || ps[platform] === 'pending' || ps[platform] === 'copied'; }); const published = all.filter(l => pubStatus[l.id]?.[platform] === 'published'); res.json({ queue: [...jobRows, ...fallback], published, total: all.length, hardened_jobs: jobRows.length }); }); app.get('/api/extension/listing/:id/photos', (req, res) => { const listing = getListing(req.params.id); if (!listing) return res.status(404).json({ error: 'Nicht gefunden' }); const baseUrl = resolvePublicBaseUrl(req); const photoUrls = (listing.photos || []).map(filename => `${baseUrl}/uploads/${String(filename).split('/').map(encodeURIComponent).join('/')}`); res.json({ photos: photoUrls, listing }); }); // --- eBay OAuth --- app.get('/auth/ebay', (req, res) => { try { const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; const url = ebayApi.getOAuthUrl(redirectUri); res.redirect(url); } catch (err) { res.status(400).send(`Fehler: ${err.message}. Bitte eBay Credentials in Einstellungen setzen.`); } }); app.get('/auth/ebay/callback', async (req, res) => { try { const { code, state } = req.query; const redirectUri = `http://localhost:${PORT}/auth/ebay/callback`; await ebayApi.handleCallback(code, state, redirectUri); res.send('

eBay verbunden!

Du kannst dieses Fenster schließen.

'); } catch (err) { res.status(500).send(`OAuth Fehler: ${err.message}`); } }); app.get('/api/ebay/status', (_req, res) => { res.json({ connected: ebayApi.isConnected(), hasCredentials: ebayApi.hasCredentials(), sandbox: process.env.EBAY_SANDBOX === 'true', }); }); app.get('/api/ebay/policies', async (_req, res) => { try { if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); const policies = await ebayApi.getPolicies(); res.json(policies); } catch (err) { res.status(500).json({ error: err.message }); } }); // --- eBay Queue --- app.get('/api/ebay/queue', (_req, res) => { res.json(getEbayQueue()); }); app.post('/api/ebay/queue', (req, res) => { const { listing_ids } = req.body; if (!listing_ids?.length) return res.status(400).json({ error: 'Keine Artikel' }); const added = addToEbayQueue(listing_ids); res.json({ added: added.length, queue: getEbayQueue() }); }); app.delete('/api/ebay/queue/:id', (req, res) => { removeFromEbayQueue(req.params.id); res.json({ ok: true }); }); app.delete('/api/ebay/queue', (req, res) => { clearEbayQueue(req.query.status || null); res.json({ ok: true }); }); app.post('/api/ebay/publish/:id', (req, res) => { try { const result = enqueuePublishingJob({ listingId: req.params.id, platform: 'ebay-de', action: 'publish', force: Boolean(req.body?.force) }); res.status(result.duplicate ? 200 : 202).json({ ok: true, queued: true, duplicate: result.duplicate, job: result.job }); } catch (error) { res.status(error.code === 'DUPLICATE_PUBLISH' ? 409 : 400).json({ error: error.message, code: error.code || 'PUBLISH_JOB_ERROR', existing: error.existing || null }); } }); app.post('/api/ebay/queue/process', async (_req, res) => { if (!ebayApi.isConnected()) return res.status(400).json({ error: 'Nicht mit eBay verbunden' }); const pending = getEbayQueue('pending'); const results = []; for (const item of pending) { updateEbayQueue(item.id, { status: 'uploading' }); try { const listing = getListing(item.listing_id); if (!listing) { updateEbayQueue(item.id, { status: 'failed', error: 'Listing nicht gefunden' }); continue; } const sku = listing.sku || `AL-${listing.id}`; const result = await ebayApi.createAndPublish(listing, sku); updateEbayQueue(item.id, { status: 'published', ebay_item_id: result.listingId, ebay_url: result.url }); createPublishLog(listing.id, 'ebay-de', 'published', result.listingId, result.url); results.push({ id: item.id, listing_id: item.listing_id, status: 'published', url: result.url }); } catch (err) { console.error(`eBay queue item ${item.id} failed:`, err); updateEbayQueue(item.id, { status: 'failed', error: err.message, retries: (item.retries || 0) + 1 }); results.push({ id: item.id, listing_id: item.listing_id, status: 'failed', error: err.message }); } } res.json({ processed: results.length, results, queue: getEbayQueue() }); }); app.post('/api/ebay/queue/retry', (_req, res) => { const failed = getEbayQueue('failed'); for (const item of failed) { updateEbayQueue(item.id, { status: 'pending', error: null }); } res.json({ retried: failed.length, queue: getEbayQueue() }); }); // --- Categories --- app.get('/api/categories/:platform', (req, res) => { const tree = getCategories(req.params.platform); const flat = getCategoriesFlat(req.params.platform); res.json({ tree, flat }); }); app.get('/api/categories/:platform/match', (req, res) => { const q = req.query.q || ''; res.json(matchCategories(req.params.platform, q)); }); // --- Studio Flow Dashboard --- app.get('/api/dashboard/workflow', (req, res) => { const requestedLimit = Number.parseInt(req.query.limit, 10); const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(requestedLimit, 1), 12) : 3; res.json(getDashboardWorkflow(limit)); }); app.get('/api/dashboard/analytics', (req, res) => { const days = Number.parseInt(req.query.days, 10) || 30; res.json(getDashboardAnalytics(days)); }); // --- Dashboard Stats --- app.get('/api/dashboard/stats', (_req, res) => { const listings = getListings(); const total = listings.length; const active = listings.filter(l => l.status === 'active').length; const sold = listings.filter(l => l.status === 'sold').length; const reserved = listings.filter(l => l.status === 'reserved').length; const totalValue = listings.reduce((s, l) => s + (l.price || 0), 0); const soldValue = listings.filter(l => l.status === 'sold').reduce((s, l) => s + (l.price || 0), 0); const platforms = {}; for (const l of listings) { platforms[l.platform] = (platforms[l.platform] || 0) + 1; } const now = new Date(); const timeline = []; for (let i = 6; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); const dateStr = d.toISOString().slice(0, 10); const count = listings.filter(l => l.created_at && l.created_at.startsWith(dateStr)).length; timeline.push({ date: dateStr, label: d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit' }), count }); } const recent = listings.slice(0, 5).map(l => ({ id: l.id, title: l.title, platform: l.platform, price: l.price, sku: l.sku, status: l.status, photo: l.photos?.[0] || null, created_at: l.created_at, })); res.json({ total, active, sold, reserved, totalValue, soldValue, platforms, timeline, recent }); }); // --- Operations Center 1.30.0: Backup, Restore, Updates & Extension diagnostics --- app.get('/api/operations/status', requireRole('admin'), (_req, res) => { try { res.json(getOperationsStatus()); } catch (error) { res.status(500).json({ error: error.message || 'Operationsstatus konnte nicht geladen werden.' }); } }); app.get('/api/operations/settings', requireRole('admin'), (_req, res) => { try { res.json(getOperationsStatus().settings); } catch (error) { res.status(500).json({ error: error.message || 'Betriebseinstellungen konnten nicht geladen werden.' }); } }); app.put('/api/operations/settings', requireRole('admin'), (req, res) => { try { const settings = updateOperationSettings(req.body || {}); if (req.user) auditLog(req.user.id, req.user.email, 'operations.settings.updated', 'system', null, JSON.stringify(settings), getClientIp(req)); res.json(settings); } catch (error) { res.status(400).json({ error: error.message || 'Betriebseinstellungen konnten nicht gespeichert werden.' }); } }); app.get('/api/operations/backups', requireRole('admin'), (_req, res) => { try { res.json({ backups: listBackups() }); } catch (error) { res.status(500).json({ error: error.message || 'Backups konnten nicht geladen werden.' }); } }); app.post('/api/operations/backups', requireRole('admin'), async (req, res) => { try { const result = await createBackup({ kind: req.body?.kind || 'manual', includeUploads: req.body?.include_uploads !== false, includeConfig: req.body?.include_config === true, label: req.body?.label || '', }); if (req.user) auditLog(req.user.id, req.user.email, 'backup.created', 'backup', result.id, JSON.stringify({ filename: result.filename, sha256: result.sha256 }), getClientIp(req)); res.status(201).json(result); } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht erstellt werden.' }); } }); app.get('/api/operations/backups/:id/download', requireRole('admin'), (req, res) => { try { const row = getBackupPath(req.params.id); res.download(row.file_path, row.file_name); } catch (error) { res.status(404).json({ error: error.message || 'Backup wurde nicht gefunden.' }); } }); app.post('/api/operations/backups/:id/verify', requireRole('admin'), (req, res) => { try { const result = verifyStoredBackup(req.params.id); if (req.user) auditLog(req.user.id, req.user.email, 'backup.verified', 'backup', req.params.id, JSON.stringify({ ok: result.ok, errors: result.errors }), getClientIp(req)); res.json(result); } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht geprüft werden.' }); } }); app.delete('/api/operations/backups/:id', requireRole('admin'), (req, res) => { try { const result = deleteBackup(req.params.id); if (req.user) auditLog(req.user.id, req.user.email, 'backup.deleted', 'backup', req.params.id, null, getClientIp(req)); res.json(result); } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht gelöscht werden.' }); } }); app.post('/api/operations/restore/stage', requireRole('admin'), operationsUpload.single('backup'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'Keine Backup-ZIP ausgewählt.' }); try { if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Für die sichere Wiederherstellung wird ein Vendoo-ZIP-Backup benötigt.'); const result = await stageRestore(readFileSync(req.file.path), req.file.originalname); if (req.user) auditLog(req.user.id, req.user.email, 'restore.staged', 'system', result.id, JSON.stringify({ safety_backup_id: result.safety_backup_id }), getClientIp(req)); res.status(202).json({ ...result, message: 'Wiederherstellung geprüft und vorbereitet. Vendoo jetzt beenden und im Setup Menüpunkt 11 ausführen.' }); } catch (error) { res.status(400).json({ error: error.message || 'Wiederherstellung konnte nicht vorbereitet werden.' }); } finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } }); app.post('/api/operations/updates/check', requireRole('admin'), async (_req, res) => { try { res.json(await checkForUpdates()); } catch (error) { res.status(400).json({ error: error.message || 'Updateprüfung fehlgeschlagen.' }); } }); app.post('/api/operations/updates/stage', requireRole('admin'), operationsUpload.single('update'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'Kein Update-ZIP ausgewählt.' }); try { if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Vendoo-Updates müssen als ZIP hochgeladen werden.'); const result = await stageUpdate(readFileSync(req.file.path), req.file.originalname); if (req.user) auditLog(req.user.id, req.user.email, 'update.staged', 'system', result.id, JSON.stringify({ from: result.version_from, to: result.version_to, safety_backup_id: result.safety_backup_id }), getClientIp(req)); res.status(202).json({ ...result, message: `Update ${result.version_to} geprüft und vorbereitet. Vendoo jetzt beenden und im Setup Menüpunkt 11 ausführen.` }); } catch (error) { res.status(400).json({ error: error.message || 'Update konnte nicht vorbereitet werden.' }); } finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } }); app.post('/api/operations/pending/cancel', requireRole('admin'), (req, res) => { try { const result = cancelPendingOperation(); if (req.user) auditLog(req.user.id, req.user.email, 'operation.cancelled', 'system', result.pending?.id || null, JSON.stringify(result.pending || {}), getClientIp(req)); res.json(result); } catch (error) { res.status(400).json({ error: error.message || 'Ausstehende Operation konnte nicht abgebrochen werden.' }); } }); app.get('/api/extensions/diagnostics', requireRole('admin'), (_req, res) => { try { res.json(getExtensionDiagnostics()); } catch (error) { res.status(500).json({ error: error.message || 'Extension-Diagnose konnte nicht geladen werden.' }); } }); app.post('/api/extensions/heartbeat', (req, res) => { try { res.json(recordExtensionHeartbeat({ clientId: req.body?.client_id, browser: req.body?.browser, version: req.body?.version, platform: req.body?.platform, capabilities: req.body?.capabilities, serverUrl: req.body?.server_url, userAgent: req.get('user-agent') || '', })); } catch (error) { res.status(400).json({ error: error.message || 'Extension-Heartbeat fehlgeschlagen.' }); } }); // Compatibility route: creates a verified database-only ZIP instead of copying an open SQLite file. app.get('/api/backup', requireRole('admin'), async (req, res) => { try { const backup = await createBackup({ kind: 'database', includeUploads: false, includeConfig: false, label: 'datenbank' }); const row = getBackupPath(backup.id); res.download(row.file_path, row.file_name); } catch (error) { res.status(500).json({ error: error.message || 'Backup konnte nicht erstellt werden.' }); } }); app.post('/api/restore', requireRole('admin'), operationsUpload.single('backup'), async (req, res) => { if (!req.file) return res.status(400).json({ error: 'Keine Backup-Datei ausgewählt.' }); try { if (extname(req.file.originalname || '').toLowerCase() !== '.zip') throw new Error('Direktes Einspielen offener DB-Dateien ist gesperrt. Bitte ein verifiziertes Vendoo-ZIP-Backup verwenden.'); const result = await stageRestore(readFileSync(req.file.path), req.file.originalname); res.status(202).json({ ok: true, ...result, message: 'Backup geprüft und vorbereitet. Vendoo beenden und Setup-Menüpunkt 11 ausführen.' }); } catch (error) { res.status(400).json({ error: error.message || 'Backup konnte nicht vorbereitet werden.' }); } finally { try { if (req.file?.path && existsSync(req.file.path)) unlinkSync(req.file.path); } catch {} } }); // --- Scheduling --- app.get('/api/schedule', (_req, res) => { res.json(getScheduledPublishes()); }); app.post('/api/schedule', (req, res) => { const { listing_id, platform, scheduled_at } = req.body; if (!listing_id || !scheduled_at) return res.status(400).json({ error: 'listing_id und scheduled_at erforderlich' }); const id = schedulePublish(listing_id, platform || 'ebay-de', scheduled_at); res.json({ ok: true, id, scheduled_at }); }); app.delete('/api/schedule/:id', (req, res) => { removeScheduledPublish(req.params.id); res.json({ ok: true }); }); // --- Price Suggestion --- app.get('/api/price-suggest', async (req, res) => { const { title, category, condition } = req.query; if (!title) return res.status(400).json({ error: 'Titel erforderlich' }); const listings = getListings(); const words = title.toLowerCase().split(/\s+/).filter(w => w.length > 2); const similar = listings .filter(l => l.price && l.price > 0) .map(l => { const lTitle = (l.title || '').toLowerCase(); const matchCount = words.filter(w => lTitle.includes(w)).length; const catMatch = category && l.category && l.category.toLowerCase().includes(category.toLowerCase()) ? 2 : 0; return { ...l, score: matchCount + catMatch }; }) .filter(s => s.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 10); if (!similar.length) return res.json({ suggestion: null, similar: [] }); const prices = similar.map(s => s.price); const avg = prices.reduce((a, b) => a + b, 0) / prices.length; const min = Math.min(...prices); const max = Math.max(...prices); res.json({ suggestion: Math.round(avg * 2) / 2, min, max, avg: Math.round(avg * 100) / 100, count: similar.length, similar: similar.slice(0, 5).map(s => ({ title: s.title, price: s.price, platform: s.platform })), }); }); // --- Providers & Platforms --- app.get('/api/providers', (_req, res) => res.json(getProviders())); app.get('/api/platforms', (_req, res) => { res.json(Object.values(platforms).map(p => ({ id: p.id, name: p.name, fields: p.fields, maxTitleLength: p.maxTitleLength, maxTags: p.maxTags, }))); }); // --- Scheduler Timer --- async function processScheduledPublishes() { const due = getDueScheduled(); for (const item of due) { updateScheduledPublish(item.id, { status: 'publishing', error: null }); try { const result = enqueuePublishingJob({ listingId: item.listing_id, platform: item.platform, action: 'publish' }); updateScheduledPublish(item.id, { status: result.duplicate ? 'queued' : 'queued', error: result.duplicate ? 'Bereits in der Publishing-Queue' : null }); console.log(`Scheduled publish ${item.id} → Publishing Job ${result.job.id}`); } catch (error) { if (error.code === 'DUPLICATE_PUBLISH') { updateScheduledPublish(item.id, { status: 'published', error: null }); } else { updateScheduledPublish(item.id, { status: 'failed', error: error.message }); } } } if (due.length) console.log(`Scheduler: ${due.length} fällige Veröffentlichungen an die gehärtete Queue übergeben`); } const scheduledPublishTimer = setInterval(processScheduledPublishes, 60_000); scheduledPublishTimer.unref?.(); // --- Trash cleanup --- function runTrashCleanup() { try { const result = cleanupTrash(30); if (result.changes > 0) console.log(`Trash cleanup: ${result.changes} alte Einträge endgültig gelöscht`); } catch (err) { console.error('Trash cleanup error:', err.message); } } const trashCleanupTimer = setInterval(runTrashCleanup, 60 * 60 * 1000); // every hour trashCleanupTimer.unref?.(); // --- Start --- if (!/^(0|false|no|off)$/i.test(String(process.env.LOCAL_IMAGE_ENABLED || 'true'))) { try { const settings = getSettings(); const bootstrap = ensureLocalFluxBootstrap({ token: process.env.LOCAL_IMAGE_TOKEN || '', installPath: process.env.LOCAL_IMAGE_INSTALL_PATH || settings.ai_model_photo_local_install_path || 'local-ai/comfyui', }); console.log(`Lokaler FLUX-Prompt-Workflow geprüft: ${bootstrap.workflow_path}`); } catch (error) { console.error('Lokale AI-Workflow-Selbstheilung fehlgeschlagen:', error.message || error); } } else { console.log('FLUX/ComfyUI ist deaktiviert oder wird als externer Dienst betrieben.'); } await loadPlatforms(); const BIND_HOST = String(process.env.VENDOO_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'; const server = app.listen(PORT, BIND_HOST, async () => { console.log(`Vendoo ${APP_VERSION} läuft auf http://${BIND_HOST === '0.0.0.0' ? 'localhost' : BIND_HOST}:${PORT}`); console.log(`Datenverzeichnis: ${runtimePathSummary().data_root}`); if (BIND_HOST === '0.0.0.0' || BIND_HOST === '::') for (const entry of getLanIpv4Addresses()) console.log(`Vendoo im LAN: http://${entry.address}:${PORT} (${entry.name})`); processScheduledPublishes(); runTrashCleanup(); }); function gracefulShutdown(signal) { if (shuttingDown) return; shuttingDown = true; console.log(`${signal}: Vendoo wird geordnet beendet ...`); clearInterval(scheduledPublishTimer); clearInterval(trashCleanupTimer); const forceTimer = setTimeout(() => { console.error('Shutdown-Zeitlimit erreicht. Prozess wird beendet.'); process.exit(1); }, 10_000); server.close(async () => { try { await platformKernel.stop(); } catch (error) { console.error('Platform-Kernel-Shutdown fehlgeschlagen:', error.message || error); } try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {} try { db.close(); } catch {} clearTimeout(forceTimer); console.log('Vendoo wurde sauber beendet.'); process.exit(0); }); } process.once('SIGTERM', () => gracefulShutdown('SIGTERM')); process.once('SIGINT', () => gracefulShutdown('SIGINT'));