← 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}`));