first push

This commit is contained in:
jason
2026-04-22 15:47:27 -05:00
parent 923ef2ec0e
commit 1552a0ea65
86 changed files with 10066 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
import { Router, Request, Response } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import slugify from 'slugify'
import { db, q, Model, Category, ModelPdf } from '../db/index'
import { requireAdmin } from '../middleware/requireAdmin'
import { convertStepFile, geometryOutputPath } from '../services/stepConverter'
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'
const base = slugify(name, { lower: true, strict: true })
let slug = base
let attempt = 0
while (q<{ id: number }>(`SELECT id FROM models WHERE slug = ?`).get(slug)) {
attempt++
slug = `${base}-${attempt}`
}
const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/')
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)
// Convert STEP/STP to pre-processed geometry JSON so the browser never
// needs to download the 22 MB WASM parser.
if (ext === 'step' || ext === 'stp') {
const absModelPath = path.join(UPLOADS_DIR, relPath)
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)
}
}
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