From 57e2196a71815b199147c484c7ca08e544072f82 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Thu, 22 Oct 2026 00:42:48 -0400 Subject: [PATCH] Fix audit snapshots, landing feedback forms, export errors, gradient on eyes - Audit log: addAudit now structuredClones previous/next so in-place edits in PATCH /api/qr-codes/:id no longer rewrite the "created" entry (or earlier "updated" entries). - Landing feedback: the rating/feedback form posts urlencoded, but only JSON was parsed, so submissions were stored as undefined and the API still answered { ok: true }. The route now parses urlencoded and JSON, validates name/message, rejects empty or unparseable bodies with 400, only accepts rating/feedback codes, and answers form posts with an HTML confirmation or error page. - Export: PDF/EPS requests return 400 with a clear message instead of 500; the /api/qr-codes/:id/render route now validates the file param. - Renderer: "Gradient on eyes" now applies the body gradient to eye frames and balls. Gradients use userSpaceOnUse so one gradient spans the whole code instead of restarting inside every module. - Regression tests for each. Co-Authored-By: Claude Opus 5.5 --- src/qr/renderer.ts | 304 +++++++------- src/server/server.ts | 931 ++++++++++++++++++++++-------------------- src/server/store.ts | 157 +++---- tests/api.test.ts | 261 +++++++----- tests/qr-core.test.ts | 152 ++++--- 5 files changed, 968 insertions(+), 837 deletions(-) diff --git a/src/qr/renderer.ts b/src/qr/renderer.ts index 4250d54..e2386d2 100644 --- a/src/qr/renderer.ts +++ b/src/qr/renderer.ts @@ -1,147 +1,157 @@ -import QRCode from 'qrcode'; -import type { DesignConfig, OutputFormat } from '../shared/types'; -import { normalizeDesignConfig } from './design'; -import { getLogoIcon } from './logo-icons'; - -export { logoGallery } from './design'; - -export interface RenderRequest { - data: string; - size: number; - file: OutputFormat; - config?: Partial; -} - -export async function renderQr({ data, size, file, config }: RenderRequest): Promise<{ body: Buffer | string; contentType: string; filename: string }> { - const design = normalizeDesignConfig(config); - const width = clampSize(size); - if (file === 'pdf' || file === 'eps') { - throw new Error(`${file.toUpperCase()} export is intentionally disabled in this MVP; PNG and SVG are supported.`); - } - if (file === 'svg') { - return { body: renderStyledSvg(data, width, design), contentType: 'image/svg+xml', filename: 'qr.svg' }; - } - const body = await QRCode.toBuffer(data, { - type: 'png', - width, - margin: 2, - errorCorrectionLevel: 'H', - color: { dark: design.bodyColor, light: design.bgColor } - }); - return { body, contentType: 'image/png', filename: 'qr.png' }; -} - -export function clampSize(size: number): number { - if (!Number.isFinite(size)) return 300; - return Math.max(200, Math.min(2000, Math.round(size / 25) * 25)); -} - -function injectLogo(svg: string, design: DesignConfig): string { - if (!design.logo) return svg; - const viewBoxMatch = svg.match(/viewBox="0 0 ([0-9.]+) ([0-9.]+)"/); - const box = viewBoxMatch ? Number(viewBoxMatch[1]) : 300; - const logoSize = box * 0.2; - const x = (box - logoSize) / 2; - const y = (box - logoSize) / 2; - const clean = design.logoMode === 'clean' ? `` : ''; - let logoMarkup: string; - if (design.logo.startsWith('#')) { - const presetName = design.logo.slice(1); - const icon = getLogoIcon(presetName); - if (icon) { - const scale = logoSize / 100; - logoMarkup = - `${clean}` + - `` + - `${icon.body}` + - ``; - } else { - const label = presetName.slice(0, 2).toUpperCase(); - logoMarkup = `${clean}${escapeXml(label)}`; - } - } else { - logoMarkup = `${clean}`; - } - return svg.replace('', `${logoMarkup}`); -} - -function renderStyledSvg(data: string, width: number, design: DesignConfig): string { - const qr = QRCode.create(data, { errorCorrectionLevel: 'H' }) as any; - const count = qr.modules.size as number; - const margin = 2; - const unit = width / (count + margin * 2); - const gradientId = `bodyGradient-${hashString(`${data}:${design.gradientColor1}:${design.gradientColor2}:${design.gradientType}`)}`; - const fill = design.gradientColor1 && design.gradientColor2 ? `url(#${gradientId})` : design.bodyColor; - const defs = design.gradientColor1 && design.gradientColor2 - ? design.gradientType === 'radial' - ? `` - : `` - : ''; - const parts = [ - ``, - defs ? `${defs}` : '', - `` - ]; - for (let row = 0; row < count; row += 1) { - for (let col = 0; col < count; col += 1) { - if (!qr.modules.get(row, col) || isFinder(col, row, count)) continue; - parts.push(drawBodyModule((col + margin) * unit, (row + margin) * unit, unit, fill, design.body)); - } - } - parts.push(drawEye(margin * unit, margin * unit, unit, design.eye1Color, design.eyeBall1Color, design)); - parts.push(drawEye((count - 7 + margin) * unit, margin * unit, unit, design.eye2Color, design.eyeBall2Color, design)); - parts.push(drawEye(margin * unit, (count - 7 + margin) * unit, unit, design.eye3Color, design.eyeBall3Color, design)); - parts.push(''); - return injectLogo(parts.join(''), design); -} - -function isFinder(col: number, row: number, count: number): boolean { - const inTop = row < 7; - const inLeft = col < 7; - const inRight = col >= count - 7; - const inBottom = row >= count - 7; - return (inTop && inLeft) || (inTop && inRight) || (inBottom && inLeft); -} - -function drawBodyModule(x: number, y: number, unit: number, fill: string, shape: string): string { - const pad = unit * 0.08; - const size = unit - pad * 2; - const cx = x + unit / 2; - const cy = y + unit / 2; - if (['dot', 'circle', 'circular', 'circle-zebra', 'circle-zebra-vertical'].includes(shape)) { - return ``; - } - if (['diamond', 'star', 'pointed', 'pointed-smooth', 'pointed-edge-cut'].includes(shape)) { - return ``; - } - const radius = ['round', 'rounded-in', 'rounded-in-smooth', 'rounded-pointed', 'mosaic', 'leaf'].includes(shape) ? unit * 0.26 : ['edge-cut', 'edge-cut-smooth', 'pointed-in', 'pointed-in-smooth'].includes(shape) ? unit * 0.12 : 0; - return ``; -} - -function drawEye(x: number, y: number, unit: number, frameColor: string, ballColor: string, design: DesignConfig): string { - const outer = unit * 7; - const inner = unit * 3; - const innerOffset = unit * 2; - const frameRadius = ['frame5', 'frame6', 'frame7', 'frame8', 'frame13', 'frame14', 'frame16'].includes(design.eye) ? unit * 1.3 : ['frame1', 'frame2', 'frame3'].includes(design.eye) ? unit * 0.5 : 0; - const ballRound = ['ball5', 'ball6', 'ball7', 'ball8', 'ball10', 'ball11', 'ball14', 'ball15', 'ball16', 'ball17', 'ball18', 'ball19'].includes(design.eyeBall); - return [ - ``, - ``, - ballRound - ? `` - : `` - ].join(''); -} - -function escapeXml(value: string): string { - return value.replace(/[<>&"']/g, (char) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[char] ?? char); -} - -function hashString(value: string): string { - let hash = 2166136261; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); -} +import QRCode from 'qrcode'; +import type { DesignConfig, OutputFormat } from '../shared/types'; +import { normalizeDesignConfig } from './design'; +import { getLogoIcon } from './logo-icons'; + +export { logoGallery } from './design'; + +export interface RenderRequest { + data: string; + size: number; + file: OutputFormat; + config?: Partial; +} + +export class UnsupportedFormatError extends Error { + readonly status = 400; + constructor(format: string) { + super(`${format.toUpperCase()} export is not supported (disabled in this MVP); use PNG or SVG.`); + this.name = 'UnsupportedFormatError'; + } +} + +export async function renderQr({ data, size, file, config }: RenderRequest): Promise<{ body: Buffer | string; contentType: string; filename: string }> { + const design = normalizeDesignConfig(config); + const width = clampSize(size); + if (file === 'pdf' || file === 'eps') { + throw new UnsupportedFormatError(file); + } + if (file === 'svg') { + return { body: renderStyledSvg(data, width, design), contentType: 'image/svg+xml', filename: 'qr.svg' }; + } + const body = await QRCode.toBuffer(data, { + type: 'png', + width, + margin: 2, + errorCorrectionLevel: 'H', + color: { dark: design.bodyColor, light: design.bgColor } + }); + return { body, contentType: 'image/png', filename: 'qr.png' }; +} + +export function clampSize(size: number): number { + if (!Number.isFinite(size)) return 300; + return Math.max(200, Math.min(2000, Math.round(size / 25) * 25)); +} + +function injectLogo(svg: string, design: DesignConfig): string { + if (!design.logo) return svg; + const viewBoxMatch = svg.match(/viewBox="0 0 ([0-9.]+) ([0-9.]+)"/); + const box = viewBoxMatch ? Number(viewBoxMatch[1]) : 300; + const logoSize = box * 0.2; + const x = (box - logoSize) / 2; + const y = (box - logoSize) / 2; + const clean = design.logoMode === 'clean' ? `` : ''; + let logoMarkup: string; + if (design.logo.startsWith('#')) { + const presetName = design.logo.slice(1); + const icon = getLogoIcon(presetName); + if (icon) { + const scale = logoSize / 100; + logoMarkup = + `${clean}` + + `` + + `${icon.body}` + + ``; + } else { + const label = presetName.slice(0, 2).toUpperCase(); + logoMarkup = `${clean}${escapeXml(label)}`; + } + } else { + logoMarkup = `${clean}`; + } + return svg.replace('', `${logoMarkup}`); +} + +function renderStyledSvg(data: string, width: number, design: DesignConfig): string { + const qr = QRCode.create(data, { errorCorrectionLevel: 'H' }) as any; + const count = qr.modules.size as number; + const margin = 2; + const unit = width / (count + margin * 2); + const gradientId = `bodyGradient-${hashString(`${data}:${design.gradientColor1}:${design.gradientColor2}:${design.gradientType}`)}`; + const fill = design.gradientColor1 && design.gradientColor2 ? `url(#${gradientId})` : design.bodyColor; + // userSpaceOnUse makes one gradient span the whole code instead of restarting inside every module. + const defs = design.gradientColor1 && design.gradientColor2 + ? design.gradientType === 'radial' + ? `` + : `` + : ''; + const eyeFill = (color: string) => (defs && design.gradientOnEyes ? fill : color); + const parts = [ + ``, + defs ? `${defs}` : '', + `` + ]; + for (let row = 0; row < count; row += 1) { + for (let col = 0; col < count; col += 1) { + if (!qr.modules.get(row, col) || isFinder(col, row, count)) continue; + parts.push(drawBodyModule((col + margin) * unit, (row + margin) * unit, unit, fill, design.body)); + } + } + parts.push(drawEye(margin * unit, margin * unit, unit, eyeFill(design.eye1Color), eyeFill(design.eyeBall1Color), design)); + parts.push(drawEye((count - 7 + margin) * unit, margin * unit, unit, eyeFill(design.eye2Color), eyeFill(design.eyeBall2Color), design)); + parts.push(drawEye(margin * unit, (count - 7 + margin) * unit, unit, eyeFill(design.eye3Color), eyeFill(design.eyeBall3Color), design)); + parts.push(''); + return injectLogo(parts.join(''), design); +} + +function isFinder(col: number, row: number, count: number): boolean { + const inTop = row < 7; + const inLeft = col < 7; + const inRight = col >= count - 7; + const inBottom = row >= count - 7; + return (inTop && inLeft) || (inTop && inRight) || (inBottom && inLeft); +} + +function drawBodyModule(x: number, y: number, unit: number, fill: string, shape: string): string { + const pad = unit * 0.08; + const size = unit - pad * 2; + const cx = x + unit / 2; + const cy = y + unit / 2; + if (['dot', 'circle', 'circular', 'circle-zebra', 'circle-zebra-vertical'].includes(shape)) { + return ``; + } + if (['diamond', 'star', 'pointed', 'pointed-smooth', 'pointed-edge-cut'].includes(shape)) { + return ``; + } + const radius = ['round', 'rounded-in', 'rounded-in-smooth', 'rounded-pointed', 'mosaic', 'leaf'].includes(shape) ? unit * 0.26 : ['edge-cut', 'edge-cut-smooth', 'pointed-in', 'pointed-in-smooth'].includes(shape) ? unit * 0.12 : 0; + return ``; +} + +function drawEye(x: number, y: number, unit: number, frameColor: string, ballColor: string, design: DesignConfig): string { + const outer = unit * 7; + const inner = unit * 3; + const innerOffset = unit * 2; + const frameRadius = ['frame5', 'frame6', 'frame7', 'frame8', 'frame13', 'frame14', 'frame16'].includes(design.eye) ? unit * 1.3 : ['frame1', 'frame2', 'frame3'].includes(design.eye) ? unit * 0.5 : 0; + const ballRound = ['ball5', 'ball6', 'ball7', 'ball8', 'ball10', 'ball11', 'ball14', 'ball15', 'ball16', 'ball17', 'ball18', 'ball19'].includes(design.eyeBall); + return [ + ``, + ``, + ballRound + ? `` + : `` + ].join(''); +} + +function escapeXml(value: string): string { + return value.replace(/[<>&"']/g, (char) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[char] ?? char); +} + +function hashString(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} diff --git a/src/server/server.ts b/src/server/server.ts index 1a960b9..eb40002 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,453 +1,478 @@ -import { randomUUID } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; -import https from 'node:https'; -import path from 'node:path'; -import express from 'express'; -import multer from 'multer'; -import { z } from 'zod'; -import createDOMPurify from 'dompurify'; -import { JSDOM } from 'jsdom'; -import { defaultDesignConfig, normalizeDesignConfig } from '../qr/design'; -import { buildPayload, isLandingPageType, payloadSchemas } from '../qr/payloads'; -import { clampSize, renderQr } from '../qr/renderer'; -import type { ContentType, OutputFormat, QrMode, QrRecord } from '../shared/types'; -import { YourlsClient } from '../yourls/client'; -import { JsonStore, publicBaseUrl } from './store'; - -const purifier = createDOMPurify(new JSDOM('').window); - -const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } }); -const app = express(); -const store = new JsonStore(); - -app.use(express.json({ limit: '1mb' })); -app.use((_req, res, next) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - next(); -}); -app.use('/uploads', express.static(path.resolve(process.env.UPLOAD_DIR || '.local/uploads'), { - setHeaders(res) { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Content-Disposition', 'inline'); - res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox"); - } -})); - -const qrCustomSchema = z.object({ - data: z.string().min(1), - size: z.coerce.number().optional().default(300), - file: z.enum(['png', 'svg', 'pdf', 'eps']).optional().default('png'), - download: z.coerce.boolean().optional().default(false), - config: z.record(z.string(), z.unknown()).optional().default({}) -}); - -app.get('/api/health', (_req, res) => { - res.json({ ok: true, service: 'qr-code-toolkit' }); -}); - -app.get(['/api/meta', '/qr/meta'], (_req, res) => { - res.json({ - defaultDesignConfig, - pdfEpsStatus: 'disabled: PNG and SVG are supported in this MVP; PDF/EPS require a print renderer phase.' - }); -}); - -app.get(['/api/qr/custom', '/qr/custom'], async (req, res, next) => { - try { - const config = typeof req.query.config === 'string' ? JSON.parse(req.query.config) : {}; - await sendQr(res, qrCustomSchema.parse({ ...req.query, config })); - } catch (error) { - next(error); - } -}); - -app.post(['/api/qr/custom', '/qr/custom'], async (req, res, next) => { - try { - await sendQr(res, qrCustomSchema.parse(req.body)); - } catch (error) { - next(error); - } -}); - -app.post(['/api/qr/upload-image', '/qr/uploadImage'], rateLimit('upload', 20), optionalAdminAuth, upload.single('file'), async (req, res, next) => { - try { - if (!req.file) throw httpError(400, 'file is required'); - validateUpload(req.file); - const uploadDir = path.resolve(process.env.UPLOAD_DIR || '.local/uploads'); - await mkdir(uploadDir, { recursive: true }); - const extension = extensionFor(req.file.mimetype, req.file.originalname); - const filename = `${randomUUID()}${extension}`; - const storagePath = path.join(uploadDir, filename); - await writeFile(storagePath, req.file.buffer); - const asset = await store.addAsset({ - originalFilename: req.file.originalname, - mimeType: req.file.mimetype, - sizeBytes: req.file.size, - storagePath, - publicPath: `/uploads/${filename}`, - buffer: req.file.buffer - }); - res.json({ file: asset.publicPath, asset }); - } catch (error) { - next(error); - } -}); - -const createQrSchema = z.object({ - name: z.string().min(1).default('Untitled QR'), - mode: z.enum(['static', 'dynamic_url', 'dynamic_payload']).default('static'), - contentType: z.string(), - contentPayload: z.record(z.string(), z.unknown()).default({}), - designConfig: z.record(z.string(), z.unknown()).default({}), - sizePx: z.coerce.number().default(300), - keyword: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).optional() -}); - -app.post('/api/qr-codes', rateLimit('qr-create', 60), optionalAdminAuth, async (req, res, next) => { - try { - const input = createQrSchema.parse(req.body); - const contentType = input.contentType as ContentType; - if (!Object.hasOwn(payloadSchemas, contentType)) throw httpError(400, 'Unknown content type'); - input.contentPayload = payloadSchemas[contentType].parse(input.contentPayload); - if (input.mode === 'dynamic_url' && contentType !== 'url') throw httpError(400, 'Dynamic URL mode requires URL content'); - const id = randomUUID(); - const createdAt = new Date().toISOString(); - const mode = normalizeMode(input.mode, contentType); - const longPayload = mode === 'dynamic_payload' ? `${publicBaseUrl()}/l/${id}` : mode === 'dynamic_url' ? `${publicBaseUrl()}/r/${id}` : buildPayload(contentType, input.contentPayload); - const dynamic = mode !== 'static'; - const yourls = makeYourlsClient(); - const short = dynamic ? await yourls.shorten(longPayload, input.keyword, input.name) : undefined; - const encodedPayload = dynamic ? short!.shorturl : longPayload; - const record: QrRecord = { - id, - name: input.name, - mode, - contentType, - contentPayload: input.contentPayload, - encodedPayload, - yourlsKeyword: short?.keyword, - yourlsShortUrl: short?.shorturl, - yourlsLongUrl: short?.longurl, - designConfig: normalizeDesignConfig(input.designConfig), - sizePx: clampSize(input.sizePx), - status: 'active', - createdAt, - updatedAt: createdAt - }; - await store.upsertQr(record); - await store.addAudit({ qrCodeId: id, actorId: 'system', eventType: 'created', previous: null, next: record }); - res.status(201).json(record); - } catch (error) { - next(error); - } -}); - -app.get('/api/qr-codes', optionalAdminAuth, async (_req, res) => { - const db = await store.all(); - res.json(db.qrCodes); -}); - -app.get('/api/qr-codes/:id', optionalAdminAuth, async (req, res, next) => { - const db = await store.all(); - const qr = db.qrCodes.find((item) => item.id === req.params.id); - if (!qr) return next(httpError(404, 'QR code not found')); - res.json({ qr, auditEvents: db.auditEvents.filter((event) => event.qrCodeId === qr.id), feedback: db.feedback.filter((item) => item.qrCodeId === qr.id) }); -}); - -app.patch('/api/qr-codes/:id', rateLimit('qr-update', 120), optionalAdminAuth, async (req, res, next) => { - try { - const db = await store.all(); - const qr = db.qrCodes.find((item) => item.id === req.params.id); - if (!qr) throw httpError(404, 'QR code not found'); - const nextPayload = payloadSchemas[qr.contentType].parse(req.body.contentPayload ?? qr.contentPayload); - const nextDesign = normalizeDesignConfig(req.body.designConfig ?? qr.designConfig); - const previous = structuredClone(qr); - qr.name = typeof req.body.name === 'string' ? req.body.name : qr.name; - qr.contentPayload = nextPayload; - qr.designConfig = nextDesign; - qr.sizePx = clampSize(req.body.sizePx ?? qr.sizePx); - qr.updatedAt = new Date().toISOString(); - if (qr.mode === 'static') { - qr.encodedPayload = buildPayload(qr.contentType, qr.contentPayload); - } else if (qr.mode === 'dynamic_payload') { - qr.yourlsLongUrl = `${publicBaseUrl()}/l/${qr.id}`; - } else { - qr.yourlsLongUrl = `${publicBaseUrl()}/r/${qr.id}`; - } - await store.upsertQr(qr); - await store.addAudit({ qrCodeId: qr.id, actorId: 'system', eventType: 'updated', previous, next: qr }); - res.json(qr); - } catch (error) { - next(error); - } -}); - -app.get('/api/qr-codes/:id/render', optionalAdminAuth, async (req, res, next) => { - try { - const db = await store.all(); - const qr = db.qrCodes.find((item) => item.id === req.params.id); - if (!qr) throw httpError(404, 'QR code not found'); - await sendQr(res, { - data: qr.encodedPayload, - size: Number(req.query.size || qr.sizePx), - file: (req.query.file || 'svg') as OutputFormat, - download: false, - config: qr.designConfig as unknown as Record - }); - } catch (error) { - next(error); - } -}); - -app.get('/api/qr-codes/:id/stats', optionalAdminAuth, async (req, res, next) => { - try { - const db = await store.all(); - const qr = db.qrCodes.find((item) => item.id === req.params.id); - if (!qr) throw httpError(404, 'QR code not found'); - if (!qr.yourlsShortUrl) return res.json({ scans: 0, source: 'static' }); - if (!makeYourlsClient().configured()) return res.json({ scans: 0, source: 'dev-fallback', shorturl: qr.yourlsShortUrl }); - res.json(await makeYourlsClient().urlStats(qr.yourlsShortUrl)); - } catch (error) { - next(error); - } -}); - -app.get('/r/:token', async (req, res, next) => { - const db = await store.all(); - const qr = db.qrCodes.find((item) => item.id === req.params.token || item.yourlsKeyword === req.params.token); - if (!qr) return next(httpError(404, 'Short URL not found')); - if (qr.mode === 'dynamic_url') { - return res.redirect(buildPayload(qr.contentType, qr.contentPayload)); - } - res.redirect(qr.yourlsLongUrl || qr.encodedPayload); -}); - -app.get('/l/:id', async (req, res, next) => { - const db = await store.all(); - const qr = db.qrCodes.find((item) => item.id === req.params.id); - if (!qr) return next(httpError(404, 'Landing page not found')); - res.type('html').send(renderLandingPage(qr)); -}); - -app.post('/api/landing/:id/feedback', rateLimit('feedback', 30), async (req, res, next) => { - const db = await store.all(); - const qr = db.qrCodes.find((item) => item.id === req.params.id); - if (!qr) return next(httpError(404, 'Landing page not found')); - db.feedback.unshift({ id: randomUUID(), qrCodeId: qr.id, submission: req.body, createdAt: new Date().toISOString() }); - await store.save(); - res.json({ ok: true }); -}); - -if (process.env.NODE_ENV === 'production') { - app.use(express.static('dist/client')); - app.get('/{*path}', (_req, res) => res.sendFile(path.resolve('dist/client/index.html'))); -} - -app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - const status = error instanceof z.ZodError ? 400 : typeof error === 'object' && error && 'status' in error ? Number((error as { status: number }).status) : 500; - res.status(status || 500).json({ error: error instanceof Error ? error.message : 'Unexpected error' }); -}); - -export function createApp(): express.Express { - return app; -} - -if (process.env.NODE_ENV !== 'test') { - const port = Number(process.env.PORT || 3000); - const host = process.env.HOST || '127.0.0.1'; - const certFile = process.env.HTTPS_CERT_FILE || process.env.LOCAL_CERT_FILE; - const keyFile = process.env.HTTPS_KEY_FILE || process.env.LOCAL_CERT_KEY_FILE; - if (certFile && keyFile) { - https.createServer({ cert: readFileSync(certFile), key: readFileSync(keyFile) }, app).listen(port, host, () => { - console.log(`QR Code Toolkit running with HTTPS at https://localhost:${port}`); - }); - } else { - app.listen(port, host, () => { - console.log(`QR Code Toolkit running at http://localhost:${port}`); - }); - } -} - -async function sendQr(res: express.Response, input: z.infer): Promise { - const rendered = await renderQr({ data: input.data, size: input.size, file: input.file, config: input.config }); - res.setHeader('Content-Type', rendered.contentType); - if (input.download) res.setHeader('Content-Disposition', `attachment; filename="${rendered.filename}"`); - res.send(rendered.body); -} - -function makeYourlsClient(): YourlsClient { - return new YourlsClient({ - apiUrl: process.env.YOURLS_API_URL, - signature: process.env.YOURLS_SIGNATURE, - shortDomain: process.env.YOURLS_SHORT_DOMAIN || `${publicBaseUrl()}/r` - }); -} - -function normalizeMode(mode: QrMode, contentType: ContentType): QrMode { - if (mode === 'dynamic_payload' || isLandingPageType(contentType)) return 'dynamic_payload'; - return mode; -} - -function validateUpload(file: Express.Multer.File): void { - const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']; - if (!allowed.includes(file.mimetype)) throw httpError(415, 'Unsupported logo type. Use PNG, JPG, GIF, or SVG.'); - if (file.mimetype === 'image/svg+xml') { - const clean = purifier.sanitize(file.buffer.toString('utf8'), { - USE_PROFILES: { svg: true, svgFilters: true }, - FORBID_TAGS: ['foreignObject', 'animate', 'animateMotion', 'animateTransform', 'set', 'use', 'style'], - FORBID_ATTR: ['href', 'xlink:href', 'style'] - }); - if (purifier.removed.some((item) => 'attribute' in item || item.element?.nodeName !== 'BODY') || !clean.includes('; - const title = escapeHtml(String(p.title || qr.name)); - const rawDescription = String(p.description || p.prompt || p.question || ''); - const description = escapeHtml(rawDescription); - const contentTypeLabel = escapeHtml(String(qr.contentType).toUpperCase()); - let body = ''; - if (['mp3', 'video'].includes(qr.contentType) && p.url) { - body += qr.contentType === 'mp3' - ? `` - : ``; - } - if (qr.contentType === 'pdf' && p.url) { - body += ``; - } - if (qr.contentType === 'social' && Array.isArray(p.links)) { - body += ``; - } - if (qr.contentType === 'app') { - const ios = p.iosUrl ? `App Store` : ''; - const android = p.androidUrl ? `Google Play` : ''; - if (ios || android) body += `
${ios}${android}
`; - } - if (qr.contentType === 'gallery' && Array.isArray(p.images)) { - body += ``; - } - if (qr.contentType === 'rating' || qr.contentType === 'feedback') { - body += `
`; - } - const lede = rawDescription ? `

${description}

` : ''; - return ` - - - - - - -${title} · CompleteTech - - - - - - -
-

FIELD ARTIFACT · ${contentTypeLabel}

-

${title}

-${lede} -
-${body} - -
- -`; -} - -function httpError(status: number, message: string): Error & { status: number } { - const error = new Error(message) as Error & { status: number }; - error.status = status; - return error; -} - -function escapeHtml(value: string): string { - return value.replace(/[<>&"']/g, (char) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[char] ?? char); -} - -function escapeAttr(value: string): string { - return escapeHtml(value).replace(/`/g, '`'); -} - -const buckets = new Map(); - -function rateLimit(scope: string, maxPerMinute: number): express.RequestHandler { - return (req, res, next) => { - const now = Date.now(); - const key = `${scope}:${req.ip}`; - const bucket = buckets.get(key); - if (!bucket || bucket.resetAt < now) { - buckets.set(key, { count: 1, resetAt: now + 60_000 }); - return next(); - } - bucket.count += 1; - if (bucket.count > maxPerMinute) return res.status(429).json({ error: 'Rate limit exceeded' }); - next(); - }; -} - -function optionalAdminAuth(req: express.Request, res: express.Response, next: express.NextFunction): void { - const token = process.env.ADMIN_TOKEN; - if (!token) return next(); - if (req.header('x-admin-token') === token) return next(); - res.status(401).json({ error: 'Admin token required' }); -} +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import https from 'node:https'; +import path from 'node:path'; +import express from 'express'; +import multer from 'multer'; +import { z } from 'zod'; +import createDOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; +import { defaultDesignConfig, normalizeDesignConfig } from '../qr/design'; +import { buildPayload, isLandingPageType, payloadSchemas } from '../qr/payloads'; +import { clampSize, renderQr } from '../qr/renderer'; +import type { ContentType, QrMode, QrRecord } from '../shared/types'; +import { YourlsClient } from '../yourls/client'; +import { JsonStore, publicBaseUrl } from './store'; + +const purifier = createDOMPurify(new JSDOM('').window); + +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } }); +const app = express(); +const store = new JsonStore(); + +app.use(express.json({ limit: '1mb' })); +app.use((_req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + next(); +}); +app.use('/uploads', express.static(path.resolve(process.env.UPLOAD_DIR || '.local/uploads'), { + setHeaders(res) { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Disposition', 'inline'); + res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox"); + } +})); + +const outputFormatSchema = z.enum(['png', 'svg', 'pdf', 'eps']); + +const qrCustomSchema = z.object({ + data: z.string().min(1), + size: z.coerce.number().optional().default(300), + file: outputFormatSchema.optional().default('png'), + download: z.coerce.boolean().optional().default(false), + config: z.record(z.string(), z.unknown()).optional().default({}) +}); + +app.get('/api/health', (_req, res) => { + res.json({ ok: true, service: 'qr-code-toolkit' }); +}); + +app.get(['/api/meta', '/qr/meta'], (_req, res) => { + res.json({ + defaultDesignConfig, + pdfEpsStatus: 'disabled: PNG and SVG are supported in this MVP; PDF/EPS require a print renderer phase.' + }); +}); + +app.get(['/api/qr/custom', '/qr/custom'], async (req, res, next) => { + try { + const config = typeof req.query.config === 'string' ? JSON.parse(req.query.config) : {}; + await sendQr(res, qrCustomSchema.parse({ ...req.query, config })); + } catch (error) { + next(error); + } +}); + +app.post(['/api/qr/custom', '/qr/custom'], async (req, res, next) => { + try { + await sendQr(res, qrCustomSchema.parse(req.body)); + } catch (error) { + next(error); + } +}); + +app.post(['/api/qr/upload-image', '/qr/uploadImage'], rateLimit('upload', 20), optionalAdminAuth, upload.single('file'), async (req, res, next) => { + try { + if (!req.file) throw httpError(400, 'file is required'); + validateUpload(req.file); + const uploadDir = path.resolve(process.env.UPLOAD_DIR || '.local/uploads'); + await mkdir(uploadDir, { recursive: true }); + const extension = extensionFor(req.file.mimetype, req.file.originalname); + const filename = `${randomUUID()}${extension}`; + const storagePath = path.join(uploadDir, filename); + await writeFile(storagePath, req.file.buffer); + const asset = await store.addAsset({ + originalFilename: req.file.originalname, + mimeType: req.file.mimetype, + sizeBytes: req.file.size, + storagePath, + publicPath: `/uploads/${filename}`, + buffer: req.file.buffer + }); + res.json({ file: asset.publicPath, asset }); + } catch (error) { + next(error); + } +}); + +const createQrSchema = z.object({ + name: z.string().min(1).default('Untitled QR'), + mode: z.enum(['static', 'dynamic_url', 'dynamic_payload']).default('static'), + contentType: z.string(), + contentPayload: z.record(z.string(), z.unknown()).default({}), + designConfig: z.record(z.string(), z.unknown()).default({}), + sizePx: z.coerce.number().default(300), + keyword: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/).optional() +}); + +app.post('/api/qr-codes', rateLimit('qr-create', 60), optionalAdminAuth, async (req, res, next) => { + try { + const input = createQrSchema.parse(req.body); + const contentType = input.contentType as ContentType; + if (!Object.hasOwn(payloadSchemas, contentType)) throw httpError(400, 'Unknown content type'); + input.contentPayload = payloadSchemas[contentType].parse(input.contentPayload); + if (input.mode === 'dynamic_url' && contentType !== 'url') throw httpError(400, 'Dynamic URL mode requires URL content'); + const id = randomUUID(); + const createdAt = new Date().toISOString(); + const mode = normalizeMode(input.mode, contentType); + const longPayload = mode === 'dynamic_payload' ? `${publicBaseUrl()}/l/${id}` : mode === 'dynamic_url' ? `${publicBaseUrl()}/r/${id}` : buildPayload(contentType, input.contentPayload); + const dynamic = mode !== 'static'; + const yourls = makeYourlsClient(); + const short = dynamic ? await yourls.shorten(longPayload, input.keyword, input.name) : undefined; + const encodedPayload = dynamic ? short!.shorturl : longPayload; + const record: QrRecord = { + id, + name: input.name, + mode, + contentType, + contentPayload: input.contentPayload, + encodedPayload, + yourlsKeyword: short?.keyword, + yourlsShortUrl: short?.shorturl, + yourlsLongUrl: short?.longurl, + designConfig: normalizeDesignConfig(input.designConfig), + sizePx: clampSize(input.sizePx), + status: 'active', + createdAt, + updatedAt: createdAt + }; + await store.upsertQr(record); + await store.addAudit({ qrCodeId: id, actorId: 'system', eventType: 'created', previous: null, next: record }); + res.status(201).json(record); + } catch (error) { + next(error); + } +}); + +app.get('/api/qr-codes', optionalAdminAuth, async (_req, res) => { + const db = await store.all(); + res.json(db.qrCodes); +}); + +app.get('/api/qr-codes/:id', optionalAdminAuth, async (req, res, next) => { + const db = await store.all(); + const qr = db.qrCodes.find((item) => item.id === req.params.id); + if (!qr) return next(httpError(404, 'QR code not found')); + res.json({ qr, auditEvents: db.auditEvents.filter((event) => event.qrCodeId === qr.id), feedback: db.feedback.filter((item) => item.qrCodeId === qr.id) }); +}); + +app.patch('/api/qr-codes/:id', rateLimit('qr-update', 120), optionalAdminAuth, async (req, res, next) => { + try { + const db = await store.all(); + const qr = db.qrCodes.find((item) => item.id === req.params.id); + if (!qr) throw httpError(404, 'QR code not found'); + const nextPayload = payloadSchemas[qr.contentType].parse(req.body.contentPayload ?? qr.contentPayload); + const nextDesign = normalizeDesignConfig(req.body.designConfig ?? qr.designConfig); + const previous = structuredClone(qr); + qr.name = typeof req.body.name === 'string' ? req.body.name : qr.name; + qr.contentPayload = nextPayload; + qr.designConfig = nextDesign; + qr.sizePx = clampSize(req.body.sizePx ?? qr.sizePx); + qr.updatedAt = new Date().toISOString(); + if (qr.mode === 'static') { + qr.encodedPayload = buildPayload(qr.contentType, qr.contentPayload); + } else if (qr.mode === 'dynamic_payload') { + qr.yourlsLongUrl = `${publicBaseUrl()}/l/${qr.id}`; + } else { + qr.yourlsLongUrl = `${publicBaseUrl()}/r/${qr.id}`; + } + await store.upsertQr(qr); + await store.addAudit({ qrCodeId: qr.id, actorId: 'system', eventType: 'updated', previous, next: qr }); + res.json(qr); + } catch (error) { + next(error); + } +}); + +app.get('/api/qr-codes/:id/render', optionalAdminAuth, async (req, res, next) => { + try { + const db = await store.all(); + const qr = db.qrCodes.find((item) => item.id === req.params.id); + if (!qr) throw httpError(404, 'QR code not found'); + await sendQr(res, { + data: qr.encodedPayload, + size: Number(req.query.size || qr.sizePx), + file: outputFormatSchema.parse(req.query.file ?? 'svg'), + download: false, + config: qr.designConfig as unknown as Record + }); + } catch (error) { + next(error); + } +}); + +app.get('/api/qr-codes/:id/stats', optionalAdminAuth, async (req, res, next) => { + try { + const db = await store.all(); + const qr = db.qrCodes.find((item) => item.id === req.params.id); + if (!qr) throw httpError(404, 'QR code not found'); + if (!qr.yourlsShortUrl) return res.json({ scans: 0, source: 'static' }); + if (!makeYourlsClient().configured()) return res.json({ scans: 0, source: 'dev-fallback', shorturl: qr.yourlsShortUrl }); + res.json(await makeYourlsClient().urlStats(qr.yourlsShortUrl)); + } catch (error) { + next(error); + } +}); + +app.get('/r/:token', async (req, res, next) => { + const db = await store.all(); + const qr = db.qrCodes.find((item) => item.id === req.params.token || item.yourlsKeyword === req.params.token); + if (!qr) return next(httpError(404, 'Short URL not found')); + if (qr.mode === 'dynamic_url') { + return res.redirect(buildPayload(qr.contentType, qr.contentPayload)); + } + res.redirect(qr.yourlsLongUrl || qr.encodedPayload); +}); + +app.get('/l/:id', async (req, res, next) => { + const db = await store.all(); + const qr = db.qrCodes.find((item) => item.id === req.params.id); + if (!qr) return next(httpError(404, 'Landing page not found')); + res.type('html').send(renderLandingPage(qr)); +}); + +const feedbackSchema = z.object({ + name: z.string().trim().max(200).optional().default(''), + message: z.string().trim().max(5000).optional().default('') +}).refine((value) => value.name.length > 0 || value.message.length > 0, { message: 'Feedback must include a name or a message' }); + +// The landing page form posts application/x-www-form-urlencoded; API clients may post JSON. +app.post('/api/landing/:id/feedback', rateLimit('feedback', 30), express.urlencoded({ extended: false, limit: '64kb' }), async (req, res, next) => { + const fromForm = Boolean(req.is('application/x-www-form-urlencoded')); + try { + const db = await store.all(); + const qr = db.qrCodes.find((item) => item.id === req.params.id); + if (!qr || !['rating', 'feedback'].includes(qr.contentType)) throw httpError(404, 'Landing page not found'); + if (!req.body || typeof req.body !== 'object') throw httpError(400, 'Feedback must be sent as a form or JSON body'); + const parsed = feedbackSchema.safeParse(req.body); + if (!parsed.success) throw httpError(400, parsed.error.issues[0]?.message || 'Invalid feedback'); + db.feedback.unshift({ id: randomUUID(), qrCodeId: qr.id, submission: parsed.data, createdAt: new Date().toISOString() }); + await store.save(); + if (fromForm) return res.type('html').send(renderFeedbackResult(qr, true, 'Thanks, your feedback was received.')); + res.json({ ok: true }); + } catch (error) { + if (!fromForm) return next(error); + const status = typeof error === 'object' && error && 'status' in error ? Number((error as { status: number }).status) : 500; + const message = status < 500 && error instanceof Error ? error.message : 'Your feedback could not be saved. Please try again.'; + res.status(status).type('html').send(renderFeedbackResult(null, false, message)); + } +}); + +if (process.env.NODE_ENV === 'production') { + app.use(express.static('dist/client')); + app.get('/{*path}', (_req, res) => res.sendFile(path.resolve('dist/client/index.html'))); +} + +app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const status = error instanceof z.ZodError ? 400 : typeof error === 'object' && error && 'status' in error ? Number((error as { status: number }).status) : 500; + res.status(status || 500).json({ error: error instanceof Error ? error.message : 'Unexpected error' }); +}); + +export function createApp(): express.Express { + return app; +} + +if (process.env.NODE_ENV !== 'test') { + const port = Number(process.env.PORT || 3000); + const host = process.env.HOST || '127.0.0.1'; + const certFile = process.env.HTTPS_CERT_FILE || process.env.LOCAL_CERT_FILE; + const keyFile = process.env.HTTPS_KEY_FILE || process.env.LOCAL_CERT_KEY_FILE; + if (certFile && keyFile) { + https.createServer({ cert: readFileSync(certFile), key: readFileSync(keyFile) }, app).listen(port, host, () => { + console.log(`QR Code Toolkit running with HTTPS at https://localhost:${port}`); + }); + } else { + app.listen(port, host, () => { + console.log(`QR Code Toolkit running at http://localhost:${port}`); + }); + } +} + +async function sendQr(res: express.Response, input: z.infer): Promise { + const rendered = await renderQr({ data: input.data, size: input.size, file: input.file, config: input.config }); + res.setHeader('Content-Type', rendered.contentType); + if (input.download) res.setHeader('Content-Disposition', `attachment; filename="${rendered.filename}"`); + res.send(rendered.body); +} + +function makeYourlsClient(): YourlsClient { + return new YourlsClient({ + apiUrl: process.env.YOURLS_API_URL, + signature: process.env.YOURLS_SIGNATURE, + shortDomain: process.env.YOURLS_SHORT_DOMAIN || `${publicBaseUrl()}/r` + }); +} + +function normalizeMode(mode: QrMode, contentType: ContentType): QrMode { + if (mode === 'dynamic_payload' || isLandingPageType(contentType)) return 'dynamic_payload'; + return mode; +} + +function validateUpload(file: Express.Multer.File): void { + const allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']; + if (!allowed.includes(file.mimetype)) throw httpError(415, 'Unsupported logo type. Use PNG, JPG, GIF, or SVG.'); + if (file.mimetype === 'image/svg+xml') { + const clean = purifier.sanitize(file.buffer.toString('utf8'), { + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: ['foreignObject', 'animate', 'animateMotion', 'animateTransform', 'set', 'use', 'style'], + FORBID_ATTR: ['href', 'xlink:href', 'style'] + }); + if (purifier.removed.some((item) => 'attribute' in item || item.element?.nodeName !== 'BODY') || !clean.includes('; + const title = escapeHtml(String(p.title || qr.name)); + const rawDescription = String(p.description || p.prompt || p.question || ''); + const description = escapeHtml(rawDescription); + const contentTypeLabel = escapeHtml(String(qr.contentType).toUpperCase()); + let body = ''; + if (['mp3', 'video'].includes(qr.contentType) && p.url) { + body += qr.contentType === 'mp3' + ? `` + : ``; + } + if (qr.contentType === 'pdf' && p.url) { + body += ``; + } + if (qr.contentType === 'social' && Array.isArray(p.links)) { + body += ``; + } + if (qr.contentType === 'app') { + const ios = p.iosUrl ? `App Store` : ''; + const android = p.androidUrl ? `Google Play` : ''; + if (ios || android) body += `
${ios}${android}
`; + } + if (qr.contentType === 'gallery' && Array.isArray(p.images)) { + body += ``; + } + if (qr.contentType === 'rating' || qr.contentType === 'feedback') { + body += `
`; + } + const lede = rawDescription ? `

${description}

` : ''; + return ` + + + + + + +${title} · CompleteTech + + + + + + +
+

FIELD ARTIFACT · ${contentTypeLabel}

+

${title}

+${lede} +
+${body} + +
+ +`; +} + +function renderFeedbackResult(qr: QrRecord | null, ok: boolean, message: string): string { + const back = qr ? `

Back

` : ''; + return `${ok ? 'Thank you' : 'Feedback not sent'}

${ok ? 'Thank you' : 'Feedback not sent'}

${escapeHtml(message)}

${back}
`; +} + +function httpError(status: number, message: string): Error & { status: number } { + const error = new Error(message) as Error & { status: number }; + error.status = status; + return error; +} + +function escapeHtml(value: string): string { + return value.replace(/[<>&"']/g, (char) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[char] ?? char); +} + +function escapeAttr(value: string): string { + return escapeHtml(value).replace(/`/g, '`'); +} + +const buckets = new Map(); + +function rateLimit(scope: string, maxPerMinute: number): express.RequestHandler { + return (req, res, next) => { + const now = Date.now(); + const key = `${scope}:${req.ip}`; + const bucket = buckets.get(key); + if (!bucket || bucket.resetAt < now) { + buckets.set(key, { count: 1, resetAt: now + 60_000 }); + return next(); + } + bucket.count += 1; + if (bucket.count > maxPerMinute) return res.status(429).json({ error: 'Rate limit exceeded' }); + next(); + }; +} + +function optionalAdminAuth(req: express.Request, res: express.Response, next: express.NextFunction): void { + const token = process.env.ADMIN_TOKEN; + if (!token) return next(); + if (req.header('x-admin-token') === token) return next(); + res.status(401).json({ error: 'Admin token required' }); +} diff --git a/src/server/store.ts b/src/server/store.ts index b4a70dc..0a13672 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -1,75 +1,82 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import type { AssetRecord, AuditEvent, QrRecord } from '../shared/types'; - -export interface DatabaseShape { - qrCodes: QrRecord[]; - auditEvents: AuditEvent[]; - assets: AssetRecord[]; - feedback: Array<{ id: string; qrCodeId: string; submission: unknown; createdAt: string }>; -} - -const defaultDb: DatabaseShape = { qrCodes: [], auditEvents: [], assets: [], feedback: [] }; - -export class JsonStore { - private db: DatabaseShape | null = null; - - constructor(private readonly dataFile = process.env.DATA_FILE || '.local/data/db.json') {} - - async all(): Promise { - if (this.db) return this.db; - await mkdir(path.dirname(this.dataFile), { recursive: true }); - try { - this.db = JSON.parse(await readFile(this.dataFile, 'utf8')) as DatabaseShape; - } catch { - this.db = structuredClone(defaultDb); - await this.save(); - } - return this.db; - } - - async save(): Promise { - if (!this.db) this.db = structuredClone(defaultDb); - await mkdir(path.dirname(this.dataFile), { recursive: true }); - await writeFile(this.dataFile, JSON.stringify(this.db, null, 2)); - } - - async upsertQr(record: QrRecord): Promise { - const db = await this.all(); - const index = db.qrCodes.findIndex((qr) => qr.id === record.id); - if (index >= 0) db.qrCodes[index] = record; - else db.qrCodes.unshift(record); - await this.save(); - return record; - } - - async addAudit(event: Omit): Promise { - const db = await this.all(); - const saved = { ...event, id: randomUUID(), createdAt: new Date().toISOString() }; - db.auditEvents.unshift(saved); - await this.save(); - return saved; - } - - async addAsset(input: Omit & { buffer: Buffer }): Promise { - const db = await this.all(); - const saved: AssetRecord = { - id: randomUUID(), - originalFilename: input.originalFilename, - mimeType: input.mimeType, - sizeBytes: input.sizeBytes, - storagePath: input.storagePath, - publicPath: input.publicPath, - sha256: createHash('sha256').update(input.buffer).digest('hex'), - createdAt: new Date().toISOString() - }; - db.assets.unshift(saved); - await this.save(); - return saved; - } -} - -export function publicBaseUrl(): string { - return process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 3000}`; -} +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { AssetRecord, AuditEvent, QrRecord } from '../shared/types'; + +export interface DatabaseShape { + qrCodes: QrRecord[]; + auditEvents: AuditEvent[]; + assets: AssetRecord[]; + feedback: Array<{ id: string; qrCodeId: string; submission: unknown; createdAt: string }>; +} + +const defaultDb: DatabaseShape = { qrCodes: [], auditEvents: [], assets: [], feedback: [] }; + +export class JsonStore { + private db: DatabaseShape | null = null; + + constructor(private readonly dataFile = process.env.DATA_FILE || '.local/data/db.json') {} + + async all(): Promise { + if (this.db) return this.db; + await mkdir(path.dirname(this.dataFile), { recursive: true }); + try { + this.db = JSON.parse(await readFile(this.dataFile, 'utf8')) as DatabaseShape; + } catch { + this.db = structuredClone(defaultDb); + await this.save(); + } + return this.db; + } + + async save(): Promise { + if (!this.db) this.db = structuredClone(defaultDb); + await mkdir(path.dirname(this.dataFile), { recursive: true }); + await writeFile(this.dataFile, JSON.stringify(this.db, null, 2)); + } + + async upsertQr(record: QrRecord): Promise { + const db = await this.all(); + const index = db.qrCodes.findIndex((qr) => qr.id === record.id); + if (index >= 0) db.qrCodes[index] = record; + else db.qrCodes.unshift(record); + await this.save(); + return record; + } + + async addAudit(event: Omit): Promise { + const db = await this.all(); + // Snapshot previous/next so later in-place edits of the live record cannot rewrite history. + const saved: AuditEvent = { + ...event, + previous: structuredClone(event.previous), + next: structuredClone(event.next), + id: randomUUID(), + createdAt: new Date().toISOString() + }; + db.auditEvents.unshift(saved); + await this.save(); + return saved; + } + + async addAsset(input: Omit & { buffer: Buffer }): Promise { + const db = await this.all(); + const saved: AssetRecord = { + id: randomUUID(), + originalFilename: input.originalFilename, + mimeType: input.mimeType, + sizeBytes: input.sizeBytes, + storagePath: input.storagePath, + publicPath: input.publicPath, + sha256: createHash('sha256').update(input.buffer).digest('hex'), + createdAt: new Date().toISOString() + }; + db.assets.unshift(saved); + await this.save(); + return saved; + } +} + +export function publicBaseUrl(): string { + return process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 3000}`; +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 7ce2dac..417c7d6 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,97 +1,164 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import request from 'supertest'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -let tempDir = ''; -let app: Awaited>; - -async function loadApp() { - vi.resetModules(); - process.env.NODE_ENV = 'test'; - process.env.DATA_FILE = path.join(tempDir, 'db.json'); - process.env.UPLOAD_DIR = path.join(tempDir, 'uploads'); - process.env.APP_PUBLIC_URL = 'http://localhost:3000'; - const module = await import('../src/server/server'); - return module.createApp(); -} - -describe('API', () => { - beforeEach(async () => { - tempDir = await mkdtemp(path.join(tmpdir(), 'ctech-qr-')); - app = await loadApp(); - }); - - afterEach(async () => { - delete process.env.ADMIN_TOKEN; - await rm(tempDir, { recursive: true, force: true }); - }); - - it('renders SVG and PNG through API parity route', async () => { - const svg = await request(app).post('/api/qr/custom').send({ data: 'https://example.com', file: 'svg', size: 300 }); - expect(svg.status).toBe(200); - expect(svg.headers['content-type']).toContain('image/svg+xml'); - expect(svg.body.toString()).toContain(' { - const created = await request(app).post('/api/qr-codes').send({ - name: 'Dynamic URL', - mode: 'dynamic_url', - contentType: 'url', - keyword: 'launch', - contentPayload: { url: 'https://example.com/a' } - }); - expect(created.status).toBe(201); - const original = created.body.encodedPayload; - expect(original).toContain('/r/launch'); - expect(created.body.yourlsLongUrl).toBe(`http://localhost:3000/r/${created.body.id}`); - - const updated = await request(app).patch(`/api/qr-codes/${created.body.id}`).send({ contentPayload: { url: 'https://example.com/b' } }); - expect(updated.status).toBe(200); - expect(updated.body.encodedPayload).toBe(original); - expect(updated.body.yourlsLongUrl).toBe(created.body.yourlsLongUrl); - - const redirect = await request(app).get('/r/launch').redirects(0); - expect(redirect.status).toBe(302); - expect(redirect.headers.location).toBe('https://example.com/b'); - - const detail = await request(app).get(`/api/qr-codes/${created.body.id}`); - expect(detail.body.auditEvents.length).toBeGreaterThanOrEqual(2); - }); - - it('accepts safe SVG upload and rejects active SVG', async () => { - const safe = await request(app).post('/api/qr/upload-image').attach('file', Buffer.from(''), { filename: 'logo.svg', contentType: 'image/svg+xml' }); - expect(safe.status).toBe(200); - expect(safe.body.file).toContain('/uploads/'); - - const unsafe = await request(app).post('/api/qr/upload-image').attach('file', Buffer.from(''), { filename: 'evil.svg', contentType: 'image/svg+xml' }); - expect(unsafe.status).toBe(400); - }); - - it('rejects active SVG event handlers beyond onload', async () => { - const unsafe = await request(app).post('/api/qr/upload-image').attach('file', Buffer.from(''), { filename: 'evil.svg', contentType: 'image/svg+xml' }); - expect(unsafe.status).toBe(400); - }); - - it('validates dynamic landing payloads before storing them', async () => { - const result = await request(app).post('/api/qr-codes').send({ - mode: 'dynamic_payload', contentType: 'social', - contentPayload: { links: [{ label: 'Unsafe', url: 'javascript:alert(1)' }] } - }); - expect(result.status).toBe(400); - expect((await request(app).get('/api/qr-codes')).body).toEqual([]); - }); - - it('protects management reads and writes when configured', async () => { - process.env.ADMIN_TOKEN = 'test-only-token'; - expect((await request(app).get('/api/qr-codes')).status).toBe(401); - expect((await request(app).post('/api/qr-codes').send({})).status).toBe(401); - expect((await request(app).get('/api/qr-codes').set('x-admin-token', 'test-only-token')).status).toBe(200); - }); -}); +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let tempDir = ''; +let app: Awaited>; + +async function loadApp() { + vi.resetModules(); + process.env.NODE_ENV = 'test'; + process.env.DATA_FILE = path.join(tempDir, 'db.json'); + process.env.UPLOAD_DIR = path.join(tempDir, 'uploads'); + process.env.APP_PUBLIC_URL = 'http://localhost:3000'; + const module = await import('../src/server/server'); + return module.createApp(); +} + +describe('API', () => { + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'ctech-qr-')); + app = await loadApp(); + }); + + afterEach(async () => { + delete process.env.ADMIN_TOKEN; + await rm(tempDir, { recursive: true, force: true }); + }); + + it('renders SVG and PNG through API parity route', async () => { + const svg = await request(app).post('/api/qr/custom').send({ data: 'https://example.com', file: 'svg', size: 300 }); + expect(svg.status).toBe(200); + expect(svg.headers['content-type']).toContain('image/svg+xml'); + expect(svg.body.toString()).toContain(' { + const created = await request(app).post('/api/qr-codes').send({ + name: 'Dynamic URL', + mode: 'dynamic_url', + contentType: 'url', + keyword: 'launch', + contentPayload: { url: 'https://example.com/a' } + }); + expect(created.status).toBe(201); + const original = created.body.encodedPayload; + expect(original).toContain('/r/launch'); + expect(created.body.yourlsLongUrl).toBe(`http://localhost:3000/r/${created.body.id}`); + + const updated = await request(app).patch(`/api/qr-codes/${created.body.id}`).send({ contentPayload: { url: 'https://example.com/b' } }); + expect(updated.status).toBe(200); + expect(updated.body.encodedPayload).toBe(original); + expect(updated.body.yourlsLongUrl).toBe(created.body.yourlsLongUrl); + + const redirect = await request(app).get('/r/launch').redirects(0); + expect(redirect.status).toBe(302); + expect(redirect.headers.location).toBe('https://example.com/b'); + + const detail = await request(app).get(`/api/qr-codes/${created.body.id}`); + expect(detail.body.auditEvents.length).toBeGreaterThanOrEqual(2); + }); + + it('keeps the created audit snapshot intact after later edits', async () => { + const created = await request(app).post('/api/qr-codes').send({ + name: 'Audit', mode: 'dynamic_url', contentType: 'url', contentPayload: { url: 'https://example.com/original' } + }); + expect(created.status).toBe(201); + await request(app).patch(`/api/qr-codes/${created.body.id}`).send({ name: 'Renamed', contentPayload: { url: 'https://example.com/first-edit' } }); + await request(app).patch(`/api/qr-codes/${created.body.id}`).send({ contentPayload: { url: 'https://example.com/second-edit' } }); + + const detail = await request(app).get(`/api/qr-codes/${created.body.id}`); + const events = detail.body.auditEvents as Array<{ eventType: string; previous: any; next: any }>; + const createdEvent = events.find((event) => event.eventType === 'created')!; + expect(createdEvent.next.name).toBe('Audit'); + expect(createdEvent.next.contentPayload.url).toBe('https://example.com/original'); + const updates = events.filter((event) => event.eventType === 'updated').reverse(); + expect(updates[0].previous.contentPayload.url).toBe('https://example.com/original'); + expect(updates[0].next.contentPayload.url).toBe('https://example.com/first-edit'); + expect(updates[1].previous.contentPayload.url).toBe('https://example.com/first-edit'); + expect(updates[1].next.contentPayload.url).toBe('https://example.com/second-edit'); + }); + + it('stores landing-page feedback posted by the HTML form (urlencoded)', async () => { + const created = await request(app).post('/api/qr-codes').send({ + name: 'Rate us', mode: 'dynamic_payload', contentType: 'rating', contentPayload: { title: 'Rate us', question: 'How did we do?' } + }); + expect(created.status).toBe(201); + const page = await request(app).get(`/l/${created.body.id}`); + expect(page.text).toContain(`action="/api/landing/${created.body.id}/feedback"`); + + const form = await request(app).post(`/api/landing/${created.body.id}/feedback`).type('form').send({ name: 'Ada', message: 'Great' }); + expect(form.status).toBe(200); + expect(form.headers['content-type']).toContain('text/html'); + + const json = await request(app).post(`/api/landing/${created.body.id}/feedback`).send({ name: 'Grace', message: 'Also great' }); + expect(json.status).toBe(200); + expect(json.body).toEqual({ ok: true }); + + const detail = await request(app).get(`/api/qr-codes/${created.body.id}`); + const submissions = detail.body.feedback.map((item: { submission: unknown }) => item.submission); + expect(submissions).toContainEqual({ name: 'Ada', message: 'Great' }); + expect(submissions).toContainEqual({ name: 'Grace', message: 'Also great' }); + }); + + it('rejects empty or unparseable feedback instead of reporting success', async () => { + const created = await request(app).post('/api/qr-codes').send({ + name: 'Feedback', mode: 'dynamic_payload', contentType: 'feedback', contentPayload: { title: 'Tell us' } + }); + const empty = await request(app).post(`/api/landing/${created.body.id}/feedback`).type('form').send({ name: '', message: '' }); + expect(empty.status).toBe(400); + const plain = await request(app).post(`/api/landing/${created.body.id}/feedback`).set('Content-Type', 'text/plain').send('hello'); + expect(plain.status).toBe(400); + const detail = await request(app).get(`/api/qr-codes/${created.body.id}`); + expect(detail.body.feedback).toEqual([]); + }); + + it('answers PDF/EPS export requests with a 4xx instead of a 500', async () => { + for (const file of ['pdf', 'eps']) { + const post = await request(app).post('/api/qr/custom').send({ data: 'https://example.com', file }); + expect(post.status).toBe(400); + expect(post.body.error).toMatch(/not supported/i); + const get = await request(app).get('/qr/custom').query({ data: 'https://example.com', file }); + expect(get.status).toBe(400); + } + const created = await request(app).post('/api/qr-codes').send({ name: 'Static', contentType: 'url', contentPayload: { url: 'https://example.com' } }); + expect((await request(app).get(`/api/qr-codes/${created.body.id}/render`).query({ file: 'pdf' })).status).toBe(400); + expect((await request(app).get(`/api/qr-codes/${created.body.id}/render`).query({ file: 'gif' })).status).toBe(400); + }); + + it('accepts safe SVG upload and rejects active SVG', async () => { + const safe = await request(app).post('/api/qr/upload-image').attach('file', Buffer.from(''), { filename: 'logo.svg', contentType: 'image/svg+xml' }); + expect(safe.status).toBe(200); + expect(safe.body.file).toContain('/uploads/'); + + const unsafe = await request(app).post('/api/qr/upload-image').attach('file', Buffer.from(''), { filename: 'evil.svg', contentType: 'image/svg+xml' }); + expect(unsafe.status).toBe(400); + }); + + it('rejects active SVG event handlers beyond onload', async () => { + const unsafe = await request(app).post('/api/qr/upload-image').attach('file', Buffer.from(''), { filename: 'evil.svg', contentType: 'image/svg+xml' }); + expect(unsafe.status).toBe(400); + }); + + it('validates dynamic landing payloads before storing them', async () => { + const result = await request(app).post('/api/qr-codes').send({ + mode: 'dynamic_payload', contentType: 'social', + contentPayload: { links: [{ label: 'Unsafe', url: 'javascript:alert(1)' }] } + }); + expect(result.status).toBe(400); + expect((await request(app).get('/api/qr-codes')).body).toEqual([]); + }); + + it('protects management reads and writes when configured', async () => { + process.env.ADMIN_TOKEN = 'test-only-token'; + expect((await request(app).get('/api/qr-codes')).status).toBe(401); + expect((await request(app).post('/api/qr-codes').send({})).status).toBe(401); + expect((await request(app).get('/api/qr-codes').set('x-admin-token', 'test-only-token')).status).toBe(200); + }); +}); diff --git a/tests/qr-core.test.ts b/tests/qr-core.test.ts index 58cc28d..841cb60 100644 --- a/tests/qr-core.test.ts +++ b/tests/qr-core.test.ts @@ -1,65 +1,87 @@ -import { describe, expect, it } from 'vitest'; -import { defaultDesignConfig, templates } from '../src/qr/design'; -import { logoGallery } from '../src/qr/design'; -import { getLogoIcon } from '../src/qr/logo-icons'; -import { buildPayload } from '../src/qr/payloads'; -import { renderQr } from '../src/qr/renderer'; -import type { ContentType } from '../src/shared/types'; - -describe('QR payload builders', () => { - it.each<[ContentType, Record, string]>([ - ['url', { url: 'https://example.com' }, 'https://example.com'], - ['text', { text: 'hello' }, 'hello'], - ['email', { email: 'hello@example.com', subject: 'Hi there', message: 'Body text' }, 'mailto:hello@example.com?subject=Hi+there&body=Body+text'], - ['phone', { phone: '+15555550100' }, 'tel:+15555550100'], - ['sms', { phone: '+15555550100', message: 'Hi' }, 'SMSTO:+15555550100:Hi'], - ['maps', { latitude: 40, longitude: -73 }, 'https://maps.google.com/local?q=40,-73'], - ['facebook', { mode: 'share', url: 'https://example.com' }, 'https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fexample.com'], - ['twitter', { mode: 'tweet', tweet: 'hello world' }, 'https://twitter.com/intent/tweet?text=hello%20world'], - ['youtube', { url: 'https://youtube.com/watch?v=abc' }, 'https://youtube.com/watch?v=abc'], - ['wifi', { ssid: 'example;guest', password: 'p:a,s', encryption: 'WPA' }, 'WIFI:S:example\\;guest;T:WPA;P:p\\:a\\,s;;'], - ['crypto', { currency: 'bitcoin', address: 'abc', amount: 1.5 }, 'bitcoin:abc?amount=1.5'] - ])('builds %s payload', (type, input, expected) => { - expect(buildPayload(type, input)).toBe(expected); - }); - - it('builds vCard and MeCard payloads', () => { - expect(buildPayload('vcard', { firstName: 'Ada', lastName: 'Lovelace', version: '3.0', email: 'ada@example.com' })).toContain('BEGIN:VCARD'); - expect(buildPayload('mecard', { firstName: 'Ada', lastName: 'Lovelace', birthday: '1815-12-10', notes: 'math;engine' })).toContain('BDAY:18151210'); - expect(buildPayload('mecard', { notes: 'math;engine' })).toContain('NOTE:math,engine'); - }); - - it('builds event payload with normalized dates', () => { - const payload = buildPayload('event', { title: 'Launch', location: 'Online', start: '2026-05-07T12:30:00', end: '2026-05-07T13:30:00' }); - expect(payload).toContain('BEGIN:VEVENT'); - expect(payload).toContain('DTSTART:20260507T123000'); - }); -}); - -describe('design and renderer', () => { - it('ships all scoped templates', () => { - expect(templates.map((template) => template.name)).toEqual(['Classic', 'CompleteTech', 'Easy', 'Facebook', 'Twitter', 'Youtube', 'Monkey', 'Rain', 'Jungle', 'Mosaic', 'Dot', 'Coffee', 'Ninja', 'Bitcoin']); - }); - - it('resolves every logo gallery preset to an SVG icon', () => { - expect(logoGallery.map((name) => [name, Boolean(getLogoIcon(name))])).toEqual( - logoGallery.map((name) => [name, true]) - ); - }); - - it('renders SVG and PNG', async () => { - const svg = await renderQr({ data: 'https://example.com', size: 300, file: 'svg', config: { ...defaultDesignConfig, logo: '#ctech-mark', logoMode: 'clean' } }); - expect(svg.contentType).toBe('image/svg+xml'); - expect(String(svg.body)).toContain(' { - await expect(renderQr({ data: 'https://example.com', size: 300, file: 'pdf', config: defaultDesignConfig })).rejects.toThrow('disabled'); - await expect(renderQr({ data: 'https://example.com', size: 300, file: 'eps', config: defaultDesignConfig })).rejects.toThrow('disabled'); - }); -}); +import { describe, expect, it } from 'vitest'; +import { defaultDesignConfig, templates } from '../src/qr/design'; +import { logoGallery } from '../src/qr/design'; +import { getLogoIcon } from '../src/qr/logo-icons'; +import { buildPayload } from '../src/qr/payloads'; +import { renderQr } from '../src/qr/renderer'; +import type { ContentType } from '../src/shared/types'; + +describe('QR payload builders', () => { + it.each<[ContentType, Record, string]>([ + ['url', { url: 'https://example.com' }, 'https://example.com'], + ['text', { text: 'hello' }, 'hello'], + ['email', { email: 'hello@example.com', subject: 'Hi there', message: 'Body text' }, 'mailto:hello@example.com?subject=Hi+there&body=Body+text'], + ['phone', { phone: '+15555550100' }, 'tel:+15555550100'], + ['sms', { phone: '+15555550100', message: 'Hi' }, 'SMSTO:+15555550100:Hi'], + ['maps', { latitude: 40, longitude: -73 }, 'https://maps.google.com/local?q=40,-73'], + ['facebook', { mode: 'share', url: 'https://example.com' }, 'https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fexample.com'], + ['twitter', { mode: 'tweet', tweet: 'hello world' }, 'https://twitter.com/intent/tweet?text=hello%20world'], + ['youtube', { url: 'https://youtube.com/watch?v=abc' }, 'https://youtube.com/watch?v=abc'], + ['wifi', { ssid: 'example;guest', password: 'p:a,s', encryption: 'WPA' }, 'WIFI:S:example\\;guest;T:WPA;P:p\\:a\\,s;;'], + ['crypto', { currency: 'bitcoin', address: 'abc', amount: 1.5 }, 'bitcoin:abc?amount=1.5'] + ])('builds %s payload', (type, input, expected) => { + expect(buildPayload(type, input)).toBe(expected); + }); + + it('builds vCard and MeCard payloads', () => { + expect(buildPayload('vcard', { firstName: 'Ada', lastName: 'Lovelace', version: '3.0', email: 'ada@example.com' })).toContain('BEGIN:VCARD'); + expect(buildPayload('mecard', { firstName: 'Ada', lastName: 'Lovelace', birthday: '1815-12-10', notes: 'math;engine' })).toContain('BDAY:18151210'); + expect(buildPayload('mecard', { notes: 'math;engine' })).toContain('NOTE:math,engine'); + }); + + it('builds event payload with normalized dates', () => { + const payload = buildPayload('event', { title: 'Launch', location: 'Online', start: '2026-05-07T12:30:00', end: '2026-05-07T13:30:00' }); + expect(payload).toContain('BEGIN:VEVENT'); + expect(payload).toContain('DTSTART:20260507T123000'); + }); +}); + +describe('design and renderer', () => { + it('ships all scoped templates', () => { + expect(templates.map((template) => template.name)).toEqual(['Classic', 'CompleteTech', 'Easy', 'Facebook', 'Twitter', 'Youtube', 'Monkey', 'Rain', 'Jungle', 'Mosaic', 'Dot', 'Coffee', 'Ninja', 'Bitcoin']); + }); + + it('resolves every logo gallery preset to an SVG icon', () => { + expect(logoGallery.map((name) => [name, Boolean(getLogoIcon(name))])).toEqual( + logoGallery.map((name) => [name, true]) + ); + }); + + it('renders SVG and PNG', async () => { + const svg = await renderQr({ data: 'https://example.com', size: 300, file: 'svg', config: { ...defaultDesignConfig, logo: '#ctech-mark', logoMode: 'clean' } }); + expect(svg.contentType).toBe('image/svg+xml'); + expect(String(svg.body)).toContain(' { + await expect(renderQr({ data: 'https://example.com', size: 300, file: 'pdf', config: defaultDesignConfig })).rejects.toThrow('disabled'); + await expect(renderQr({ data: 'https://example.com', size: 300, file: 'eps', config: defaultDesignConfig })).rejects.toThrow('disabled'); + }); +}); + +describe('gradient on eyes', () => { + const base = { ...defaultDesignConfig, gradientColor1: '#ff0000', gradientColor2: '#0000ff', eye1Color: '#123456', eyeBall1Color: '#abcdef' }; + + it('keeps eye colors when gradientOnEyes is off', async () => { + const svg = String((await renderQr({ data: 'https://example.com', size: 300, file: 'svg', config: { ...base, gradientOnEyes: false } })).body); + expect(svg).toContain('fill="#123456"'); + expect(svg).toContain('fill="#abcdef"'); + }); + + it('paints eye frames and balls with the body gradient when gradientOnEyes is on', async () => { + const render = async (gradientOnEyes: boolean) => String((await renderQr({ data: 'https://example.com', size: 300, file: 'svg', config: { ...base, gradientOnEyes } })).body); + const off = await render(false); + const on = await render(true); + expect(on).not.toContain('fill="#123456"'); + expect(on).not.toContain('fill="#abcdef"'); + expect(on).toContain('gradientUnits="userSpaceOnUse"'); + const gradientFills = (svg: string) => svg.split('fill="url(#').length - 1; + // 3 eyes x (frame + ball) switch to the gradient. + expect(gradientFills(on)).toBe(gradientFills(off) + 6); + }); +});