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
+149
View File
@@ -0,0 +1,149 @@
import '../style.css'
// ---- Toast utility -------------------------------------------------------
function showToast(msg: string, durationMs = 2500) {
const toast = document.getElementById('toast')
const toastMsg = document.getElementById('toast-msg')
if (!toast || !toastMsg) return
toastMsg.textContent = msg
toast.classList.remove('opacity-0', 'translate-y-2', 'pointer-events-none')
toast.classList.add('opacity-100', 'translate-y-0')
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-y-2', 'pointer-events-none')
toast.classList.remove('opacity-100', 'translate-y-0')
}, durationMs)
}
// ---- Copy-link buttons ---------------------------------------------------
document.querySelectorAll<HTMLButtonElement>('.copy-link-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const url = btn.dataset.url ?? ''
try {
await navigator.clipboard.writeText(url)
showToast('Link copied to clipboard')
} catch {
// Fallback for non-secure contexts
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
showToast('Link copied to clipboard')
}
})
})
// ---- Visibility toggles --------------------------------------------------
document.querySelectorAll<HTMLButtonElement>('.visibility-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.modelId
if (!id) return
try {
const res = await fetch(`/api/admin/models/${id}/visibility`, { method: 'POST' })
const data = await res.json() as { is_public: 0 | 1 }
const isPublic = data.is_public === 1
const dot = btn.querySelector('span')
btn.dataset.isPublic = String(data.is_public)
btn.className = btn.className.replace(
/bg-(green|gray)-\S+|border-(green|gray)-\S+|text-(green|gray)-\S+/g, ''
)
btn.classList.add(
...(isPublic
? ['bg-green-500/10', 'border-green-500/30', 'text-green-400']
: ['bg-gray-700/50', 'border-gray-700', 'text-gray-500'])
)
if (dot) {
dot.className = `w-1.5 h-1.5 rounded-full ${isPublic ? 'bg-green-400' : 'bg-gray-500'}`
}
btn.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
node.textContent = ` ${isPublic ? 'Public' : 'Private'}`
}
})
showToast(isPublic ? 'Model set to public' : 'Model set to private')
} catch {
showToast('Failed to update visibility')
}
})
})
// ---- File drop zone ------------------------------------------------------
const dropZone = document.getElementById('drop-zone')
const fileInput = document.getElementById('model_file') as HTMLInputElement | null
const fileNameDisplay = document.getElementById('file-name')
if (dropZone && fileInput) {
dropZone.addEventListener('dragover', e => {
e.preventDefault()
dropZone.classList.add('border-accent')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-accent')
})
dropZone.addEventListener('drop', e => {
e.preventDefault()
dropZone.classList.remove('border-accent')
const files = e.dataTransfer?.files
if (files?.length) {
// Transfer to the real file input via DataTransfer
const dt = new DataTransfer()
dt.items.add(files[0])
fileInput.files = dt.files
showFileName(files[0].name)
// Auto-fill name field if empty
const nameInput = document.getElementById('name') as HTMLInputElement | null
if (nameInput && !nameInput.value) {
nameInput.value = files[0].name.replace(/\.(step|stp|stl)$/i, '')
}
}
})
fileInput.addEventListener('change', () => {
if (fileInput.files?.[0]) showFileName(fileInput.files[0].name)
})
function showFileName(name: string) {
if (fileNameDisplay) {
fileNameDisplay.textContent = `Selected: ${name}`
fileNameDisplay.classList.remove('hidden')
}
}
}
// ---- Inline category edit toggle ----------------------------------------
document.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('[class*="hover:bg"]') as HTMLElement
const form = row?.querySelector<HTMLElement>('.edit-form')
const actionBtns = row?.querySelector<HTMLElement>('.action-buttons')
if (form && actionBtns) {
form.classList.remove('hidden')
form.classList.add('flex')
actionBtns.classList.add('hidden')
}
})
})
document.querySelectorAll<HTMLElement>('.cancel-edit').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('[class*="hover:bg"]') as HTMLElement
const form = row?.querySelector<HTMLElement>('.edit-form')
const actionBtns = row?.querySelector<HTMLElement>('.action-buttons')
if (form && actionBtns) {
form.classList.add('hidden')
form.classList.remove('flex')
actionBtns.classList.remove('hidden')
}
})
})
+31
View File
@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* { box-sizing: border-box; }
:root {
--accent: #3b82f6;
}
html {
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar styling for dark mode */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
}
@layer utilities {
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
+379
View File
@@ -0,0 +1,379 @@
import '../style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { STLLoader } from 'three/addons/loaders/STLLoader.js'
import type { GeometryFile } from '../../server/services/stepConverter'
// ---- Data injected by viewer.ejs -----------------------------------------
declare const __STEPVIEW__: {
modelId: number
fileType: 'step' | 'stp' | 'stl'
shareUrl: string
hasPdfs: boolean
hasGeometry: boolean // pre-processed geometry JSON exists (STEP/STP only)
}
// ---- Scene state ---------------------------------------------------------
let renderer: THREE.WebGLRenderer
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let controls: OrbitControls
// ---- UI helpers ----------------------------------------------------------
function setLoading(msg: string) {
const el = document.getElementById('loading-msg')
if (el) el.textContent = msg
}
function hideLoading() {
const overlay = document.getElementById('loading-overlay') as HTMLElement
overlay.style.opacity = '0'
setTimeout(() => { overlay.style.display = 'none' }, 300)
}
function showError(msg: string) {
document.getElementById('loading-overlay')!.style.display = 'none'
const overlay = document.getElementById('error-overlay')!
overlay.classList.remove('hidden')
overlay.classList.add('flex')
const msgEl = document.getElementById('error-msg')
if (msgEl) msgEl.textContent = msg
}
function showToast(msg: string, duration = 2200) {
const toast = document.getElementById('toast')!
const toastMsg = document.getElementById('toast-msg')!
toastMsg.textContent = msg
toast.classList.remove('opacity-0', 'pointer-events-none')
toast.classList.add('opacity-100')
setTimeout(() => {
toast.classList.add('opacity-0', 'pointer-events-none')
toast.classList.remove('opacity-100')
}, duration)
}
// ---- Scene setup ---------------------------------------------------------
function buildScene(canvas: HTMLCanvasElement) {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setClearColor(0x0a0a0f, 1)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.1
scene = new THREE.Scene()
// Subtle environment fog
scene.fog = new THREE.FogExp2(0x0a0a0f, 0.0008)
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.001, 10000)
camera.position.set(5, 3, 8)
// Lighting
const ambient = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(ambient)
const key = new THREE.DirectionalLight(0xffffff, 2.0)
key.position.set(10, 20, 10)
key.castShadow = true
key.shadow.mapSize.set(2048, 2048)
key.shadow.camera.near = 0.1
key.shadow.camera.far = 500
scene.add(key)
const fill = new THREE.DirectionalLight(0x8899cc, 0.4)
fill.position.set(-10, 5, -10)
scene.add(fill)
const rim = new THREE.DirectionalLight(0xffffff, 0.2)
rim.position.set(0, -5, -10)
scene.add(rim)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.06
controls.minDistance = 0.001
controls.maxDistance = 5000
controls.panSpeed = 0.8
controls.rotateSpeed = 0.6
controls.zoomSpeed = 1.2
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
}
// ---- Render loop ---------------------------------------------------------
function startRenderLoop() {
const clock = new THREE.Clock()
function tick() {
requestAnimationFrame(tick)
controls.update()
renderer.render(scene, camera)
void clock
}
tick()
}
// ---- Camera fit ----------------------------------------------------------
function fitCamera(object: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(object)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
if (maxDim === 0) return
const fovRad = camera.fov * (Math.PI / 180)
const dist = (maxDim / (2 * Math.tan(fovRad / 2))) * 1.6
camera.near = maxDim * 0.0005
camera.far = maxDim * 200
camera.updateProjectionMatrix()
camera.position.set(
center.x + dist * 0.55,
center.y + dist * 0.35,
center.z + dist,
)
camera.lookAt(center)
controls.target.copy(center)
controls.update()
}
// ---- Ground grid ---------------------------------------------------------
function addGrid(object: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(object)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.z) * 3
const grid = new THREE.GridHelper(maxDim, 20, 0x1a1a2a, 0x1a1a2a)
grid.position.set(center.x, box.min.y - 0.001, center.z)
scene.add(grid)
}
// ---- Material factory ----------------------------------------------------
function makeMaterial(color: [number, number, number] | null): THREE.MeshStandardMaterial {
const c = color
? new THREE.Color(color[0], color[1], color[2])
: new THREE.Color(0x8fa3b8)
return new THREE.MeshStandardMaterial({
color: c,
roughness: 0.45,
metalness: 0.25,
side: THREE.DoubleSide,
})
}
// ---- STEP/STP loader (geometry JSON, pre-processed server-side) ----------
async function loadStepGeometry(): Promise<void> {
setLoading('Fetching geometry…')
const res = await fetch(`/files/geometry/${__STEPVIEW__.modelId}`)
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { processing?: boolean }
if (body.processing) {
throw new Error('This model is still being processed. Please try again in a moment.')
}
throw new Error(`Could not load geometry (HTTP ${res.status})`)
}
setLoading('Building 3D scene…')
const data = await res.json() as GeometryFile
if (!data.meshes || data.meshes.length === 0) {
throw new Error('Geometry file contains no meshes.')
}
const group = new THREE.Group()
for (const mesh of data.meshes) {
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3))
if (mesh.normals && mesh.normals.length > 0) {
geo.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3))
}
if (mesh.indices && mesh.indices.length > 0) {
geo.setIndex(new THREE.Uint32BufferAttribute(mesh.indices, 1))
}
if (!mesh.normals || mesh.normals.length === 0) {
geo.computeVertexNormals()
}
group.add(new THREE.Mesh(geo, makeMaterial(mesh.color)))
}
scene.add(group)
fitCamera(group)
addGrid(group)
}
// ---- STL loader (client-side, Three.js built-in) -------------------------
async function loadStl(): Promise<void> {
setLoading('Fetching STL file…')
const loader = new STLLoader()
const geometry = await new Promise<THREE.BufferGeometry>((resolve, reject) => {
loader.load(
`/files/model/${__STEPVIEW__.modelId}`,
resolve,
(xhr) => {
if (xhr.total) {
const pct = Math.round((xhr.loaded / xhr.total) * 100)
setLoading(`Downloading STL… ${pct}%`)
}
},
reject,
)
})
setLoading('Building 3D scene…')
geometry.computeVertexNormals()
// Center geometry at origin
geometry.computeBoundingBox()
const center = new THREE.Vector3()
geometry.boundingBox!.getCenter(center)
geometry.translate(-center.x, -center.y, -center.z)
const mesh = new THREE.Mesh(geometry, makeMaterial(null))
mesh.castShadow = true
mesh.receiveShadow = true
scene.add(mesh)
fitCamera(mesh)
addGrid(mesh)
}
// ---- Viewer toolbar ------------------------------------------------------
function wireToolbar() {
// Copy-link
const copyBtn = document.getElementById('copy-link-btn') as HTMLButtonElement | null
if (copyBtn) {
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(__STEPVIEW__.shareUrl)
} catch {
const ta = Object.assign(document.createElement('textarea'), {
value: __STEPVIEW__.shareUrl,
style: 'position:fixed;opacity:0',
})
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
const orig = copyBtn.innerHTML
copyBtn.textContent = '✓ Copied!'
setTimeout(() => { copyBtn.innerHTML = orig }, 2000)
})
}
// Reset camera
const resetBtn = document.getElementById('reset-camera-btn')
if (resetBtn) {
resetBtn.addEventListener('click', () => {
// Re-fit camera to scene objects (exclude grid)
const objects = scene.children.filter(c => !(c instanceof THREE.GridHelper))
const group = new THREE.Group()
objects.forEach(o => group.add(o.clone()))
if (group.children.length) fitCamera(group)
})
}
// Wireframe toggle
let wireframe = false
const wireBtn = document.getElementById('wireframe-btn')
if (wireBtn) {
wireBtn.addEventListener('click', () => {
wireframe = !wireframe
scene.traverse(obj => {
if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) {
obj.material.wireframe = wireframe
}
})
wireBtn.classList.toggle('text-accent', wireframe)
})
}
// PDF panel
const pdfToggle = document.getElementById('pdf-toggle-btn')
const pdfPanel = document.getElementById('pdf-panel')
const pdfClose = document.getElementById('pdf-close-btn')
if (pdfToggle && pdfPanel) {
pdfToggle.addEventListener('click', () => pdfPanel.classList.toggle('closed'))
}
if (pdfClose && pdfPanel) {
pdfClose.addEventListener('click', () => pdfPanel.classList.add('closed'))
}
// PDF tabs
document.querySelectorAll<HTMLButtonElement>('.pdf-tab').forEach(tab => {
tab.addEventListener('click', () => {
const idx = tab.dataset.tab
document.querySelectorAll('.pdf-tab').forEach(t => {
t.classList.remove('border-accent', 'text-white')
t.classList.add('border-transparent', 'text-gray-500')
})
tab.classList.add('border-accent', 'text-white')
tab.classList.remove('border-transparent', 'text-gray-500')
document.querySelectorAll<HTMLElement>('.pdf-frame').forEach(f => {
f.classList.toggle('hidden', f.dataset.frame !== idx)
})
})
})
}
// ---- Boot ----------------------------------------------------------------
async function boot() {
const canvas = document.getElementById('viewer-canvas') as HTMLCanvasElement
try {
buildScene(canvas)
startRenderLoop()
wireToolbar()
if (__STEPVIEW__.fileType === 'stl') {
await loadStl()
} else {
// STEP / STP
if (!__STEPVIEW__.hasGeometry) {
throw new Error(
'This model has not finished processing yet. ' +
'Please check back shortly or contact your administrator.'
)
}
await loadStepGeometry()
}
hideLoading()
} catch (err) {
console.error('[StepView]', err)
showError(err instanceof Error ? err.message : String(err))
}
}
boot()
export {}
+100
View File
@@ -0,0 +1,100 @@
import 'dotenv/config'
import express from 'express'
import compression from 'compression'
import session from 'express-session'
import path from 'path'
import fs from 'fs'
// Initialize DB (runs schema + seeds) before anything else
import './db/index'
import { injectBrand } from './middleware/injectBrand'
import { warmUpOcct } from './services/stepConverter'
import authRoutes from './routes/auth'
import adminModelRoutes from './routes/adminModels'
import adminCategoryRoutes from './routes/adminCategories'
import adminSettingsRoutes from './routes/adminSettings'
import viewerRoutes from './routes/viewer'
import fileRoutes from './routes/files'
const app = express()
const PORT = parseInt(process.env.PORT ?? '3000', 10)
// Ensure upload dirs exist
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
for (const sub of ['models', 'pdfs', 'thumbnails', 'brand']) {
fs.mkdirSync(path.join(UPLOADS_DIR, sub), { recursive: true })
}
// Warm up WASM in background so the first STEP upload is fast
warmUpOcct()
// ---- View engine ----------------------------------------------------------
app.set('view engine', 'ejs')
app.set('views', path.join(process.cwd(), 'views'))
// ---- Compression (gzip geometry JSON, etc.) ------------------------------
app.use(compression())
// ---- Static files --------------------------------------------------------
app.use(express.static(path.join(process.cwd(), 'public')))
// ---- Body parsing --------------------------------------------------------
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
// ---- Sessions ------------------------------------------------------------
app.use(session({
secret: process.env.SESSION_SECRET ?? 'dev-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production' && process.env.TRUST_PROXY === 'true',
maxAge: 8 * 60 * 60 * 1000, // 8 hours
},
}))
// ---- Global locals -------------------------------------------------------
app.use(injectBrand)
// Inject first-run warning flag when admin password is still the default
app.use((req, res, next) => {
const isAdminRoute = req.path.startsWith('/admin')
const isLoggedIn = (req.session as { isAdmin?: boolean }).isAdmin
res.locals.defaultPassword = isAdminRoute && isLoggedIn
&& (process.env.ADMIN_PASS ?? 'changeme') === 'changeme'
next()
})
// ---- Routes --------------------------------------------------------------
app.use(authRoutes)
app.use(adminModelRoutes)
app.use(adminCategoryRoutes)
app.use(adminSettingsRoutes)
app.use(fileRoutes)
app.use(viewerRoutes)
// ---- Health check --------------------------------------------------------
app.get('/health', (_req, res) => res.json({ status: 'ok', version: '0.1.0' }))
// ---- 404 -----------------------------------------------------------------
app.use((_req, res) => {
res.status(404).render('error', { status: 404, message: 'Page not found.' })
})
// ---- Error handler -------------------------------------------------------
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err)
res.status(500).render('error', { status: 500, message: 'An unexpected error occurred.' })
})
app.listen(PORT, () => {
console.log(`StepView running on http://localhost:${PORT}`)
console.log(`Admin: http://localhost:${PORT}/admin`)
if ((process.env.ADMIN_PASS ?? 'changeme') === 'changeme') {
console.warn('⚠ WARNING: ADMIN_PASS is still set to the default "changeme". Set it in .env before exposing to a network.')
}
})
export default app
+128
View File
@@ -0,0 +1,128 @@
import { DatabaseSync } from 'node:sqlite'
import bcrypt from 'bcryptjs'
import path from 'path'
import fs from 'fs'
import { SCHEMA_SQL, DEFAULT_SETTINGS } from './schema'
const DATA_DIR = process.env.DATA_DIR ?? path.join(process.cwd(), 'data')
fs.mkdirSync(DATA_DIR, { recursive: true })
const DB_PATH = path.join(DATA_DIR, 'stepview.db')
export const db = new DatabaseSync(DB_PATH)
db.exec(SCHEMA_SQL)
// Seed settings that don't yet exist
const upsertSetting = db.prepare(
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`
)
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
upsertSetting.run(key, value)
}
// Sync admin password hash from env on every startup
const envPass = process.env.ADMIN_PASS ?? 'changeme'
const newHash = bcrypt.hashSync(envPass, 12)
db.prepare(`UPDATE settings SET value = ? WHERE key = 'admin_pass_hash'`).run(newHash)
// Bootstrap brand name/accent from env only if still at schema defaults
const envBrandName = process.env.BRAND_NAME
if (envBrandName) {
const current = db.prepare(`SELECT value FROM settings WHERE key = 'brand_name'`).get() as { value: string } | undefined
if (current?.value === DEFAULT_SETTINGS.brand_name) {
db.prepare(`UPDATE settings SET value = ? WHERE key = 'brand_name'`).run(envBrandName)
}
}
const envAccent = process.env.BRAND_ACCENT
if (envAccent) {
const current = db.prepare(`SELECT value FROM settings WHERE key = 'brand_accent'`).get() as { value: string } | undefined
if (current?.value === DEFAULT_SETTINGS.brand_accent) {
db.prepare(`UPDATE settings SET value = ? WHERE key = 'brand_accent'`).run(envAccent)
}
}
// ---- Types ---------------------------------------------------------------
export interface Model {
id: number
slug: string
name: string
description: string | null
category_id: number | null
file_path: string
file_type: 'step' | 'stp' | 'stl'
thumbnail_path: string | null
is_public: 0 | 1
created_at: string
updated_at: string
}
export interface Category {
id: number
name: string
slug: string
description: string | null
sort_order: number
created_at: string
}
export interface ModelPdf {
id: number
model_id: number
display_name: string
file_path: string
sort_order: number
}
export interface BrandSettings {
brand_name: string
brand_tagline: string
brand_accent: string
brand_logo_path: string
brand_favicon_path: string
}
// ---- Typed query helpers -------------------------------------------------
// node:sqlite returns Record<string,SQLOutputValue> which requires double-cast
// to reach our typed interfaces. These helpers centralise that pattern.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SqlParam = any
export function q<T>(sql: string) {
const stmt = db.prepare(sql)
return {
all: (...params: SqlParam[]): T[] => stmt.all(...params) as unknown as T[],
get: (...params: SqlParam[]): T | undefined => stmt.get(...params) as unknown as T | undefined,
run: (...params: SqlParam[]) => stmt.run(...params),
}
}
// ---- Helpers -------------------------------------------------------------
export function getAllSettings(): Record<string, string> {
const rows = q<{ key: string; value: string }>(`SELECT key, value FROM settings`).all()
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}
export function getSetting(key: string): string {
const row = q<{ value: string }>(`SELECT value FROM settings WHERE key = ?`).get(key)
return row?.value ?? ''
}
export function setSetting(key: string, value: string): void {
db.prepare(
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`
).run(key, value)
}
export function getBrandSettings(): BrandSettings {
const s = getAllSettings()
return {
brand_name: s.brand_name ?? 'StepView',
brand_tagline: s.brand_tagline ?? '3D Model Viewer',
brand_accent: s.brand_accent ?? '#3b82f6',
brand_logo_path: s.brand_logo_path ?? '',
brand_favicon_path: s.brand_favicon_path ?? '',
}
}
+54
View File
@@ -0,0 +1,54 @@
export const SCHEMA_SQL = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
file_path TEXT NOT NULL,
file_type TEXT NOT NULL CHECK(file_type IN ('step', 'stp', 'stl')),
thumbnail_path TEXT,
is_public INTEGER NOT NULL DEFAULT 1 CHECK(is_public IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS model_pdfs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
file_path TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_models_slug ON models(slug);
CREATE INDEX IF NOT EXISTS idx_models_category_id ON models(category_id);
CREATE INDEX IF NOT EXISTS idx_models_is_public ON models(is_public);
CREATE INDEX IF NOT EXISTS idx_model_pdfs_model ON model_pdfs(model_id);
`
export const DEFAULT_SETTINGS: Record<string, string> = {
brand_name: 'StepView',
brand_tagline: '3D Model Viewer',
brand_accent: '#3b82f6',
brand_logo_path: '',
brand_favicon_path: '',
admin_pass_hash: '',
}
+23
View File
@@ -0,0 +1,23 @@
import { Request, Response, NextFunction } from 'express'
import { getBrandSettings, BrandSettings } from '../db/index'
// Cache brand settings for 60 s so every request doesn't hit SQLite.
let cache: BrandSettings | null = null
let cacheExpiresAt = 0
export function injectBrand(_req: Request, res: Response, next: NextFunction): void {
const now = Date.now()
if (!cache || now > cacheExpiresAt) {
cache = getBrandSettings()
cacheExpiresAt = now + 60_000
}
res.locals.brand = cache
next()
}
// Call this whenever brand settings are updated so the next request picks
// up the changes immediately rather than waiting for the cache to expire.
export function invalidateBrandCache(): void {
cache = null
cacheExpiresAt = 0
}
+11
View File
@@ -0,0 +1,11 @@
import { Request, Response, NextFunction } from 'express'
// Applied to all file-serving routes. Prevents browsers from triggering a
// Save As dialog and strips Accept-Ranges so partial downloads are not possible.
export function noDownload(_req: Request, res: Response, next: NextFunction): void {
res.removeHeader('Content-Disposition')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Accept-Ranges', 'none')
res.setHeader('Cache-Control', 'no-store')
next()
}
+14
View File
@@ -0,0 +1,14 @@
import { Request, Response, NextFunction } from 'express'
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if ((req.session as { isAdmin?: boolean }).isAdmin) {
next()
return
}
const isApi = req.path.startsWith('/api/')
if (isApi) {
res.status(401).json({ error: 'Unauthorized' })
} else {
res.redirect(`/admin/login?next=${encodeURIComponent(req.originalUrl)}`)
}
}
+73
View File
@@ -0,0 +1,73 @@
import { Router, Request, Response } from 'express'
import slugify from 'slugify'
import { db, q, Category } from '../db/index'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
router.get('/admin/categories', requireAdmin, (_req: Request, res: Response) => {
const categories = q<Category & { model_count: number }>(`
SELECT c.*, COUNT(m.id) AS model_count
FROM categories c
LEFT JOIN models m ON m.category_id = c.id
GROUP BY c.id
ORDER BY c.sort_order, c.name
`).all()
res.render('admin/categories', { categories, error: null })
})
router.post('/admin/categories', requireAdmin, (req: Request, res: Response) => {
const { name, description } = req.body as { name: string; description?: string }
if (!name?.trim()) {
const categories = q<Category & { model_count: number }>(`
SELECT *, 0 AS model_count FROM categories ORDER BY sort_order, name
`).all()
res.render('admin/categories', { categories, error: 'Category name is required.' })
return
}
const base = slugify(name.trim(), { lower: true, strict: true })
let slug = base
let attempt = 0
while (q<{ id: number }>(`SELECT id FROM categories WHERE slug = ?`).get(slug)) {
attempt++
slug = `${base}-${attempt}`
}
const maxOrder = q<{ m: number | null }>(`SELECT MAX(sort_order) AS m FROM categories`).get()?.m ?? -1
db.prepare(`INSERT INTO categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)`).run(
name.trim(), slug, description?.trim() || null, maxOrder + 1
)
res.redirect('/admin/categories')
})
router.post('/admin/categories/:id/edit', requireAdmin, (req: Request, res: Response) => {
const { name, description } = req.body as { name: string; description?: string }
db.prepare(`UPDATE categories SET name = ?, description = ? WHERE id = ?`).run(
name.trim(), description?.trim() || null, req.params.id
)
res.redirect('/admin/categories')
})
router.post('/admin/categories/:id/delete', requireAdmin, (req: Request, res: Response) => {
db.prepare(`DELETE FROM categories WHERE id = ?`).run(req.params.id)
res.redirect('/admin/categories')
})
router.post('/api/admin/categories/reorder', requireAdmin, (req: Request, res: Response) => {
const { ids } = req.body as { ids: number[] }
if (!Array.isArray(ids)) { res.status(400).json({ error: 'ids must be an array' }); return }
const update = db.prepare(`UPDATE categories SET sort_order = ? WHERE id = ?`)
db.exec('BEGIN')
try {
ids.forEach((id, index) => update.run(index, id))
db.exec('COMMIT')
} catch (err) {
db.exec('ROLLBACK')
throw err
}
res.json({ ok: true })
})
export default router
+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
+82
View File
@@ -0,0 +1,82 @@
import { Router, Request, Response } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import { getAllSettings, setSetting } from '../db/index'
import { requireAdmin } from '../middleware/requireAdmin'
import { invalidateBrandCache } from '../middleware/injectBrand'
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
const logoStorage = multer.diskStorage({
destination: path.join(UPLOADS_DIR, 'brand'),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase()
cb(null, `logo${ext}`)
},
})
const faviconStorage = multer.diskStorage({
destination: path.join(UPLOADS_DIR, 'brand'),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase()
cb(null, `favicon${ext}`)
},
})
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.svg', '.webp', '.ico'])
function imageFilter(_req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) {
const ext = path.extname(file.originalname).toLowerCase()
if (ALLOWED_IMAGE_EXTS.has(ext)) cb(null, true)
else cb(new Error('Only image files are allowed for logo/favicon'))
}
const uploadLogo = multer({ storage: logoStorage, fileFilter: imageFilter, limits: { fileSize: 2 * 1024 * 1024 } })
const uploadFavicon = multer({ storage: faviconStorage, fileFilter: imageFilter, limits: { fileSize: 512 * 1024 } })
const router = Router()
router.get('/admin/settings', requireAdmin, (_req: Request, res: Response) => {
const settings = getAllSettings()
res.render('admin/settings', { settings, saved: false, error: null })
})
router.post('/admin/settings', requireAdmin, (req: Request, res: Response) => {
const allowed = ['brand_name', 'brand_tagline', 'brand_accent'] as const
for (const key of allowed) {
const val = (req.body as Record<string, string>)[key]?.trim()
if (val !== undefined) setSetting(key, val)
}
invalidateBrandCache()
const settings = getAllSettings()
res.render('admin/settings', { settings, saved: true, error: null })
})
router.post('/admin/settings/logo', requireAdmin, uploadLogo.single('logo'), (_req: Request, res: Response) => {
if (_req.file) {
setSetting('brand_logo_path', `brand/${_req.file.filename}`)
invalidateBrandCache()
}
res.redirect('/admin/settings')
})
router.post('/admin/settings/favicon', requireAdmin, uploadFavicon.single('favicon'), (req: Request, res: Response) => {
if (req.file) {
setSetting('brand_favicon_path', `brand/${req.file.filename}`)
invalidateBrandCache()
}
res.redirect('/admin/settings')
})
router.post('/admin/settings/logo/delete', requireAdmin, (_req: Request, res: Response) => {
for (const ext of ['.png', '.jpg', '.jpeg', '.svg', '.webp']) {
const f = path.join(UPLOADS_DIR, 'brand', `logo${ext}`)
if (fs.existsSync(f)) fs.unlinkSync(f)
}
setSetting('brand_logo_path', '')
invalidateBrandCache()
res.redirect('/admin/settings')
})
export default router
+47
View File
@@ -0,0 +1,47 @@
import { Router, Request, Response } from 'express'
import bcrypt from 'bcryptjs'
import { getSetting } from '../db/index'
const router = Router()
router.get('/admin/login', (req: Request, res: Response) => {
if ((req.session as { isAdmin?: boolean }).isAdmin) {
res.redirect('/admin')
return
}
res.render('admin/login', {
error: null,
next: req.query.next ?? '/admin',
})
})
router.post('/admin/login', async (req: Request, res: Response) => {
const { username, password } = req.body as { username: string; password: string }
const expectedUser = process.env.ADMIN_USER ?? 'admin'
const storedHash = getSetting('admin_pass_hash')
const userMatch = username === expectedUser
const passMatch = storedHash ? await bcrypt.compare(password, storedHash) : false
if (userMatch && passMatch) {
;(req.session as { isAdmin?: boolean }).isAdmin = true
const next = (req.body.next as string | undefined) ?? '/admin'
// Guard against open redirect
const safeNext = next.startsWith('/') ? next : '/admin'
res.redirect(safeNext)
return
}
res.render('admin/login', {
error: 'Invalid username or password.',
next: req.body.next ?? '/admin',
})
})
router.post('/admin/logout', (req: Request, res: Response) => {
req.session.destroy(() => {
res.redirect('/admin/login')
})
})
export default router
+124
View File
@@ -0,0 +1,124 @@
import { Router, Request, Response } from 'express'
import path from 'path'
import fs from 'fs'
import { q, Model, ModelPdf, getSetting } from '../db/index'
import { noDownload } from '../middleware/noDownload'
import { geometryOutputPath } from '../services/stepConverter'
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
const router = Router()
function safeResolve(relPath: string): string | null {
const abs = path.resolve(UPLOADS_DIR, relPath)
if (!abs.startsWith(path.resolve(UPLOADS_DIR))) return null
return abs
}
router.get('/files/model/:id', noDownload, (req: Request, res: Response) => {
const model = q<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
if (!model) { res.status(404).end(); return }
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
res.status(403).end(); return
}
const abs = safeResolve(model.file_path)
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
const ext = path.extname(model.file_path).toLowerCase()
const mime = ext === '.stl' ? 'model/stl' : 'application/octet-stream'
res.setHeader('Content-Type', mime)
fs.createReadStream(abs).pipe(res)
})
router.get('/files/pdf/:modelId/:pdfId', noDownload, (req: Request, res: Response) => {
const model = q<{ is_public: 0 | 1 }>(`SELECT is_public FROM models WHERE id = ?`).get(req.params.modelId)
if (!model) { res.status(404).end(); return }
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
res.status(403).end(); return
}
const pdf = q<ModelPdf>(`SELECT * FROM model_pdfs WHERE id = ? AND model_id = ?`).get(req.params.pdfId, req.params.modelId)
if (!pdf) { res.status(404).end(); return }
const abs = safeResolve(pdf.file_path)
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
res.setHeader('Content-Type', 'application/pdf')
fs.createReadStream(abs).pipe(res)
})
router.get('/brand/logo', (_req: Request, res: Response) => {
const relPath = getSetting('brand_logo_path')
if (!relPath) { res.status(404).end(); return }
const abs = safeResolve(relPath)
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
const ext = path.extname(abs).toLowerCase()
const mimeMap: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon',
}
res.setHeader('Content-Type', mimeMap[ext] ?? 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=3600')
fs.createReadStream(abs).pipe(res)
})
router.get('/brand/favicon', (_req: Request, res: Response) => {
const relPath = getSetting('brand_favicon_path')
if (!relPath) { res.status(404).end(); return }
const abs = safeResolve(relPath)
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
res.setHeader('Content-Type', 'image/x-icon')
res.setHeader('Cache-Control', 'public, max-age=86400')
fs.createReadStream(abs).pipe(res)
})
// Pre-processed geometry JSON for STEP/STP files (served with gzip by compression middleware)
router.get('/files/geometry/:id', (req: Request, res: Response) => {
const model = q<Model>(`SELECT * FROM models WHERE id = ?`).get(req.params.id)
if (!model) { res.status(404).json({ error: 'Not found' }); return }
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
res.status(403).json({ error: 'Forbidden' }); return
}
const absModelPath = safeResolve(model.file_path)
if (!absModelPath) { res.status(404).json({ error: 'Model file not found' }); return }
const geoPath = geometryOutputPath(absModelPath)
if (!fs.existsSync(geoPath)) {
res.status(404).json({ error: 'Geometry not yet processed', processing: true })
return
}
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'public, max-age=3600')
fs.createReadStream(geoPath).pipe(res)
})
router.get('/files/thumbnail/:id', (req: Request, res: Response) => {
const model = q<Pick<Model, 'thumbnail_path' | 'is_public'>>(
`SELECT thumbnail_path, is_public FROM models WHERE id = ?`
).get(req.params.id)
if (!model?.thumbnail_path) { res.status(404).end(); return }
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
res.status(403).end(); return
}
const abs = safeResolve(model.thumbnail_path)
if (!abs || !fs.existsSync(abs)) { res.status(404).end(); return }
res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'public, max-age=3600')
fs.createReadStream(abs).pipe(res)
})
export default router
+79
View File
@@ -0,0 +1,79 @@
import { Router, Request, Response } from 'express'
import path from 'path'
import fs from 'fs'
import { q, Model, ModelPdf, Category } from '../db/index'
import { geometryOutputPath } from '../services/stepConverter'
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? path.join(process.cwd(), 'uploads')
const router = Router()
router.get('/view/:slug', (req: Request, res: Response) => {
const model = 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
WHERE m.slug = ?
`).get(req.params.slug)
if (!model) {
res.status(404).render('error', { status: 404, message: 'Model not found.' })
return
}
if (!model.is_public && !(req.session as { isAdmin?: boolean }).isAdmin) {
res.status(403).render('error', { status: 403, message: 'This model is not publicly available.' })
return
}
const pdfs = q<ModelPdf>(`SELECT * FROM model_pdfs WHERE model_id = ? ORDER BY sort_order`).all(model.id)
const baseUrl = process.env.BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`
// Determine whether pre-processed geometry is available for STEP/STP
let hasGeometry = false
if (model.file_type === 'step' || model.file_type === 'stp') {
const absPath = path.resolve(UPLOADS_DIR, model.file_path)
hasGeometry = fs.existsSync(geometryOutputPath(absPath))
}
res.render('viewer', {
model,
pdfs,
shareUrl: `${baseUrl}/view/${model.slug}`,
hasGeometry,
})
})
// ---- Public model index --------------------------------------------------
router.get('/', (_req: Request, res: Response) => {
// Fetch categories that have at least one public model, plus their models
const categories = q<Category & { model_count: number }>(`
SELECT c.*, COUNT(m.id) AS model_count
FROM categories c
INNER JOIN models m ON m.category_id = c.id AND m.is_public = 1
GROUP BY c.id
HAVING model_count > 0
ORDER BY c.sort_order, c.name
`).all()
// For each category, fetch its models
const categorised = categories.map(cat => ({
category: cat,
models: q<Model>(`
SELECT * FROM models
WHERE is_public = 1 AND category_id = ?
ORDER BY created_at DESC
`).all(cat.id),
}))
const uncategorized = q<Model>(`
SELECT * FROM models
WHERE is_public = 1 AND category_id IS NULL
ORDER BY created_at DESC
`).all()
res.render('index', { categorised, uncategorized })
})
export default router
+68
View File
@@ -0,0 +1,68 @@
import occtimport from 'occt-import-js'
import fs from 'fs'
import path from 'path'
export interface GeometryMesh {
positions: number[]
normals: number[] | null
indices: number[]
color: [number, number, number] | null
}
export interface GeometryFile {
version: 1
sourceFile: string
meshCount: number
meshes: GeometryMesh[]
}
// Singleton — WASM initializes once per Node process (~3 s on first call, instant after)
let _occt: Awaited<ReturnType<typeof occtimport>> | null = null
let _init: Promise<Awaited<ReturnType<typeof occtimport>>> | null = null
async function getOcct() {
if (_occt) return _occt
if (!_init) _init = occtimport().then(m => { _occt = m; return m })
return _init
}
// Warm up the WASM module in the background so the first upload doesn't pay
// the initialization cost during the HTTP request.
export function warmUpOcct(): void {
getOcct().catch(() => {/* non-fatal */})
}
export async function convertStepFile(
inputPath: string,
outputPath: string,
): Promise<{ meshCount: number }> {
const occt = await getOcct()
const buffer = fs.readFileSync(inputPath)
const result = occt.ReadStepFile(new Uint8Array(buffer), null)
if (!result.success || result.meshes.length === 0) {
throw new Error('STEP/STP parse failed — file may be corrupt or unsupported.')
}
const geo: GeometryFile = {
version: 1,
sourceFile: path.basename(inputPath),
meshCount: result.meshes.length,
meshes: result.meshes.map(mesh => ({
positions: Array.from(mesh.attributes.position.array),
normals: mesh.attributes.normal ? Array.from(mesh.attributes.normal.array) : null,
indices: Array.from(mesh.index.array),
color: mesh.color ? [mesh.color[0], mesh.color[1], mesh.color[2]] : null,
})),
}
fs.writeFileSync(outputPath, JSON.stringify(geo))
return { meshCount: result.meshes.length }
}
/** Returns the expected geometry output path for a given model file path. */
export function geometryOutputPath(modelFilePath: string): string {
const dir = path.dirname(modelFilePath)
const base = path.basename(modelFilePath, path.extname(modelFilePath))
return path.join(dir, `${base}.geometry.json`)
}
+20
View File
@@ -0,0 +1,20 @@
declare module 'occt-import-js' {
interface MeshAttributes {
position: { array: Float32Array }
normal?: { array: Float32Array }
}
interface OcctMesh {
attributes: MeshAttributes
index: { array: Uint32Array }
color: number[] | null
}
interface ReadResult {
success: boolean
meshes: OcctMesh[]
}
interface OcctModule {
ReadStepFile(buffer: Uint8Array, params: null): ReadResult
}
function occtimport(options?: { locateFile?: (f: string) => string }): Promise<OcctModule>
export default occtimport
}