Files
vendoo/server.mjs
T
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

3063 lines
142 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, updateRuntimeConfig } from './lib/config-store.mjs';
import { deleteSecret, getSecret, hasSecret, listSecretMetadata, secretStoreStatus, setSecret } from './lib/secret-store.mjs';
import { getRequestTelemetry, recordSecurityBlock, requestContextMiddleware } from './lib/structured-logger.mjs';
import { createPlatformKernel } from './app/kernel/platform-kernel.mjs';
import { getSecurityFoundationStatus } from './app/modules/security/index.mjs';
import { objectSchema } from './app/kernel/input-schema.mjs';
import { getThemeContract } from './app/core/themes/theme-contract.mjs';
import { getThemeService } from './app/modules/themes/index.mjs';
import { configureAuthModule } from './app/modules/auth/index.mjs';
import { registerPublicAuthRoutes } from './app/modules/auth/routes.mjs';
import { registerUsersRoutes } from './app/modules/users/routes.mjs';
import { registerSessionRoutes } from './app/modules/sessions/routes.mjs';
import { configureSettingsModule } from './app/modules/settings/index.mjs';
import { registerSettingsRoutes } from './app/modules/settings/routes.mjs';
import { configureListingsModule } from './app/modules/listings/index.mjs';
import { listingsRepository } from './app/modules/listings/repository.mjs';
import { registerListingsRoutes } from './app/modules/listings/routes.mjs';
import { configureInventoryModule } from './app/modules/inventory/index.mjs';
import { inventoryRepository } from './app/modules/inventory/repository.mjs';
import { registerInventoryRoutes } from './app/modules/inventory/routes.mjs';
import { configureMediaModule } from './app/modules/media/index.mjs';
import { mediaRepository } from './app/modules/media/repository.mjs';
import { registerMediaRoutes } from './app/modules/media/routes.mjs';
import { registerModuleManagerRoutes } from './app/modules/module-manager/routes.mjs';
import { configureDeploymentsModule } from './app/modules/deployments/index.mjs';
import { registerDeploymentRoutes } from './app/modules/deployments/routes.mjs';
import { configureAiModule } from './app/modules/ai/index.mjs';
import { configureFluxStudioModule } from './app/modules/flux-studio/index.mjs';
import { configurePublishingModule } from './app/modules/publishing/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, stopAiModelJobLoop } from './lib/ai-model-jobs.mjs';
import {
enqueueFluxPromptJob, getFluxPromptJob, listFluxPromptJobs, paginateFluxPromptJobs, getFluxPromptJobSummary,
cancelFluxPromptJob, retryFluxPromptJob, clearFluxPromptQueue, archiveFluxPromptJobs, restoreFluxPromptJobs,
deleteArchivedFluxPromptJobs, startFluxPromptJobLoop, stopFluxPromptJobLoop,
} from './lib/ai-flux-prompt-jobs.mjs';
import {
listImageBatchProfiles, createImageBatchProfile, updateImageBatchProfile, deleteImageBatchProfile,
createImageBatchJob, getImageBatchJob, paginateImageBatchJobs, pauseImageBatchJob, resumeImageBatchJob,
cancelImageBatchJob, retryImageBatchJob, retryImageBatchItem, deleteImageBatchJob, getImageBatchOutputPaths,
startImageBatchJobLoop, stopImageBatchJobLoop,
} from './lib/image-batch-jobs.mjs';
import {
enqueuePublishingJob, getPublishingJob, paginatePublishingJobs, listPublishingEvents,
retryPublishingJob, cancelPublishingJob, deletePublishingJobs, claimExtensionJob, completeExtensionJob,
configurePublishingProcessors, startPublishingJobLoop, stopPublishingJobLoop, 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);
const modulePackageUpload = multer({
storage: multer.memoryStorage(),
fileFilter: (_req, file, cb) => {
const ext = extname(file.originalname || '').toLowerCase();
const allowedMime = ['application/json', 'application/octet-stream', 'application/vnd.vendoo.module+json'];
cb(null, ext === '.vmod' || allowedMime.includes(String(file.mimetype || '').toLowerCase()));
},
limits: { files: 1, fileSize: 10 * 1024 * 1024 },
});
ensureRuntimeDirectories();
let shuttingDown = false;
configureAuthModule({
setupTokenMatches(value) {
const expectedText = String(process.env.VENDOO_SETUP_TOKEN || '').trim();
if (!expectedText) return true;
const provided = Buffer.from(String(value || ''));
const expected = Buffer.from(expectedText);
return provided.length === expected.length && timingSafeEqual(provided, expected);
},
});
configureSettingsModule({ etsyApi, ebayApi });
configureListingsModule({ repository: listingsRepository });
configureInventoryModule({ repository: inventoryRepository });
configureMediaModule({ uploadRoot: UPLOADS_DIR, sharp, repository: mediaRepository });
configureAiModule({
startModelJobs: startAiModelJobLoop,
stopModelJobs: stopAiModelJobLoop,
startBatchJobs: startImageBatchJobLoop,
stopBatchJobs: stopImageBatchJobLoop,
});
configureFluxStudioModule({ start: startFluxPromptJobLoop, stop: stopFluxPromptJobLoop });
configureDeploymentsModule({ createBackup, checkForUpdates });
configurePublishingModule({
async start() {
const migration = migrateLegacyEbayQueue();
if (migration.migrated) console.log(`Publishing: ${migration.migrated} alte eBay-Queue-Einträge übernommen`);
recoverPublishingJobs();
startPublishingJobLoop();
},
stop: stopPublishingJobLoop,
});
const platformKernel = createPlatformKernel({ version: APP_VERSION, hasPermission });
await platformKernel.start();
startAutomaticBackupLoop();
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}`);
},
});
function plainTextToSafeHtml(text) {
const escaped = String(text || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return escaped.split(/\n{2,}/).map(part => `<p>${part.replace(/\n/g, '<br>')}</p>`).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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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'", 'https://cdn.jsdelivr.net'],
scriptSrcAttr: ["'none'"],
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'"],
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null,
};
for (const [key, value] of Object.entries(SECURITY_CSP)) if (value === null) delete SECURITY_CSP[key];
app.use(helmet({
contentSecurityPolicy: { directives: SECURITY_CSP },
crossOriginEmbedderPolicy: false,
referrerPolicy: { policy: 'same-origin' },
strictTransportSecurity: process.env.NODE_ENV === 'production' ? { maxAge: 15552000, includeSubDomains: true } : false,
}));
app.use(express.json({ limit: '2mb' }));
app.use(cookieParser());
app.use(requestContextMiddleware);
app.use((req, res, next) => {
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'))) { recordSecurityBlock('host'); return res.status(421).json({ error: 'Host nicht freigegeben. VENDOO_ALLOWED_HOSTS prüfen.', request_id: req.requestId }); }
const origin = req.get('origin');
if (origin && !isAllowedOrigin(req, origin)) { recordSecurityBlock('origin'); return res.status(403).json({ error: 'Origin nicht freigegeben.', request_id: req.requestId }); }
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_pass && !hasSecret('SMTP_PASS')) {
setSecret('SMTP_PASS', settings.smtp_pass, { source: 'legacy-db-migration' });
updateSettings({ smtp_pass: '' });
}
const smtpSecret = getSecret('SMTP_PASS');
if (settings.smtp_host && settings.smtp_user && smtpSecret) {
const ok = reinitMailer({
host: settings.smtp_host,
port: settings.smtp_port || '587',
user: settings.smtp_user,
pass: smtpSecret,
});
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();
// --- Public routes (no auth) ---
app.use('/login.html', express.static(join(__dirname, 'public', 'login.html')));
registerPublicAuthRoutes({
app,
routes: platformKernel.routes,
deps: {
setupTokenRequired: Boolean(SETUP_TOKEN), isMailerConfigured, requireAuth, csrfProtect, sendMagicLink,
auditLog, getClientIp,
setSessionCookies(req, res, session, csrf) {
res.cookie('auth_token', session.token, secureCookieOptions(req));
res.cookie('csrf_token', csrf, csrfCookieOptions(req));
},
clearSessionCookies(res) {
res.clearCookie('auth_token');
res.clearCookie('csrf_token');
},
},
});
// --- 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);
function moduleOwnerForApiPath(pathname) {
const path = String(pathname || '').toLowerCase();
if (path.startsWith('/publishing') || path.startsWith('/publish') || path.startsWith('/ebay') || path.startsWith('/etsy') || path.startsWith('/extension/publishing') || path.startsWith('/schedule')) return 'vendoo.publishing';
if (path.startsWith('/flux') || path.startsWith('/local-image')) return 'vendoo.flux-studio';
if (path.startsWith('/quality')) return 'vendoo.quality';
if (path.startsWith('/ai-model') || path.startsWith('/image-batch') || path === '/generate' || path.startsWith('/providers') || path.startsWith('/prompt-lab') || /^\/listings\/[^/]+\/ai-model/.test(path)) return 'vendoo.ai';
return null;
}
app.use('/api', (req, res, next) => {
const owner = moduleOwnerForApiPath(req.path);
if (!owner || platformKernel.modules.isEnabled(owner)) return next();
return res.status(503).json({ error: 'Dieses Vendoo-Modul ist deaktiviert.', code: 'MODULE_DISABLED', module: owner, request_id: req.requestId });
});
// 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: 'security.secrets.read', method: 'GET', path: '/api/security/secrets', owner: 'vendoo.security',
policy: 'security.secrets.manage', audit: 'security.secrets.read', rateLimit: 'administration', csrf: false, stability: 'stable',
handler: async (_req, res) => res.json({ secrets: listSecretMetadata(), store: secretStoreStatus() }),
});
platformKernel.routes.register(app, {
id: 'security.secrets.update', method: 'PUT', path: '/api/security/secrets/:name', owner: 'vendoo.security',
policy: 'security.secrets.manage', audit: 'security.secret.updated', rateLimit: 'administration', csrf: true, stability: 'stable',
input: objectSchema({ value: { type: 'string', maxLength: 16384, trim: false } }),
handler: async (req, res) => {
const current = listSecretMetadata().find(item => item.name === req.params.name);
if (!current || !current.editable) return res.status(400).json({ error: 'Dieses Secret kann nicht manuell geändert werden.', request_id: req.requestId });
const metadata = setSecret(req.params.name, req.validatedBody?.value ?? '', { source: 'security-center' });
auditLog(req.user.id, req.user.email, metadata.configured ? 'security.secret.updated' : 'security.secret.deleted', 'secret', metadata.name, null, getClientIp(req));
res.json(metadata);
},
});
platformKernel.routes.register(app, {
id: 'security.secrets.delete', method: 'DELETE', path: '/api/security/secrets/:name', owner: 'vendoo.security',
policy: 'security.secrets.manage', audit: 'security.secret.deleted', rateLimit: 'administration', csrf: true, stability: 'stable',
handler: async (req, res) => {
const current = listSecretMetadata().find(item => item.name === req.params.name);
if (!current || !current.editable) return res.status(400).json({ error: 'Dieses Secret kann nicht manuell gelöscht werden.', request_id: req.requestId });
const metadata = deleteSecret(req.params.name);
auditLog(req.user.id, req.user.email, 'security.secret.deleted', 'secret', metadata.name, null, getClientIp(req));
res.json(metadata);
},
});
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)),
});
registerUsersRoutes({
app, routes: platformKernel.routes,
deps: { auditLog, getClientIp, sendMagicLink, sendCredentials },
});
registerSessionRoutes({
app, routes: platformKernel.routes,
deps: { auditLog, getClientIp },
});
registerSettingsRoutes({
app, routes: platformKernel.routes,
deps: { auditLog, getClientIp, isMailerConfigured, testMailer },
});
registerMediaRoutes({ app, routes: platformKernel.routes, deps: { auditLog, getClientIp } });
registerListingsRoutes({ app, routes: platformKernel.routes, deps: { auditLog, getClientIp, validateResourceLock } });
registerInventoryRoutes({ app, routes: platformKernel.routes, deps: { auditLog, getClientIp } });
registerModuleManagerRoutes({ app, routes: platformKernel.routes, upload: modulePackageUpload, deps: { auditLog, getClientIp } });
registerDeploymentRoutes({ app, routes: platformKernel.routes, deps: { auditLog, getClientIp } });
// --- 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.');
if (/^(0|false|no)$/i.test(String(process.env.SMTP_TLS_REJECT_UNAUTHORIZED || 'true'))) warnings.push('SMTP-Zertifikatsprüfung ist deaktiviert. Das ist nur für kontrollierte Testsysteme zulässig.');
const foundation = getSecurityFoundationStatus();
if (foundation.secrets.environment > 0) warnings.push(`${foundation.secrets.environment} Secret(s) stammen noch aus Umgebungsvariablen statt aus dem verschlüsselten Secret-Speicher.`);
if (foundation.secrets.encrypted === 0) warnings.push('Der verschlüsselte Secret-Speicher enthält noch keine Zugangsdaten. Bestehende Umgebungsvariablen können weiter funktionieren, sind aber weniger zentral verwaltbar.');
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')),
csp: { enforced: true, script_inline_allowed: false, style_inline_allowed: true },
secrets: foundation.secrets, requests: foundation.requests,
});
});
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: 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: 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)),
});
});
// 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();
setSecret('LOCAL_IMAGE_TOKEN', token, { source: 'token-regeneration' });
res.json({ ok: true, token });
} catch (error) {
res.status(500).json({ error: error.message || 'Lokaler Token konnte nicht erneuert werden.' });
}
});
// Media Library routes are owned by vendoo.media.
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 and trash routes are owned by vendoo.listings.
// --- 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 routes are owned by vendoo.inventory.
// --- 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);
});
// --- 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('<html><body><h2>Etsy verbunden!</h2><p>Du kannst dieses Fenster schließen.</p><script>window.opener && window.opener.postMessage("etsy-connected","*"); setTimeout(()=>window.close(),2000);</script></body></html>');
} 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('<html><body><h2>eBay verbunden!</h2><p>Du kannst dieses Fenster schließen.</p><script>window.opener && window.opener.postMessage("ebay-connected","*"); setTimeout(()=>window.close(),2000);</script></body></html>');
} 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'));