require('dotenv').config(); const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const AdmZip = require('adm-zip'); const db = require('./lib/db'); const store = require('./lib/store'); const fileProcessor = require('./lib/fileProcessor'); const thingiverse = require('./lib/importers/thingiverse'); const generic = require('./lib/importers/generic'); const flaresolverr = require('./lib/flaresolverr'); const makerworld = require('./lib/importers/makerworld'); const printables = require('./lib/importers/printables'); const converter = require('./lib/converter'); const licenses = require('./lib/licenses'); const cookieParser = require('cookie-parser'); const auth = require('./lib/auth'); const mailer = require('./lib/mailer'); const fingerprint = require('./lib/fingerprint'); const duplicates = require('./lib/duplicates'); const archives = require('./lib/archives'); const thumbnails = require('./lib/thumbnails'); const handoff = require('./lib/handoff'); const ai = require('./lib/ai'); const aiRuns = require('./lib/aiRuns'); const autotag = require('./lib/autotag'); const embeddings = require('./lib/embeddings'); const app = express(); const PORT = process.env.PORT || 3000; const UPLOAD_DIR = './uploads'; app.set('trust proxy', 1); // behind Traefik — trust X-Forwarded-* for protocol/cookies app.use(express.json({ limit: '8mb' })); // generous for base64 thumbnail PNGs app.use(cookieParser()); app.use(express.static('public')); app.use('/thumbnails', express.static('thumbnails')); // In-browser readers for STEP (OpenCascade) and Rhino 3DM, served from node_modules. app.use('/vendor/occt', express.static(path.join(__dirname, 'node_modules/occt-import-js/dist'))); app.use('/vendor/rhino3dm', express.static(path.join(__dirname, 'node_modules/rhino3dm'))); // Gate the API: everything under /api requires auth except the bootstrap config // and the auth endpoints themselves. app.use('/api', (req, res, next) => { if (req.path === '/config' || req.path.startsWith('/auth/')) return next(); return auth.requireAuth(req, res, next); }); // ----------------------------------------------------------------- helpers function ensureUploadDir() { if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); } function uniqueName(originalName) { const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; return suffix + path.extname(originalName); } // Build a files[] entry from a file already on disk: extracts geometry metadata. function describeFile(filePath, fileName, originalName) { const format = fileProcessor.formatOf(originalName); const size = fs.statSync(filePath).size; const meta = fileProcessor.extractMetadata(filePath, format); return { fileName, filePath, originalName, format, size, ...meta }; } // Choose a thumbnail for a model from its files: an embedded 3MF preview, else // a picture that came with the model. function pickThumbnail(files) { const threeMf = files.find((f) => f.format === '3mf'); const embedded = threeMf && fileProcessor.extract3mfThumbnail(threeMf.filePath, path.parse(threeMf.fileName).name); if (embedded) return embedded; const image = files.find((f) => ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(f.format)); return image ? fileProcessor.copyAsThumbnail(image.filePath, path.parse(image.fileName).name) : null; } // Transform a stored model into the public shape (thumbnail path -> URL). function publicModel(m) { if (!m) return m; return { ...m, thumbnailUrl: m.thumbnail_path ? `/thumbnails/${path.basename(m.thumbnail_path)}` : null, }; } // ----------------------------------------------------------------- multer const storage = multer.diskStorage({ destination: (req, file, cb) => { ensureUploadDir(); cb(null, UPLOAD_DIR); }, filename: (req, file, cb) => cb(null, uniqueName(file.originalname)), }); const upload = multer({ storage, fileFilter: (req, file, cb) => { if (fileProcessor.isAllowed(file.originalname)) cb(null, true); else cb(new Error(`Unsupported file type. Allowed: ${fileProcessor.ALLOWED_FORMATS.join(', ')}`)); }, }); // ----------------------------------------------------------------- settings // Editable secrets. Resolved from the DB first, then the .env fallback. const SETTINGS = { thingiverse_token: { env: 'THINGIVERSE_TOKEN', label: 'Thingiverse API token', secret: true }, flaresolverr_url: { env: 'FLARESOLVERR_URL', label: 'FlareSolverr URL', secret: false }, makerworld_token: { env: 'MAKERWORLD_TOKEN', label: 'MakerWorld auth token', secret: true }, printables_token: { env: 'PRINTABLES_TOKEN', label: 'Printables auth token', secret: true }, smtp_host: { env: 'SMTP_HOST', label: 'SMTP host', secret: false }, smtp_port: { env: 'SMTP_PORT', label: 'SMTP port', secret: false }, smtp_secure: { env: 'SMTP_SECURE', label: 'SMTP SSL (true/false)', secret: false }, smtp_user: { env: 'SMTP_USER', label: 'SMTP username', secret: false }, smtp_pass: { env: 'SMTP_PASS', label: 'SMTP password', secret: true }, smtp_from: { env: 'SMTP_FROM', label: 'From address', secret: false }, ai_provider: { env: 'AI_PROVIDER', label: 'AI provider', secret: false }, ai_base_url: { env: 'AI_BASE_URL', label: 'AI API address', secret: false }, ai_api_key: { env: 'AI_API_KEY', label: 'AI API key', secret: true }, ai_model: { env: 'AI_MODEL', label: 'AI model', secret: false }, ai_send_images: { env: 'AI_SEND_IMAGES', label: 'Send thumbnails to the AI', secret: false }, ai_price_input: { env: 'AI_PRICE_INPUT', label: 'Input price ($ per million tokens)', secret: false }, ai_price_output: { env: 'AI_PRICE_OUTPUT', label: 'Output price ($ per million tokens)', secret: false }, }; const AI_SETTING_KEYS = Object.keys(SETTINGS).filter((k) => k.startsWith('ai_')); async function resolveAiConfig() { const values = {}; for (const key of AI_SETTING_KEYS) values[key] = (await resolveSetting(key)).value; return ai.configFrom(values); } async function resolveSetting(key) { const fromDb = await store.getSetting(key); if (fromDb) return { value: fromDb, source: 'database' }; const fromEnv = process.env[SETTINGS[key].env]; if (fromEnv) return { value: fromEnv, source: 'env' }; return { value: null, source: null }; } function maskSecret(value) { if (!value) return null; return value.length <= 4 ? '••••' : `••••${value.slice(-4)}`; } app.get('/api/config', async (req, res, next) => { try { const thingiverse = await resolveSetting('thingiverse_token'); const flare = await resolveSetting('flaresolverr_url'); const makerworld = await resolveSetting('makerworld_token'); const printables = await resolveSetting('printables_token'); const alreadyConfigured = Boolean(thingiverse.value || flare.value || makerworld.value || printables.value); const setupComplete = alreadyConfigured || Boolean(await store.getSetting('setup_complete')); res.json({ importers: { thingiverse: Boolean(thingiverse.value), flaresolverr: Boolean(flare.value) }, formats: fileProcessor.MODEL_FORMATS, setupComplete, }); } catch (e) { next(e); } }); // Settings for the UI — secrets are masked, never returned in full. app.get('/api/settings', async (req, res, next) => { try { const out = {}; for (const key of Object.keys(SETTINGS)) { const resolved = await resolveSetting(key); const secret = SETTINGS[key].secret; out[key] = { label: SETTINGS[key].label, set: Boolean(resolved.value), source: resolved.source, // 'database' | 'env' | null secret, hint: secret ? maskSecret(resolved.value) : resolved.value, // show non-secrets in full value: secret ? null : resolved.value, // non-secrets can be prefilled in the form editable: resolved.source !== 'env', // env-provided values are read-only here }; } res.json(out); } catch (e) { next(e); } }); // ----------------------------------------------------------------- auth async function resolveSmtp() { return { host: (await resolveSetting('smtp_host')).value, port: (await resolveSetting('smtp_port')).value, secure: (await resolveSetting('smtp_secure')).value, user: (await resolveSetting('smtp_user')).value, pass: (await resolveSetting('smtp_pass')).value, from: (await resolveSetting('smtp_from')).value, }; } // Public: tells the login UI whether to show "create account" vs "sign in". app.get('/api/auth/status', async (req, res, next) => { try { const needsSetup = (await store.countUsers()) === 0; const user = await auth.currentUser(req); const smtp = await resolveSmtp(); res.json({ needsSetup, authenticated: Boolean(user), user: user ? { id: user.id, email: user.email } : null, smtpConfigured: Boolean(smtp.host), }); } catch (e) { next(e); } }); app.get('/api/auth/me', auth.requireAuth, (req, res) => { res.json({ user: { id: req.user.id, email: req.user.email } }); }); // First-run account creation (only allowed while there are no users). app.post('/api/auth/register', async (req, res, next) => { try { if ((await store.countUsers()) > 0) return res.status(403).json({ error: 'Registration is closed.' }); const { email, password } = req.body || {}; if (!email || !password) return res.status(400).json({ error: 'Email and password are required.' }); if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' }); const user = await store.createUser(email, await auth.hashPassword(password)); auth.setAuthCookie(res, await auth.issueToken(user)); res.status(201).json({ user: { id: user.id, email: user.email } }); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'That email is already registered.' }); next(e); } }); app.post('/api/auth/login', async (req, res, next) => { try { const { email, password } = req.body || {}; const user = email ? await store.getUserByEmail(email) : null; if (!user || !(await auth.verifyPassword(password || '', user.password_hash))) { return res.status(401).json({ error: 'Invalid email or password.' }); } auth.setAuthCookie(res, await auth.issueToken(user)); res.json({ user: { id: user.id, email: user.email } }); } catch (e) { next(e); } }); app.post('/api/auth/logout', (req, res) => { auth.clearAuthCookie(res); res.json({ ok: true }); }); // Always responds ok (don't reveal whether an email exists). app.post('/api/auth/forgot', async (req, res, next) => { try { const { email } = req.body || {}; const user = email ? await store.getUserByEmail(email) : null; if (user) { const smtp = await resolveSmtp(); if (smtp.host) { const { token, hash } = auth.makeResetToken(); await store.createPasswordReset(user.id, hash, new Date(Date.now() + 3600 * 1000)); const base = `${req.protocol}://${req.get('host')}`; await mailer.sendPasswordReset(smtp, user.email, `${base}/?reset=${token}`) .catch((err) => console.error('Reset email failed:', err.message)); } } res.json({ ok: true }); } catch (e) { next(e); } }); app.post('/api/auth/reset', async (req, res, next) => { try { const { token, password } = req.body || {}; if (!token || !password) return res.status(400).json({ error: 'Token and new password are required.' }); if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' }); const reset = await store.getValidReset(auth.hashResetToken(token)); if (!reset) return res.status(400).json({ error: 'This reset link is invalid or has expired.' }); await store.updateUserPassword(reset.user_id, await auth.hashPassword(password)); await store.markResetUsed(reset.id); res.json({ ok: true }); } catch (e) { next(e); } }); // ----------------------------------------------------------------- license rules // List all known licenses + their commercial-use policy (auto-registers any // license currently used by a model that isn't in the table yet). app.get('/api/licenses', async (req, res, next) => { try { for (const name of await store.distinctModelLicenses()) { if (!(await store.getLicenseRule(name))) await store.upsertLicenseRule(name, licenses.inferCommercial(name)); } await store.reconcileModelsToRules(); // keep old imports aligned to current policy res.json(await store.listLicenseRules()); } catch (e) { next(e); } }); // Set a license's policy and apply it to every model with that license. app.put('/api/licenses', async (req, res, next) => { try { const { name } = req.body || {}; if (!name) return res.status(400).json({ error: 'License name required' }); const c = req.body.commercial === true ? true : req.body.commercial === false ? false : null; await store.upsertLicenseRule(name, c); await store.applyLicenseRuleToModels(name, c); res.json({ ok: true }); } catch (e) { next(e); } }); // Mark the first-run setup wizard as done (so it won't show again). app.post('/api/setup/complete', async (req, res, next) => { try { await store.setSetting('setup_complete', 'true'); res.json({ ok: true }); } catch (e) { next(e); } }); app.put('/api/settings', async (req, res, next) => { try { for (const [key, value] of Object.entries(req.body || {})) { if (!SETTINGS[key]) continue; // ignore unknown keys if (value === undefined) continue; // omitted = leave unchanged await store.setSetting(key, value === null ? '' : String(value).trim()); } res.json({ message: 'Settings saved' }); } catch (e) { next(e); } }); // ----------------------------------------------------------------- models app.get('/api/models', async (req, res, next) => { try { const models = await store.getAllModels({ trash: req.query.view === 'trash' }); res.json(models.map(publicModel)); } catch (e) { next(e); } }); app.get('/api/models/:id', async (req, res, next) => { try { const model = await store.getModel(req.params.id); if (!model) return res.status(404).json({ error: 'Model not found' }); res.json(publicModel(model)); } catch (e) { next(e); } }); // Upload one or more files as a single model. app.post('/api/models', upload.array('files'), async (req, res, next) => { try { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files uploaded' }); } const files = req.files.map((f) => describeFile(f.path, f.filename, f.originalname)); const model = await store.createModel({ name: req.body.name || req.files[0].originalname, description: req.body.description || '', print_settings: parseJson(req.body.printSettings, {}), tags: parseJson(req.body.tags, []), projects: parseJson(req.body.projects, []), thumbnail_path: pickThumbnail(files), files, }); fingerprint.kick(); res.status(201).json(publicModel(model)); } catch (e) { next(e); } }); // Add more files to an existing model. app.post('/api/models/:id/files', upload.array('files'), async (req, res, next) => { try { const existing = await store.getModel(req.params.id); if (!existing) return res.status(404).json({ error: 'Model not found' }); const files = (req.files || []).map((f) => describeFile(f.path, f.filename, f.originalname)); const model = await store.addFiles(req.params.id, files); fingerprint.kick(); res.json(publicModel(model)); } catch (e) { next(e); } }); app.put('/api/models/:id', async (req, res, next) => { try { const updates = { ...req.body }; // Sellability is derived from the license rule, so re-resolve it if the // license changed (registers the new license if it's not seen before). if (updates.license !== undefined) updates.commercial_use = await resolveCommercial(updates.license); const model = await store.updateModel(req.params.id, updates); if (!model) return res.status(404).json({ error: 'Model not found' }); fingerprint.kick(); // names and tags feed automatic tags and search res.json(publicModel(model)); } catch (e) { next(e); } }); // Re-fetch metadata (license, commercial use, description) from the source — // without re-downloading any files. app.post('/api/models/:id/refresh-metadata', async (req, res, next) => { try { const model = await store.getModel(req.params.id); if (!model) return res.status(404).json({ error: 'Model not found' }); if (!model.source_site || (!model.source_url && !model.source_id)) { return res.status(400).json({ error: 'This model has no source to refresh from.' }); } const info = await refreshMetadata(model); const updates = { license: info.license ?? null, commercial_use: info.commercial ?? null }; if (info.description) updates.description = info.description; const updated = await store.updateModel(model.id, updates); res.json(publicModel(updated)); } catch (e) { next(e); } }); // Returns { license, commercial, description } from the model's source. async function refreshMetadata(model) { const site = model.source_site; if (site === 'cubee3d') return { license: 'Cubee3D Commercial License', commercial: await resolveCommercial('Cubee3D Commercial License', true) }; if (site === 'printables' && model.source_id) { const { value: token } = await resolveSetting('printables_token'); const p = await printables.fetchModel(model.source_id, token); return { license: p.license, commercial: await resolveCommercial(p.license, licenses.inferCommercial(p.licenseContent || p.license)) }; } if (site === 'thingiverse' && model.source_id) { const { value: token } = await resolveSetting('thingiverse_token'); if (!token) throw new Error('Thingiverse token required (set it in Settings).'); const meta = await thingiverse.fetchModel(model.source_id, token); return { license: meta.license, commercial: await resolveCommercial(meta.license), description: meta.description }; } // makerworld / web: read the page (FlareSolverr handles Cloudflare). const html = await fetchPageHtml(model.source_url); const meta = generic.parseMetadata(html, model.source_url); const license = site === 'makerworld' ? makerworld.parseLicense(html) : meta.license; return { license, commercial: await resolveCommercial(license), description: meta.description }; } // Re-download files from the source into an existing model (recovers imports // that came back empty/partial, e.g. when FlareSolverr was flaky). Only adds // files not already present. app.post('/api/models/:id/download-files', async (req, res, next) => { try { const model = await store.getModel(req.params.id); if (!model) return res.status(404).json({ error: 'Model not found' }); if (!model.source_site || (!model.source_url && !model.source_id)) { return res.status(400).json({ error: 'This model has no source to download from.' }); } const existingNames = new Set((model.files || []).map((f) => f.originalName)); const newFiles = await fetchSourceFiles(model, existingNames); if (newFiles.length === 0) { return res.status(200).json({ ...publicModel(model), added: 0 }); } const updated = await store.addFiles(model.id, newFiles); fingerprint.kick(); res.json({ ...publicModel(updated), added: newFiles.length }); } catch (e) { next(e); } }); // Returns model files[] downloaded from the model's source, skipping any whose // original name is already present. async function fetchSourceFiles(model, skipNames) { if (model.source_site === 'makerworld') { const html = await fetchPageHtml(model.source_url); return downloadMakerworldFiles(html, skipNames); } if (model.source_site === 'printables' && model.source_id) { const { files } = await downloadPrintablesFiles(model.source_id, skipNames); return files; } if (model.source_site === 'thingiverse' && model.source_id) { const { value: token } = await resolveSetting('thingiverse_token'); if (!token) { const e = new Error('Thingiverse token required (set it in Settings).'); e.status = 400; throw e; } const meta = await thingiverse.fetchModel(model.source_id, token); return downloadThingiverseFiles(meta, skipNames); } const e = new Error('No downloadable file source for this model.'); e.status = 400; throw e; } // Moves a model to the trash (restorable). ?permanent=true deletes it and its // files from disk — only for a model that's already in the trash. app.delete('/api/models/:id', async (req, res, next) => { try { const model = await store.getModel(req.params.id); if (!model) return res.status(404).json({ error: 'Model not found' }); if (req.query.permanent === 'true') { if (!model.deleted_at) { return res.status(400).json({ error: 'Move the model to the trash before deleting it permanently.' }); } await purgeModel(model.id); return res.json({ message: 'Model permanently deleted' }); } await store.trashModels([model.id]); res.json({ message: 'Model moved to trash' }); } catch (e) { next(e); } }); app.post('/api/models/:id/restore', async (req, res, next) => { try { if (!(await store.restoreModel(req.params.id))) { return res.status(404).json({ error: 'Model not found in trash' }); } res.json(publicModel(await store.getModel(req.params.id))); } catch (e) { next(e); } }); // Empty the trash: permanently delete every trashed model and its files. app.delete('/api/trash', async (req, res, next) => { try { const ids = await store.trashedModelIds(); for (const id of ids) await purgeModel(id); res.json({ deleted: ids.length }); } catch (e) { next(e); } }); // Delete a model's rows, then its files, GLB conversions and thumbnail on disk. async function purgeModel(id) { const removed = await store.deleteModel(id); if (!removed) return; removed.fileIds.forEach(archives.removeExtracted); removeDerivedFiles(removed.fileIds, removed.entryIds); for (const p of [...removed.filePaths, ...removed.glbPaths, removed.thumbnailPath]) { if (!p) continue; try { fs.unlinkSync(p); } catch (err) { if (err.code !== 'ENOENT') console.warn(`Could not delete ${p}:`, err.message); } } } // Store a client-rendered thumbnail (PNG data URL) for a model. app.post('/api/models/:id/thumbnail', async (req, res, next) => { try { const model = await store.getModel(req.params.id); if (!model) return res.status(404).json({ error: 'Model not found' }); const match = /^data:image\/png;base64,(.+)$/.exec(req.body.image || ''); if (!match) return res.status(400).json({ error: 'Expected a PNG data URL' }); if (!fs.existsSync('./thumbnails')) fs.mkdirSync('./thumbnails', { recursive: true }); const name = `model-${model.id}-${Date.now()}.png`; fs.writeFileSync(path.join('./thumbnails', name), Buffer.from(match[1], 'base64')); // Remove the previous generated thumbnail, if any. if (model.thumbnail_path && fs.existsSync(model.thumbnail_path)) { try { fs.unlinkSync(model.thumbnail_path); } catch { /* ignore */ } } const updated = await store.updateModel(model.id, { thumbnail_path: `./thumbnails/${name}` }); res.json(publicModel(updated)); } catch (e) { next(e); } }); // Download all of a model's files as a single zip. app.get('/api/models/:id/download', async (req, res, next) => { try { const { rows } = await db.query( 'SELECT original_name, file_path FROM files WHERE model_id = $1 ORDER BY id', [req.params.id]); if (rows.length === 0) return res.status(404).json({ error: 'No files for this model' }); const model = await store.getModel(req.params.id); const zip = new AdmZip(); const used = new Set(); for (const f of rows) { const abs = path.resolve(f.file_path); if (!fs.existsSync(abs)) continue; // de-dup entry names so identically-named files don't clobber each other let name = f.original_name; for (let i = 2; used.has(name); i++) { const ext = path.extname(f.original_name); name = `${path.basename(f.original_name, ext)} (${i})${ext}`; } used.add(name); zip.addLocalFile(abs, '', name); } const zipName = `${(model && model.name ? model.name : 'model').replace(/[^\w.-]+/g, '_')}.zip`; res.set('Content-Disposition', `attachment; filename="${zipName}"`); res.set('Content-Type', 'application/zip'); res.send(zip.toBuffer()); } catch (e) { next(e); } }); // Send a stored file as a download, or with ?inline=1 for display in the page // (images, PDFs, text). SVGs are sandboxed so scripts inside them can't run. function sendStored(req, res, absPath, name) { res.set('X-Content-Type-Options', 'nosniff'); const ext = path.extname(name).toLowerCase(); if (ext === '.svg') res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox"); if (req.query.inline !== '1') return res.download(absPath, name); if (['.md', '.mtl', '.txt'].includes(ext)) res.type('text/plain'); res.sendFile(absPath); } // Serve a stored file (downloads, and the in-browser viewers). app.get('/api/files/:id/raw', async (req, res, next) => { try { const file = await store.getFile(req.params.id); if (!file) return res.status(404).json({ error: 'File not found' }); const abs = path.resolve(file.file_path); if (!fs.existsSync(abs)) return res.status(404).json({ error: 'File missing on disk' }); sendStored(req, res, abs, file.original_name); } catch (e) { next(e); } }); // What's inside an archive file (indexed in the background). app.get('/api/files/:id/entries', async (req, res, next) => { try { const file = await store.getFile(req.params.id); if (!file) return res.status(404).json({ error: 'File not found' }); if (!fileProcessor.ARCHIVE_FORMATS.includes(file.format)) return res.status(400).json({ error: 'Not an archive' }); res.json({ status: file.archive_status || 'pending', error: file.archive_error, entries: await store.getArchiveEntries(file.id), }); } catch (e) { next(e); } }); // Serve a file from inside an archive (extracted on first use, then cached). app.get('/api/entries/:id/raw', async (req, res, next) => { try { const entry = await store.getArchiveEntry(req.params.id); if (!entry) return res.status(404).json({ error: 'Entry not found' }); const abs = await archives.extractEntry(entry.archive, entry); sendStored(req, res, abs, path.posix.basename(entry.path)); } catch (e) { next(e); } }); // ----------------------------------------------------------------- slicer hand-off // "Open in slicer" launches the slicer with a link it downloads itself. The slicer // has no session cookie, so the link carries a short-lived grant for one file. async function handoffTarget(kind, id) { if (!Number.isInteger(Number(id))) return null; if (kind === 'file') { const file = await store.getFile(Number(id)); return file && { name: file.original_name, format: file.format, resolve: async () => path.resolve(file.file_path) }; } if (kind === 'entry') { const entry = await store.getArchiveEntry(Number(id)); return entry && { name: path.posix.basename(entry.path), format: entry.format, resolve: () => archives.extractEntry(entry.archive, entry), }; } return null; } // -> { path: "/dl//", name, expiresIn }. The browser adds its own // origin, which is the address this machine already reaches the server on. app.post('/api/handoff', async (req, res, next) => { try { const { kind, id } = req.body || {}; const target = await handoffTarget(kind, id); if (!target) return res.status(404).json({ error: 'File not found' }); if (!handoff.SLICEABLE.includes(target.format)) { return res.status(415).json({ error: `.${target.format} files can't be opened in a slicer` }); } const token = handoff.createToken(await auth.getSecret(), { kind, id: Number(id) }); res.json({ path: `/dl/${token}/${handoff.urlName(target.name)}`, name: target.name, expiresIn: handoff.TTL_SECONDS }); } catch (e) { next(e); } }); // Outside /api on purpose: the token is the only credential. app.get('/dl/:token/:name', async (req, res, next) => { try { const grant = handoff.readToken(await auth.getSecret(), req.params.token); if (!grant) return res.status(403).type('text/plain').send('This download link has expired. Open the model in Printhive again.'); const target = await handoffTarget(grant.kind, grant.id); if (!target) return res.status(404).type('text/plain').send('File not found'); const abs = await target.resolve(); if (!fs.existsSync(abs)) return res.status(404).type('text/plain').send('File missing on disk'); res.set({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' }); res.download(abs, target.name); } catch (e) { next(e); } }); // Serve a 3MF as GLB, converting on first request (cached thereafter). This lets // the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox. // ?plate=N converts just that plate of a slicer project. const glbConversions = new Map(); // output path -> in-flight Promise // Converts into a temporary file and renames it into place, so a half-written // GLB is never served. function convertOnce(inputPath, outPath, plate = null) { if (!glbConversions.has(outPath)) { fs.mkdirSync(path.dirname(outPath), { recursive: true }); const tmp = `${outPath}.part`; glbConversions.set(outPath, converter.convert3mfToGlb(inputPath, tmp, { plate }) .then((result) => { fs.renameSync(tmp, outPath); return result; }) .catch((err) => { if (/no plate \d+/.test(err.message)) err.status = 404; throw err; }) .finally(() => { fs.rmSync(tmp, { force: true }); glbConversions.delete(outPath); })); } return glbConversions.get(outPath); } // Bump when the converter's output changes (e.g. filament colours), so previews // cached by an older version get rebuilt instead of served. const GLB_VERSION = 2; const glbName = (base, plate) => `${base}${plate ? `-plate${plate}` : ''}-v${GLB_VERSION}.glb`; function plateParam(req) { const n = Number(req.query.plate); return Number.isInteger(n) && n > 0 ? n : null; } app.get('/api/files/:id/glb', async (req, res, next) => { try { const file = await store.getFile(req.params.id); if (!file) return res.status(404).json({ error: 'File not found' }); if (file.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' }); const plate = plateParam(req); if (plate) { const platePath = path.resolve('./glb', glbName(file.id, plate)); if (!fs.existsSync(platePath)) await convertOnce(path.resolve(file.file_path), platePath, plate); return res.sendFile(platePath); } let glbPath = file.glb_path; if (!glbPath || path.basename(glbPath) !== glbName(file.id) || !fs.existsSync(glbPath)) { if (glbPath) fs.rmSync(glbPath, { force: true }); // made by an older converter glbPath = path.join('./glb', glbName(file.id)); const result = await convertOnce(path.resolve(file.file_path), path.resolve(glbPath)); await store.updateFileConversion(file.id, { glb_path: glbPath, volume: result.volume_cm3, bbox: result.bbox }); } res.sendFile(path.resolve(glbPath)); } catch (e) { next(e); } }); app.get('/api/entries/:id/glb', async (req, res, next) => { try { const entry = await store.getArchiveEntry(req.params.id); if (!entry) return res.status(404).json({ error: 'Entry not found' }); if (entry.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' }); const plate = plateParam(req); const glbPath = path.resolve('./glb', glbName(`entry-${entry.id}`, plate)); if (!fs.existsSync(glbPath)) await convertOnce(await archives.extractEntry(entry.archive, entry), glbPath, plate); res.sendFile(glbPath); } catch (e) { next(e); } }); // Plates and filaments of a 3MF slicer project (Bambu Studio / OrcaSlicer), // read once per server run. Plate previews land in thumbnails/plates/. const PLATE_IMAGE_DIR = './thumbnails/plates'; const projectInfoCache = new Map(); // "-" -> Promise async function threeMfPath(kind, id) { const unsupported = () => Object.assign(new Error('Only 3MF files have plates'), { status: 415 }); if (kind === 'files') { const file = await store.getFile(id); if (!file) return null; if (file.format !== '3mf') throw unsupported(); return path.resolve(file.file_path); } const entry = await store.getArchiveEntry(id); if (!entry) return null; if (entry.format !== '3mf') throw unsupported(); return archives.extractEntry(entry.archive, entry); } for (const kind of ['files', 'entries']) { app.get(`/api/${kind}/:id/project`, async (req, res, next) => { try { const key = `${kind}-${Number(req.params.id)}`; if (!projectInfoCache.has(key)) { const abs = await threeMfPath(kind, req.params.id); if (!abs) return res.status(404).json({ error: 'Not found' }); const imagesDir = path.resolve(PLATE_IMAGE_DIR, key); const job = converter.readProjectInfo(abs, imagesDir).then((info) => ({ ...info, plates: info.plates.map((p) => { const image = [`${p.index}-small.png`, `${p.index}.png`].find((n) => fs.existsSync(path.join(imagesDir, n))); return { ...p, thumbnailUrl: image ? `/thumbnails/plates/${key}/${image}` : null }; }), })); projectInfoCache.set(key, job); job.catch(() => projectInfoCache.delete(key)); } res.json(await projectInfoCache.get(key)); } catch (e) { next(e); } }); } // Removes cached previews (GLBs incl. per-plate ones) and plate images for // deleted files and archive entries. function removeDerivedFiles(fileIds, entryIds) { const owned = new Set([...fileIds.map(String), ...entryIds.map((id) => `entry-${id}`)]); let names = []; try { names = fs.readdirSync('./glb'); } catch { /* no previews yet */ } for (const name of names) { const m = /^(.+?)(?:-plate\d+)?(?:-v\d+)?\.glb$/.exec(name); if (m && owned.has(m[1])) fs.rmSync(path.join('./glb', name), { force: true }); } for (const id of fileIds) { fs.rmSync(path.join(PLATE_IMAGE_DIR, `files-${id}`), { recursive: true, force: true }); projectInfoCache.delete(`files-${id}`); } for (const id of entryIds) { fs.rmSync(path.join(PLATE_IMAGE_DIR, `entries-${id}`), { recursive: true, force: true }); projectInfoCache.delete(`entries-${id}`); } } // ----------------------------------------------------------------- import // Unified import. Thingiverse downloads files automatically (needs a token); // Printables / MakerWorld / any other URL import page metadata + preview image, // and the user attaches the files afterwards. app.post('/api/import', async (req, res, next) => { try { const url = (req.body.url || '').trim(); const projects = req.body.projects || []; const detected = generic.detectSite(url); if (!detected) return res.status(400).json({ error: 'That does not look like a valid URL.' }); if (detected.site === 'thingiverse') { const model = await importThingiverse(url, projects); return res.status(201).json(model); } // Metadata-only import for everything else. if (detected.id) { const existing = await store.findModelBySource(detected.site, detected.id); if (existing) return res.status(409).json({ error: 'Already imported', model: publicModel(existing) }); } // Best-effort: read the page (via FlareSolverr if it's Cloudflare-blocked). // If it still can't be read, fall back to a stub so it's not a dead end. let html = null; let meta = { title: null, description: '', image: null, author: null }; let metadataFetched = false; try { html = await fetchPageHtml(url); meta = generic.parseMetadata(html, url); // Printables og:title carries a " | Download free STL model | Printables.com" suffix. if (detected.site === 'printables' && meta.title) meta.title = meta.title.split(' | ')[0].trim(); // cubee3d (Wix) serves a generic site-wide og:title, so use the URL slug instead. if (detected.site === 'cubee3d') meta.title = generic.nameFromUrl(url); metadataFetched = Boolean(meta.title); } catch (err) { console.warn('Metadata fetch failed, creating stub model:', err.message); } // Download the actual files where we can (MakerWorld via FlareSolverr+token, // Printables via its public GraphQL API), and capture the license. let files = []; let license = meta.license || null; let commercial = null; if (detected.site === 'makerworld' && html) { files = await downloadMakerworldFiles(html).catch((err) => { console.warn('MakerWorld file download failed:', err.message); return []; }); license = makerworld.parseLicense(html) || license; commercial = await resolveCommercial(license); } else if (detected.site === 'printables' && detected.id) { const result = await downloadPrintablesFiles(detected.id).catch((err) => { console.warn('Printables file download failed:', err.message); return { files: [] }; }); files = result.files; if (result.license) license = result.license; commercial = result.commercial ?? await resolveCommercial(license); } else if (detected.site === 'cubee3d') { // Auth-gated Wix site — files added manually. You hold a commercial license. license = 'Cubee3D Commercial License'; commercial = await resolveCommercial(license, true); } else { commercial = await resolveCommercial(license); } const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files); const model = await store.createModel({ name: meta.title || generic.nameFromUrl(url), description: meta.description || '', source_site: detected.site, source_url: url, source_id: detected.id, license, commercial_use: commercial, designer: meta.author ? { name: meta.author, source_site: detected.site } : null, thumbnail_path: thumbnail, tags: [], projects, files, }); fingerprint.kick(); res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched }); } catch (e) { next(e); } }); async function importThingiverse(url, projects) { const { value: token } = await resolveSetting('thingiverse_token'); if (!token) { const e = new Error('Thingiverse needs an API token — add one in Settings.'); e.status = 400; throw e; } const thingId = thingiverse.parse(url); if (!thingId) { const e = new Error('Could not find a Thingiverse thing id in that URL.'); e.status = 400; throw e; } const existing = await store.findModelBySource('thingiverse', String(thingId)); if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; } const meta = await thingiverse.fetchModel(thingId, token); const model = await downloadAndCreateModel(meta, projects); fingerprint.kick(); return publicModel(model); } // Fetch a page's HTML. Tries a direct request first; if the response is a // Cloudflare challenge (or the fetch fails) and FlareSolverr is configured, // retries through FlareSolverr to solve the challenge. async function fetchPageHtml(url) { const { value: flareUrl } = await resolveSetting('flaresolverr_url'); let direct = null; try { direct = await generic.fetchHtml(url); if (direct.ok && !generic.isCloudflareChallenge(direct.html)) return direct.html; } catch (err) { if (!flareUrl) throw err; } if (flareUrl) return flaresolverr.solve(flareUrl, url); if (direct && direct.ok) return direct.html; // 200 but looked like a challenge throw new Error(`Could not fetch page (HTTP ${direct ? direct.status : 'error'}).`); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Commercial-use for a license name, from the license_rules table. Unknown // licenses are auto-registered with a best-effort default (overridable in Settings). async function resolveCommercial(licenseName, fallback) { if (!licenseName) return null; const rule = await store.getLicenseRule(licenseName); if (rule) return rule.commercial; const seed = fallback !== undefined ? fallback : licenses.inferCommercial(licenseName); await store.upsertLicenseRule(licenseName, seed); return seed; } // Download every printable instance of a MakerWorld model (needs token + FlareSolverr). // skipNames: original filenames already present (so a re-download only adds new ones). async function downloadMakerworldFiles(html, skipNames = new Set()) { const { value: token } = await resolveSetting('makerworld_token'); const { value: flareUrl } = await resolveSetting('flaresolverr_url'); if (!token || !flareUrl) return []; const instances = makerworld.parseInstances(html); ensureUploadDir(); const files = []; for (let i = 0; i < instances.length; i++) { if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls const inst = instances[i]; let dl; try { dl = await makerworld.resolveDownload(flareUrl, inst.id, token); } catch (err) { console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message); continue; } if (!dl || !dl.url) continue; const original = dl.name || `instance-${inst.id}.3mf`; if (skipNames.has(original)) continue; const resp = await fetch(dl.url); if (!resp.ok) { console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); continue; } const fileName = uniqueName(original); const filePath = path.join(UPLOAD_DIR, fileName); fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer())); files.push(describeFile(filePath, fileName, original)); } return files; } // Download a Printables model's files via its public GraphQL API (token optional). // Returns { files, license, commercial }. async function downloadPrintablesFiles(printId, skipNames = new Set()) { const { value: token } = await resolveSetting('printables_token'); const model = await printables.fetchModel(printId, token); ensureUploadDir(); const files = []; for (const f of model.files) { if (!fileProcessor.isAllowed(f.name)) continue; // skip pdfs/images/etc. if (skipNames.has(f.name)) continue; let link; try { link = await printables.getDownloadLink(printId, f.fileType, f.id, token); } catch (err) { console.warn(`Printables link failed for ${f.name}:`, err.message); continue; } const resp = await fetch(link); if (!resp.ok) { console.warn(`Printables CDN download ${resp.status} for ${f.name}`); continue; } const fileName = uniqueName(f.name); const filePath = path.join(UPLOAD_DIR, fileName); fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer())); files.push(describeFile(filePath, fileName, f.name)); } const commercial = await resolveCommercial(model.license, licenses.inferCommercial(model.licenseContent || model.license)); return { files, license: model.license, commercial }; } // Download an og:image to thumbnails/ and return its path (or null on failure). async function downloadImageThumbnail(imageUrl) { try { const resp = await fetch(imageUrl); if (!resp.ok) return null; const type = resp.headers.get('content-type') || ''; const ext = type.includes('png') ? 'png' : type.includes('webp') ? 'webp' : type.includes('gif') ? 'gif' : 'jpg'; if (!fs.existsSync('./thumbnails')) fs.mkdirSync('./thumbnails', { recursive: true }); const name = `import-${Date.now()}-${Math.round(Math.random() * 1e6)}.${ext}`; fs.writeFileSync(path.join('./thumbnails', name), Buffer.from(await resp.arrayBuffer())); return `./thumbnails/${name}`; } catch (err) { console.error('Preview image download failed:', err.message); return null; } } // Download the model files from a Thingiverse metadata object. async function downloadThingiverseFiles(meta, skipNames = new Set()) { ensureUploadDir(); const files = []; for (const dl of meta.downloads) { if (!fileProcessor.isAllowed(dl.name)) continue; // skip readmes/images if (skipNames.has(dl.name)) continue; const resp = await fetch(dl.url); if (!resp.ok) { console.error(`Download failed (${resp.status}): ${dl.name}`); continue; } const buffer = Buffer.from(await resp.arrayBuffer()); const fileName = uniqueName(dl.name); const filePath = path.join(UPLOAD_DIR, fileName); fs.writeFileSync(filePath, buffer); files.push(describeFile(filePath, fileName, dl.name)); } return files; } // Download every file referenced by an importer's metadata, then create the model. async function downloadAndCreateModel(meta, projects) { const files = await downloadThingiverseFiles(meta); if (files.length === 0) throw new Error('No importable model files found on that page.'); return store.createModel({ name: meta.name, description: meta.description, source_site: meta.source_site, source_url: meta.source_url, source_id: meta.source_id, license: meta.license, commercial_use: await resolveCommercial(meta.license), designer: meta.designer, tags: meta.tags, projects, thumbnail_path: pickThumbnail(files), files, }); } // ----------------------------------------------------------------- projects app.get('/api/projects', async (req, res, next) => { try { res.json(await store.getAllProjects()); } catch (e) { next(e); } }); app.post('/api/projects', async (req, res, next) => { try { if (!req.body.name) return res.status(400).json({ error: 'Collection name is required' }); res.status(201).json(await store.createProject(req.body)); } catch (e) { next(e); } }); app.put('/api/projects/:id', async (req, res, next) => { try { const project = await store.updateProject(req.params.id, req.body); if (!project) return res.status(404).json({ error: 'Collection not found' }); res.json(project); } catch (e) { next(e); } }); app.delete('/api/projects/:id', async (req, res, next) => { try { const ok = await store.deleteProject(req.params.id); if (!ok) return res.status(404).json({ error: 'Collection not found' }); res.json({ message: 'Collection deleted' }); } catch (e) { next(e); } }); // ----------------------------------------------------------------- tags app.get('/api/tags', async (req, res, next) => { try { res.json(await store.getAllTags()); } catch (e) { next(e); } }); // ----------------------------------------------------------------- library / duplicates // Duplicate groups among non-trashed models, minus ones marked "not duplicates". async function findDuplicateGroups() { const [files, dismissed] = await Promise.all([ store.duplicateCandidateFiles(), store.dismissedDuplicateSignatures()]); const skip = new Set(dismissed); return duplicates.groupDuplicates(files).filter((g) => !skip.has(duplicates.signature(g.modelIds))); } app.get('/api/library/stats', async (req, res, next) => { try { const [trash, groups, pending, thumbnailsPending, tagsPending, aiRunning] = await Promise.all([ store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount(), autotag.pendingCount(), aiRuns.hasActiveRun()]); res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending, tagsPending, aiRunning }); } catch (e) { next(e); } }); app.get('/api/duplicates', async (req, res, next) => { try { const [groups, pending] = await Promise.all([findDuplicateGroups(), fingerprint.pendingCount()]); const ids = groups.flatMap((g) => g.modelIds); const [found, counts] = await Promise.all([store.getModelsByIds(ids), store.matchableCounts(ids)]); const byId = new Map(found.map((m) => [m.id, m])); const result = groups .map((g) => ({ ...g, models: g.modelIds.map((id) => byId.get(id)).filter(Boolean) })) .filter((g) => g.models.length > 1) .map((g) => ({ signature: duplicates.signature(g.modelIds), exact: g.exact, recommendedId: duplicates.recommendKeep(g.models), models: g.models.map((m) => ({ ...publicModel(m), matchedFiles: g.matchedFiles[m.id], matchableFiles: counts[m.id] ?? m.files.length, })), })); res.json({ pending, groups: result }); } catch (e) { next(e); } }); // Keep one model of a duplicate group; the rest are merged into it and trashed. app.post('/api/duplicates/resolve', async (req, res, next) => { try { const keepId = Number(req.body.keepId); const trashIds = [...new Set((req.body.trashIds || []).map(Number))]; if (!Number.isInteger(keepId) || trashIds.length === 0 || trashIds.some((id) => !Number.isInteger(id) || id === keepId)) { return res.status(400).json({ error: 'Pass keepId and a non-empty trashIds list that excludes it.' }); } const found = await store.getModelsByIds([keepId, ...trashIds]); if (found.length !== trashIds.length + 1 || found.some((m) => m.deleted_at)) { return res.status(409).json({ error: 'Some of those models are gone or already in the trash — refresh and try again.' }); } await store.mergeAndTrash(keepId, trashIds); res.json(publicModel(await store.getModel(keepId))); } catch (e) { next(e); } }); app.post('/api/duplicates/dismiss', async (req, res, next) => { try { const ids = (req.body.modelIds || []).map(Number).filter(Number.isInteger); if (new Set(ids).size < 2) return res.status(400).json({ error: 'Pass at least two modelIds.' }); await store.dismissDuplicateGroup(duplicates.signature(ids)); res.json({ ok: true }); } catch (e) { next(e); } }); // ----------------------------------------------------------------- misc function parseJson(value, fallback) { if (value === undefined || value === null || value === '') return fallback; try { return JSON.parse(value); } catch { return fallback; } } // ----------------------------------------------------------------- AI tagging // Which models a run covers: not yet tagged by AI, everything, or a chosen list. async function aiScopeIds(scope, ids) { if (scope === 'ids') return (Array.isArray(ids) ? ids : []).map(Number).filter(Number.isInteger); const { rows } = await db.query( `SELECT id FROM models WHERE deleted_at IS NULL ${scope === 'all' ? '' : 'AND ai_tagged_at IS NULL'} ORDER BY id`); return rows.map((r) => r.id); } app.get('/api/ai/status', async (req, res, next) => { try { const config = await resolveAiConfig(); const { rows: [counts] } = await db.query( `SELECT COUNT(*)::int AS all, COUNT(*) FILTER (WHERE ai_tagged_at IS NULL)::int AS untagged FROM models WHERE deleted_at IS NULL`); const { apiKey, ...safe } = config; res.json({ config: { ...safe, keySet: Boolean(apiKey) }, problem: ai.missingConfig(config), providers: ai.PROVIDERS, counts, search: await embeddings.status(), autotag: { enabled: await autotag.isEnabled(), pending: await autotag.pendingCount() }, }); } catch (e) { next(e); } }); // Tags a sample model with the saved settings, sending a library thumbnail too // when images are on, to show whether the key, model and image input all work. app.post('/api/ai/test', async (req, res, next) => { try { const config = await resolveAiConfig(); const problem = ai.missingConfig(config); if (problem) return res.status(400).json({ error: problem }); let image = null; if (config.sendImages) { const { rows } = await db.query( `SELECT id, thumbnail_path FROM models WHERE thumbnail_path IS NOT NULL AND deleted_at IS NULL ORDER BY id DESC LIMIT 10`); image = rows.map((m) => aiRuns.thumbnailDataUrl(m)).find(Boolean) || null; } const started = Date.now(); const sample = { name: 'Flexi articulated dragon', files: [{ originalName: 'flexi_dragon_print_in_place.3mf' }], tags: [] }; try { const result = await ai.describeModel(config, sample, { image }); res.json({ ok: true, model: config.model, ms: Date.now() - started, tags: result.tags, description: result.description, images: !image ? 'not tried' : result.imageUsed ? 'accepted' : 'not supported by this model', usage: result.usage, cost: result.cost, }); } catch (err) { res.status(502).json({ error: ai.fatalMessage(err) || err.message }); } } catch (e) { next(e); } }); app.post('/api/ai/estimate', async (req, res, next) => { try { const ids = await aiScopeIds(req.body && req.body.scope, req.body && req.body.ids); res.json({ count: ids.length, ...ai.estimate(ids.length, await resolveAiConfig()) }); } catch (e) { next(e); } }); app.get('/api/ai/runs', async (req, res, next) => { try { res.json(await aiRuns.listRuns()); } catch (e) { next(e); } }); app.post('/api/ai/runs', async (req, res, next) => { try { const { scope, ids, overwriteDescriptions, maxCost } = req.body || {}; const run = await aiRuns.createRun(await aiScopeIds(scope, ids), { overwriteDescriptions, maxCost }); res.status(201).json(run); } catch (e) { next(e); } }); app.get('/api/ai/runs/:id', async (req, res, next) => { try { const run = await aiRuns.getRun(Number(req.params.id)); if (!run) return res.status(404).json({ error: 'Run not found' }); res.json(run); } catch (e) { next(e); } }); app.post('/api/ai/runs/:id/stop', async (req, res, next) => { try { if (!(await aiRuns.stop(Number(req.params.id)))) return res.status(409).json({ error: 'This run isn’t going' }); res.json(await aiRuns.getRun(Number(req.params.id))); } catch (e) { next(e); } }); app.post('/api/ai/runs/:id/resume', async (req, res, next) => { try { if (!(await aiRuns.resume(Number(req.params.id)))) return res.status(409).json({ error: 'Nothing left to do in this run' }); res.json(await aiRuns.getRun(Number(req.params.id))); } catch (e) { next(e); } }); // ----------------------------------------------------------------- semantic search app.get('/api/search', async (req, res, next) => { try { const q = String(req.query.q || '').trim(); if (!q) return res.json({ results: [] }); try { res.json({ results: await embeddings.search(q) }); } catch (err) { res.status(503).json({ error: `Search by meaning isn’t available: ${err.message}` }); } } catch (e) { next(e); } }); // ----------------------------------------------------------------- automatic tags async function autotagState() { return { enabled: await autotag.isEnabled(), pending: await autotag.pendingCount(), builtIn: autotag.BUILT_IN_RULES, rules: await autotag.listRules(), }; } app.get('/api/autotag', async (req, res, next) => { try { res.json(await autotagState()); } catch (e) { next(e); } }); app.put('/api/autotag', async (req, res, next) => { try { const { enabled, removeTags } = req.body || {}; const removed = await autotag.setEnabled(Boolean(enabled), { removeTags: Boolean(removeTags) }); fingerprint.kick(); res.json({ ...(await autotagState()), removed }); } catch (e) { next(e); } }); app.post('/api/autotag/rules', async (req, res, next) => { try { await autotag.addRule(req.body && req.body.phrase, req.body && req.body.tag); fingerprint.kick(); res.status(201).json(await autotagState()); } catch (e) { next(e); } }); app.delete('/api/autotag/rules/:id', async (req, res, next) => { try { await autotag.removeRule(Number(req.params.id)); fingerprint.kick(); res.json(await autotagState()); } catch (e) { next(e); } }); app.post('/api/autotag/reapply', async (req, res, next) => { try { await autotag.reapply(); fingerprint.kick(); res.json(await autotagState()); } catch (e) { next(e); } }); // Central error handler. app.use((err, req, res, next) => { if (!err.status) console.error('Request error:', err.message); res.status(err.status || 500).json({ error: err.message }); }); db.init() .then(() => { app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)); fingerprint.kick(); // backfill fingerprints, tags and the search index return aiRuns.init(resolveAiConfig); }) .catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });