Taha Saif
Deploy Ecom Hub API backend (Express + Drizzle + Clerk + R2)
74bfb65
Raw History Blame Contribute Delete
6 kB
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.' });
}
});