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
+191
View File
@@ -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);
});