292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
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<string, string>
|
|
|
|
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<Model & { category_name: string | null; pdf_count: number }>(sql).all(...params)
|
|
const categories = q<Category>(`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<Category>(`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<Category>(`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<Category>(`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<Model>(`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<Model>(`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<Model>(`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<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
|
|
if (!model) { res.redirect('/admin'); return }
|
|
|
|
const categories = q<Category>(`SELECT * FROM categories ORDER BY sort_order, name`).all()
|
|
const pdfs = q<ModelPdf>(`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<Model>(`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<ModelPdf>(`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<Model & { category_name: string | null }>(`
|
|
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
|