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:
2026-09-17 17:43:03 +07:00
parent 608901bd0f
commit 8c6e7930db
57 changed files with 9359 additions and 2 deletions
+158
View File
@@ -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;
}