b4d5d2926b
Every member gets /photos — their own uploads, counted against a 12-photo cap, each card showing the tagline and the technical line the studio would print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full resolution look, stores the strip (tag/title/meta) with the upload so the landing reel frames it the same way, and refuses past the cap. EXPORT now burns that strip into the file: the amber #TAG over the photo's top-left plus a dark caption band below carrying the recipe name and the ISO / grain / warmth line. The live preview stays clean, and the saved upload stays clean too — the reel draws its own frame from the stored labels, so a burned band would tag the tag twice. Admins manage any photo through DELETE /api/photos/:id; members only their own. The users table's photo counts stay in step with the folder.
459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
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;
|
|
// A phone's 12MP JPEG lands around 4-8MB, so 3MB rejected real photos with a
|
|
// 413. The client downscales to 2048px before uploading (see shrinkForUpload),
|
|
// which keeps normal uploads well under this; the cap stays generous for a
|
|
// full-size PNG or a photo that arrived from elsewhere. Under nginx's
|
|
// `client_max_body_size 16m`, so an over-limit upload is still rejected with
|
|
// our JSON error instead of nginx's HTML 413.
|
|
export const MAX_PHOTO_BYTES = 12 * 1024 * 1024;
|
|
export const MAX_PHOTOS_PER_USER = 12;
|
|
|
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
// Uploaded originals. Filenames are server-generated hex — a user filename
|
|
// never reaches the filesystem, so there is no traversal or collision surface.
|
|
const UPLOAD_DIR = join(DATA_DIR, 'uploads');
|
|
mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
export const photoPath = (file: string) => join(UPLOAD_DIR, file);
|
|
|
|
// Profile pictures, one per account, named the same way.
|
|
const AVATAR_DIR = join(DATA_DIR, 'avatars');
|
|
mkdirSync(AVATAR_DIR, { recursive: true });
|
|
export const avatarPath = (file: string) => join(AVATAR_DIR, file);
|
|
|
|
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 TABLE IF NOT EXISTS photos (
|
|
id INTEGER PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
file TEXT NOT NULL,
|
|
mime TEXT NOT NULL,
|
|
bytes INTEGER NOT NULL,
|
|
created_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);
|
|
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
|
|
`);
|
|
|
|
// Where a curated photo is allowed to appear on the landing page: the community
|
|
// strip, the live tester's preview, the creator lab's preview, or the QR card.
|
|
// One is picked at random out of its slot on every page load.
|
|
export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const;
|
|
export type PhotoSlot = (typeof PHOTO_SLOTS)[number];
|
|
export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
|
|
typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v);
|
|
|
|
// The column arrived after the first strips were already on disk, so add it in
|
|
// place — `CREATE TABLE IF NOT EXISTS` would silently skip an existing table.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'slot')) {
|
|
db.exec(`ALTER TABLE photos ADD COLUMN slot TEXT NOT NULL DEFAULT 'strip'`);
|
|
}
|
|
}
|
|
|
|
// The avatar column arrived after the first accounts did, same as photos.slot.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'avatar')) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`);
|
|
}
|
|
}
|
|
|
|
// Moderation state, added after the first accounts existed:
|
|
// blocked — may not sign in (or stay signed in); the row is kept whole.
|
|
// deleted_at — "removed" from the site: hidden from the strip, cannot sign
|
|
// in, but restorable. A hard DELETE is the separate, final act.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'blocked')) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`);
|
|
}
|
|
if (!cols.some((c) => c.name === 'deleted_at')) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN deleted_at TEXT`);
|
|
}
|
|
}
|
|
|
|
// The strip's own labels, added after the first contributions were on disk: the
|
|
// tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title
|
|
// and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are
|
|
// optional and length-capped by the route that accepts them.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
|
for (const name of ['tag', 'title', 'meta']) {
|
|
if (!cols.some((c) => c.name === name)) {
|
|
db.exec(`ALTER TABLE photos ADD COLUMN ${name} TEXT`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// `avatar` is the stored file name, or null for "no picture".
|
|
export type User = {
|
|
id: number;
|
|
email: string;
|
|
avatar: string | null;
|
|
blocked: number;
|
|
deletedAt: string | null;
|
|
};
|
|
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, avatar: null, blocked: 0, deletedAt: null };
|
|
} 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, avatar, blocked, deleted_at AS deletedAt, 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, avatar, blocked, deleted_at AS deletedAt FROM users WHERE id = ?')
|
|
.get(id) as User | undefined;
|
|
}
|
|
|
|
// Swaps the picture and hands back the file it replaced, so the caller can
|
|
// unlink it — the row is the only index of what is on disk.
|
|
export function setUserAvatar(id: number, file: string): string | null {
|
|
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
|
|
if (!row) return null;
|
|
db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(file, id);
|
|
return row.avatar;
|
|
}
|
|
|
|
// Avatars are public by nature — they sit next to a name — so this is not
|
|
// session-gated. It returns only the row's own file name, never a client path.
|
|
export function userAvatar(id: number): string | undefined {
|
|
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
|
|
return row?.avatar ?? 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;
|
|
}
|
|
const user = findUserById(row.userId);
|
|
// Belt to the braces of the session sweep in setUserBlocked/setUserRemoved.
|
|
if (!user || user.blocked || user.deletedAt) return undefined;
|
|
return user;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ---- contributed strip photos -------------------------------------------
|
|
// The public shape carries no owner: the landing page is anonymous, so the
|
|
// uploader's email must never be reachable from an unauthenticated request.
|
|
// `tag`/`title`/`meta` are the frame's own labels (see the migration above).
|
|
export type Photo = {
|
|
id: number;
|
|
createdAt: string;
|
|
slot: PhotoSlot;
|
|
tag: string | null;
|
|
title: string | null;
|
|
meta: string | null;
|
|
};
|
|
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
|
|
export type PhotoMeta = { tag?: string | null; title?: string | null; meta?: string | null };
|
|
|
|
// One SELECT list, so the four call sites cannot drift apart.
|
|
const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
|
|
photos.tag AS tag, photos.title AS title, photos.meta AS meta`;
|
|
|
|
export function listPhotos(): Photo[] {
|
|
return db
|
|
.prepare(
|
|
`SELECT ${PHOTO_COLUMNS}
|
|
FROM photos JOIN users ON users.id = photos.user_id
|
|
WHERE users.deleted_at IS NULL
|
|
ORDER BY photos.id DESC`,
|
|
)
|
|
.all() as Photo[];
|
|
}
|
|
|
|
export function listPhotosWithOwner(): AdminPhoto[] {
|
|
return db
|
|
.prepare(
|
|
`SELECT ${PHOTO_COLUMNS},
|
|
photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes,
|
|
users.email AS email
|
|
FROM photos JOIN users ON users.id = photos.user_id
|
|
ORDER BY photos.id DESC`,
|
|
)
|
|
.all() as AdminPhoto[];
|
|
}
|
|
|
|
// A member's own folder, newest first. No JOIN: the owner is the caller.
|
|
export function listPhotosByUser(userId: number): Photo[] {
|
|
return db
|
|
.prepare(`SELECT ${PHOTO_COLUMNS} FROM photos WHERE user_id = ? ORDER BY photos.id DESC`)
|
|
.all(userId) as Photo[];
|
|
}
|
|
|
|
// Admin listing: one row per account with how many photos it owns. Blocked and
|
|
// removed accounts stay listed — a removed one has to be findable to restore it.
|
|
export type AdminUser = {
|
|
id: number;
|
|
email: string;
|
|
createdAt: string;
|
|
photos: number;
|
|
avatar: string | null;
|
|
blocked: number;
|
|
deletedAt: string | null;
|
|
};
|
|
|
|
export function listUsersWithCounts(): AdminUser[] {
|
|
return db
|
|
.prepare(
|
|
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
|
|
users.avatar AS avatar, users.blocked AS blocked,
|
|
users.deleted_at AS deletedAt, COUNT(photos.id) AS photos
|
|
FROM users LEFT JOIN photos ON photos.user_id = users.id
|
|
GROUP BY users.id
|
|
ORDER BY users.id`,
|
|
)
|
|
.all() as AdminUser[];
|
|
}
|
|
|
|
// ---- moderation -------------------------------------------------------------
|
|
// Blocking and removing both drop the account's live sessions: the state has to
|
|
// take effect on the next request, not whenever the cookie happens to expire.
|
|
export function setUserBlocked(id: number, blocked: boolean): boolean {
|
|
const info = db.prepare('UPDATE users SET blocked = ? WHERE id = ?').run(blocked ? 1 : 0, id);
|
|
if (info.changes > 0 && blocked) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
|
return info.changes > 0;
|
|
}
|
|
|
|
export function setUserRemoved(id: number, removed: boolean): boolean {
|
|
const info = db
|
|
.prepare('UPDATE users SET deleted_at = ? WHERE id = ?')
|
|
.run(removed ? now() : null, id);
|
|
if (info.changes > 0 && removed) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
|
return info.changes > 0;
|
|
}
|
|
|
|
// The final act: the row and everything hanging off it. Returns the files the
|
|
// caller has to unlink — the rows are the only index of what is on disk.
|
|
export function deleteUser(id: number): { photos: string[]; avatar: string | null } | undefined {
|
|
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as
|
|
| { avatar: string | null }
|
|
| undefined;
|
|
if (!row) return undefined;
|
|
const photos = (db.prepare('SELECT file FROM photos WHERE user_id = ?').all(id) as { file: string }[]).map(
|
|
(r) => r.file,
|
|
);
|
|
if (db.prepare('DELETE FROM users WHERE id = ?').run(id).changes === 0) return undefined;
|
|
db.prepare('DELETE FROM photos WHERE user_id = ?').run(id);
|
|
db.prepare('DELETE FROM recipes WHERE user_id = ?').run(id);
|
|
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
|
return { photos, avatar: row.avatar };
|
|
}
|
|
|
|
// Profile edits. The email column is UNIQUE, so a taken address comes back as
|
|
// false rather than a thrown constraint; the password uses the same hash the
|
|
// sign-up path writes.
|
|
export function updateUserEmail(id: number, email: string): boolean {
|
|
try {
|
|
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
|
|
return true;
|
|
} catch (err) {
|
|
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return false;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function setUserPassword(id: number, password: string): void {
|
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
|
|
}
|
|
|
|
export function countPhotos(userId: number): number {
|
|
return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n;
|
|
}
|
|
|
|
export function createPhoto(
|
|
userId: number,
|
|
file: string,
|
|
mime: string,
|
|
bytes: number,
|
|
meta?: PhotoMeta,
|
|
): Photo {
|
|
const ts = now();
|
|
const info = db
|
|
.prepare(
|
|
'INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
)
|
|
.run(userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null);
|
|
// A fresh upload is a strip photo until the curator moves it to a live slot.
|
|
return {
|
|
id: Number(info.lastInsertRowid),
|
|
createdAt: ts,
|
|
slot: 'strip',
|
|
tag: meta?.tag ?? null,
|
|
title: meta?.title ?? null,
|
|
meta: meta?.meta ?? null,
|
|
};
|
|
}
|
|
|
|
// The stored file name is only ever used through here, and callers must still
|
|
// reject anything that is not a single path segment (see server.ts).
|
|
export function photoFile(id: number): { file: string; mime: string } | undefined {
|
|
return db.prepare('SELECT file, mime FROM photos WHERE id = ?').get(id) as
|
|
| { file: string; mime: string }
|
|
| undefined;
|
|
}
|
|
|
|
export function deletePhoto(id: number): string | undefined {
|
|
const row = db.prepare('SELECT file FROM photos WHERE id = ?').get(id) as { file: string } | undefined;
|
|
if (!row) return undefined;
|
|
db.prepare('DELETE FROM photos WHERE id = ?').run(id);
|
|
return row.file;
|
|
}
|
|
|
|
// The owner's own delete: the user_id in the WHERE is the whole authorisation,
|
|
// so a member can never name someone else's row.
|
|
export function deletePhotoOf(userId: number, id: number): string | undefined {
|
|
const row = db.prepare('SELECT file FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as
|
|
| { file: string }
|
|
| undefined;
|
|
if (!row) return undefined;
|
|
db.prepare('DELETE FROM photos WHERE id = ? AND user_id = ?').run(id, userId);
|
|
return row.file;
|
|
}
|
|
|
|
// Curating, not moderating: where this photo is allowed to surface.
|
|
export function setPhotoSlot(id: number, slot: PhotoSlot): boolean {
|
|
return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0;
|
|
}
|
|
|
|
export function deleteAllPhotos(): string[] {
|
|
const files = (db.prepare('SELECT file FROM photos').all() as { file: string }[]).map((r) => r.file);
|
|
db.prepare('DELETE FROM photos').run();
|
|
return files;
|
|
}
|