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'));