324 lines
26 KiB
JavaScript
324 lines
26 KiB
JavaScript
import http from 'node:http';
|
|
import crypto from 'node:crypto';
|
|
import { Pool } from 'pg';
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const APP_BASE_URL = (process.env.APP_BASE_URL || '').replace(/\/$/, '');
|
|
const ISSUER = (process.env.KEYCLOAK_ISSUER || '').replace(/\/$/, '');
|
|
const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || '';
|
|
const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
|
|
const DATABASE_URL = process.env.DATABASE_URL || '';
|
|
|
|
for (const [name, value] of Object.entries({ APP_BASE_URL, ISSUER, CLIENT_ID, CLIENT_SECRET, DATABASE_URL })) {
|
|
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
|
}
|
|
|
|
const pool = new Pool({ connectionString: DATABASE_URL, max: 8 });
|
|
const SESSION_COOKIE = 'cooking_session';
|
|
const STATE_COOKIE = 'cooking_oidc_state';
|
|
|
|
function esc(value) {
|
|
return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
}
|
|
|
|
function parseCookies(req) {
|
|
const out = {};
|
|
for (const part of (req.headers.cookie || '').split(';')) {
|
|
const i = part.indexOf('=');
|
|
if (i < 0) continue;
|
|
try { out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1)); } catch {}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function setCookie(res, name, value, maxAge = 3600) {
|
|
const cookie = `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
|
|
const existing = res.getHeader('Set-Cookie');
|
|
res.setHeader('Set-Cookie', existing ? [...(Array.isArray(existing) ? existing : [existing]), cookie] : cookie);
|
|
}
|
|
|
|
function b64url(input) {
|
|
return Buffer.from(input).toString('base64url');
|
|
}
|
|
|
|
function sign(value) {
|
|
return crypto.createHmac('sha256', CLIENT_SECRET).update(value).digest('base64url');
|
|
}
|
|
|
|
function makeSession(user, ttlSeconds) {
|
|
const payload = b64url(JSON.stringify({
|
|
sub: user.sub,
|
|
name: user.preferred_username || user.name || 'cook',
|
|
exp: Math.floor(Date.now() / 1000) + ttlSeconds
|
|
}));
|
|
return `${payload}.${sign(payload)}`;
|
|
}
|
|
|
|
function readSession(req) {
|
|
const raw = parseCookies(req)[SESSION_COOKIE];
|
|
if (!raw) return null;
|
|
const [payload, signature] = raw.split('.');
|
|
if (!payload || !signature) return null;
|
|
const expected = sign(payload);
|
|
const a = Buffer.from(signature);
|
|
const b = Buffer.from(expected);
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
try {
|
|
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
if (!session.sub || !session.exp || session.exp < Math.floor(Date.now() / 1000)) return null;
|
|
return session;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function redirect(res, location) {
|
|
res.writeHead(302, { Location: location, 'cache-control': 'no-store' });
|
|
res.end();
|
|
}
|
|
|
|
function send(res, status, body, type = 'text/html; charset=utf-8') {
|
|
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
res.end(body);
|
|
}
|
|
|
|
async function readForm(req) {
|
|
let raw = '';
|
|
for await (const chunk of req) {
|
|
raw += chunk;
|
|
if (raw.length > 1024 * 1024) throw new Error('Request too large');
|
|
}
|
|
return Object.fromEntries(new URLSearchParams(raw));
|
|
}
|
|
|
|
function slugify(title) {
|
|
const base = String(title).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'recipe';
|
|
return `${base}-${crypto.randomBytes(3).toString('hex')}`;
|
|
}
|
|
|
|
function normaliseTags(value) {
|
|
const tags = String(value || '').split(',').map(v => v.trim().toLowerCase().replace(/[^a-z0-9 -]/g, '').replace(/\s+/g, '-')).filter(Boolean);
|
|
return [...new Set(tags)].slice(0, 8);
|
|
}
|
|
|
|
function clampInt(value, min, max, fallback = 0) {
|
|
const n = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(n)) return fallback;
|
|
return Math.max(min, Math.min(max, n));
|
|
}
|
|
|
|
function nl2br(value) {
|
|
return esc(value).replace(/\n/g, '<br>');
|
|
}
|
|
|
|
function nav(session) {
|
|
return `<header class="nav"><a class="logo" href="/">cooking<span>.</span></a><nav>
|
|
<a href="/">Discover</a>
|
|
${session ? `<a href="/mine">My recipes</a><a class="button small" href="/new">+ New recipe</a><span class="user">${esc(session.name)}</span><a href="/logout">Logout</a>` : `<a class="button small" href="/login">Sign in</a>`}
|
|
</nav></header>`;
|
|
}
|
|
|
|
function page(title, body, session, options = {}) {
|
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>${esc(title)} · cooking</title>
|
|
<style>
|
|
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#2e2924;background:#fbf8f2;--ink:#2e2924;--muted:#756d64;--line:#e9e1d6;--paper:#fffdf9;--accent:#d85b32;--accent2:#f4d8c6;--green:#476b50}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top right,#f8e7d9 0,transparent 32rem),#fbf8f2;color:var(--ink)}a{color:inherit;text-decoration:none}.shell{max-width:1120px;margin:0 auto;padding:0 22px 70px}.nav{height:82px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:24px}.logo{font-family:Georgia,serif;font-size:30px;font-weight:700;letter-spacing:-1px}.logo span{color:var(--accent)}nav{display:flex;align-items:center;gap:18px;font-size:14px}.user{padding:7px 10px;border-radius:999px;background:#eee7dc;color:#62594f}.button,button{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:12px;background:var(--accent);color:white;font:inherit;font-weight:700;padding:12px 16px;cursor:pointer}.button.small{padding:9px 12px}.button.secondary,button.secondary{background:#eee7dc;color:#554c43}.button.danger,button.danger{background:#8d3b33}.hero{padding:64px 0 34px;display:grid;grid-template-columns:minmax(0,1.4fr) minmax(280px,.6fr);gap:48px;align-items:end}.eyebrow{text-transform:uppercase;letter-spacing:.16em;font-size:12px;font-weight:800;color:var(--accent)}h1,h2,h3{font-family:Georgia,serif;line-height:1.05}h1{font-size:clamp(42px,7vw,76px);letter-spacing:-2px;margin:10px 0 18px}h2{font-size:34px;margin:0 0 18px}h3{font-size:23px;margin:0}.lede{font-size:18px;color:var(--muted);max-width:650px;line-height:1.6}.search{display:grid;grid-template-columns:1fr auto;gap:9px;background:var(--paper);padding:8px;border:1px solid var(--line);border-radius:15px;box-shadow:0 10px 35px #6b4f3212}.search input,input,textarea,select{width:100%;border:1px solid #ddd3c7;background:#fffdf9;color:var(--ink);border-radius:11px;padding:12px 13px;font:inherit;outline:none}.search input{border:0;background:transparent}.search input:focus,input:focus,textarea:focus,select:focus{border-color:#d89b81;box-shadow:0 0 0 3px #f5dfd5}.chips{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0 30px}.chip{background:#f0e8dd;border:1px solid #e5d8c9;padding:7px 10px;border-radius:999px;font-size:13px;color:#655b51}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px}.card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden;min-height:240px;box-shadow:0 12px 32px #6b4f320b;transition:.15s transform,.15s box-shadow}.card:hover{transform:translateY(-3px);box-shadow:0 18px 42px #6b4f3218}.card-top{height:12px;background:linear-gradient(90deg,var(--accent),#efa56f)}.card-body{padding:22px;display:flex;flex-direction:column;flex:1}.card h3{margin-bottom:10px}.summary{color:var(--muted);line-height:1.5;flex:1}.meta{display:flex;justify-content:space-between;gap:12px;margin-top:18px;padding-top:14px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}.empty{border:1px dashed #d8cbbb;border-radius:18px;text-align:center;padding:55px 20px;color:var(--muted);grid-column:1/-1}.form-card,.recipe{max-width:860px;margin:42px auto 0;background:var(--paper);border:1px solid var(--line);border-radius:20px;padding:30px;box-shadow:0 18px 50px #6b4f320d}.field{margin-bottom:18px}.field label{display:block;font-size:13px;font-weight:800;margin-bottom:7px}.field small{color:var(--muted)}.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}.three{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:24px}.recipe-head{display:grid;grid-template-columns:1fr auto;gap:25px;align-items:start}.recipe h1{font-size:clamp(38px,6vw,64px);margin-bottom:10px}.recipe-sub{color:var(--muted);font-size:17px;line-height:1.55}.stats{display:flex;gap:10px;flex-wrap:wrap;margin:24px 0}.stat{background:#f4eee6;border-radius:12px;padding:10px 12px;font-size:13px}.recipe-grid{display:grid;grid-template-columns:.75fr 1.25fr;gap:34px;margin-top:30px}.recipe-section{border-top:1px solid var(--line);padding-top:22px}.recipe-section h2{font-size:27px}.ingredients{margin:0;padding-left:20px;line-height:1.8}.steps{white-space:pre-line;line-height:1.8}.ownerbar{display:flex;gap:8px}.badge{display:inline-flex;border-radius:999px;padding:6px 9px;font-size:12px;background:#ecf1e9;color:var(--green);font-weight:800}.private{background:#eee7dc;color:#71675d}.flash{margin:24px 0;padding:12px 14px;background:#edf4eb;color:#3f6247;border-radius:12px}.footer{margin-top:60px;padding-top:25px;border-top:1px solid var(--line);color:var(--muted);font-size:13px;display:flex;justify-content:space-between}.mine-head{padding:45px 0 24px;display:flex;justify-content:space-between;align-items:end;gap:20px}.mine-head h1{font-size:50px;margin:0}
|
|
@media(max-width:800px){.hero{grid-template-columns:1fr;gap:22px;padding-top:42px}.grid{grid-template-columns:1fr 1fr}.recipe-grid{grid-template-columns:1fr}.recipe-head{grid-template-columns:1fr}.three{grid-template-columns:1fr 1fr}.nav{height:auto;padding:18px 0;align-items:flex-start}.nav nav{flex-wrap:wrap;justify-content:flex-end}}
|
|
@media(max-width:560px){.shell{padding:0 14px 50px}.grid{grid-template-columns:1fr}.two,.three{grid-template-columns:1fr}.form-card,.recipe{padding:20px;margin-top:24px}.nav{display:block}.nav nav{margin-top:14px;justify-content:flex-start;gap:11px}.user{display:none}.hero{padding-top:34px}.mine-head{align-items:flex-start;flex-direction:column}.recipe h1{font-size:42px}}
|
|
</style></head><body><div class="shell">${nav(session)}${body}<footer class="footer"><span>cooking.arrowstack.dev</span><span>Share good food.</span></footer></div></body></html>`;
|
|
}
|
|
|
|
function recipeForm(recipe = {}) {
|
|
const tags = Array.isArray(recipe.tags) ? recipe.tags.join(', ') : '';
|
|
const published = recipe.published !== false;
|
|
return `<div class="field"><label>Recipe title</label><input name="title" maxlength="160" required value="${esc(recipe.title || '')}" placeholder="e.g. Sunday tomato pasta"></div>
|
|
<div class="field"><label>Short description</label><textarea name="summary" rows="3" maxlength="500" placeholder="What makes this worth cooking?">${esc(recipe.summary || '')}</textarea></div>
|
|
<div class="three"><div class="field"><label>Servings</label><input type="number" name="servings" min="1" max="50" value="${esc(recipe.servings || 4)}"></div><div class="field"><label>Prep minutes</label><input type="number" name="prep_minutes" min="0" max="1440" value="${esc(recipe.prep_minutes || 15)}"></div><div class="field"><label>Cook minutes</label><input type="number" name="cook_minutes" min="0" max="1440" value="${esc(recipe.cook_minutes || 30)}"></div></div>
|
|
<div class="field"><label>Ingredients <small>one per line</small></label><textarea name="ingredients" rows="9" required placeholder="2 tbsp olive oil 1 onion, diced 400g tomatoes">${esc(recipe.ingredients || '')}</textarea></div>
|
|
<div class="field"><label>Method</label><textarea name="instructions" rows="11" required placeholder="Write the steps in the way you actually cook them.">${esc(recipe.instructions || '')}</textarea></div>
|
|
<div class="field"><label>Tags <small>comma separated</small></label><input name="tags" value="${esc(tags)}" placeholder="pasta, quick, vegetarian"></div>
|
|
<div class="field"><label><input style="width:auto;margin-right:8px" type="checkbox" name="published" value="1" ${published ? 'checked' : ''}> Share publicly</label></div>`;
|
|
}
|
|
|
|
async function initDb() {
|
|
await pool.query(`CREATE TABLE IF NOT EXISTS recipes (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 160),
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
ingredients TEXT NOT NULL,
|
|
instructions TEXT NOT NULL,
|
|
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
servings INTEGER NOT NULL DEFAULT 4 CHECK (servings BETWEEN 1 AND 50),
|
|
prep_minutes INTEGER NOT NULL DEFAULT 0 CHECK (prep_minutes BETWEEN 0 AND 1440),
|
|
cook_minutes INTEGER NOT NULL DEFAULT 0 CHECK (cook_minutes BETWEEN 0 AND 1440),
|
|
published BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_by TEXT NOT NULL,
|
|
author_name TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`);
|
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_recipes_published_created ON recipes (published, created_at DESC)`);
|
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes (created_by, updated_at DESC)`);
|
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_recipes_tags ON recipes USING GIN (tags)`);
|
|
}
|
|
|
|
await initDb();
|
|
console.log('cooking schema ready');
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url, APP_BASE_URL);
|
|
const session = readSession(req);
|
|
|
|
if (url.pathname === '/favicon.ico') return send(res, 204, '', 'image/x-icon');
|
|
if (url.pathname === '/health') {
|
|
await pool.query('SELECT id FROM recipes LIMIT 1');
|
|
return send(res, 200, JSON.stringify({ ok: true, database: 'connected', schema: 1 }), 'application/json; charset=utf-8');
|
|
}
|
|
|
|
if (url.pathname === '/login') {
|
|
const state = crypto.randomBytes(24).toString('base64url');
|
|
setCookie(res, STATE_COOKIE, state, 300);
|
|
const auth = new URL(`${ISSUER}/protocol/openid-connect/auth`);
|
|
auth.searchParams.set('client_id', CLIENT_ID);
|
|
auth.searchParams.set('redirect_uri', `${APP_BASE_URL}/callback`);
|
|
auth.searchParams.set('response_type', 'code');
|
|
auth.searchParams.set('scope', 'openid profile email');
|
|
auth.searchParams.set('state', state);
|
|
return redirect(res, auth.toString());
|
|
}
|
|
|
|
if (url.pathname === '/callback') {
|
|
const cookies = parseCookies(req);
|
|
const state = url.searchParams.get('state') || '';
|
|
const code = url.searchParams.get('code') || '';
|
|
if (!code || !state || cookies[STATE_COOKIE] !== state) return redirect(res, '/login');
|
|
|
|
const tokenRes = await fetch(`${ISSUER}/protocol/openid-connect/token`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: `${APP_BASE_URL}/callback`, client_id: CLIENT_ID, client_secret: CLIENT_SECRET })
|
|
});
|
|
const token = await tokenRes.json();
|
|
if (!tokenRes.ok || !token.access_token) return send(res, 502, 'Keycloak token exchange failed', 'text/plain; charset=utf-8');
|
|
|
|
const userRes = await fetch(`${ISSUER}/protocol/openid-connect/userinfo`, { headers: { Authorization: `Bearer ${token.access_token}` } });
|
|
const user = await userRes.json();
|
|
if (!userRes.ok || !user.sub) return send(res, 502, 'Keycloak userinfo failed', 'text/plain; charset=utf-8');
|
|
|
|
const ttl = Math.max(60, Math.min(Number(token.expires_in || 900), 3600));
|
|
setCookie(res, SESSION_COOKIE, makeSession(user, ttl), ttl);
|
|
setCookie(res, STATE_COOKIE, '', 0);
|
|
return redirect(res, '/');
|
|
}
|
|
|
|
if (url.pathname === '/logout') {
|
|
setCookie(res, SESSION_COOKIE, '', 0);
|
|
setCookie(res, STATE_COOKIE, '', 0);
|
|
return redirect(res, '/');
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/') {
|
|
const q = String(url.searchParams.get('q') || '').trim().slice(0, 80);
|
|
const tag = String(url.searchParams.get('tag') || '').trim().toLowerCase().slice(0, 40);
|
|
const where = ['published = TRUE'];
|
|
const values = [];
|
|
if (q) {
|
|
values.push(`%${q}%`);
|
|
where.push(`(title ILIKE $${values.length} OR summary ILIKE $${values.length})`);
|
|
}
|
|
if (tag) {
|
|
values.push(tag);
|
|
where.push(`$${values.length} = ANY(tags)`);
|
|
}
|
|
const result = await pool.query(`SELECT slug,title,summary,tags,servings,prep_minutes,cook_minutes,author_name,created_at FROM recipes WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT 60`, values);
|
|
const tagsResult = await pool.query(`SELECT tag, COUNT(*)::int AS count FROM recipes, unnest(tags) tag WHERE published = TRUE GROUP BY tag ORDER BY count DESC, tag LIMIT 14`);
|
|
const cards = result.rows.map(r => `<a class="card" href="/recipe/${encodeURIComponent(r.slug)}"><div class="card-top"></div><div class="card-body"><h3>${esc(r.title)}</h3><div class="summary">${esc(r.summary || 'A shared recipe waiting to be cooked.')}</div><div class="chips">${r.tags.slice(0,3).map(t => `<span class="chip">${esc(t)}</span>`).join('')}</div><div class="meta"><span>by ${esc(r.author_name)}</span><span>${r.prep_minutes + r.cook_minutes} min · ${r.servings} serves</span></div></div></a>`).join('');
|
|
const chips = tagsResult.rows.map(r => `<a class="chip" href="/?tag=${encodeURIComponent(r.tag)}">#${esc(r.tag)} <strong>${r.count}</strong></a>`).join('');
|
|
const body = `<section class="hero"><div><div class="eyebrow">Recipes worth passing on</div><h1>Cook it. Love it.<br>Share it.</h1><div class="lede">A simple home for recipes from people you know. No life story before the ingredients.</div></div><form class="search" method="get" action="/"><input name="q" value="${esc(q)}" placeholder="Search recipes…"><button>Search</button></form></section>${chips ? `<div class="chips">${chips}</div>` : ''}<section class="grid">${cards || `<div class="empty"><h3>No recipes yet</h3><p>${session ? 'Share the first one.' : 'Sign in and share the first one.'}</p>${session ? '<a class="button" href="/new">Add a recipe</a>' : '<a class="button" href="/login">Sign in</a>'}</div>`}</section>`;
|
|
return send(res, 200, page('Discover recipes', body, session));
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/new') {
|
|
if (!session) return redirect(res, '/login');
|
|
return send(res, 200, page('New recipe', `<div class="form-card"><div class="eyebrow">New recipe</div><h2>Share something good</h2><form method="post" action="/recipes">${recipeForm()}<div class="actions"><button>Publish recipe</button><a class="button secondary" href="/">Cancel</a></div></form></div>`, session));
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/recipes') {
|
|
if (!session) return redirect(res, '/login');
|
|
const form = await readForm(req);
|
|
const title = String(form.title || '').trim().slice(0,160);
|
|
const summary = String(form.summary || '').trim().slice(0,500);
|
|
const ingredients = String(form.ingredients || '').trim().slice(0,12000);
|
|
const instructions = String(form.instructions || '').trim().slice(0,20000);
|
|
if (!title || !ingredients || !instructions) return send(res, 400, 'Title, ingredients and method are required', 'text/plain; charset=utf-8');
|
|
const slug = slugify(title);
|
|
await pool.query(`INSERT INTO recipes (slug,title,summary,ingredients,instructions,tags,servings,prep_minutes,cook_minutes,published,created_by,author_name) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, [slug,title,summary,ingredients,instructions,normaliseTags(form.tags),clampInt(form.servings,1,50,4),clampInt(form.prep_minutes,0,1440,0),clampInt(form.cook_minutes,0,1440,0),form.published === '1',session.sub,session.name]);
|
|
return redirect(res, `/recipe/${encodeURIComponent(slug)}`);
|
|
}
|
|
|
|
if (req.method === 'GET' && url.pathname === '/mine') {
|
|
if (!session) return redirect(res, '/login');
|
|
const result = await pool.query(`SELECT slug,title,summary,tags,servings,prep_minutes,cook_minutes,published,updated_at FROM recipes WHERE created_by=$1 ORDER BY updated_at DESC`, [session.sub]);
|
|
const cards = result.rows.map(r => `<a class="card" href="/recipe/${encodeURIComponent(r.slug)}"><div class="card-top"></div><div class="card-body"><div><span class="badge ${r.published ? '' : 'private'}">${r.published ? 'Public' : 'Private'}</span></div><h3 style="margin-top:12px">${esc(r.title)}</h3><div class="summary">${esc(r.summary)}</div><div class="meta"><span>${r.prep_minutes + r.cook_minutes} min</span><span>Updated ${esc(new Date(r.updated_at).toLocaleDateString('en-NZ'))}</span></div></div></a>`).join('');
|
|
const body = `<div class="mine-head"><div><div class="eyebrow">Your kitchen</div><h1>My recipes</h1></div><a class="button" href="/new">+ New recipe</a></div><section class="grid">${cards || '<div class="empty">No recipes yet.</div>'}</section>`;
|
|
return send(res, 200, page('My recipes', body, session));
|
|
}
|
|
|
|
const editMatch = url.pathname.match(/^\/recipe\/([^/]+)\/edit$/);
|
|
if (req.method === 'GET' && editMatch) {
|
|
if (!session) return redirect(res, '/login');
|
|
const slug = decodeURIComponent(editMatch[1]);
|
|
const result = await pool.query('SELECT * FROM recipes WHERE slug=$1 AND created_by=$2', [slug, session.sub]);
|
|
const recipe = result.rows[0];
|
|
if (!recipe) return send(res, 404, 'Recipe not found', 'text/plain; charset=utf-8');
|
|
return send(res, 200, page(`Edit ${recipe.title}`, `<div class="form-card"><div class="eyebrow">Edit recipe</div><h2>${esc(recipe.title)}</h2><form method="post" action="/recipe/${encodeURIComponent(slug)}/update">${recipeForm(recipe)}<div class="actions"><button>Save changes</button><a class="button secondary" href="/recipe/${encodeURIComponent(slug)}">Cancel</a></div></form></div>`, session));
|
|
}
|
|
|
|
const updateMatch = url.pathname.match(/^\/recipe\/([^/]+)\/update$/);
|
|
if (req.method === 'POST' && updateMatch) {
|
|
if (!session) return redirect(res, '/login');
|
|
const slug = decodeURIComponent(updateMatch[1]);
|
|
const form = await readForm(req);
|
|
const title = String(form.title || '').trim().slice(0,160);
|
|
const summary = String(form.summary || '').trim().slice(0,500);
|
|
const ingredients = String(form.ingredients || '').trim().slice(0,12000);
|
|
const instructions = String(form.instructions || '').trim().slice(0,20000);
|
|
if (!title || !ingredients || !instructions) return send(res, 400, 'Title, ingredients and method are required', 'text/plain; charset=utf-8');
|
|
const result = await pool.query(`UPDATE recipes SET title=$1,summary=$2,ingredients=$3,instructions=$4,tags=$5,servings=$6,prep_minutes=$7,cook_minutes=$8,published=$9,updated_at=NOW() WHERE slug=$10 AND created_by=$11 RETURNING slug`, [title,summary,ingredients,instructions,normaliseTags(form.tags),clampInt(form.servings,1,50,4),clampInt(form.prep_minutes,0,1440,0),clampInt(form.cook_minutes,0,1440,0),form.published === '1',slug,session.sub]);
|
|
if (!result.rowCount) return send(res, 404, 'Recipe not found', 'text/plain; charset=utf-8');
|
|
return redirect(res, `/recipe/${encodeURIComponent(slug)}`);
|
|
}
|
|
|
|
const deleteMatch = url.pathname.match(/^\/recipe\/([^/]+)\/delete$/);
|
|
if (req.method === 'POST' && deleteMatch) {
|
|
if (!session) return redirect(res, '/login');
|
|
const slug = decodeURIComponent(deleteMatch[1]);
|
|
await pool.query('DELETE FROM recipes WHERE slug=$1 AND created_by=$2', [slug, session.sub]);
|
|
return redirect(res, '/mine');
|
|
}
|
|
|
|
const recipeMatch = url.pathname.match(/^\/recipe\/([^/]+)$/);
|
|
if (req.method === 'GET' && recipeMatch) {
|
|
const slug = decodeURIComponent(recipeMatch[1]);
|
|
const result = await pool.query('SELECT * FROM recipes WHERE slug=$1', [slug]);
|
|
const recipe = result.rows[0];
|
|
if (!recipe || (!recipe.published && session?.sub !== recipe.created_by)) return send(res, 404, 'Recipe not found', 'text/plain; charset=utf-8');
|
|
const owner = session?.sub === recipe.created_by;
|
|
const ingredientItems = recipe.ingredients.split(/\r?\n/).map(x => x.trim()).filter(Boolean).map(x => `<li>${esc(x)}</li>`).join('');
|
|
const tagLinks = recipe.tags.map(t => `<a class="chip" href="/?tag=${encodeURIComponent(t)}">#${esc(t)}</a>`).join('');
|
|
const ownerbar = owner ? `<div class="ownerbar"><a class="button secondary" href="/recipe/${encodeURIComponent(slug)}/edit">Edit</a><form method="post" action="/recipe/${encodeURIComponent(slug)}/delete" onsubmit="return confirm('Delete this recipe?')"><button class="danger">Delete</button></form></div>` : '';
|
|
const body = `<article class="recipe"><div class="recipe-head"><div><div class="eyebrow">Shared by ${esc(recipe.author_name)}</div><h1>${esc(recipe.title)}</h1><div class="recipe-sub">${esc(recipe.summary)}</div></div>${ownerbar}</div><div class="stats"><span class="stat">🍽 ${recipe.servings} servings</span><span class="stat">🔪 ${recipe.prep_minutes} min prep</span><span class="stat">🔥 ${recipe.cook_minutes} min cook</span>${recipe.published ? '<span class="badge">Public recipe</span>' : '<span class="badge private">Private</span>'}</div>${tagLinks ? `<div class="chips">${tagLinks}</div>` : ''}<div class="recipe-grid"><section class="recipe-section"><h2>Ingredients</h2><ul class="ingredients">${ingredientItems}</ul></section><section class="recipe-section"><h2>Method</h2><div class="steps">${nl2br(recipe.instructions)}</div></section></div></article>`;
|
|
return send(res, 200, page(recipe.title, body, session));
|
|
}
|
|
|
|
return send(res, 404, 'Not found', 'text/plain; charset=utf-8');
|
|
} catch (err) {
|
|
console.error(err);
|
|
return send(res, 500, 'Internal server error', 'text/plain; charset=utf-8');
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, '0.0.0.0', () => console.log(`arrowstack-cooking listening on :${PORT}`));
|