← back to CelebritySignatures
server.js
172 lines
#!/usr/bin/env node
// Zero-dependency server for the CelebritySignatures grid + murals storefront.
// Adds lightweight email/password accounts (scrypt + cookie sessions, JSON-backed)
// so a visitor can SAVE a mural placement they set in the wall studio.
import { createServer } from 'node:http';
import { readFile, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = process.env.PORT || 9920;
const DATA = join(ROOT, 'data');
const TYPES = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.svg': 'image/svg+xml',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon',
};
// ---- tiny JSON store --------------------------------------------------------
async function load(name, fallback) { try { return JSON.parse(await readFile(join(DATA, name), 'utf8')); } catch { return fallback; } }
async function store(name, val) { await writeFile(join(DATA, name), JSON.stringify(val, null, 2)); }
function readBody(req) {
return new Promise((resolve, reject) => {
let raw = '';
req.on('data', c => { raw += c; if (raw.length > 2e5) reject(new Error('too large')); });
req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('bad json')); } });
req.on('error', reject);
});
}
function sendJSON(res, code, obj, headers = {}) { res.writeHead(code, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); }
// ---- auth helpers -----------------------------------------------------------
const COOKIE = 'cs_sess';
function hashPw(pw, salt) { return scryptSync(pw, salt, 64).toString('hex'); }
function verifyPw(pw, salt, hash) {
const a = Buffer.from(hashPw(pw, salt), 'hex'), b = Buffer.from(hash, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
function parseCookies(req) {
const out = {}; (req.headers.cookie || '').split(';').forEach(p => { const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
return out;
}
async function currentUser(req) {
const sessions = await load('sessions.json', {});
const tok = parseCookies(req)[COOKIE];
const s = tok && sessions[tok];
if (!s) return null;
const users = await load('users.json', []);
return users.find(u => u.id === s.userId) || null;
}
async function newSession(userId) {
const sessions = await load('sessions.json', {});
const tok = randomBytes(24).toString('hex');
sessions[tok] = { userId, at: new Date().toISOString() };
await store('sessions.json', sessions);
return tok;
}
const setCookie = tok => `${COOKIE}=${tok}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000`;
const clearCookie = `${COOKIE}=; HttpOnly; Path=/; Max-Age=0`;
const pubUser = u => ({ email: u.email, name: u.name || '' });
createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://x');
let path = decodeURIComponent(url.pathname);
const M = req.method;
// ===== AUTH =====
if (path === '/api/auth/signup' && M === 'POST') {
const b = await readBody(req);
const email = String(b.email || '').trim().toLowerCase();
const pw = String(b.password || '');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return sendJSON(res, 400, { ok: false, error: 'invalid email' });
if (pw.length < 6) return sendJSON(res, 400, { ok: false, error: 'password must be at least 6 characters' });
const users = await load('users.json', []);
if (users.some(u => u.email === email)) return sendJSON(res, 409, { ok: false, error: 'account already exists — sign in instead' });
const salt = randomBytes(16).toString('hex');
const user = { id: createHash('sha256').update(email + Date.now()).digest('hex').slice(0, 16), email, name: String(b.name || '').slice(0, 80), salt, hash: hashPw(pw, salt), at: new Date().toISOString() };
users.push(user); await store('users.json', users);
const tok = await newSession(user.id);
return sendJSON(res, 200, { ok: true, user: pubUser(user) }, { 'Set-Cookie': setCookie(tok) });
}
if (path === '/api/auth/login' && M === 'POST') {
const b = await readBody(req);
const email = String(b.email || '').trim().toLowerCase();
const users = await load('users.json', []);
const u = users.find(x => x.email === email);
if (!u || !verifyPw(String(b.password || ''), u.salt, u.hash)) return sendJSON(res, 401, { ok: false, error: 'wrong email or password' });
const tok = await newSession(u.id);
return sendJSON(res, 200, { ok: true, user: pubUser(u) }, { 'Set-Cookie': setCookie(tok) });
}
if (path === '/api/auth/logout' && M === 'POST') {
const sessions = await load('sessions.json', {});
const tok = parseCookies(req)[COOKIE];
if (tok) { delete sessions[tok]; await store('sessions.json', sessions); }
return sendJSON(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
}
if (path === '/api/auth/me' && M === 'GET') {
const u = await currentUser(req);
return sendJSON(res, 200, u ? { ok: true, user: pubUser(u) } : { ok: true, user: null });
}
// ===== SAVED PLACEMENTS (per account) =====
if (path === '/api/saves') {
const u = await currentUser(req);
if (!u) return sendJSON(res, 401, { ok: false, error: 'sign in to save' });
const all = await load('saves.json', []);
if (M === 'GET') return sendJSON(res, 200, { ok: true, saves: all.filter(s => s.userId === u.id).sort((a, b) => b.at.localeCompare(a.at)) });
if (M === 'POST') {
const b = await readBody(req);
if (!b.mural) return sendJSON(res, 400, { ok: false, error: 'mural required' });
const id = randomBytes(8).toString('hex');
const rec = { id, userId: u.id, at: new Date().toISOString(),
mural: String(b.mural).slice(0, 60), mural_title: String(b.mural_title || '').slice(0, 120),
wall_w: +b.wall_w || null, wall_h: +b.wall_h || null,
placement: b.placement && typeof b.placement === 'object' ? { from_left_ft: +b.placement.from_left_ft || 0, off_floor_ft: +b.placement.off_floor_ft || 0 } : null,
scene: String(b.scene || '').slice(0, 40), label: String(b.label || '').slice(0, 80) };
all.push(rec); await store('saves.json', all);
return sendJSON(res, 200, { ok: true, save: rec });
}
if (M === 'DELETE') {
const id = url.searchParams.get('id');
const kept = all.filter(s => !(s.id === id && s.userId === u.id));
await store('saves.json', kept);
return sendJSON(res, 200, { ok: true, removed: all.length - kept.length });
}
}
// ===== made-to-order request (existing) =====
if (path === '/api/mural-order' && M === 'POST') {
const b = await readBody(req);
if (!b.name || !b.email) return sendJSON(res, 400, { ok: false, error: 'name and email required' });
const u = await currentUser(req);
const list = await load('mural-orders.json', []);
const id = (list.at(-1)?.id || 1000) + 1;
list.push({ id, at: new Date().toISOString(), account: u ? u.email : null, ...b });
await store('mural-orders.json', list);
return sendJSON(res, 200, { ok: true, id });
}
// ===== static + page routes =====
if (path === '/') path = '/public/index.html';
if (path === '/murals') path = '/public/murals.html';
if (path === '/account.js') path = '/public/account.js';
if (path === '/api/signatures') path = '/data/celebrity_signatures.json';
if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
const SHORT = { d: 'declaration-of-independence', p: 'politics', s: 'sports', h: 'hollywood', c: 'movies-classic', t: 'tv', o: 'oldest-signatures' };
const km = path.match(/^\/k\/([a-z0-9-]+)$/);
if (km) path = `/output/collage-${SHORT[km[1]] || km[1]}-named.png`;
const file = join(ROOT, path);
if (!file.startsWith(ROOT)) { res.writeHead(403).end('forbidden'); return; }
// Inject the account/save widget into the murals page WITHOUT editing murals.html.
if (path === '/public/murals.html') {
let html = await readFile(file, 'utf8');
html = html.replace('</body>', '<script src="/account.js"></script>\n</body>');
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' });
res.end(html); return;
}
const body = await readFile(file);
res.writeHead(200, { 'Content-Type': TYPES[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
res.end(body);
} catch (e) {
if (e.message === 'bad json' || e.message === 'too large') { sendJSON(res, 400, { ok: false, error: e.message }); return; }
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('not found');
}
}).listen(PORT, () => console.log(`CelebritySignatures → http://localhost:${PORT}`));