Rich solo composer: WYSIWYG editor, sanitized HTML bodies, media uploads, CTA labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 15:38:46 -05:00
parent f6a3befe09
commit 2cc2855488
8 changed files with 150 additions and 16 deletions
+47 -1
View File
@@ -28,6 +28,9 @@ const SITE_FILE = path.join(DATA_DIR, 'site.json');
const db = require('./db');
fs.mkdirSync(DATA_DIR, { recursive: true });
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
const uploadCounts = new Map(); // email:day -> uploads today
const chatHits = new Map();
function chatLimited(ip) {
const now = Date.now(), rec = chatHits.get(ip);
@@ -116,7 +119,8 @@ function siteConfig() {
// ---- helpers ----
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2' };
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2',
'.gif': 'image/gif', '.webm': 'video/webm' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:";
function baseHeaders(extra) {
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
@@ -136,6 +140,19 @@ function sendFile(res, file) {
res.end(data);
});
}
function readRaw(req, maxBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let n = 0;
req.on('data', c => {
n += c.length;
if (n > maxBytes) { req.destroy(); reject(new Error('too big')); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
let d = ''; let n = 0;
@@ -527,6 +544,33 @@ const server = http.createServer(async (req, res) => {
}
return json(res, r.error ? 404 : 200, r);
}
// media upload for solo ads: raw body, size-capped, magic-byte verified
if (p === '/api/my/upload' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
'video/mp4': 'mp4', 'video/webm': 'webm' };
if (!EXT[ct]) return json(res, 400, { error: 'Use a PNG, JPG, WebP, GIF, MP4 or WebM file.' });
const isVideo = ct.startsWith('video/');
const key = s.email + ':' + new Date().toISOString().slice(0, 10);
if ((uploadCounts.get(key) || 0) >= 10) return json(res, 400, { error: 'Upload limit for today reached (10 files).' });
let buf;
try { buf = await readRaw(req, isVideo ? 25 * 1024 * 1024 : 3 * 1024 * 1024); }
catch (e) { return json(res, 400, { error: 'File too large. Images up to 3MB, video up to 25MB.' }); }
const magicOk = buf.length > 16 && (
(ct === 'image/png' && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) ||
(ct === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) ||
(ct === 'image/webp' && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') ||
(ct === 'image/gif' && buf.slice(0, 4).toString() === 'GIF8') ||
(ct === 'video/mp4' && buf.slice(4, 8).toString() === 'ftyp') ||
(ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3));
if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' });
const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct];
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
}
m = /^\/api\/my\/inbox\/(\d+)\/claim$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
@@ -631,6 +675,8 @@ const server = http.createServer(async (req, res) => {
if (p === '/contract') return sendFile(res, path.join(PUBLIC_DIR, 'contract.html'));
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html'));
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1]));
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
const file = path.join(PUBLIC_DIR, safe);
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);