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>
This commit is contained in:
dlawler489 2026-06-22 13:15:17 +10:00
commit a37db15a3f
22 changed files with 4673 additions and 0 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
# Copy to .env and fill in.
DATABASE_URL=postgres://USER@localhost:5432/stl_library
PORT=3000
# Thingiverse import — get a free app token at https://www.thingiverse.com/developers
THINGIVERSE_TOKEN=

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
node_modules/
data/
uploads/
thumbnails/
*.log
.DS_Store
.env
glb/
tools/venv/

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# Printhive
A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP, GCODE, ZIP) into a searchable library — with in-browser 3D previews, collections, license/commercial-use tracking, and one-paste **import from popular model sites**.
## Features
- **Library** of models, each owning one or more files; tags, collections, descriptions, print settings.
- **Import from URL**:
- **Thingiverse** — downloads files automatically via the official API (needs a free app token).
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token.
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
- **In-browser 3D viewer** (Three.js) for STL/OBJ, and for 3MF via a server-side **trimesh GLB conversion** (handles Bambu/MakerWorld production-extension files the JS loader can't).
- **Auto thumbnails** rendered from the model; embedded 3MF thumbnails used when present.
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
- **Collections** — group models (a model can be in many); rename/delete without losing models.
- **Editing** — rename/retag, edit print settings, override commercial-use, refresh metadata from source, re-download files, download all files as a zip.
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
## Tech stack
- Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL.
- Three.js (viewer) via CDN import map.
- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion and volume.
- Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources.
## Setup
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
2. **Python sidecar** (for 3MF previews/volume):
```bash
python3 -m venv tools/venv
tools/venv/bin/pip install trimesh numpy networkx lxml
```
3. **Install deps and run**:
```bash
npm install
cp .env.example .env # set DATABASE_URL (+ optional tokens)
npm start
```
4. Open http://localhost:3000
## Configuration
Settings live in the database (editable in the app's **Settings** menu) and fall back to environment variables:
| Setting | Env var | Purpose |
|---|---|---|
| Thingiverse API token | `THINGIVERSE_TOKEN` | Thingiverse auto-download |
| FlareSolverr URL | `FLARESOLVERR_URL` | Read Cloudflare-protected sites (MakerWorld) |
| MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) |
| Printables auth token | `PRINTABLES_TOKEN` | Optional; account-gated Printables files |
## Notes
- Auto-download from MakerWorld/Printables relies on their private/session APIs and can break if they change; failed/partial imports can be recovered with "Download files from source".
- The commercial-use badge is a best-effort read of the license — always confirm on the source page before selling.

84
db/schema.sql Normal file
View file

@ -0,0 +1,84 @@
-- STL Library Manager schema
-- A "model" is one logical download (a Thingiverse thing, a Printables model, a
-- single uploaded STL). A model owns one or more files (the actual .stl/.3mf/...).
-- Simple key/value app settings (API tokens, site auth). Editable from the UI.
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS designers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
source_site TEXT, -- thingiverse | printables | makerworld | ...
profile_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (name, source_site)
);
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
date_created TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS models (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
source_site TEXT, -- where it came from, null for manual upload
source_url TEXT, -- original page URL (for attribution)
source_id TEXT, -- the site's id for the thing/model
license TEXT,
designer_id INTEGER REFERENCES designers(id) ON DELETE SET NULL,
thumbnail_path TEXT,
print_settings JSONB NOT NULL DEFAULT '{}'::jsonb,
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
file_name TEXT NOT NULL, -- on-disk name (unique suffix)
original_name TEXT NOT NULL, -- name as downloaded/uploaded
file_path TEXT NOT NULL, -- path on disk
format TEXT NOT NULL, -- stl | 3mf | obj | step | gcode | zip | ...
size BIGINT NOT NULL DEFAULT 0,
volume DOUBLE PRECISION, -- cm^3 (from node-stl), when computable
bbox JSONB, -- {x,y,z} bounding box
facets INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS model_projects (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
PRIMARY KEY (model_id, project_id)
);
CREATE TABLE IF NOT EXISTS model_tags (
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (model_id, tag_id)
);
-- Cached GLB conversion of a 3MF, for in-browser 3D preview.
ALTER TABLE files ADD COLUMN IF NOT EXISTS glb_path TEXT;
-- Whether the model's license permits commercial use (selling). true/false/null.
ALTER TABLE models ADD COLUMN IF NOT EXISTS commercial_use BOOLEAN;
CREATE INDEX IF NOT EXISTS idx_files_model ON files(model_id);
CREATE INDEX IF NOT EXISTS idx_model_projects_p ON model_projects(project_id);
CREATE INDEX IF NOT EXISTS idx_model_tags_t ON model_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_id);

32
lib/converter.js Normal file
View file

@ -0,0 +1,32 @@
// Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can
// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't).
const path = require('path');
const { execFile } = require('child_process');
const PYTHON = path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const SCRIPT = path.join(__dirname, '..', 'tools', 'convert_3mf.py');
// Returns { glb, volume_cm3, bbox, faces } or throws.
function convert3mfToGlb(inputPath, outputPath) {
return new Promise((resolve, reject) => {
execFile(
PYTHON,
[SCRIPT, inputPath, outputPath],
{ maxBuffer: 16 * 1024 * 1024, timeout: 120000 },
(err, stdout, stderr) => {
if (err) return reject(new Error(stderr ? stderr.split('\n').slice(-3).join(' ') : err.message));
let result;
try {
result = JSON.parse(stdout.trim().split('\n').pop());
} catch {
return reject(new Error('Converter returned unparseable output.'));
}
if (result.error) return reject(new Error(result.error));
resolve(result);
}
);
});
}
module.exports = { convert3mfToGlb };

39
lib/db.js Normal file
View file

@ -0,0 +1,39 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
pool.on('error', (err) => {
console.error('Unexpected Postgres pool error:', err);
});
// Apply schema.sql on startup (idempotent — uses CREATE TABLE IF NOT EXISTS).
async function init() {
const schema = fs.readFileSync(path.join(__dirname, '..', 'db', 'schema.sql'), 'utf8');
await pool.query(schema);
}
function query(text, params) {
return pool.query(text, params);
}
// Run a function inside a transaction, passing it a dedicated client.
async function transaction(fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
module.exports = { pool, query, transaction, init };

60
lib/fileProcessor.js Normal file
View file

@ -0,0 +1,60 @@
const fs = require('fs');
const path = require('path');
const NodeStl = require('node-stl');
const AdmZip = require('adm-zip');
const THUMBNAIL_DIR = './thumbnails';
const MODEL_FORMATS = ['stl', '3mf', 'obj', 'step', 'stp', 'gcode', 'gco', 'zip'];
function ensureThumbnailDir() {
if (!fs.existsSync(THUMBNAIL_DIR)) fs.mkdirSync(THUMBNAIL_DIR, { recursive: true });
}
function formatOf(filename) {
return path.extname(filename).slice(1).toLowerCase();
}
function isAllowed(filename) {
return MODEL_FORMATS.includes(formatOf(filename));
}
// Geometry metadata. Only STL is computed natively today; others return nulls
// (3MF/OBJ/STEP volume can be added later via a Python/trimesh sidecar).
function extractMetadata(filePath, format) {
if (format !== 'stl') return { volume: null, bbox: null, facets: null };
try {
const stl = new NodeStl(filePath);
const bb = stl.boundingBox; // [x, y, z]
return {
volume: Number.isFinite(stl.volume) ? stl.volume : null,
bbox: Array.isArray(bb) ? { x: bb[0], y: bb[1], z: bb[2] } : null,
facets: null, // node-stl does not expose facet count
};
} catch (err) {
console.error('STL metadata extraction failed:', err.message);
return { volume: null, bbox: null, facets: null };
}
}
// A .3mf is a zip; slicers embed a PNG preview under /Metadata/. If present,
// pull it out as the model thumbnail. Returns a path under thumbnails/ or null.
function extract3mfThumbnail(filePath, baseName) {
try {
ensureThumbnailDir();
const zip = new AdmZip(filePath);
const entry = zip.getEntries().find((e) => {
const n = e.entryName.toLowerCase();
return n.endsWith('.png') && (n.includes('thumbnail') || n.includes('/metadata/'));
});
if (!entry) return null;
const out = path.join(THUMBNAIL_DIR, `${baseName}.png`);
fs.writeFileSync(out, entry.getData());
return out;
} catch (err) {
console.error('3MF thumbnail extraction failed:', err.message);
return null;
}
}
module.exports = { MODEL_FORMATS, formatOf, isAllowed, extractMetadata, extract3mfThumbnail };

55
lib/flaresolverr.js Normal file
View file

@ -0,0 +1,55 @@
// Minimal FlareSolverr client. FlareSolverr runs a headless browser that solves
// Cloudflare's anti-bot challenge and returns the resolved page HTML.
// https://github.com/FlareSolverr/FlareSolverr
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// opts: { maxTimeout, cookies: [{ name, value, domain }], retries }
async function solve(flaresolverrUrl, targetUrl, opts = {}) {
const endpoint = flaresolverrUrl.replace(/\/+$/, '') + '/v1';
const body = { cmd: 'request.get', url: targetUrl, maxTimeout: opts.maxTimeout || 60000 };
if (opts.cookies) body.cookies = opts.cookies;
// FlareSolverr serializes browser sessions and returns 500 on back-to-back
// requests, so retry transient failures with a growing backoff.
const retries = opts.retries ?? 4;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
if (attempt > 0) await sleep(2000 * attempt);
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.status >= 500) { lastErr = new Error(`FlareSolverr HTTP ${res.status}`); continue; }
if (!res.ok) throw new Error(`FlareSolverr HTTP ${res.status}`);
const data = await res.json();
if (data.status !== 'ok' || !data.solution) throw new Error(`FlareSolverr: ${data.message || 'no solution'}`);
return data.solution.response;
} catch (err) {
lastErr = err;
if (!/HTTP 5\d\d|fetch failed|ECONNRESET|network/i.test(err.message)) throw err;
}
}
throw lastErr;
}
// Like solve(), but for endpoints that return JSON. A real browser wraps JSON
// in a <pre> tag, so strip tags before parsing.
async function solveJson(flaresolverrUrl, targetUrl, opts = {}) {
const html = await solve(flaresolverrUrl, targetUrl, opts);
// The browser wraps JSON in <pre> and HTML-encodes entities (notably & -> &amp;
// inside signed URLs), so strip tags and decode before parsing.
const text = html.replace(/<[^>]*>/g, '')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<')
.replace(/&gt;/g, '>').replace(/&amp;/g, '&')
.trim();
try {
return JSON.parse(text);
} catch {
throw new Error('FlareSolverr did not return parseable JSON.');
}
}
module.exports = { solve, solveJson };

