- 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>
115 lines
3.5 KiB
TypeScript
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
|
|
}
|