Add self-contained docker/ stack for the web UI
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export const SESSION_COOKIE = 'rc_session';
|
||||
export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days
|
||||
export const MAX_RECIPE_BYTES = 256 * 1024;
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
export const db = new Database(join(DATA_DIR, 'recipescam.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
|
||||
`);
|
||||
|
||||
export type User = { id: number; email: string };
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
name: string;
|
||||
recipe: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// scrypt: per-user random salt, stored as "salt:hash" (hex).
|
||||
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 } as const;
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, SCRYPT.keylen, SCRYPT).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const expected = Buffer.from(hash, 'hex');
|
||||
const actual = scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
// Burned on unknown-email logins so response time does not leak account existence.
|
||||
export const DUMMY_HASH = hashPassword('invalid-password-placeholder');
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
export function createUser(email: string, password: string): User | null {
|
||||
try {
|
||||
const info = db
|
||||
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
|
||||
.run(email, hashPassword(password), now());
|
||||
return { id: Number(info.lastInsertRowid), email };
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
|
||||
return db
|
||||
.prepare('SELECT id, email, password_hash FROM users WHERE email = ?')
|
||||
.get(email) as (User & { password_hash: string }) | undefined;
|
||||
}
|
||||
|
||||
export function findUserById(id: number): User | undefined {
|
||||
return db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
export function createSession(userId: number): string {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_S * 1000).toISOString();
|
||||
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(
|
||||
token,
|
||||
userId,
|
||||
expiresAt,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function sessionUser(token: string): User | undefined {
|
||||
const row = db
|
||||
.prepare('SELECT token, user_id AS userId, expires_at AS expiresAt FROM sessions WHERE token = ?')
|
||||
.get(token) as { token: string; userId: number; expiresAt: string } | undefined;
|
||||
if (!row) return undefined;
|
||||
if (row.expiresAt <= now()) {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(row.token); // lazy cleanup
|
||||
return undefined;
|
||||
}
|
||||
return findUserById(row.userId);
|
||||
}
|
||||
|
||||
export function deleteSession(token: string): void {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
}
|
||||
|
||||
export function listRecipes(userId: number): Recipe[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT id, name, json, created_at AS createdAt, updated_at AS updatedAt FROM recipes WHERE user_id = ? ORDER BY updated_at DESC, id DESC',
|
||||
)
|
||||
.all(userId) as { id: number; name: string; json: string; createdAt: string; updatedAt: string }[];
|
||||
return rows.map((r) => ({ id: r.id, name: r.name, recipe: JSON.parse(r.json), createdAt: r.createdAt, updatedAt: r.updatedAt }));
|
||||
}
|
||||
|
||||
export function getRecipe(userId: number, id: number): Recipe | undefined {
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT id, name, json, created_at AS createdAt, updated_at AS updatedAt FROM recipes WHERE id = ? AND user_id = ?',
|
||||
)
|
||||
.get(id, userId) as { id: number; name: string; json: string; createdAt: string; updatedAt: string } | undefined;
|
||||
return row && { id: row.id, name: row.name, recipe: JSON.parse(row.json), createdAt: row.createdAt, updatedAt: row.updatedAt };
|
||||
}
|
||||
|
||||
export function createRecipe(userId: number, name: string, recipe: unknown): Recipe {
|
||||
const ts = now();
|
||||
const info = db
|
||||
.prepare('INSERT INTO recipes (user_id, name, json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(userId, name, JSON.stringify(recipe), ts, ts);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
return { id, name, recipe, createdAt: ts, updatedAt: ts };
|
||||
}
|
||||
|
||||
export function updateRecipe(userId: number, id: number, name: string, recipe: unknown): Recipe | undefined {
|
||||
const ts = now();
|
||||
const info = db
|
||||
.prepare('UPDATE recipes SET name = ?, json = ?, updated_at = ? WHERE id = ? AND user_id = ?')
|
||||
.run(name, JSON.stringify(recipe), ts, id, userId);
|
||||
if (info.changes === 0) return undefined;
|
||||
return getRecipe(userId, id);
|
||||
}
|
||||
|
||||
export function deleteRecipe(userId: number, id: number): boolean {
|
||||
return db.prepare('DELETE FROM recipes WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import {
|
||||
MAX_RECIPE_BYTES,
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE_S,
|
||||
DUMMY_HASH,
|
||||
createRecipe,
|
||||
createSession,
|
||||
createUser,
|
||||
deleteRecipe,
|
||||
deleteSession,
|
||||
findUserByEmail,
|
||||
listRecipes,
|
||||
sessionUser,
|
||||
updateRecipe,
|
||||
verifyPassword,
|
||||
type Recipe,
|
||||
type User,
|
||||
} from './db';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const HOST = '0.0.0.0';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const MAX_EMAIL = 254;
|
||||
const MIN_PASSWORD = 8;
|
||||
const MAX_PASSWORD = 200;
|
||||
const MAX_NAME = 120;
|
||||
|
||||
const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 });
|
||||
|
||||
// Bodyless DELETE/logout requests still often carry Content-Type: application/json.
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const raw = (body as string).trim();
|
||||
if (raw === '') return done(null, undefined);
|
||||
try {
|
||||
done(null, JSON.parse(raw));
|
||||
} catch {
|
||||
done(Object.assign(new Error('invalid JSON body'), { statusCode: 400 }));
|
||||
}
|
||||
});
|
||||
|
||||
// Single error shape for the whole API: { error: "..." }
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
const e = err as { statusCode?: number; message?: string };
|
||||
const status = e.statusCode && e.statusCode >= 400 ? e.statusCode : 500;
|
||||
if (status >= 500) req.log.error(err);
|
||||
reply.status(status).send({ error: status >= 500 ? 'internal_error' : (e.message ?? 'error') });
|
||||
});
|
||||
app.setNotFoundHandler((_req, reply) => reply.status(404).send({ error: 'not_found' }));
|
||||
|
||||
// ---- cookie helpers (hand-rolled: only one cookie, no plugin needed) ----
|
||||
function cookieOf(req: FastifyRequest, name: string): string | undefined {
|
||||
const raw = req.headers.cookie;
|
||||
if (!raw) return undefined;
|
||||
for (const part of raw.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setSession(reply: FastifyReply, token: string): void {
|
||||
reply.header(
|
||||
'set-cookie',
|
||||
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}`,
|
||||
);
|
||||
}
|
||||
|
||||
function clearSession(reply: FastifyReply): void {
|
||||
reply.header('set-cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
||||
}
|
||||
|
||||
// ---- validation at the trust boundary ----
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
function bodyOf(req: FastifyRequest): Json | undefined {
|
||||
const b = req.body as unknown;
|
||||
return b !== null && typeof b === 'object' && !Array.isArray(b) ? (b as Json) : undefined;
|
||||
}
|
||||
|
||||
function credentials(b: Json): { email: string; password: string } | string {
|
||||
const email = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
|
||||
const password = typeof b.password === 'string' ? b.password : '';
|
||||
if (!email || email.length > MAX_EMAIL || !EMAIL_RE.test(email)) return 'invalid email';
|
||||
if (password.length < MIN_PASSWORD || password.length > MAX_PASSWORD)
|
||||
return `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters`;
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
function recipePayload(b: Json): { name: string; recipe: Json } | string {
|
||||
const name = typeof b.name === 'string' ? b.name.trim() : '';
|
||||
const recipe = b.recipe;
|
||||
if (!name || name.length > MAX_NAME) return `name must be 1-${MAX_NAME} characters`;
|
||||
if (recipe === null || typeof recipe !== 'object' || Array.isArray(recipe)) return 'recipe must be an object';
|
||||
if (Buffer.byteLength(JSON.stringify(recipe)) > MAX_RECIPE_BYTES) return 'recipe too large';
|
||||
return { name, recipe: recipe as Json };
|
||||
}
|
||||
|
||||
function auth(req: FastifyRequest): User | undefined {
|
||||
const token = cookieOf(req, SESSION_COOKIE);
|
||||
return token ? sessionUser(token) : undefined;
|
||||
}
|
||||
|
||||
// ---- routes ----
|
||||
app.get('/api/health', async () => ({ ok: true }));
|
||||
|
||||
app.post('/api/auth/signup', async (req, reply) => {
|
||||
const b = bodyOf(req);
|
||||
if (!b) return reply.status(400).send({ error: 'invalid body' });
|
||||
const creds = credentials(b);
|
||||
if (typeof creds === 'string') return reply.status(400).send({ error: creds });
|
||||
if (findUserByEmail(creds.email)) return reply.status(409).send({ error: 'email already registered' });
|
||||
const user = createUser(creds.email, creds.password);
|
||||
if (!user) return reply.status(409).send({ error: 'email already registered' });
|
||||
setSession(reply, createSession(user.id));
|
||||
return reply.status(201).send({ user });
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, reply) => {
|
||||
const b = bodyOf(req);
|
||||
if (!b || typeof b.email !== 'string' || typeof b.password !== 'string')
|
||||
return reply.status(400).send({ error: 'invalid body' });
|
||||
const email = b.email.trim().toLowerCase();
|
||||
const row = findUserByEmail(email);
|
||||
const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH);
|
||||
if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' });
|
||||
setSession(reply, createSession(row.id));
|
||||
return reply.status(200).send({ user: { id: row.id, email: row.email } });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
const token = cookieOf(req, SESSION_COOKIE);
|
||||
if (token) deleteSession(token);
|
||||
clearSession(reply);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', async (req, reply) => {
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
return reply.status(200).send({ user });
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (req, reply) => {
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
return reply.status(200).send({ recipes: listRecipes(user.id) });
|
||||
});
|
||||
|
||||
app.post('/api/recipes', async (req, reply) => {
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
const b = bodyOf(req);
|
||||
const payload = b && recipePayload(b);
|
||||
if (typeof payload === 'string' || !payload)
|
||||
return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' });
|
||||
const recipe: Recipe = createRecipe(user.id, payload.name, payload.recipe);
|
||||
return reply.status(201).send({ recipe });
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
|
||||
const b = bodyOf(req);
|
||||
const payload = b && recipePayload(b);
|
||||
if (typeof payload === 'string' || !payload)
|
||||
return reply.status(payload === 'recipe too large' ? 413 : 400).send({ error: payload ?? 'invalid body' });
|
||||
const recipe = updateRecipe(user.id, id, payload.name, payload.recipe);
|
||||
if (!recipe) return reply.status(404).send({ error: 'recipe not found' });
|
||||
return reply.status(200).send({ recipe });
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
|
||||
const user = auth(req);
|
||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
|
||||
if (!deleteRecipe(user.id, id)) return reply.status(404).send({ error: 'recipe not found' });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app
|
||||
.listen({ port: PORT, host: HOST })
|
||||
.catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user