diff --git a/server.js b/server.js new file mode 100644 index 0000000..a485cdf --- /dev/null +++ b/server.js @@ -0,0 +1,323 @@ +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', `
New recipe

Share something good

${recipeForm()}
Cancel
`, 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 = `
Your kitchen

My recipes

+ New recipe
${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}`, `
Edit recipe

${esc(recipe.title)}

${recipeForm(recipe)}
Cancel
`, 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 ? `
    Edit
    ` : ''; + 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}
    ` : ''}

    Ingredients

      ${ingredientItems}

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