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>
1460 lines
62 KiB
JavaScript
1460 lines
62 KiB
JavaScript
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); });
|