78
lib/importers/generic.js Normal file
View file

@ -0,0 +1,78 @@
// Generic page-metadata importer for sites without a usable download API
// (Printables, MakerWorld, or any URL). Reads Open Graph / <title> tags so we
// can create a model with name, description, and a preview image; the actual
// model files are attached by the user afterwards.
function detectSite(url) {
let host;
try {
const parsed = new URL(url);
if (!/^https?:$/.test(parsed.protocol)) return null;
host = parsed.hostname.replace(/^www\./, '');
} catch {
return null;
}
if (host.includes('thingiverse.com')) return { site: 'thingiverse' };
if (host.includes('printables.com')) return { site: 'printables', id: (url.match(/\/model\/(\d+)/) || [])[1] || null };
if (host.includes('makerworld.com')) return { site: 'makerworld', id: (url.match(/\/models\/(\d+)/) || [])[1] || null };
if (host.includes('cubee3d.com')) {
let slug = null;
try { slug = new URL(url).pathname.split('/').filter(Boolean).pop() || null; } catch { /* ignore */ }
return { site: 'cubee3d', id: slug };
}
return { site: 'web', id: null };
}
function decodeEntities(str) {
return String(str)
.replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ');
}
function metaContent(html, attr, value) {
// Matches <meta {attr}="{value}" ... content="..."> in either attribute order.
const a = html.match(new RegExp(`<meta[^>]+${attr}=["']${value}["'][^>]+content=["']([^"']*)["']`, 'i'));
if (a) return decodeEntities(a[1]);
const b = html.match(new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${value}["']`, 'i'));
return b ? decodeEntities(b[1]) : null;
}
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36';
// A page is a Cloudflare interstitial (not the real content) when it looks like this.
function isCloudflareChallenge(html) {
return /just a moment|cf-browser-verification|challenge-platform|cf_chl_opt/i.test(html || '');
}
async function fetchHtml(url) {
const res = await fetch(url, { headers: { 'User-Agent': BROWSER_UA, Accept: 'text/html' } });
const html = await res.text();
if (!res.ok && !html) throw new Error(`Could not fetch page (HTTP ${res.status}).`);
return { ok: res.ok, status: res.status, html };
}
// Extract Open Graph / title metadata from raw HTML.
function parseMetadata(html, url) {
const titleTag = (html.match(/<title[^>]*>([^<]*)<\/title>/i) || [])[1];
return {
title: metaContent(html, 'property', 'og:title') || (titleTag ? decodeEntities(titleTag).trim() : null),
description: metaContent(html, 'property', 'og:description') || metaContent(html, 'name', 'description'),
image: metaContent(html, 'property', 'og:image'),
author: metaContent(html, 'name', 'author') || metaContent(html, 'property', 'og:site_name'),
};
}
// Best-effort human name from a URL slug, e.g.
// /models/98765-cool-widget -> "Cool widget". Used when a page can't be read.
function nameFromUrl(url) {
try {
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
const words = decodeURIComponent(seg).replace(/^\d+-?/, '').replace(/[-_]+/g, ' ').trim();
if (!words) return new URL(url).hostname.replace(/^www\./, '');
return words.charAt(0).toUpperCase() + words.slice(1);
} catch {
return url;
}
}
module.exports = { detectSite, fetchHtml, parseMetadata, isCloudflareChallenge, nameFromUrl };

View file

@ -0,0 +1,44 @@
// MakerWorld file downloader. MakerWorld is behind Cloudflare and its downloads
// are auth-gated, so we go through FlareSolverr (solves Cloudflare) carrying the
// user's `token` cookie (authenticates). The download API returns a short-lived
// signed CDN URL, which we then fetch directly.
const flaresolverr = require('../flaresolverr');
// A model page embeds its printable "instances" in __NEXT_DATA__. Each instance
// id is what the download API takes.
function parseInstances(html) {
const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return [];
let data;
try { data = JSON.parse(m[1]); } catch { return []; }
let result = [];
(function walk(node) {
if (result.length || !node || typeof node !== 'object') return;
if (Array.isArray(node.instances) && node.instances.length && typeof node.instances[0].id === 'number') {
result = node.instances
.filter((x) => x && typeof x.id === 'number')
.map((x) => ({ id: x.id, title: x.title || '' }));
return;
}
for (const key of Object.keys(node)) { walk(node[key]); if (result.length) return; }
})(data);
return result;
}
// Resolve an instance to { name, url } (a signed, short-lived CDN download URL).
async function resolveDownload(flaresolverrUrl, instanceId, token) {
const api = `https://makerworld.com/api/v1/design-service/instance/${instanceId}/f3mf?type=download&fileType=&devModelName=N1`;
return flaresolverr.solveJson(flaresolverrUrl, api, {
cookies: [{ name: 'token', value: token, domain: '.makerworld.com' }],
});
}
// Pull the license string out of the page data (e.g. "MakerWorld Exclusive License").
function parseLicense(html) {
const m = html.match(/"license"\s*:\s*"([^"]+)"/);
return m ? m[1] : null;
}
module.exports = { parseInstances, resolveDownload, parseLicense };

View file

@ -0,0 +1,67 @@
// Printables importer. Printables exposes a public GraphQL API at
// api.printables.com/graphql — the file list and per-file download links are
// available anonymously for free models. A bearer token (optional) is sent when
// configured, for anything that needs an account.
const ENDPOINT = 'https://api.printables.com/graphql/';
async function gql(query, variables, token) {
const headers = { 'Content-Type': 'application/json', Origin: 'https://www.printables.com' };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(ENDPOINT, { method: 'POST', headers, body: JSON.stringify({ query, variables }) });
if (!res.ok) throw new Error(`Printables GraphQL HTTP ${res.status}`);
const json = await res.json();
if (json.errors) throw new Error('Printables GraphQL: ' + json.errors.map((e) => e.message).join('; '));
return json.data;
}
const PRINT_QUERY = `query($id: ID!) {
print(id: $id) {
id name
license { name abbreviation content }
stls { id name fileSize }
slas { id name fileSize }
otherFiles { id name fileSize }
}
}`;
// Returns { name, license, licenseContent, files: [{ id, name, fileType }] }.
// (Commercial use is decided by the caller from licenseContent — the on-page
// excludeCommercialUsage flag is unreliable, e.g. false for the strictly
// non-commercial "Standard Digital File License".)
async function fetchModel(printId, token) {
const data = await gql(PRINT_QUERY, { id: String(printId) }, token);
const p = data.print;
if (!p) throw new Error('Print not found');
const files = [];
(p.stls || []).forEach((f) => files.push({ id: f.id, name: f.name, fileType: 'stl' }));
(p.slas || []).forEach((f) => files.push({ id: f.id, name: f.name, fileType: 'sla' }));
(p.otherFiles || []).forEach((f) => files.push({ id: f.id, name: f.name, fileType: 'other' }));
return {
name: p.name,
license: p.license ? p.license.name : null,
licenseContent: p.license ? p.license.content : null,
files,
};
}
const DOWNLOAD_MUTATION = `mutation($printId: ID!, $files: [DownloadFileInput], $src: DownloadSourceEnum!) {
getDownloadLink(printId: $printId, files: $files, source: $src) {
ok
output { link }
}
}`;
// Resolve one file to a direct CDN download URL.
async function getDownloadLink(printId, fileType, fileId, token) {
const data = await gql(
DOWNLOAD_MUTATION,
{ printId: String(printId), files: [{ fileType, ids: [String(fileId)] }], src: 'model_detail' },
token
);
const r = data.getDownloadLink;
if (!r || !r.ok || !r.output || !r.output.link) throw new Error('No download link returned');
return r.output.link;
}
module.exports = { fetchModel, getDownloadLink };

View file

@ -0,0 +1,55 @@
// Thingiverse importer — uses the official REST API (https://www.thingiverse.com/developers).
// Needs a free app token in THINGIVERSE_TOKEN.
const API = 'https://api.thingiverse.com';
// Accepts .../thing:12345, .../thing:12345/files, or a bare id. Returns id or null.
function parse(url) {
if (/^\d+$/.test(url.trim())) return url.trim();
const m = String(url).match(/thing:(\d+)/i);
return m ? m[1] : null;
}
async function api(pathname, token) {
const res = await fetch(`${API}${pathname}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
throw new Error(`Thingiverse API ${res.status} for ${pathname}`);
}
return res.json();
}
// Returns a normalized model description + the list of files to download.
async function fetchModel(thingId, token) {
const [thing, files, tags] = await Promise.all([
api(`/things/${thingId}`, token),
api(`/things/${thingId}/files`, token),
api(`/things/${thingId}/tags`, token).catch(() => []),
]);
const creator = thing.creator || {};
return {
source_site: 'thingiverse',
source_id: String(thingId),
source_url: thing.public_url || `https://www.thingiverse.com/thing:${thingId}`,
name: thing.name || `Thing ${thingId}`,
description: stripHtml(thing.description || ''),
license: thing.license || null,
designer: creator.name
? { name: creator.name, source_site: 'thingiverse', profile_url: creator.public_url }
: null,
tags: (tags || []).map((t) => t.name).filter(Boolean),
downloads: (files || []).map((f) => ({
name: f.name,
// download_url is an API endpoint that 302s to the real file; needs the token.
url: `${f.download_url}?access_token=${encodeURIComponent(token)}`,
})),
};
}
function stripHtml(html) {
return String(html).replace(/<[^>]*>/g, '').replace(/\s+\n/g, '\n').trim();
}
module.exports = { parse, fetchModel };

21
lib/licenses.js Normal file
View file

@ -0,0 +1,21 @@
// Best-effort guess at whether a license permits commercial use (selling prints),
// from its name/abbreviation or — preferably — its full license text. Returns
// true / false / null (unknown). This is a hint only; the UI tells the user to
// verify before selling, and it deliberately leans toward NOT sellable when
// ambiguous (the safer error for "can I sell this?").
function inferCommercial(text) {
if (!text) return null;
const s = String(text).replace(/<[^>]*>/g, ' ').toLowerCase();
// Restrictive / non-commercial — checked first so it wins over stray "sell"s.
if (/non-?commercial|personal use|community use|[-_\s]nc([-_\s]|$)|exclusive|standard digital file|all rights reserved|^\s*none\s*$|charge money|shall not[^.]*\bsell|not be sold/.test(s)) {
return false;
}
// Clearly permissive (commercial use OK). NC variants are already excluded above.
if (/cc0|public ?domain|creative commons|attribution|cc[-_\s]?by|^\s*mit\b|^\s*bsd\b|^\s*l?gpl\b|royalty[- ]free|commercial use (is )?(allowed|permitted)|may be sold/.test(s)) {
return true;
}
return null;
}
module.exports = { inferCommercial };

244
lib/store.js Normal file
View file

@ -0,0 +1,244 @@
const db = require('./db');
// ---- shared SELECT that hydrates a model with tags, projects, files, designer ----
const MODEL_SELECT = `
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
COALESCE(t.tags, '[]'::json) AS tags,
COALESCE(p.projects, '[]'::json) AS projects,
COALESCE(f.files, '[]'::json) AS files
FROM models m
LEFT JOIN designers d ON d.id = m.designer_id
LEFT JOIN LATERAL (
SELECT json_agg(tg.name ORDER BY tg.name) AS tags
FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id
WHERE mt.model_id = m.id
) t ON true
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('id', pr.id, 'name', pr.name) ORDER BY pr.name) AS projects
FROM model_projects mp JOIN projects pr ON pr.id = mp.project_id
WHERE mp.model_id = m.id
) p ON true
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object(
'id', fl.id, 'originalName', fl.original_name, 'fileName', fl.file_name,
'format', fl.format, 'size', fl.size, 'volume', fl.volume,
'bbox', fl.bbox, 'facets', fl.facets) ORDER BY fl.id) AS files
FROM files fl WHERE fl.model_id = m.id
) f ON true
`;
function shapeModel(row) {
if (!row) return null;
const { designer_id, designer_name, designer_url, ...rest } = row;
return {
...rest,
designer: designer_id ? { id: designer_id, name: designer_name, url: designer_url } : null,
};
}
// ---------------------------------------------------------------- models
async function getAllModels() {
const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
return rows.map(shapeModel);
}
async function getModel(id) {
const { rows } = await db.query(`${MODEL_SELECT} WHERE m.id = $1`, [id]);
return shapeModel(rows[0]);
}
async function findModelBySource(site, sourceId) {
const { rows } = await db.query(
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2`, [site, sourceId]);
return shapeModel(rows[0]);
}
// data = { name, description, source_site, source_url, source_id, license,
// designer:{name,source_site,profile_url}, thumbnail_path, print_settings,
// tags:[string], projects:[id], files:[{...}] }
async function createModel(data) {
return db.transaction(async (client) => {
let designerId = null;
if (data.designer && data.designer.name) {
designerId = await upsertDesigner(client, data.designer);
}
const { rows } = await client.query(
`INSERT INTO models (name, description, source_site, source_url, source_id,
license, commercial_use, designer_id, thumbnail_path, print_settings)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,
[data.name, data.description || '', data.source_site || null, data.source_url || null,
data.source_id || null, data.license || null, data.commercial_use ?? null, designerId,
data.thumbnail_path || null, data.print_settings || {}]
);
const modelId = rows[0].id;
await setTags(client, modelId, data.tags || []);
await setProjects(client, modelId, data.projects || []);
for (const file of data.files || []) {
await insertFile(client, modelId, file);
}
return modelId;
}).then(getModel);
}
async function updateModel(id, updates) {
await db.transaction(async (client) => {
const fields = [];
const values = [];
let i = 1;
for (const col of ['name', 'description', 'license', 'commercial_use', 'thumbnail_path']) {
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
}
if (updates.print_settings !== undefined) {
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
}
if (fields.length) {
fields.push(`date_modified = now()`);
values.push(id);
await client.query(`UPDATE models SET ${fields.join(', ')} WHERE id = $${i}`, values);
}
if (updates.tags !== undefined) await setTags(client, id, updates.tags);
if (updates.projects !== undefined) await setProjects(client, id, updates.projects);
});
return getModel(id);
}
// Returns the on-disk file rows so the caller can unlink them.
async function deleteModel(id) {
const { rows } = await db.query('SELECT file_path, glb_path FROM files WHERE model_id = $1', [id]);
const model = await db.query('SELECT thumbnail_path FROM models WHERE id = $1', [id]);
const result = await db.query('DELETE FROM models WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) return null;
return {
filePaths: rows.map((r) => r.file_path),
glbPaths: rows.map((r) => r.glb_path).filter(Boolean),
thumbnailPath: model.rows[0] && model.rows[0].thumbnail_path,
};
}
async function addFiles(modelId, files) {
await db.transaction(async (client) => {
for (const file of files) await insertFile(client, modelId, file);
});
return getModel(modelId);
}
// Record a 3MF's GLB conversion + computed geometry on the file row.
async function updateFileConversion(id, { glb_path, volume, bbox }) {
await db.query(
`UPDATE files SET glb_path = $2,
volume = COALESCE($3, volume),
bbox = COALESCE($4, bbox)
WHERE id = $1`,
[id, glb_path, volume ?? null, bbox ?? null]);
}
async function insertFile(client, modelId, file) {
await client.query(
`INSERT INTO files (model_id, file_name, original_name, file_path, format, size, volume, bbox, facets)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[modelId, file.fileName, file.originalName, file.filePath, file.format,
file.size || 0, file.volume ?? null, file.bbox ?? null, file.facets ?? null]
);
}
// ---------------------------------------------------------------- tags
async function setTags(client, modelId, tagNames) {
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
for (const raw of tagNames) {
const name = String(raw).trim();
if (!name) continue;
const { rows } = await client.query(
`INSERT INTO tags (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]);
await client.query(
'INSERT INTO model_tags (model_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[modelId, rows[0].id]);
}
}
async function getAllTags() {
const { rows } = await db.query('SELECT name FROM tags ORDER BY name');
return rows.map((r) => r.name);
}
// ---------------------------------------------------------------- projects
async function setProjects(client, modelId, projectIds) {
await client.query('DELETE FROM model_projects WHERE model_id = $1', [modelId]);
for (const pid of projectIds) {
await client.query(
'INSERT INTO model_projects (model_id, project_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
[modelId, pid]);
}
}
async function getAllProjects() {
const { rows } = await db.query(`
SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
COUNT(mp.model_id)::int AS model_count
FROM projects p
LEFT JOIN model_projects mp ON mp.project_id = p.id
GROUP BY p.id ORDER BY p.name`);
return rows;
}
async function getProject(id) {
const { rows } = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
return rows[0] || null;
}
async function createProject({ name, description }) {
const { rows } = await db.query(
'INSERT INTO projects (name, description) VALUES ($1,$2) RETURNING *',
[name, description || '']);
return rows[0];
}
async function updateProject(id, { name, description }) {
const { rows } = await db.query(
`UPDATE projects SET name = COALESCE($2, name),
description = COALESCE($3, description), date_modified = now()
WHERE id = $1 RETURNING *`, [id, name ?? null, description ?? null]);
return rows[0] || null;
}
async function deleteProject(id) {
const { rowCount } = await db.query('DELETE FROM projects WHERE id = $1', [id]);
return rowCount > 0;
}
// ---------------------------------------------------------------- settings
async function getSetting(key) {
const { rows } = await db.query('SELECT value FROM settings WHERE key = $1', [key]);
return rows.length ? rows[0].value : null;
}
// value of '' or null clears the setting.
async function setSetting(key, value) {
if (value === null || value === '') {
await db.query('DELETE FROM settings WHERE key = $1', [key]);
return;
}
await db.query(
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
[key, value]);
}
// ---------------------------------------------------------------- designers
async function upsertDesigner(client, { name, source_site, profile_url }) {
const { rows } = await client.query(
`INSERT INTO designers (name, source_site, profile_url) VALUES ($1,$2,$3)
ON CONFLICT (name, source_site) DO UPDATE SET profile_url = EXCLUDED.profile_url
RETURNING id`, [name, source_site || null, profile_url || null]);
return rows[0].id;
}
module.exports = {
getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
updateFileConversion,
getAllTags,
getAllProjects, getProject, createProject, updateProject, deleteProject,
getSetting, setSetting,
};

1544
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "printhive",
"version": "1.0.0",
"description": "Printhive — a web app for organizing 3D-printing files (STL, 3MF, OBJ, ...) with URL import",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [
"stl",
"3d-printing",
"library",
"manager"
],
"author": "",
"license": "MIT",
"dependencies": {
"adm-zip": "^0.5.17",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0",
"pg": "^8.22.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

778
public/app.js Normal file
View file

@ -0,0 +1,778 @@
// State
let models = [];
let projects = [];
let config = { importers: {} };
let selectedProject = 'all';
let selectedTags = [];
let currentView = 'grid';
// DOM
const fileGrid = document.getElementById('fileGrid');
const projectsList = document.getElementById('projectsList');
const tagsList = document.getElementById('tagsList');
const searchInput = document.getElementById('searchInput');
const uploadModal = document.getElementById('uploadModal');
const projectModal = document.getElementById('projectModal');
const importModal = document.getElementById('importModal');
const settingsModal = document.getElementById('settingsModal');
const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal');
document.addEventListener('DOMContentLoaded', () => {
loadData();
setupEventListeners();
});
async function loadData() {
try {
const [modelsRes, projectsRes, configRes] = await Promise.all([
fetch('/api/models'),
fetch('/api/projects'),
fetch('/api/config'),
]);
models = await modelsRes.json();
projects = await projectsRes.json();
config = await configRes.json();
renderProjects();
renderTags();
renderModels();
updateCounts();
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
} catch (error) {
console.error('Error loading data:', error);
}
}
// Render thumbnails in the browser for models that don't have one yet, then
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
const thumbAttempted = new Set();
async function generateMissingThumbnails() {
if (!window.ModelViewer) return;
for (const m of models) {
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
const file = (m.files || []).find((f) => window.ModelViewer.RENDERABLE.includes(f.format));
if (!file) continue;
thumbAttempted.add(m.id);
try {
const src = viewerSourceFor(file.id, file.format);
const image = await window.ModelViewer.snapshot(src.url, src.format, 512);
if (!image) continue;
const res = await fetch(`/api/models/${m.id}/thumbnail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image }),
});
if (!res.ok) continue;
const updated = await res.json();
m.thumbnailUrl = updated.thumbnailUrl;
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${m.id}"] .file-thumbnail`);
if (thumb && updated.thumbnailUrl) {
thumb.innerHTML = `<img src="${updated.thumbnailUrl}" alt="" loading="lazy">`;
}
} catch (err) {
console.warn('Thumbnail generation failed for model', m.id, err);
}
}
}
// ---------------------------------------------------------------- rendering
function renderProjects() {
const items = projects.map((p) => `
<div class="project-item${String(selectedProject) === String(p.id) ? ' active' : ''}" data-project-id="${p.id}">
<span class="project-name">${escapeHtml(p.name)}</span>
<span class="project-meta">
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">&#9998;</button>
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">&times;</button>
<span class="count" data-project-count="${p.id}">${p.model_count}</span>
</span>
</div>`).join('');
const allItem = projectsList.querySelector('[data-project-id="all"]');
allItem.classList.toggle('active', selectedProject === 'all');
projectsList.innerHTML = '';
projectsList.appendChild(allItem);
projectsList.insertAdjacentHTML('beforeend', items);
}
function renderTags() {
const all = new Set();
models.forEach((m) => (m.tags || []).forEach((t) => all.add(t)));
tagsList.innerHTML = Array.from(all).sort().map((tag) => `
<span class="tag${selectedTags.includes(tag) ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`).join('');
}
function renderModels() {
const filtered = getFilteredModels();
if (filtered.length === 0) {
fileGrid.innerHTML = `
<div class="empty-state">
<h3>No models found</h3>
<p>Upload files or import from a URL to get started</p>
</div>`;
return;
}
fileGrid.className = currentView === 'grid' ? 'file-grid' : 'file-grid list-view';
fileGrid.innerHTML = filtered.map((m) => {
const totalSize = (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
const thumb = m.thumbnailUrl
? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">`
: '<span>📦</span>';
return `
<div class="file-card" data-model-id="${m.id}">
<div class="file-thumbnail">${thumb}</div>
<div class="file-info">
<div class="file-name">${escapeHtml(m.name)}</div>
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''}
${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''}
${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="file-meta">${m.files.length} file${m.files.length === 1 ? '' : 's'} ${formatFileSize(totalSize)} ${formatDate(m.date_added)}</div>
</div>
</div>`;
}).join('');
}
function getFilteredModels() {
let result = models;
if (selectedProject !== 'all') {
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject)));
}
if (selectedTags.length > 0) {
result = result.filter((m) => selectedTags.some((t) => (m.tags || []).includes(t)));
}
const term = searchInput.value.toLowerCase().trim();
if (term) {
result = result.filter((m) =>
m.name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)));
}
return result;
}
function updateCounts() {
document.getElementById('allFilesCount').textContent = models.length;
}
// ---------------------------------------------------------------- events
function setupEventListeners() {
document.getElementById('uploadBtn').addEventListener('click', openUploadModal);
document.getElementById('newProjectBtn').addEventListener('click', openProjectModal);
document.getElementById('importBtn').addEventListener('click', openImportModal);
document.getElementById('settingsBtn').addEventListener('click', openSettingsModal);
document.getElementById('settingsForm').addEventListener('submit', handleSaveSettings);
document.getElementById('cancelSettings').addEventListener('click', () => closeModal(settingsModal));
document.querySelectorAll('.clear-setting').forEach((btn) => {
btn.addEventListener('click', () => handleClearSetting(btn.dataset.setting));
});
document.querySelectorAll('.close-btn').forEach((btn) => {
btn.addEventListener('click', (e) => closeModal(e.target.closest('.modal')));
});
document.querySelectorAll('.modal').forEach((modal) => {
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(modal); });
});
document.getElementById('uploadForm').addEventListener('submit', handleUpload);
document.getElementById('cancelUpload').addEventListener('click', () => closeModal(uploadModal));
document.getElementById('projectForm').addEventListener('submit', handleCreateProject);
document.getElementById('cancelProject').addEventListener('click', () => closeModal(projectModal));
document.getElementById('importForm').addEventListener('submit', handleImport);
document.getElementById('cancelImport').addEventListener('click', () => closeModal(importModal));
document.getElementById('fileInput').addEventListener('change', (e) => {
const f = e.target.files[0];
const nameField = document.getElementById('fileName');
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
});
projectsList.addEventListener('click', (e) => {
const item = e.target.closest('.project-item');
if (!item) return;
const id = item.dataset.projectId;
const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models';
if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; }
if (e.target.closest('.collection-delete')) { handleDeleteCollection(id, name); return; }
document.querySelectorAll('.project-item').forEach((i) => i.classList.remove('active'));
item.classList.add('active');
selectedProject = id;
document.getElementById('contentTitle').textContent = id === 'all' ? 'All Models' : name;
renderModels();
});
tagsList.addEventListener('click', (e) => {
if (!e.target.classList.contains('tag')) return;
const tag = e.target.dataset.tag;
e.target.classList.toggle('active');
if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag);
else selectedTags.push(tag);
renderModels();
});
searchInput.addEventListener('input', renderModels);
document.querySelectorAll('.view-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
e.target.classList.add('active');
currentView = e.target.dataset.view;
renderModels();
});
});
fileGrid.addEventListener('click', (e) => {
const card = e.target.closest('.file-card');
if (card) openModelDetail(card.dataset.modelId);
});
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
document.getElementById('downloadFilesBtn').addEventListener('click', handleDownloadFiles);
document.getElementById('editForm').addEventListener('submit', handleEditModel);
document.getElementById('cancelEdit').addEventListener('click', () => closeModal(editModal));
document.getElementById('addFilesBtn').addEventListener('click', () => document.getElementById('addFilesInput').click());
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
}
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
let currentDetailFile = null;
let currentDetailThumb = null; // model image, used as a viewer fallback
function closeModal(modal) {
modal.classList.remove('active');
if (modal === fileDetailModal && window.ModelViewer) window.ModelViewer.dispose();
}
// ---------------------------------------------------------------- modals
function openUploadModal() {
document.getElementById('fileProjects').innerHTML =
projects.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
uploadModal.classList.add('active');
}
function openProjectModal() {
document.getElementById('projectForm').reset();
projectModal.classList.add('active');
}
async function openSettingsModal() {
document.getElementById('settingsForm').reset();
document.getElementById('settingsStatus').textContent = '';
try {
const settings = await (await fetch('/api/settings')).json();
document.querySelectorAll('.setting-status').forEach((el) => {
const s = settings[el.dataset.status];
if (!s) { el.textContent = '—'; return; }
el.textContent = s.set
? `set ${s.hint || ''}${s.source === 'env' ? ' (from .env — edit here to override)' : ''}`
: 'not set';
el.className = `setting-status ${s.set ? 'is-set' : 'is-unset'}`;
});
// Prefill non-secret fields (e.g. the FlareSolverr URL) with their current value.
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
const s = settings[input.dataset.setting];
if (s && !s.secret && s.value) input.value = s.value;
});
} catch (err) {
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
}
settingsModal.classList.add('active');
}
function openImportModal() {
document.getElementById('importForm').reset();
document.getElementById('importStatus').textContent = '';
const tvEnabled = config.importers && config.importers.thingiverse;
const flareEnabled = config.importers && config.importers.flaresolverr;
const mw = flareEnabled ? 'MakerWorld' : 'MakerWorld (needs FlareSolverr in Settings)';
document.getElementById('importHint').innerHTML = tvEnabled
? `Thingiverse imports files automatically. Printables, ${mw} &amp; other sites import the title, description &amp; preview image — then add the files you downloaded.`
: `Printables, ${mw} &amp; other sites import the title, description &amp; preview image — then add the files you downloaded. Add a Thingiverse token in Settings for automatic file downloads.`;
document.getElementById('importSubmit').disabled = false;
importModal.classList.add('active');
}
function openModelDetail(modelId) {
const model = models.find((m) => String(m.id) === String(modelId));
if (!model) return;
fileDetailModal.dataset.currentModelId = modelId;
currentDetailThumb = model.thumbnailUrl || null;
document.getElementById('detailFileName').textContent = model.name;
document.getElementById('detailDescription').textContent = model.description || 'No description';
// Source
const src = document.getElementById('detailSource');
if (model.source_url) {
const designer = model.designer ? ` by ${escapeHtml(model.designer.name)}` : '';
src.innerHTML = `<a href="${escapeHtml(model.source_url)}" target="_blank" rel="noopener">${escapeHtml(model.source_site || 'link')}${designer}</a>`;
} else {
src.textContent = 'Manual upload';
}
renderDetailLicense(model);
document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none';
// "Download files from source" — only for sites we can auto-download from.
const autoDownloadSites = ['thingiverse', 'printables', 'makerworld'];
const dlBtn = document.getElementById('downloadFilesBtn');
if (model.source_url && autoDownloadSites.includes(model.source_site)) {
dlBtn.style.display = '';
dlBtn.textContent = model.files.length === 0 ? 'Download files from source' : 'Re-check source for files';
} else {
dlBtn.style.display = 'none';
}
// Tags / projects / print settings
document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length)
? model.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('') : 'No tags';
const ps = model.projects || [];
document.getElementById('detailProjects').innerHTML = ps.length
? ps.map((p) => `<div>${escapeHtml(p.name)}</div>`).join('') : 'Not in any collection';
const settings = model.print_settings || {};
const settingRows = Object.entries(settings).filter(([, v]) => v);
document.getElementById('detailPrintSettings').innerHTML = settingRows.length
? settingRows.map(([k, v]) => `<div><strong>${escapeHtml(k)}:</strong> ${escapeHtml(String(v))}</div>`).join('')
: 'No print settings';
// File list (+ a "download all as zip" action when there's more than one file)
const fileInfoEl = document.getElementById('detailFileInfo');
if (model.files.length === 0) {
const autoSite = ['thingiverse', 'printables', 'makerworld'].includes(model.source_site);
const hint = !model.source_url
? 'No files yet — use “Add files” below.'
: autoSite
? 'No files yet — use “Download files from source” below.'
: 'Manual source — download the files from the source page, then use “Add files” below.';
fileInfoEl.innerHTML = `<div class="lic-note">${hint}</div>`;
} else {
const downloadAll = model.files.length > 1
? `<a class="btn btn-secondary btn-sm download-all" href="/api/models/${model.id}/download">Download all ${model.files.length} files (.zip)</a>`
: '';
fileInfoEl.innerHTML = downloadAll + model.files.map((f) => `
<div class="file-row">
<span>${escapeHtml(f.originalName)} <em>(${formatFileSize(f.size)}${f.volume ? `, ${Number(f.volume).toFixed(2)} cm³` : ''})</em></span>
<a href="/api/files/${f.id}/raw" download>download</a>
</div>`).join('');
}
// Viewer tabs
const renderable = model.files.filter((f) => window.ModelViewer.RENDERABLE.includes(f.format));
const tabs = document.getElementById('detailFileTabs');
tabs.innerHTML = renderable.map((f, i) => `
<button class="file-tab${i === 0 ? ' active' : ''}" data-file-id="${f.id}" data-format="${f.format}">${escapeHtml(f.originalName)}</button>`).join('');
tabs.querySelectorAll('.file-tab').forEach((tab) => {
tab.addEventListener('click', () => {
tabs.querySelectorAll('.file-tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
loadViewer(tab.dataset.fileId, tab.dataset.format);
});
});
fileDetailModal.classList.add('active');
currentDetailFile = null;
if (renderable.length) {
loadViewer(renderable[0].id, renderable[0].format);
} else {
document.getElementById('regenThumbBtn').style.display = 'none';
window.ModelViewer.render(document.getElementById('detailViewer'), '', 'none', { fallbackImage: currentDetailThumb });
}
}
// Maps a file to what the viewer should load: 3MF goes through the server's
// GLB conversion; everything else is served raw.
function viewerSourceFor(fileId, format) {
return format === '3mf'
? { url: `/api/files/${fileId}/glb`, format: 'glb' }
: { url: `/api/files/${fileId}/raw`, format };
}
function renderDetailLicense(model) {
const com = model.commercial_use; // true / false / null
const badge = com === true
? '<span class="lic-badge ok">Commercial use allowed</span>'
: com === false
? '<span class="lic-badge no">Non-commercial only</span>'
: '<span class="lic-badge unknown">Commercial use unclear</span>';
document.getElementById('detailLicense').innerHTML =
`<div>${model.license ? escapeHtml(model.license) : 'Not specified'} ${badge}</div>` +
'<div class="lic-note">A guess from the license name — always confirm on the source page before selling.</div>';
}
function openEditModal() {
const id = fileDetailModal.dataset.currentModelId;
const model = models.find((m) => String(m.id) === String(id));
if (!model) return;
editModal.dataset.editId = id;
document.getElementById('editName').value = model.name || '';
document.getElementById('editDescription').value = model.description || '';
document.getElementById('editTags').value = (model.tags || []).join(', ');
document.getElementById('editLicense').value = model.license || '';
document.getElementById('editCommercial').value =
model.commercial_use === true ? 'true' : model.commercial_use === false ? 'false' : '';
const ps = model.print_settings || {};
document.getElementById('editMaterial').value = ps.material || '';
document.getElementById('editLayerHeight').value = ps.layerHeight || '';
document.getElementById('editInfill').value = ps.infill || '';
document.getElementById('editSupports').value = ps.supports || '';
const current = new Set((model.projects || []).map((p) => String(p.id)));
document.getElementById('editProjects').innerHTML = projects
.map((p) => `<option value="${p.id}"${current.has(String(p.id)) ? ' selected' : ''}>${escapeHtml(p.name)}</option>`)
.join('');
editModal.classList.add('active');
}
async function handleEditModel(e) {
e.preventDefault();
const id = editModal.dataset.editId;
const commercial = document.getElementById('editCommercial').value;
const body = {
name: document.getElementById('editName').value,
description: document.getElementById('editDescription').value,
license: document.getElementById('editLicense').value || null,
commercial_use: commercial === '' ? null : commercial === 'true',
tags: splitTags(document.getElementById('editTags').value),
projects: Array.from(document.getElementById('editProjects').selectedOptions).map((o) => Number(o.value)),
print_settings: {
material: document.getElementById('editMaterial').value,
layerHeight: document.getElementById('editLayerHeight').value,
infill: document.getElementById('editInfill').value,
supports: document.getElementById('editSupports').value,
},
};
try {
const res = await fetch(`/api/models/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error);
closeModal(editModal);
await loadData();
openModelDetail(id); // refresh the (still-open) detail view
} catch (err) {
alert('Failed to save changes: ' + err.message);
}
}
async function handleDownloadFiles() {
const id = fileDetailModal.dataset.currentModelId;
const btn = document.getElementById('downloadFilesBtn');
btn.disabled = true;
btn.textContent = 'Downloading… (this can take a moment)';
try {
const res = await fetch(`/api/models/${id}/download-files`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
await loadData();
openModelDetail(id);
if (data.added === 0) alert('No new files were found (the source returned nothing new).');
} catch (err) {
alert('Download failed: ' + err.message);
btn.disabled = false;
}
}
async function handleRefreshMetadata() {
const modelId = fileDetailModal.dataset.currentModelId;
const btn = document.getElementById('refreshLicenseBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
try {
const res = await fetch(`/api/models/${modelId}/refresh-metadata`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
// update in-memory state, the license panel, and the card badge
const idx = models.findIndex((m) => String(m.id) === String(modelId));
if (idx !== -1) models[idx] = updated;
renderDetailLicense(updated);
renderModels();
btn.textContent = 'Updated ✓';
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1500);
} catch (err) {
btn.textContent = 'Refresh failed';
console.warn('Refresh metadata failed:', err.message);
setTimeout(() => { btn.textContent = 'Refresh from source'; }, 1800);
} finally {
btn.disabled = false;
}
}
function loadViewer(fileId, format) {
const src = viewerSourceFor(fileId, format);
currentDetailFile = { id: fileId, viewerUrl: src.url, viewerFormat: src.format };
document.getElementById('regenThumbBtn').style.display = '';
window.ModelViewer.render(document.getElementById('detailViewer'), src.url, src.format, { fallbackImage: currentDetailThumb });
}
function updateCardThumbnail(modelId, thumbnailUrl) {
const m = models.find((x) => String(x.id) === String(modelId));
if (m) m.thumbnailUrl = thumbnailUrl;
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${modelId}"] .file-thumbnail`);
if (thumb && thumbnailUrl) thumb.innerHTML = `<img src="${thumbnailUrl}" alt="" loading="lazy">`;
}
async function handleRegenThumbnail() {
const modelId = fileDetailModal.dataset.currentModelId;
if (!currentDetailFile) { alert('No previewable file to render.'); return; }
const btn = document.getElementById('regenThumbBtn');
btn.disabled = true;
btn.textContent = 'Rendering…';
try {
const image = await window.ModelViewer.snapshot(currentDetailFile.viewerUrl, currentDetailFile.viewerFormat, 512);
const res = await fetch(`/api/models/${modelId}/thumbnail`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image }),
});
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
updateCardThumbnail(modelId, updated.thumbnailUrl);
thumbAttempted.add(Number(modelId));
btn.textContent = 'Done ✓';
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1500);
} catch (err) {
console.warn('Thumbnail regen failed:', err.message);
btn.textContent = "Can't render this file";
setTimeout(() => { btn.textContent = 'Regenerate thumbnail'; }, 1800);
} finally {
btn.disabled = false;
}
}
async function handleAddFiles(e) {
const input = e.target;
if (!input.files.length) return;
const modelId = fileDetailModal.dataset.currentModelId;
const formData = new FormData();
Array.from(input.files).forEach((f) => formData.append('files', f));
try {
const res = await fetch(`/api/models/${modelId}/files`, { method: 'POST', body: formData });
if (!res.ok) throw new Error((await res.json()).error);
input.value = '';
await loadData();
openModelDetail(modelId); // reopen with the new files
} catch (err) {
alert('Failed to add files: ' + err.message);
}
}
// ---------------------------------------------------------------- handlers
async function handleUpload(e) {
e.preventDefault();
const formData = new FormData();
const input = document.getElementById('fileInput');
Array.from(input.files).forEach((f) => formData.append('files', f));
formData.append('name', document.getElementById('fileName').value);
formData.append('description', document.getElementById('fileDescription').value);
formData.append('tags', JSON.stringify(splitTags(document.getElementById('fileTags').value)));
formData.append('projects', JSON.stringify(
Array.from(document.getElementById('fileProjects').selectedOptions).map((o) => Number(o.value))));
formData.append('printSettings', JSON.stringify({
material: document.getElementById('printMaterial').value,
layerHeight: document.getElementById('printLayerHeight').value,
infill: document.getElementById('printInfill').value,
supports: document.getElementById('printSupports').value,
}));
try {
const res = await fetch('/api/models', { method: 'POST', body: formData });
if (res.ok) {
closeModal(uploadModal);
document.getElementById('uploadForm').reset();
await loadData();
} else {
alert('Upload failed: ' + (await res.json()).error);
}
} catch (err) {
alert('Upload failed: ' + err.message);
}
}
async function handleImport(e) {
e.preventDefault();
const status = document.getElementById('importStatus');
const submit = document.getElementById('importSubmit');
status.textContent = 'Importing… fetching metadata and downloading files.';
submit.disabled = true;
try {
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: document.getElementById('importUrl').value }),
});
const data = await res.json();
if (res.ok) {
await loadData();
if (data.metadataOnly) {
status.textContent = data.metadataFetched
? `Imported "${data.name}". Opening it so you can add the downloaded files…`
: `Couldn't read that page (the site blocks automated access), so I created a stub named "${data.name}". Opening it so you can add files…`;
setTimeout(() => { closeModal(importModal); openModelDetail(data.id); }, 1200);
} else {
status.textContent = `Imported "${data.name}" with ${data.files.length} file(s).`;
setTimeout(() => closeModal(importModal), 900);
}
} else if (res.status === 409) {
status.textContent = 'That model is already in your library.';
} else {
status.textContent = 'Import failed: ' + data.error;
}
} catch (err) {
status.textContent = 'Import failed: ' + err.message;
} finally {
submit.disabled = false;
}
}
async function handleSaveSettings(e) {
e.preventDefault();
const status = document.getElementById('settingsStatus');
// Only send fields the user actually typed into (blank = leave unchanged).
const payload = {};
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
});
if (Object.keys(payload).length === 0) {
status.textContent = 'Nothing to save.';
return;
}
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error((await res.json()).error);
status.textContent = 'Saved.';
await loadData(); // refresh config so import button reflects new token
setTimeout(() => closeModal(settingsModal), 600);
} catch (err) {
status.textContent = 'Save failed: ' + err.message;
}
}
async function handleClearSetting(key) {
if (!confirm('Clear this value?')) return;
const status = document.getElementById('settingsStatus');
try {
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: null }),
});
if (!res.ok) throw new Error((await res.json()).error);
status.textContent = 'Cleared.';
await loadData();
await openSettingsModal();
} catch (err) {
status.textContent = 'Clear failed: ' + err.message;
}
}
async function handleRenameCollection(id, currentName) {
const name = prompt('Rename collection:', currentName);
if (name === null) return;
const trimmed = name.trim();
if (!trimmed || trimmed === currentName) return;
try {
const res = await fetch(`/api/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) throw new Error((await res.json()).error);
if (String(selectedProject) === String(id)) {
document.getElementById('contentTitle').textContent = trimmed;
}
await loadData();
} catch (err) {
alert('Failed to rename collection: ' + err.message);
}
}
async function handleDeleteCollection(id, name) {
if (!confirm(`Delete the collection "${name}"?\n\nThe models in it are kept — only the collection is removed.`)) return;
try {
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
if (String(selectedProject) === String(id)) {
selectedProject = 'all';
document.getElementById('contentTitle').textContent = 'All Models';
}
await loadData();
} catch (err) {
alert('Failed to delete collection: ' + err.message);
}
}
async function handleCreateProject(e) {
e.preventDefault();
try {
const res = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('projectName').value,
description: document.getElementById('projectDescription').value,
}),
});
if (res.ok) {
closeModal(projectModal);
document.getElementById('projectForm').reset();
await loadData();
} else {
alert('Failed to create collection:' + (await res.json()).error);
}
} catch (err) {
alert('Failed to create collection:' + err.message);
}
}
async function handleDeleteModel() {
const id = fileDetailModal.dataset.currentModelId;
if (!id || !confirm('Delete this model and all its files?')) return;
try {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
if (res.ok) {
closeModal(fileDetailModal);
await loadData();
} else {
alert('Failed to delete: ' + (await res.json()).error);
}
} catch (err) {
alert('Failed to delete: ' + err.message);
}
}
// ---------------------------------------------------------------- utils
function splitTags(value) {
return value.split(',').map((t) => t.trim()).filter(Boolean);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text == null ? '' : text;
return div.innerHTML;
}
function formatFileSize(bytes) {
bytes = Number(bytes) || 0;
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}

361
public/index.html Normal file
View file

@ -0,0 +1,361 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Printhive</title>
<link rel="stylesheet" href="styles.css">
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div class="container">
<header>
<h1 class="brand">
<svg class="logo" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12 2.5 20.5 7v10L12 21.5 3.5 17V7L12 2.5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M12 7.5 16.3 10v4L12 16.5 7.7 14v-4L12 7.5Z" fill="currentColor" opacity="0.85"/>
</svg>
<span>Print<span class="accent">hive</span></span>
</h1>
<div class="header-actions">
<button id="settingsBtn" class="btn btn-secondary">Settings</button>
<button id="newProjectBtn" class="btn btn-secondary">New Collection</button>
<button id="importBtn" class="btn btn-secondary">Import from URL</button>
<button id="uploadBtn" class="btn btn-primary">Upload Files</button>
</div>
</header>
<div class="main-content">
<aside class="sidebar">
<div class="sidebar-section">
<h3>Collections</h3>
<div id="projectsList" class="projects-list">
<div class="project-item active" data-project-id="all">
<span>All Models</span>
<span class="count" id="allFilesCount">0</span>
</div>
</div>
</div>
<div class="sidebar-section">
<h3>Tags</h3>
<div id="tagsList" class="tags-list"></div>
</div>
<div class="sidebar-section">
<h3>Search</h3>
<input type="text" id="searchInput" placeholder="Search models..." class="search-input">
</div>
</aside>
<main class="content">
<div class="content-header">
<h2 id="contentTitle">All Models</h2>
<div class="view-options">
<button class="view-btn active" data-view="grid">Grid</button>
<button class="view-btn" data-view="list">List</button>
</div>
</div>
<div id="fileGrid" class="file-grid"></div>
</main>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Settings</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="settingsForm">
<h3 class="settings-group-title">Import sources</h3>
<div class="form-group">
<label for="thingiverseToken">Thingiverse API token</label>
<input type="password" id="thingiverseToken" data-setting="thingiverse_token"
placeholder="Paste your App Token" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="thingiverse_token"></span>.
Get a free token at
<a href="https://www.thingiverse.com/developers" target="_blank" rel="noopener">thingiverse.com/developers</a>
(create an app → copy the App Token). Leave blank to keep the current value; clear it with the button.
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="thingiverse_token">Clear token</button>
</div>
<div class="form-group">
<label for="flaresolverrUrl">FlareSolverr URL</label>
<input type="text" id="flaresolverrUrl" data-setting="flaresolverr_url"
placeholder="http://192.168.1.124:8191" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="flaresolverr_url"></span>.
Lets imports read Cloudflare-protected sites like MakerWorld by solving the challenge
with a headless browser. Point this at your FlareSolverr instance.
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="flaresolverr_url">Clear</button>
</div>
<div class="form-group">
<label for="makerworldToken">MakerWorld auth token</label>
<input type="password" id="makerworldToken" data-setting="makerworld_token"
placeholder="Bearer token from your logged-in session" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="makerworld_token"></span>.
Lets Printhive download files from MakerWorld using your account. Paste the bearer
token from your logged-in browser session (see instructions in chat).
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="makerworld_token">Clear token</button>
</div>
<div class="form-group">
<label for="printablesToken">Printables auth token</label>
<input type="password" id="printablesToken" data-setting="printables_token"
placeholder="Bearer token from your logged-in session" autocomplete="off">
<p class="form-hint">
Status: <span class="setting-status" data-status="printables_token"></span>.
Lets Printhive download files from Printables using your account. Paste the bearer
token from your logged-in browser session (see instructions in chat).
</p>
<button type="button" class="btn btn-link clear-setting" data-setting="printables_token">Clear token</button>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelSettings">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
<div id="settingsStatus" class="import-status"></div>
</div>
</div>
</div>
<!-- Import from URL Modal -->
<div id="importModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Import from URL</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="importForm">
<div class="form-group">
<label for="importUrl">Model URL</label>
<input type="url" id="importUrl" placeholder="https://www.thingiverse.com/thing:12345" required>
<p class="form-hint" id="importHint"></p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelImport">Cancel</button>
<button type="submit" class="btn btn-primary" id="importSubmit">Import</button>
</div>
</form>
<div id="importStatus" class="import-status"></div>
</div>
</div>
</div>
<!-- Upload Modal -->
<div id="uploadModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Upload Model Files</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="uploadForm">
<div class="form-group">
<label for="fileInput">Files * <span class="form-hint-inline">(stl, 3mf, obj, step, gcode, zip — multiple allowed)</span></label>
<input type="file" id="fileInput" accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip" multiple required>
</div>
<div class="form-group">
<label for="fileName">Name *</label>
<input type="text" id="fileName" required>
</div>
<div class="form-group">
<label for="fileDescription">Description</label>
<textarea id="fileDescription" rows="3"></textarea>
</div>
<div class="form-group">
<label for="fileTags">Tags (comma-separated)</label>
<input type="text" id="fileTags" placeholder="miniature, terrain, hero">
</div>
<div class="form-group">
<label for="fileProjects">Collections</label>
<select id="fileProjects" multiple size="4"></select>
</div>
<div class="form-group">
<label>Print Settings</label>
<div class="print-settings">
<input type="text" id="printMaterial" placeholder="Material (e.g., PLA)">
<input type="text" id="printLayerHeight" placeholder="Layer Height (e.g., 0.2mm)">
<input type="text" id="printInfill" placeholder="Infill (e.g., 20%)">
<input type="text" id="printSupports" placeholder="Supports (e.g., Yes/No)">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelUpload">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</form>
</div>
</div>
</div>
<!-- Collection Modal -->
<div id="projectModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>New Collection</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="projectForm">
<div class="form-group">
<label for="projectName">Collection Name *</label>
<input type="text" id="projectName" required>
</div>
<div class="form-group">
<label for="projectDescription">Description</label>
<textarea id="projectDescription" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelProject">Cancel</button>
<button type="submit" class="btn btn-primary">Create Collection</button>
</div>
</form>
</div>
</div>
</div>
<!-- Model Detail Modal -->
<div id="fileDetailModal" class="modal">
<div class="modal-content large">
<div class="modal-header">
<h2 id="detailFileName"></h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<div class="file-detail-content">
<div class="file-detail-preview">
<div id="detailViewer" class="model-viewer"></div>
<div id="detailFileTabs" class="file-tabs"></div>
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
</div>
<div class="file-detail-info">
<div class="info-section">
<h3>Source</h3>
<div id="detailSource"></div>
</div>
<div class="info-section">
<h3>License</h3>
<div id="detailLicense"></div>
<button type="button" class="btn btn-secondary btn-sm" id="refreshLicenseBtn" style="margin-top:10px">Refresh from source</button>
</div>
<div class="info-section">
<h3>Description</h3>
<p id="detailDescription"></p>
</div>
<div class="info-section">
<h3>Tags</h3>
<div id="detailTags" class="tags"></div>
</div>
<div class="info-section">
<h3>Collections</h3>
<div id="detailProjects"></div>
</div>
<div class="info-section">
<h3>Print Settings</h3>
<div id="detailPrintSettings"></div>
</div>
<div class="info-section">
<h3>Files</h3>
<div id="detailFileInfo"></div>
<button type="button" class="btn btn-secondary btn-sm" id="downloadFilesBtn" style="margin-top:10px; display:none"></button>
</div>
<input type="file" id="addFilesInput" multiple style="display:none"
accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip">
<div class="form-actions">
<button class="btn btn-danger" id="deleteFileBtn">Delete</button>
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
<button class="btn btn-primary" id="editModelBtn">Edit</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Model Modal -->
<div id="editModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Edit model</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="editForm">
<div class="form-group">
<label for="editName">Name *</label>
<input type="text" id="editName" required>
</div>
<div class="form-group">
<label for="editDescription">Description</label>
<textarea id="editDescription" rows="3"></textarea>
</div>
<div class="form-group">
<label for="editTags">Tags (comma-separated)</label>
<input type="text" id="editTags" placeholder="miniature, terrain, hero">
</div>
<div class="form-group">
<label for="editProjects">Collections</label>
<select id="editProjects" multiple size="4"></select>
</div>
<div class="form-group">
<label for="editLicense">License</label>
<input type="text" id="editLicense">
</div>
<div class="form-group">
<label for="editCommercial">Commercial use (sellable?)</label>
<select id="editCommercial">
<option value="">Unknown</option>
<option value="true">Allowed — sellable</option>
<option value="false">Non-commercial only</option>
</select>
</div>
<div class="form-group">
<label>Print Settings</label>
<div class="print-settings">
<input type="text" id="editMaterial" placeholder="Material (e.g., PLA)">
<input type="text" id="editLayerHeight" placeholder="Layer Height (e.g., 0.2mm)">
<input type="text" id="editInfill" placeholder="Infill (e.g., 20%)">
<input type="text" id="editSupports" placeholder="Supports (e.g., Yes/No)">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelEdit">Cancel</button>
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
</div>
</div>
</div>
<script type="module" src="viewer.js"></script>
<script src="app.js"></script>
</body>
</html>

244
public/styles.css Normal file
View file

@ -0,0 +1,244 @@
/* Printhive — Workshop Dark theme */
:root {
--bg: #15171c;
--surface: #1e2128;
--surface-2: #262a32;
--surface-3: #2d323b;
--border: #2f343d;
--border-strong: #3b424d;
--text: #e7e9ee;
--text-muted: #9aa0ab;
--text-dim: #6b7280;
--accent: #f0972a;
--accent-hover: #ffab45;
--accent-soft: rgba(240, 151, 42, 0.14);
--accent-text: #1a1205;
--danger: #e35d5b;
--danger-soft: rgba(227, 93, 91, 0.14);
--success: #4cc38a;
--radius: 10px;
--radius-sm: 7px;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--sans);
background: var(--bg);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { min-height: 100vh; display: flex; flex-direction: column; }
/* ---- header ---- */
header {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
background: rgba(21, 23, 28, 0.85);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 11px; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; }
.brand .logo { width: 26px; height: 26px; color: var(--accent); }
.brand .accent { color: var(--accent); }
.header-actions { display: flex; gap: 10px; }
/* ---- buttons ---- */
.btn {
font-family: inherit;
font-size: 14px;
font-weight: 500;
padding: 9px 16px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.05s;
}
.btn:active { transform: scale(0.98); }
.btn-sm { font-size: 12px; padding: 6px 12px; align-self: flex-start; }
.download-all { display: inline-block; text-decoration: none; margin-bottom: 10px; }
.download-all:hover { text-decoration: none; }
.btn-primary { background: var(--accent); color: var(--accent-text); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: transparent; color: var(--text-muted); border-color: var(--border-strong); }
.btn-secondary:hover { background: var(--surface-2); color: var(--text); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: var(--danger-soft); }
/* ---- layout ---- */
.main-content { flex: 1; display: grid; grid-template-columns: 260px 1fr; gap: 24px; padding: 24px 28px; max-width: 1500px; width: 100%; margin: 0 auto; }
.sidebar { display: flex; flex-direction: column; gap: 22px; }
.sidebar-section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
.sidebar-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 12px; }
.projects-list { display: flex; flex-direction: column; gap: 2px; }
.project-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 10px; border-radius: var(--radius-sm); cursor: pointer;
color: var(--text-muted); font-size: 14px; transition: background 0.12s, color 0.12s;
}
.project-item:hover { background: var(--surface-2); color: var(--text); }
.project-item.active { background: var(--accent-soft); color: var(--accent); }
.project-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-meta { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.collection-action { opacity: 0; background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 13px; line-height: 1; padding: 2px 3px; font-family: inherit; transition: color 0.12s, opacity 0.12s; }
.project-item:hover .collection-action { opacity: 1; }
.collection-action:hover { color: var(--text); }
.collection-delete:hover { color: var(--danger); }
.project-item .count { font-family: var(--mono); font-size: 12px; background: var(--surface-3); color: var(--text-muted); padding: 1px 8px; border-radius: 10px; }
.project-item.active .count { background: var(--accent); color: var(--accent-text); }
.tags-list { display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
font-size: 12px; padding: 4px 10px; border-radius: 12px; cursor: pointer;
background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border);
transition: all 0.12s;
}
.tag:hover { border-color: var(--border-strong); color: var(--text); }
.tag.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
/* ---- form fields ---- */
.search-input, input[type="text"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
width: 100%;
font-family: inherit;
font-size: 14px;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
transition: border-color 0.12s, box-shadow 0.12s;
}
input::placeholder, textarea::placeholder { color: var(--text-dim); }
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
input[type="file"] { padding: 8px; color: var(--text-muted); }
textarea { resize: vertical; }
label { display: block; font-size: 13px; font-weight: 500; color: var(--text-muted); margin-bottom: 7px; }
/* ---- content ---- */
.content-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.content-header h2 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
.view-options { display: flex; gap: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px; }
.view-btn { font-family: inherit; font-size: 13px; padding: 5px 14px; border: none; background: transparent; color: var(--text-muted); border-radius: 5px; cursor: pointer; }
.view-btn.active { background: var(--surface-3); color: var(--text); }
/* ---- model grid ---- */
.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 18px; }
.file-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.file-card:hover { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); }
.file-thumbnail {
width: 100%; height: 185px;
background: radial-gradient(circle at 50% 40%, #232730 0%, #14161a 100%);
display: flex; align-items: center; justify-content: center;
color: var(--accent); font-size: 46px;
border-bottom: 1px solid var(--border);
}
.file-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
.file-info { padding: 14px 16px; }
.file-name { font-size: 15px; font-weight: 600; margin-bottom: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.source-badge { display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; background: var(--accent-soft); color: var(--accent); padding: 2px 8px; border-radius: 10px; margin-bottom: 8px; }
.com-badge { display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; margin: 0 0 8px 6px; }
.com-badge.ok { background: rgba(76, 195, 138, 0.16); color: var(--success); }
.com-badge.no { background: var(--danger-soft); color: var(--danger); }
.lic-badge { display: inline-block; font-size: 11px; padding: 2px 9px; border-radius: 10px; margin-left: 6px; }
.lic-badge.ok { background: rgba(76, 195, 138, 0.16); color: var(--success); }
.lic-badge.no { background: var(--danger-soft); color: var(--danger); }
.lic-badge.unknown { background: var(--surface-3); color: var(--text-muted); }
.lic-note { font-size: 12px; color: var(--text-dim); margin-top: 6px; }
.file-description { font-size: 13px; color: var(--text-muted); margin-bottom: 10px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.file-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 10px; }
.file-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--surface-2); color: var(--text-muted); }
.file-meta { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
/* list view */
.file-grid.list-view { grid-template-columns: 1fr; }
.file-grid.list-view .file-card { display: flex; align-items: stretch; }
.file-grid.list-view .file-thumbnail { width: 130px; height: auto; min-height: 90px; flex-shrink: 0; border-bottom: none; border-right: 1px solid var(--border); font-size: 30px; }
.file-grid.list-view .file-info { flex: 1; }
.empty-state { grid-column: 1 / -1; text-align: center; padding: 80px 20px; color: var(--text-dim); }
.empty-state h3 { font-size: 18px; color: var(--text-muted); margin-bottom: 8px; }
/* ---- modals ---- */
.modal {
display: none; position: fixed; inset: 0; z-index: 100;
background: rgba(8, 9, 12, 0.7); backdrop-filter: blur(3px);
align-items: flex-start; justify-content: center; padding: 60px 20px; overflow-y: auto;
}
.modal.active { display: flex; }
.modal-content { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); width: 100%; max-width: 520px; }
.modal-content.large { max-width: 960px; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.modal-header h2 { font-size: 18px; font-weight: 600; }
.close-btn { background: none; border: none; color: var(--text-dim); font-size: 26px; line-height: 1; cursor: pointer; transition: color 0.12s; }
.close-btn:hover { color: var(--text); }
.modal-body { padding: 22px; }
.form-group { margin-bottom: 18px; }
.print-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
/* ---- model detail ---- */
.file-detail-content { display: grid; grid-template-columns: 1.1fr 1fr; gap: 26px; }
.file-detail-preview { display: flex; flex-direction: column; gap: 10px; }
.model-viewer { width: 100%; height: 380px; background: radial-gradient(circle at 50% 40%, #232730 0%, #131519 100%); border: 1px solid var(--border); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; }
.model-viewer canvas { display: block; border-radius: var(--radius); }
.viewer-msg { color: var(--text-dim); font-size: 14px; padding: 20px; text-align: center; }
.viewer-fallback { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 12px; }
.viewer-fallback img { max-width: 100%; max-height: 320px; object-fit: contain; border-radius: var(--radius-sm); }
.viewer-fallback span { color: var(--text-dim); font-size: 12px; text-align: center; }
.file-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.file-tab { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 5px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; font-family: inherit; }
.file-tab:hover { color: var(--text); }
.file-tab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
.info-section { margin-bottom: 22px; }
.info-section:last-of-type { margin-bottom: 0; }
.info-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 9px; }
.info-section p, .info-section div { color: var(--text); font-size: 14px; line-height: 1.6; }
.info-section .tag { cursor: default; }
.file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
.file-row:last-child { border-bottom: none; }
.file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; }
/* ---- import / settings ---- */
.import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; }
.form-hint { font-size: 12px; color: var(--text-dim); margin-top: 7px; line-height: 1.5; }
.form-hint-inline { font-size: 12px; color: var(--text-dim); font-weight: 400; }
.settings-group-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 14px; }
.setting-status.is-set { color: var(--success); font-weight: 500; }
.setting-status.is-unset { color: var(--danger); font-weight: 500; }
.btn-link { background: none; border: none; color: var(--danger); padding: 6px 0 0; font-size: 13px; cursor: pointer; font-family: inherit; }
.btn-link:hover { text-decoration: underline; }
@media (max-width: 860px) {
.main-content { grid-template-columns: 1fr; }
.file-detail-content { grid-template-columns: 1fr; }
}

162
public/viewer.js Normal file
View file

@ -0,0 +1,162 @@
// In-browser 3D preview for STL / 3MF / OBJ files, loaded as an ES module.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// Formats the renderer can draw directly. 3MF is rendered via server-converted
// GLB (see RENDERABLE for what the app treats as previewable input).
const SUPPORTED = ['stl', 'obj', 'glb'];
const RENDERABLE = ['stl', '3mf', 'obj'];
let active = null; // { renderer, controls, rafId }
function dispose() {
if (!active) return;
cancelAnimationFrame(active.rafId);
active.controls.dispose();
active.renderer.dispose();
const el = active.renderer.domElement;
if (el.parentNode) el.parentNode.removeChild(el);
active = null;
}
function message(container, text) {
container.innerHTML = `<div class="viewer-msg">${text}</div>`;
}
// When 3D rendering isn't possible (e.g. Bambu/MakerWorld 3MF project files that
// the loader can't parse), show the model's preview image instead of an error.
function showFallback(container, image, text) {
if (image) {
container.innerHTML =
`<div class="viewer-fallback"><img src="${image}" alt=""><span>${text}</span></div>`;
} else {
message(container, text);
}
}
async function loadObject(url, format) {
if (format === 'stl') {
const geometry = await new STLLoader().loadAsync(url);
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({ color: 0xc4cad6, metalness: 0.15, roughness: 0.65 });
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2; // STL is typically Z-up; sit it flat
return mesh;
}
if (format === 'obj') return new OBJLoader().loadAsync(url);
if (format === '3mf') return new ThreeMFLoader().loadAsync(url);
if (format === 'glb') {
const gltf = await new GLTFLoader().loadAsync(url);
gltf.scene.rotation.x = -Math.PI / 2; // 3MF/trimesh is Z-up; sit it upright
return gltf.scene;
}
throw new Error(`Unsupported format ${format}`);
}
async function render(container, url, format, opts = {}) {
dispose();
container.innerHTML = '';
if (!SUPPORTED.includes(format)) {
showFallback(container, opts.fallbackImage, `No 3D preview for .${format} files.`);
return;
}
const width = container.clientWidth || 600;
const height = container.clientHeight || 420;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1e6);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(1, 1, 1); scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
let object;
try {
object = await loadObject(url, format);
} catch (err) {
renderer.dispose();
renderer.forceContextLoss();
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
const note = format === '3mf'
? 'Interactive 3D isnt supported for this 3MF (Bambu/MakerWorld project file).'
: 'This file couldnt be rendered in 3D.';
showFallback(container, opts.fallbackImage, note);
return;
}
// Center the object and frame the camera around it.
const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
object.position.sub(center);
scene.add(object);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
camera.position.set(maxDim * 1.4, maxDim * 1.1, maxDim * 1.8);
camera.near = maxDim / 100;
camera.far = maxDim * 100;
camera.updateProjectionMatrix();
controls.target.set(0, 0, 0);
controls.update();
active = { renderer, controls, rafId: 0 };
const loop = () => {
if (!active) return;
active.rafId = requestAnimationFrame(loop);
controls.update();
renderer.render(scene, camera);
};
loop();
}
// Render a model once to an offscreen canvas and return a PNG data URL.
// Used to generate card thumbnails. Transparent background so it composites
// over the card surface.
async function snapshot(url, format, size = 512) {
if (!SUPPORTED.includes(format)) return null;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
renderer.setSize(size, size);
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1e6);
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(1, 1, 1); scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill);
try {
const object = await loadObject(url, format);
const box = new THREE.Box3().setFromObject(object);
const dim = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
object.position.sub(center);
scene.add(object);
const maxDim = Math.max(dim.x, dim.y, dim.z) || 1;
camera.position.set(maxDim * 1.3, maxDim * 1.0, maxDim * 1.7);
camera.near = maxDim / 100;
camera.far = maxDim * 100;
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();
renderer.render(scene, camera);
return renderer.domElement.toDataURL('image/png');
} finally {
renderer.dispose();
renderer.forceContextLoss();
}
}
window.ModelViewer = { render, dispose, snapshot, SUPPORTED, RENDERABLE };

659
server.js Normal file
View file

@ -0,0 +1,659 @@
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); });

44
tools/convert_3mf.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB.
Usage: convert_3mf.py <input.3mf> <output.glb>
Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
"""
import sys
import json
import trimesh
def main():
if len(sys.argv) != 3:
print(json.dumps({"error": "usage: convert_3mf.py <in.3mf> <out.glb>"}))
return 2
in_path, out_path = sys.argv[1], sys.argv[2]
scene = trimesh.load(in_path, force="scene")
geoms = list(scene.geometry.values())
if not geoms:
print(json.dumps({"error": "no geometry found in 3MF"}))
return 1
scene.export(out_path, file_type="glb")
combined = trimesh.util.concatenate(geoms)
ext = combined.bounds[1] - combined.bounds[0]
summary = {
"glb": out_path,
"bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)},
"faces": int(sum(len(g.faces) for g in geoms)),
}
try:
# trimesh volume is in model units (mm) -> cm^3 to match the rest of the app
summary["volume_cm3"] = round(float(combined.volume) / 1000.0, 3)
except Exception:
summary["volume_cm3"] = None
print(json.dumps(summary))
return 0
if __name__ == "__main__":
sys.exit(main())