printhive/server.js
dlawler489 a37db15a3f Initial commit: Printhive 3D-print file library
Self-hosted Express + Postgres app for organizing STL/3MF/OBJ/etc. with:
- Model/file library, tags, collections, print settings
- URL import: Thingiverse (API), Printables (GraphQL), MakerWorld
  (FlareSolverr + token), cubee3d/other (metadata + manual files)
- In-browser 3D viewer; 3MF rendered via Python/trimesh GLB conversion
- Auto thumbnails, license/commercial-use tracking, in-app editing
- Settings (API tokens / FlareSolverr) stored in DB with .env fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:15:17 +10:00

659 lines
28 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 app = express();
const PORT = process.env.PORT || 3000;
const UPLOAD_DIR = './uploads';
app.use(express.json({ limit: '8mb' })); // generous for base64 thumbnail PNGs
app.use(express.static('public'));
app.use('/thumbnails', express.static('thumbnails'));
// ----------------------------------------------------------------- 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 },
};
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');
res.json({
importers: { thingiverse: Boolean(thingiverse.value), flaresolverr: Boolean(flare.value) },
formats: fileProcessor.MODEL_FORMATS,
});
} 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); }
});
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 model = await store.updateModel(req.params.id, req.body);
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: 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: 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: licenses.inferCommercial(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: licenses.inferCommercial(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 = licenses.inferCommercial(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 ?? licenses.inferCommercial(license);
} else if (detected.site === 'cubee3d') {
// Auth-gated Wix site — files added manually. You hold a commercial license.
license = 'Cubee3D Commercial License';
commercial = true;
} else {
commercial = licenses.inferCommercial(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));
// 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 = 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: licenses.inferCommercial(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); });