import { Router, Request, Response } from 'express' import multer from 'multer' import path from 'path' import fs from 'fs' import crypto from 'crypto' import slugify from 'slugify' import { db, q, Model, Category, ModelPdf } from '../db/index' import { requireAdmin } from '../middleware/requireAdmin' import { convertStepFile, geometryOutputPath, GeometryFile } from '../services/stepConverter' import { thumbnailOutputPath, geometryToTriangles, stlBufferToTriangles, renderThumbnail } from '../services/thumbnailGenerator' const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads') const MAX_FILE_BYTES = parseInt(process.env.MAX_FILE_MB ?? '500', 10) * 1024 * 1024 const ALLOWED_MODEL_EXTS = new Set(['.step', '.stp', '.stl']) const ALLOWED_PDF_EXTS = new Set(['.pdf']) const modelStorage = multer.diskStorage({ destination: path.join(UPLOADS_DIR, 'models'), filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() const base = slugify(path.basename(file.originalname, ext), { lower: true, strict: true }) cb(null, `${Date.now()}-${base}${ext}`) }, }) const pdfStorage = multer.diskStorage({ destination: path.join(UPLOADS_DIR, 'pdfs'), filename: (_req, file, cb) => { const base = slugify(path.basename(file.originalname, '.pdf'), { lower: true, strict: true }) cb(null, `${Date.now()}-${base}.pdf`) }, }) function modelFileFilter(_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) { const ext = path.extname(file.originalname).toLowerCase() if (ALLOWED_MODEL_EXTS.has(ext)) cb(null, true) else cb(new Error(`Unsupported file type: ${ext}`)) } function pdfFileFilter(_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) { const ext = path.extname(file.originalname).toLowerCase() if (ALLOWED_PDF_EXTS.has(ext)) cb(null, true) else cb(new Error('Only PDF files are allowed')) } const uploadModel = multer({ storage: modelStorage, fileFilter: modelFileFilter, limits: { fileSize: MAX_FILE_BYTES } }) const uploadPdf = multer({ storage: pdfStorage, fileFilter: pdfFileFilter, limits: { fileSize: MAX_FILE_BYTES } }) const router = Router() // ---- Dashboard ----------------------------------------------------------- router.get('/admin', requireAdmin, (req: Request, res: Response) => { // Use `search` as the query param name to avoid collision with the `q` // helper imported from db/index. const { search, category_id, visibility } = req.query as Record let sql = ` SELECT m.*, c.name AS category_name, (SELECT COUNT(*) FROM model_pdfs WHERE model_id = m.id) AS pdf_count FROM models m LEFT JOIN categories c ON c.id = m.category_id WHERE 1=1 ` const params: string[] = [] if (search) { sql += ` AND (m.name LIKE ? OR m.description LIKE ?)` params.push(`%${search}%`, `%${search}%`) } if (category_id) { sql += ` AND m.category_id = ?` params.push(category_id) } if (visibility === 'public') sql += ` AND m.is_public = 1` if (visibility === 'private') sql += ` AND m.is_public = 0` sql += ` ORDER BY m.created_at DESC` const models = q(sql).all(...params) const categories = q(`SELECT * FROM categories ORDER BY sort_order, name`).all() const totalCount = (q<{ n: number }>(`SELECT COUNT(*) AS n FROM models`).get())?.n ?? 0 const publicCount = (q<{ n: number }>(`SELECT COUNT(*) AS n FROM models WHERE is_public = 1`).get())?.n ?? 0 res.render('admin/dashboard', { models, categories, totalCount, publicCount, filters: { search, category_id, visibility }, baseUrl: process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`, }) }) // ---- Upload form --------------------------------------------------------- router.get('/admin/upload', requireAdmin, (_req: Request, res: Response) => { const categories = q(`SELECT * FROM categories ORDER BY sort_order, name`).all() res.render('admin/upload', { categories, error: null }) }) router.post('/admin/models', requireAdmin, uploadModel.single('model_file'), async (req: Request, res: Response) => { try { if (!req.file) { const categories = q(`SELECT * FROM categories ORDER BY sort_order, name`).all() res.render('admin/upload', { categories, error: 'A model file is required.' }) return } const { name, description, category_id, is_public } = req.body as { name: string; description?: string; category_id?: string; is_public?: string } const ext = path.extname(req.file.originalname).toLowerCase().replace('.', '') as 'step' | 'stp' | 'stl' // Generate a random, unguessable 12-character hex slug (48 bits of entropy). // The model name is stored separately for display — the URL reveals nothing // about the content, making enumeration infeasible without an admin link. let slug = crypto.randomBytes(6).toString('hex') while (q<{ id: number }>(`SELECT id FROM models WHERE slug = ?`).get(slug)) { slug = crypto.randomBytes(6).toString('hex') // collision is astronomically unlikely } const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/') const insertResult = db.prepare(` INSERT INTO models (slug, name, description, category_id, file_path, file_type, is_public) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(slug, name.trim(), description?.trim() || null, category_id ? parseInt(category_id, 10) : null, relPath, ext, is_public === 'on' ? 1 : 0) const modelId = Number(insertResult.lastInsertRowid) const absModelPath = path.join(UPLOADS_DIR, relPath) // Convert STEP/STP to pre-processed geometry JSON so the browser never // needs to download the 22 MB WASM parser, then generate a thumbnail // from the resulting geometry data. if (ext === 'step' || ext === 'stp') { const geoOutPath = geometryOutputPath(absModelPath) try { await convertStepFile(absModelPath, geoOutPath) } catch (convErr) { // Conversion failure is non-fatal — the model is saved; the viewer // will show a friendly error instead of crashing the upload. console.error('[stepConverter] conversion failed:', (convErr as Error).message) } // Generate thumbnail from the geometry JSON (only if conversion succeeded) if (fs.existsSync(geoOutPath)) { try { const geo = JSON.parse(fs.readFileSync(geoOutPath, 'utf8')) as GeometryFile const tris = geometryToTriangles(geo) const thumbPath = thumbnailOutputPath(UPLOADS_DIR, modelId) await renderThumbnail(tris, thumbPath) const thumbRel = path.relative(UPLOADS_DIR, thumbPath).replace(/\\/g, '/') db.prepare(`UPDATE models SET thumbnail_path = ? WHERE id = ?`).run(thumbRel, modelId) } catch (thumbErr) { console.error('[thumbnail] STEP generation failed:', (thumbErr as Error).message) } } } else if (ext === 'stl') { // Parse the STL directly on the server — no intermediate JSON needed try { const stlBuffer = fs.readFileSync(absModelPath) const tris = stlBufferToTriangles(stlBuffer) const thumbPath = thumbnailOutputPath(UPLOADS_DIR, modelId) await renderThumbnail(tris, thumbPath) const thumbRel = path.relative(UPLOADS_DIR, thumbPath).replace(/\\/g, '/') db.prepare(`UPDATE models SET thumbnail_path = ? WHERE id = ?`).run(thumbRel, modelId) } catch (thumbErr) { console.error('[thumbnail] STL generation failed:', (thumbErr as Error).message) } } res.redirect('/admin') } catch (err) { const categories = q(`SELECT * FROM categories ORDER BY sort_order, name`).all() res.render('admin/upload', { categories, error: (err as Error).message }) } } ) // ---- Attach PDF ---------------------------------------------------------- router.post('/admin/models/:id/pdf', requireAdmin, uploadPdf.single('pdf_file'), (req: Request, res: Response) => { const model = q(`SELECT id FROM models WHERE id = ?`).get(req.params.id) if (!model || !req.file) { res.redirect('/admin'); return } const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/') const displayName = (req.body.display_name as string | undefined)?.trim() || req.file.originalname const maxOrder = q<{ m: number | null }>(`SELECT MAX(sort_order) AS m FROM model_pdfs WHERE model_id = ?`).get(model.id)?.m ?? -1 db.prepare(`INSERT INTO model_pdfs (model_id, display_name, file_path, sort_order) VALUES (?, ?, ?, ?)`).run(model.id, displayName, relPath, maxOrder + 1) res.redirect(`/admin/models/${model.id}/edit`) } ) // ---- Toggle visibility (JSON) -------------------------------------------- router.post('/api/admin/models/:id/visibility', requireAdmin, (req: Request, res: Response) => { const model = q(`SELECT id, is_public FROM models WHERE id = ?`).get(req.params.id) if (!model) { res.status(404).json({ error: 'Not found' }); return } const newValue = model.is_public === 1 ? 0 : 1 db.prepare(`UPDATE models SET is_public = ?, updated_at = datetime('now') WHERE id = ?`).run(newValue, model.id) res.json({ is_public: newValue }) }) // ---- Delete model -------------------------------------------------------- router.post('/admin/models/:id/delete', requireAdmin, (req: Request, res: Response) => { const model = q(`SELECT * FROM models WHERE id = ?`).get(req.params.id) if (!model) { res.redirect('/admin'); return } const modelFile = path.join(UPLOADS_DIR, model.file_path) if (fs.existsSync(modelFile)) fs.unlinkSync(modelFile) const pdfs = q<{ file_path: string }>(`SELECT file_path FROM model_pdfs WHERE model_id = ?`).all(model.id) for (const pdf of pdfs) { const pdfFile = path.join(UPLOADS_DIR, pdf.file_path) if (fs.existsSync(pdfFile)) fs.unlinkSync(pdfFile) } if (model.thumbnail_path) { const thumb = path.join(UPLOADS_DIR, model.thumbnail_path) if (fs.existsSync(thumb)) fs.unlinkSync(thumb) } db.prepare(`DELETE FROM models WHERE id = ?`).run(model.id) res.redirect('/admin') }) // ---- Edit model ---------------------------------------------------------- router.get('/admin/models/:id/edit', requireAdmin, (req: Request, res: Response) => { const model = q(`SELECT * FROM models WHERE id = ?`).get(req.params.id) if (!model) { res.redirect('/admin'); return } const categories = q(`SELECT * FROM categories ORDER BY sort_order, name`).all() const pdfs = q(`SELECT * FROM model_pdfs WHERE model_id = ? ORDER BY sort_order`).all(model.id) res.render('admin/edit', { model, categories, pdfs, error: null, baseUrl: process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`, }) }) router.post('/admin/models/:id/edit', requireAdmin, (req: Request, res: Response) => { const model = q(`SELECT * FROM models WHERE id = ?`).get(req.params.id) if (!model) { res.redirect('/admin'); return } const { name, description, category_id, is_public } = req.body as { name: string; description?: string; category_id?: string; is_public?: string } db.prepare(`UPDATE models SET name = ?, description = ?, category_id = ?, is_public = ?, updated_at = datetime('now') WHERE id = ?`).run( name.trim(), description?.trim() || null, category_id ? parseInt(category_id, 10) : null, is_public === 'on' ? 1 : 0, model.id ) res.redirect('/admin') }) // ---- Delete PDF ---------------------------------------------------------- router.post('/admin/pdfs/:id/delete', requireAdmin, (req: Request, res: Response) => { const pdf = q(`SELECT * FROM model_pdfs WHERE id = ?`).get(req.params.id) if (!pdf) { res.redirect('/admin'); return } const pdfFile = path.join(UPLOADS_DIR, pdf.file_path) if (fs.existsSync(pdfFile)) fs.unlinkSync(pdfFile) db.prepare(`DELETE FROM model_pdfs WHERE id = ?`).run(pdf.id) res.redirect(`/admin/models/${pdf.model_id}/edit`) }) // ---- JSON list ----------------------------------------------------------- router.get('/api/admin/models', requireAdmin, (_req: Request, res: Response) => { const models = q(` SELECT m.*, c.name AS category_name FROM models m LEFT JOIN categories c ON c.id = m.category_id ORDER BY m.created_at DESC `).all() res.json(models) }) export default router