printhive/server.js
dlawler489 0df38c83c7 Watch a MakerWorld collection and import models added to it
A watch reads the collection page through FlareSolverr carrying your token, so
private collections work, and imports the models it hasn't seen into a Printhive
collection of the same name (optionally AI-tagging them). Checks run on your
chosen interval, a few models at a time with pauses between, and each design is
remembered so nothing is imported twice. A refused token stops the check and the
Watched page offers to update it; models removed on MakerWorld stay here.

The import endpoint's work moved into importFromUrl() so watches reuse it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:53:35 +10:00

1460 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 collectionWatch = require('./lib/collectionWatch');
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') {
await requireMakerworldSettings(); // before the page fetch, which fails without FlareSolverr
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/<token>/<name>", 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(); // "<kind>-<id>" -> 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.
// Imports one model URL: its details, its files where they can be downloaded, and
// its license. Returns { model, existing, metadataFetched, filesError, filesErrorReason }.
// Used by the import endpoint and by watched collections.
async function importFromUrl(url, { projects = [] } = {}) {
const detected = generic.detectSite(url);
if (!detected) throw Object.assign(new Error('That does not look like a valid URL.'), { status: 400 });
if (detected.site === 'thingiverse') {
return { model: await importThingiverse(url, projects), metadataFetched: true };
}
// Metadata-only import for everything else.
if (detected.id) {
const existing = await store.findModelBySource(detected.site, detected.id);
if (existing) return { model: publicModel(existing), existing: true };
}
// 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 filesError = null; // why no files could be downloaded, shown after the import
let filesErrorReason = null; // makerworld_token: the app offers to update the token
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);
filesError = err.message;
filesErrorReason = err.reason || null;
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);
filesError = 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();
return { model: publicModel(model), metadataFetched, filesError, filesErrorReason };}
// 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 result = await importFromUrl((req.body.url || '').trim(), { projects: req.body.projects || [] });
if (result.existing) return res.status(409).json({ error: 'Already imported', model: result.model });
res.status(201).json({
...result.model,
metadataOnly: (result.model.files || []).length === 0,
metadataFetched: Boolean(result.metadataFetched),
filesError: result.filesError || null,
filesErrorReason: result.filesErrorReason || null,
});
} 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).
// MakerWorld downloads need both a FlareSolverr address and your auth token.
// Errors carry a reason the app can act on: makerworld_token opens the token prompt.
async function requireMakerworldSettings() {
const { value: token } = await resolveSetting('makerworld_token');
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
if (!flareUrl) {
throw Object.assign(new Error('Add your FlareSolverr URL in Settings to download MakerWorld files (MakerWorld is behind Cloudflare).'),
{ status: 400, reason: 'makerworld_settings' });
}
if (!token) {
throw Object.assign(new Error('Add your MakerWorld auth token to download MakerWorld files.'), { status: 400, reason: 'makerworld_token' });
}
return { token, flareUrl };
}
// Downloads a MakerWorld model's files. When nothing could be downloaded it throws
// with the reason (missing settings, an expired token, …) so the import can say why.
async function downloadMakerworldFiles(html, skipNames = new Set()) {
const { token, flareUrl } = await requireMakerworldSettings();
const instances = makerworld.parseInstances(html);
if (!instances.length) {
throw Object.assign(new Error('Couldn’t find any printable files on the MakerWorld page.'), { status: 502 });
}
ensureUploadDir();
const files = [];
const problems = [];
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);
problems.push({ message: `Couldn’t ask MakerWorld for a download link: ${err.message}` });
continue;
}
const problem = makerworld.downloadProblem(dl);
if (problem) {
console.warn(`MakerWorld instance ${inst.id}: ${problem.message}`);
problems.push(problem);
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}`);
problems.push({ message: `Downloading ${original} from MakerWorld failed (HTTP ${resp.status})` });
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));
}
if (!files.length && problems.length) {
const refused = problems.find((p) => p.tokenRejected);
throw Object.assign(new Error((refused || problems[0]).message),
{ status: 502, reason: refused ? 'makerworld_token' : undefined });
}
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()]);
const { rows: [watches] } = await db.query('SELECT COUNT(*)::int AS n FROM watched_collections WHERE enabled');
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending, tagsPending,
aiRunning, watches: watches.n });
} 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, describe, overwriteDescriptions, renameUnclear, suggestCollections, maxCost } = req.body || {};
const run = await aiRuns.createRun(await aiScopeIds(scope, ids),
{ describe, overwriteDescriptions, renameUnclear, suggestCollections, 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); }
});
// ----------------------------------------------------------------- watched collections
// Reads a MakerWorld collection page as you: a private collection is only listed
// when the request carries your token.
async function fetchMakerworldCollection(url) {
const { token, flareUrl } = await requireMakerworldSettings();
const html = await flaresolverr.solve(flareUrl, url, {
cookies: [{ name: 'token', value: token, domain: '.makerworld.com' }],
});
const collection = makerworld.parseCollection(html);
if (!collection) {
throw Object.assign(
new Error('MakerWorld didn’t show that collection. If it’s private, your MakerWorld token may have expired; otherwise check the link.'),
{ status: 502, reason: 'makerworld_token' });
}
return collection;
}
app.get('/api/watches', async (req, res, next) => {
try { res.json(await collectionWatch.list()); } catch (e) { next(e); }
});
app.get('/api/watches/:id', async (req, res, next) => {
try {
const watch = await collectionWatch.get(Number(req.params.id));
if (!watch) return res.status(404).json({ error: 'Not watching that collection' });
res.json(watch);
} catch (e) { next(e); }
});
app.post('/api/watches', async (req, res, next) => {
try {
const { url, aiTag, intervalHours } = req.body || {};
res.status(201).json(await collectionWatch.add({ url: String(url || '').trim(), aiTag, intervalHours }));
} catch (e) { next(e); }
});
app.put('/api/watches/:id', async (req, res, next) => {
try {
const watch = await collectionWatch.update(Number(req.params.id), req.body || {});
if (!watch) return res.status(404).json({ error: 'Not watching that collection' });
res.json(watch);
} catch (e) { next(e); }
});
app.delete('/api/watches/:id', async (req, res, next) => {
try {
if (!(await collectionWatch.remove(Number(req.params.id)))) return res.status(404).json({ error: 'Not watching that collection' });
res.json({ message: 'Stopped watching. The models it imported are kept.' });
} catch (e) { next(e); }
});
// Reads the collection now (so problems come back here) and imports in the background.
app.post('/api/watches/:id/check', async (req, res, next) => {
try { res.json(await collectionWatch.checkNow(Number(req.params.id))); } catch (e) { next(e); }
});
// ----------------------------------------------------------------- collection suggestions
function suggestionTarget(body) {
const name = typeof (body && body.name) === 'string' ? body.name.trim() : '';
const modelIds = Array.isArray(body && body.modelIds) ? body.modelIds.map(Number).filter(Number.isInteger) : null;
return { name, modelIds: modelIds && modelIds.length ? modelIds : null };
}
app.get('/api/collection-suggestions', async (req, res, next) => {
try { res.json(await store.listCollectionSuggestions()); } catch (e) { next(e); }
});
app.post('/api/collection-suggestions/accept', async (req, res, next) => {
try {
const { name, modelIds } = suggestionTarget(req.body);
if (!name) return res.status(400).json({ error: 'Which suggestion?' });
const result = await store.acceptCollectionSuggestion(name, modelIds);
if (!result) return res.status(404).json({ error: 'That suggestion is no longer waiting' });
res.json(result);
} catch (e) { next(e); }
});
app.post('/api/collection-suggestions/dismiss', async (req, res, next) => {
try {
const { name, modelIds } = suggestionTarget(req.body);
if (!name) return res.status(400).json({ error: 'Which suggestion?' });
res.json({ dismissed: await store.dismissCollectionSuggestion(name, modelIds) });
} 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, ...(err.reason ? { reason: err.reason } : {}) });
});
db.init()
.then(() => {
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
fingerprint.kick(); // backfill fingerprints, tags and the search index
collectionWatch.init({
fetchCollection: fetchMakerworldCollection,
importFromUrl,
startAiRun: (modelIds) => aiRuns.createRun(modelIds, { describe: true }),
});
collectionWatch.start();
return aiRuns.init(resolveAiConfig);
})
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });