import { Router, raw, type Request, type Response } from 'express'; import { and, desc, eq, inArray } from 'drizzle-orm'; import { db, articlesTable } from '@workspace/db'; import { SaveArticleBody } from '@workspace/api-zod'; import sanitizeHtml from 'sanitize-html'; import { articleImageFile, saveArticleImage } from '../lib/articleImages'; export const publicArticlesRouter = Router(); export const adminArticlesRouter = Router(); const samples = ['s1', 's2', 's3', 's4', 's5', 's6', 's7', 's8']; const sampleSlugs = ['shipping-policy-that-builds-trust', 'find-products-customers-love', 'first-100-orders', 'choosing-a-dropshipping-supplier', 'international-delivery-basics', 'small-shop-product-photography', 'choosing-an-ecommerce-platform', 'supplier-sample-checklist']; publicArticlesRouter.get('/articles', async (_req, res) => { res.setHeader('Cache-Control', 'no-store'); const articles = await db.select().from(articlesTable).where(eq(articlesTable.status, 'Published')).orderBy(desc(articlesTable.publishedAt)); const overrides = await db.select({ id: articlesTable.id }).from(articlesTable).where(inArray(articlesTable.id, samples)); res.json({ articles, overriddenSamples: overrides.map(row => row.id) }); }); publicArticlesRouter.get('/article-images/:id', async (req, res) => { if (!/^[a-f0-9-]{36}$/.test(req.params.id)) { res.sendStatus(404); return; } const file = articleImageFile(req.params.id); try { const [metadata] = await file.getMetadata(); res.setHeader('Content-Type', metadata.contentType || 'application/octet-stream'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); file.createReadStream().on('error', () => { if (!res.headersSent) res.sendStatus(404); else res.destroy(); }).pipe(res); } catch { res.sendStatus(404); } }); // Mounted only after the existing Clerk admin-role guard. adminArticlesRouter.get('/articles', async (_req, res) => { res.json(await db.select().from(articlesTable).orderBy(desc(articlesTable.updatedAt))); }); adminArticlesRouter.put('/articles/:id', async (req, res) => { const id = req.params.id; if (!/^(?:s[1-8]|[a-f0-9-]{36})$/.test(id)) { res.status(400).json({ error: 'Invalid article identifier.' }); return; } const parsed = SaveArticleBody.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: 'Check the title, slug, author and article fields.' }); return; } const input = parsed.data; if (sampleSlugs.includes(input.slug) && samples[sampleSlugs.indexOf(input.slug)] !== id) { res.status(409).json({ error: 'That slug belongs to an existing sample article. Choose a different slug.' }); return; } if (!input.title.trim() || !input.author.trim()) { res.status(400).json({ error: 'Title and author are required.' }); return; } const content = sanitizeHtml(input.content, { allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote', 'hr', 'a', 'span', 'mark', 'pre', 'code'], allowedAttributes: { '*': ['style'], a: ['href', 'title', 'rel', 'target'], ol: ['start'], mark: ['data-color'] }, allowedSchemes: ['http', 'https', 'mailto', 'tel'], allowedStyles: { '*': { 'text-align': [/^(left|center|right|justify)$/], 'color': [/^(#[a-f0-9]{3,8}|[a-z]+|rgba?\([\d., %]+\))$/i], 'background-color': [/^(#[a-f0-9]{3,8}|[a-z]+|rgba?\([\d., %]+\))$/i], 'font-size': [/^\d{1,3}(px|pt|rem|em)$/], 'font-family': [/^[a-zA-Z ,'"-]+$/], } }, }); if (input.status === 'Published' && !sanitizeHtml(content, { allowedTags: [], allowedAttributes: {} }).trim()) { res.status(400).json({ error: 'Add article content before publishing.' }); return; } // Only durable uploaded images or existing bundled sample assets are accepted. if (input.image && !/^\/api\/article-images\/[a-f0-9-]{36}$/.test(input.image) && !(samples.includes(id) && /^\/(?:@fs\/|assets\/|src\/)/.test(input.image))) { res.status(400).json({ error: 'Upload the featured image before saving.' }); return; } const [existing] = await db.select().from(articlesTable).where(eq(articlesTable.id, id)); if ((existing?.version || 0) !== input.version) { res.status(409).json({ error: 'This article changed in another session. Copy your changes before reloading.' }); return; } const values = { ...input, content, title: input.title.trim(), author: input.author.trim(), version: input.version + 1, updatedAt: new Date(), publishedAt: input.status === 'Published' ? existing?.publishedAt || new Date() : null, }; try { const [article] = existing ? await db.update(articlesTable).set(values).where(and(eq(articlesTable.id, id), eq(articlesTable.version, input.version))).returning() : await db.insert(articlesTable).values({ ...values, id }).returning(); if (!article) { res.status(409).json({ error: 'Another session saved this article. Copy your changes before reloading.' }); return; } res.json(article); } catch (error) { const code = (error as { cause?: { code?: string }; code?: string }).cause?.code || (error as { code?: string }).code; if (code === '23505') { res.status(409).json({ error: 'That article slug already exists. Choose a different slug.' }); return; } req.log.error('Article save failed.'); res.status(503).json({ error: 'Article could not be saved. Your changes remain in the editor. Please retry.' }); } }); adminArticlesRouter.post('/article-images', raw({ type: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], limit: '5mb' }), async (req: Request, res: Response) => { if (!Buffer.isBuffer(req.body) || !req.body.length) { res.status(400).json({ error: 'Choose a supported image up to 5 MB.' }); return; } try { res.json({ image: await saveArticleImage(req.body) }); } catch { res.status(400).json({ error: 'Image upload failed. Check the file and try again.' }); } });