Files
antonym/server/routes/upload.ts
Dorian 814957cd37 feat: add eslint, image upload, tests, splash tagline, security hooks
- Restore CLAUDE.md with project conventions
- ESLint config with vue3-recommended + typescript
- Image upload endpoint (POST /api/admin/upload) with 5MB limit
- Admin product form now supports image upload/preview/removal
- Vitest config + 19 tests (crypto, validation, btcpay webhook, types)
- Restore .claude/ security hooks (block-risky-bash, protect-files)
- Logo splash now shows "EVERYTHING YOU LOVE IS A PSYOP" tagline
- Add .vite/ to gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:47:42 +00:00

115 lines
3.5 KiB
TypeScript

import { Router } from 'express'
import path from 'node:path'
import fs from 'node:fs'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { adminAuth } from '../middleware/adminAuth.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'public', 'images')
export const uploadRouter = Router()
uploadRouter.use(adminAuth)
// Ensure upload directory exists
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
}
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']
uploadRouter.post('/', async (req, res) => {
try {
const contentType = req.headers['content-type'] || ''
if (!contentType.startsWith('multipart/form-data')) {
res.status(400).json({ error: { code: 'INVALID_CONTENT_TYPE', message: 'Expected multipart/form-data' } })
return
}
// Parse multipart manually using web-standard approach
const chunks: Buffer[] = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > MAX_SIZE) {
res.status(413).json({ error: { code: 'FILE_TOO_LARGE', message: 'File must be under 5MB' } })
return
}
chunks.push(chunk)
}
const body = Buffer.concat(chunks)
const boundary = contentType.split('boundary=')[1]
if (!boundary) {
res.status(400).json({ error: { code: 'NO_BOUNDARY', message: 'Missing multipart boundary' } })
return
}
// Find file data between boundaries
const boundaryBuf = Buffer.from(`--${boundary}`)
const parts = splitBuffer(body, boundaryBuf).filter((p) => p.length > 4)
const files: string[] = []
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const headers = part.subarray(0, headerEnd).toString()
const fileData = part.subarray(headerEnd + 4, part.length - 2) // trim trailing \r\n
const filenameMatch = headers.match(/filename="([^"]+)"/)
const typeMatch = headers.match(/Content-Type:\s*(\S+)/i)
if (!filenameMatch || !typeMatch) continue
const mimeType = typeMatch[1]
if (!ALLOWED_TYPES.includes(mimeType)) continue
const ext = path.extname(filenameMatch[1]).toLowerCase() || mimeExtension(mimeType)
const filename = `${crypto.randomBytes(16).toString('hex')}${ext}`
const filepath = path.join(UPLOAD_DIR, filename)
fs.writeFileSync(filepath, fileData)
files.push(`/images/${filename}`)
}
if (files.length === 0) {
res.status(400).json({ error: { code: 'NO_FILES', message: 'No valid image files found' } })
return
}
res.json({ urls: files })
} catch (err) {
console.error('Upload failed:', err)
res.status(500).json({ error: { code: 'UPLOAD_FAILED', message: 'File upload failed' } })
}
})
function mimeExtension(mime: string): string {
const map: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/avif': '.avif',
}
return map[mime] || '.jpg'
}
function splitBuffer(buf: Buffer, delimiter: Buffer): Buffer[] {
const parts: Buffer[] = []
let start = 0
while (true) {
const idx = buf.indexOf(delimiter, start)
if (idx === -1) {
if (start < buf.length) parts.push(buf.subarray(start))
break
}
if (idx > start) parts.push(buf.subarray(start, idx))
start = idx + delimiter.length
}
return parts
}