All checks were successful
build-and-push / docker (push) Successful in 28s
- Changing a license policy applies to all models with that license, and opening Settings reconciles old imports to current rules (store.reconcileModelsToRules) - Editing a model's license re-resolves its sellability from the rule - Removed the per-model commercial-use override (set by license now) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
826 lines
35 KiB
JavaScript
826 lines
35 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 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'));
|
|
|
|
// 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 (embedded 3MF preview, if any).
|
|
function pickThumbnail(files) {
|
|
const threeMf = files.find((f) => f.format === '3mf');
|
|
if (!threeMf) return null;
|
|
return fileProcessor.extract3mfThumbnail(threeMf.filePath, path.parse(threeMf.fileName).name);
|
|
}
|
|
|
|
// 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.MODEL_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 },
|
|
};
|
|
|
|
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();
|
|
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,
|
|
});
|
|
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);
|
|
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' });
|
|
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);
|
|
res.json({ ...publicModel(updated), added: newFiles.length });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// Returns model files[] downloaded from the model's source, skipping any whose
|
|
// original name is already present.
|
|
async function fetchSourceFiles(model, skipNames) {
|
|
if (model.source_site === 'makerworld') {
|
|
const html = await fetchPageHtml(model.source_url);
|
|
return downloadMakerworldFiles(html, skipNames);
|
|
}
|
|
if (model.source_site === 'printables' && model.source_id) {
|
|
const { files } = await downloadPrintablesFiles(model.source_id, skipNames);
|
|
return files;
|
|
}
|
|
if (model.source_site === 'thingiverse' && model.source_id) {
|
|
const { value: token } = await resolveSetting('thingiverse_token');
|
|
if (!token) { const e = new Error('Thingiverse token required (set it in Settings).'); e.status = 400; throw e; }
|
|
const meta = await thingiverse.fetchModel(model.source_id, token);
|
|
return downloadThingiverseFiles(meta, skipNames);
|
|
}
|
|
const e = new Error('No downloadable file source for this model.');
|
|
e.status = 400;
|
|
throw e;
|
|
}
|
|
|
|
app.delete('/api/models/:id', async (req, res, next) => {
|
|
try {
|
|
const removed = await store.deleteModel(req.params.id);
|
|
if (!removed) return res.status(404).json({ error: 'Model not found' });
|
|
for (const p of removed.filePaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
|
for (const p of removed.glbPaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
|
if (removed.thumbnailPath && fs.existsSync(removed.thumbnailPath)) fs.unlinkSync(removed.thumbnailPath);
|
|
res.json({ message: 'Model deleted' });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// 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); }
|
|
});
|
|
|
|
// Serve a raw model file (used by the in-browser 3D viewer).
|
|
app.get('/api/files/:id/raw', async (req, res, next) => {
|
|
try {
|
|
const { rows } = await db.query('SELECT file_path, original_name FROM files WHERE id = $1', [req.params.id]);
|
|
if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
|
|
const abs = path.resolve(rows[0].file_path);
|
|
if (!fs.existsSync(abs)) return res.status(404).json({ error: 'File missing on disk' });
|
|
res.download(abs, rows[0].original_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.
|
|
const glbConversions = new Map(); // fileId -> in-flight Promise
|
|
|
|
app.get('/api/files/:id/glb', async (req, res, next) => {
|
|
try {
|
|
const { rows } = await db.query('SELECT id, file_path, format, glb_path FROM files WHERE id = $1', [req.params.id]);
|
|
if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
|
|
const file = rows[0];
|
|
if (file.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' });
|
|
|
|
let glbPath = file.glb_path;
|
|
if (!glbPath || !fs.existsSync(glbPath)) {
|
|
if (!glbConversions.has(file.id)) {
|
|
glbConversions.set(file.id, convertFileToGlb(file).finally(() => glbConversions.delete(file.id)));
|
|
}
|
|
glbPath = await glbConversions.get(file.id);
|
|
}
|
|
res.sendFile(path.resolve(glbPath));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
async function convertFileToGlb(file) {
|
|
if (!fs.existsSync('./glb')) fs.mkdirSync('./glb', { recursive: true });
|
|
const out = path.join('./glb', `${file.id}.glb`);
|
|
const result = await converter.convert3mfToGlb(path.resolve(file.file_path), path.resolve(out));
|
|
await store.updateFileConversion(file.id, { glb_path: out, volume: result.volume_cm3, bbox: result.bbox });
|
|
return out;
|
|
}
|
|
|
|
// ----------------------------------------------------------------- import
|
|
// Unified import. Thingiverse downloads files automatically (needs a token);
|
|
// Printables / MakerWorld / any other URL import page metadata + preview image,
|
|
// and the user attaches the files afterwards.
|
|
app.post('/api/import', async (req, res, next) => {
|
|
try {
|
|
const url = (req.body.url || '').trim();
|
|
const projects = req.body.projects || [];
|
|
const detected = generic.detectSite(url);
|
|
if (!detected) return res.status(400).json({ error: 'That does not look like a valid URL.' });
|
|
|
|
if (detected.site === 'thingiverse') {
|
|
const model = await importThingiverse(url, projects);
|
|
return res.status(201).json(model);
|
|
}
|
|
|
|
// Metadata-only import for everything else.
|
|
if (detected.id) {
|
|
const existing = await store.findModelBySource(detected.site, detected.id);
|
|
if (existing) return res.status(409).json({ error: 'Already imported', model: publicModel(existing) });
|
|
}
|
|
// Best-effort: read the page (via FlareSolverr if it's Cloudflare-blocked).
|
|
// If it still can't be read, fall back to a stub so it's not a dead end.
|
|
let html = null;
|
|
let meta = { title: null, description: '', image: null, author: null };
|
|
let metadataFetched = false;
|
|
try {
|
|
html = await fetchPageHtml(url);
|
|
meta = generic.parseMetadata(html, url);
|
|
// Printables og:title carries a " | Download free STL model | Printables.com" suffix.
|
|
if (detected.site === 'printables' && meta.title) meta.title = meta.title.split(' | ')[0].trim();
|
|
// cubee3d (Wix) serves a generic site-wide og:title, so use the URL slug instead.
|
|
if (detected.site === 'cubee3d') meta.title = generic.nameFromUrl(url);
|
|
metadataFetched = Boolean(meta.title);
|
|
} catch (err) {
|
|
console.warn('Metadata fetch failed, creating stub model:', err.message);
|
|
}
|
|
|
|
// Download the actual files where we can (MakerWorld via FlareSolverr+token,
|
|
// Printables via its public GraphQL API), and capture the license.
|
|
let files = [];
|
|
let license = meta.license || null;
|
|
let commercial = null;
|
|
if (detected.site === 'makerworld' && html) {
|
|
files = await downloadMakerworldFiles(html).catch((err) => {
|
|
console.warn('MakerWorld file download failed:', err.message);
|
|
return [];
|
|
});
|
|
license = makerworld.parseLicense(html) || license;
|
|
commercial = await resolveCommercial(license);
|
|
} else if (detected.site === 'printables' && detected.id) {
|
|
const result = await downloadPrintablesFiles(detected.id).catch((err) => {
|
|
console.warn('Printables file download failed:', err.message);
|
|
return { files: [] };
|
|
});
|
|
files = result.files;
|
|
if (result.license) license = result.license;
|
|
commercial = result.commercial ?? await resolveCommercial(license);
|
|
} else if (detected.site === 'cubee3d') {
|
|
// Auth-gated Wix site — files added manually. You hold a commercial license.
|
|
license = 'Cubee3D Commercial License';
|
|
commercial = await resolveCommercial(license, true);
|
|
} else {
|
|
commercial = await resolveCommercial(license);
|
|
}
|
|
|
|
const thumbnail = (meta.image ? await downloadImageThumbnail(meta.image) : null) || pickThumbnail(files);
|
|
const model = await store.createModel({
|
|
name: meta.title || generic.nameFromUrl(url),
|
|
description: meta.description || '',
|
|
source_site: detected.site,
|
|
source_url: url,
|
|
source_id: detected.id,
|
|
license,
|
|
commercial_use: commercial,
|
|
designer: meta.author ? { name: meta.author, source_site: detected.site } : null,
|
|
thumbnail_path: thumbnail,
|
|
tags: [],
|
|
projects,
|
|
files,
|
|
});
|
|
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
async function importThingiverse(url, projects) {
|
|
const { value: token } = await resolveSetting('thingiverse_token');
|
|
if (!token) { const e = new Error('Thingiverse needs an API token — add one in Settings.'); e.status = 400; throw e; }
|
|
const thingId = thingiverse.parse(url);
|
|
if (!thingId) { const e = new Error('Could not find a Thingiverse thing id in that URL.'); e.status = 400; throw e; }
|
|
const existing = await store.findModelBySource('thingiverse', String(thingId));
|
|
if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; }
|
|
const meta = await thingiverse.fetchModel(thingId, token);
|
|
return publicModel(await downloadAndCreateModel(meta, projects));
|
|
}
|
|
|
|
// Fetch a page's HTML. Tries a direct request first; if the response is a
|
|
// Cloudflare challenge (or the fetch fails) and FlareSolverr is configured,
|
|
// retries through FlareSolverr to solve the challenge.
|
|
async function fetchPageHtml(url) {
|
|
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
|
|
let direct = null;
|
|
try {
|
|
direct = await generic.fetchHtml(url);
|
|
if (direct.ok && !generic.isCloudflareChallenge(direct.html)) return direct.html;
|
|
} catch (err) {
|
|
if (!flareUrl) throw err;
|
|
}
|
|
if (flareUrl) return flaresolverr.solve(flareUrl, url);
|
|
if (direct && direct.ok) return direct.html; // 200 but looked like a challenge
|
|
throw new Error(`Could not fetch page (HTTP ${direct ? direct.status : 'error'}).`);
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// Commercial-use for a license name, from the license_rules table. Unknown
|
|
// licenses are auto-registered with a best-effort default (overridable in Settings).
|
|
async function resolveCommercial(licenseName, fallback) {
|
|
if (!licenseName) return null;
|
|
const rule = await store.getLicenseRule(licenseName);
|
|
if (rule) return rule.commercial;
|
|
const seed = fallback !== undefined ? fallback : licenses.inferCommercial(licenseName);
|
|
await store.upsertLicenseRule(licenseName, seed);
|
|
return seed;
|
|
}
|
|
|
|
// Download every printable instance of a MakerWorld model (needs token + FlareSolverr).
|
|
// skipNames: original filenames already present (so a re-download only adds new ones).
|
|
async function downloadMakerworldFiles(html, skipNames = new Set()) {
|
|
const { value: token } = await resolveSetting('makerworld_token');
|
|
const { value: flareUrl } = await resolveSetting('flaresolverr_url');
|
|
if (!token || !flareUrl) return [];
|
|
|
|
const instances = makerworld.parseInstances(html);
|
|
ensureUploadDir();
|
|
const files = [];
|
|
for (let i = 0; i < instances.length; i++) {
|
|
if (i > 0) await sleep(1200); // give FlareSolverr's browser room between calls
|
|
const inst = instances[i];
|
|
let dl;
|
|
try {
|
|
dl = await makerworld.resolveDownload(flareUrl, inst.id, token);
|
|
} catch (err) {
|
|
console.warn(`MakerWorld instance ${inst.id} resolve failed:`, err.message);
|
|
continue;
|
|
}
|
|
if (!dl || !dl.url) continue;
|
|
const original = dl.name || `instance-${inst.id}.3mf`;
|
|
if (skipNames.has(original)) continue;
|
|
const resp = await fetch(dl.url);
|
|
if (!resp.ok) { console.warn(`MakerWorld CDN download ${resp.status} for ${original}`); continue; }
|
|
const fileName = uniqueName(original);
|
|
const filePath = path.join(UPLOAD_DIR, fileName);
|
|
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
|
files.push(describeFile(filePath, fileName, original));
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// Download a Printables model's files via its public GraphQL API (token optional).
|
|
// Returns { files, license, commercial }.
|
|
async function downloadPrintablesFiles(printId, skipNames = new Set()) {
|
|
const { value: token } = await resolveSetting('printables_token');
|
|
const model = await printables.fetchModel(printId, token);
|
|
ensureUploadDir();
|
|
const files = [];
|
|
for (const f of model.files) {
|
|
if (!fileProcessor.isAllowed(f.name)) continue; // skip pdfs/images/etc.
|
|
if (skipNames.has(f.name)) continue;
|
|
let link;
|
|
try {
|
|
link = await printables.getDownloadLink(printId, f.fileType, f.id, token);
|
|
} catch (err) {
|
|
console.warn(`Printables link failed for ${f.name}:`, err.message);
|
|
continue;
|
|
}
|
|
const resp = await fetch(link);
|
|
if (!resp.ok) { console.warn(`Printables CDN download ${resp.status} for ${f.name}`); continue; }
|
|
const fileName = uniqueName(f.name);
|
|
const filePath = path.join(UPLOAD_DIR, fileName);
|
|
fs.writeFileSync(filePath, Buffer.from(await resp.arrayBuffer()));
|
|
files.push(describeFile(filePath, fileName, f.name));
|
|
}
|
|
const commercial = await resolveCommercial(model.license, licenses.inferCommercial(model.licenseContent || model.license));
|
|
return { files, license: model.license, commercial };
|
|
}
|
|
|
|
// Download an og:image to thumbnails/ and return its path (or null on failure).
|
|
async function downloadImageThumbnail(imageUrl) {
|
|
try {
|
|
const resp = await fetch(imageUrl);
|
|
if (!resp.ok) return null;
|
|
const type = resp.headers.get('content-type') || '';
|
|
const ext = type.includes('png') ? 'png' : type.includes('webp') ? 'webp' : type.includes('gif') ? 'gif' : 'jpg';
|
|
if (!fs.existsSync('./thumbnails')) fs.mkdirSync('./thumbnails', { recursive: true });
|
|
const name = `import-${Date.now()}-${Math.round(Math.random() * 1e6)}.${ext}`;
|
|
fs.writeFileSync(path.join('./thumbnails', name), Buffer.from(await resp.arrayBuffer()));
|
|
return `./thumbnails/${name}`;
|
|
} catch (err) {
|
|
console.error('Preview image download failed:', err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Download the model files from a Thingiverse metadata object.
|
|
async function downloadThingiverseFiles(meta, skipNames = new Set()) {
|
|
ensureUploadDir();
|
|
const files = [];
|
|
for (const dl of meta.downloads) {
|
|
if (!fileProcessor.isAllowed(dl.name)) continue; // skip readmes/images
|
|
if (skipNames.has(dl.name)) continue;
|
|
const resp = await fetch(dl.url);
|
|
if (!resp.ok) { console.error(`Download failed (${resp.status}): ${dl.name}`); continue; }
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
const fileName = uniqueName(dl.name);
|
|
const filePath = path.join(UPLOAD_DIR, fileName);
|
|
fs.writeFileSync(filePath, buffer);
|
|
files.push(describeFile(filePath, fileName, dl.name));
|
|
}
|
|
return files;
|
|
}
|
|
|
|
// Download every file referenced by an importer's metadata, then create the model.
|
|
async function downloadAndCreateModel(meta, projects) {
|
|
const files = await downloadThingiverseFiles(meta);
|
|
if (files.length === 0) throw new Error('No importable model files found on that page.');
|
|
|
|
return store.createModel({
|
|
name: meta.name,
|
|
description: meta.description,
|
|
source_site: meta.source_site,
|
|
source_url: meta.source_url,
|
|
source_id: meta.source_id,
|
|
license: meta.license,
|
|
commercial_use: await resolveCommercial(meta.license),
|
|
designer: meta.designer,
|
|
tags: meta.tags,
|
|
projects,
|
|
thumbnail_path: pickThumbnail(files),
|
|
files,
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------------- projects
|
|
app.get('/api/projects', async (req, res, next) => {
|
|
try { res.json(await store.getAllProjects()); } catch (e) { next(e); }
|
|
});
|
|
|
|
app.post('/api/projects', async (req, res, next) => {
|
|
try {
|
|
if (!req.body.name) return res.status(400).json({ error: 'Collection name is required' });
|
|
res.status(201).json(await store.createProject(req.body));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
app.put('/api/projects/:id', async (req, res, next) => {
|
|
try {
|
|
const project = await store.updateProject(req.params.id, req.body);
|
|
if (!project) return res.status(404).json({ error: 'Collection not found' });
|
|
res.json(project);
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
app.delete('/api/projects/:id', async (req, res, next) => {
|
|
try {
|
|
const ok = await store.deleteProject(req.params.id);
|
|
if (!ok) return res.status(404).json({ error: 'Collection not found' });
|
|
res.json({ message: 'Collection deleted' });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
// ----------------------------------------------------------------- tags
|
|
app.get('/api/tags', async (req, res, next) => {
|
|
try { res.json(await store.getAllTags()); } catch (e) { next(e); }
|
|
});
|
|
|
|
// ----------------------------------------------------------------- misc
|
|
function parseJson(value, fallback) {
|
|
if (value === undefined || value === null || value === '') return fallback;
|
|
try { return JSON.parse(value); } catch { return fallback; }
|
|
}
|
|
|
|
// Central error handler.
|
|
app.use((err, req, res, next) => {
|
|
if (!err.status) console.error('Request error:', err.message);
|
|
res.status(err.status || 500).json({ error: err.message });
|
|
});
|
|
|
|
db.init()
|
|
.then(() => app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)))
|
|
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|