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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user