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, '
');
}
function nav(session) {
return ``;
}
function page(title, body, session, options = {}) {
return `
${esc(title)} · cooking
${nav(session)}${body}
`;
}
function recipeForm(recipe = {}) {
const tags = Array.isArray(recipe.tags) ? recipe.tags.join(', ') : '';
const published = recipe.published !== false;
return `
`;
}
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 => `${esc(r.title)}
${esc(r.summary || 'A shared recipe waiting to be cooked.')}
${r.tags.slice(0,3).map(t => `${esc(t)}`).join('')}
by ${esc(r.author_name)}${r.prep_minutes + r.cook_minutes} min · ${r.servings} serves
`).join('');
const chips = tagsResult.rows.map(r => `#${esc(r.tag)} ${r.count}`).join('');
const body = `Recipes worth passing on
Cook it. Love it.
Share it.
A simple home for recipes from people you know. No life story before the ingredients.
${chips ? `${chips}
` : ''}${cards || `No recipes yet
${session ? 'Share the first one.' : 'Sign in and share the first one.'}
${session ? '
Add a recipe' : '
Sign in'}
`}`;
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', ``, 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 => `${r.published ? 'Public' : 'Private'}
${esc(r.title)}
${esc(r.summary)}
${r.prep_minutes + r.cook_minutes} minUpdated ${esc(new Date(r.updated_at).toLocaleDateString('en-NZ'))}
`).join('');
const body = `${cards || 'No recipes yet.
'}`;
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}`, ``, 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 => `${esc(x)}`).join('');
const tagLinks = recipe.tags.map(t => `#${esc(t)}`).join('');
const ownerbar = owner ? `` : '';
const body = `Shared by ${esc(recipe.author_name)}
${esc(recipe.title)}
${esc(recipe.summary)}
${ownerbar}
🍽 ${recipe.servings} servings🔪 ${recipe.prep_minutes} min prep🔥 ${recipe.cook_minutes} min cook${recipe.published ? 'Public recipe' : 'Private'}
${tagLinks ? `${tagLinks}
` : ''}Method
${nl2br(recipe.instructions)}
`;
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}`));