72d5ce1c3e
Opening one of the folder's own photos and hitting SAVE PHOTO used to make a second copy of it. Now it replaces that row — same id, same place — and the look the row carried steps into its history, newest first and capped at three, because the pixels it described are gone. The frame's own column in MY PHOTOS lists those looks (click one to put its settings back on the stage) and carries the landing-page consent as a plain tick, which answers the click at once. A file from the disk clears the open id, so a fresh frame still adds one.
747 lines
27 KiB
TypeScript
747 lines
27 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 TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY,
|
|
at TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
path TEXT NOT NULL,
|
|
target TEXT,
|
|
visitor TEXT NOT NULL,
|
|
user_id INTEGER,
|
|
country TEXT,
|
|
region TEXT,
|
|
city TEXT,
|
|
browser TEXT,
|
|
os TEXT,
|
|
device TEXT
|
|
);
|
|
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);
|
|
CREATE INDEX IF NOT EXISTS idx_events_at ON events(at);
|
|
`);
|
|
|
|
// 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`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The saved-photo flow, added later still:
|
|
// recipe — the look that made these pixels (same JSON a recipe row holds), so
|
|
// the studio can open the photo again and keep editing it.
|
|
// consent — the uploader's own say over the landing strip. The params live in
|
|
// this row, never burned into the image.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'consent')) {
|
|
db.exec(`ALTER TABLE photos ADD COLUMN consent INTEGER NOT NULL DEFAULT 1`);
|
|
}
|
|
if (!cols.some((c) => c.name === 'recipe')) {
|
|
db.exec(`ALTER TABLE photos ADD COLUMN recipe TEXT`);
|
|
}
|
|
}
|
|
|
|
// The photo's own edit history: the looks it carried before the last few saves,
|
|
// newest first, capped at PHOTO_HISTORY_MAX. Re-saving an open photo replaces
|
|
// its pixels and its recipe, so the previous recipe is the only way back — a
|
|
// short JSON array is enough of a log for that.
|
|
export const PHOTO_HISTORY_MAX = 3;
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'history')) {
|
|
db.exec(`ALTER TABLE photos ADD COLUMN history 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;
|
|
// The uploader's permission for this photo to appear on the landing strip.
|
|
// The owner's own folder reads it back to draw the toggle.
|
|
consent: boolean;
|
|
};
|
|
// The owner's own row adds the look that made it, so it can be opened again,
|
|
// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX.
|
|
export type MyPhoto = Photo & { recipe: unknown | null; history: unknown[] };
|
|
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
|
|
export type PhotoMeta = {
|
|
tag?: string | null;
|
|
title?: string | null;
|
|
meta?: string | null;
|
|
recipe?: unknown;
|
|
consent?: boolean;
|
|
};
|
|
|
|
// One SELECT list, so the 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,
|
|
photos.consent AS consent`;
|
|
|
|
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
|
|
type PhotoRow = Omit<Photo, 'consent'> & { consent: number };
|
|
type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
|
|
const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 });
|
|
// A row whose JSON will not parse is still a photo: its settings are simply
|
|
// gone, not worth failing the whole folder over. Same for one bad entry in the
|
|
// history — the rest of the list still stands.
|
|
const parseJson = (raw: string | null): unknown => {
|
|
try {
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const toMyPhoto = (row: MyPhotoRow): MyPhoto => {
|
|
const history = parseJson(row.history);
|
|
return {
|
|
...toPhoto(row),
|
|
recipe: parseJson(row.recipe),
|
|
history: Array.isArray(history) ? history : [],
|
|
};
|
|
};
|
|
|
|
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 PhotoRow[]
|
|
).map(toPhoto);
|
|
}
|
|
|
|
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 (PhotoRow & { userId: number; email: string; mime: string; bytes: number })[]
|
|
).map((row) => ({ ...toPhoto(row), userId: row.userId, email: row.email, mime: row.mime, bytes: row.bytes }));
|
|
}
|
|
|
|
// A member's own folder, newest first. No JOIN: the owner is the caller. This
|
|
// is the one listing that carries `recipe` — the look to reopen the photo with.
|
|
export function listPhotosByUser(userId: number): MyPhoto[] {
|
|
return (
|
|
db
|
|
.prepare(
|
|
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history
|
|
FROM photos WHERE user_id = ? ORDER BY photos.id DESC`,
|
|
)
|
|
.all(userId) as MyPhotoRow[]
|
|
).map(toMyPhoto);
|
|
}
|
|
|
|
// 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, consent, recipe)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
userId,
|
|
file,
|
|
mime,
|
|
bytes,
|
|
ts,
|
|
meta?.tag ?? null,
|
|
meta?.title ?? null,
|
|
meta?.meta ?? null,
|
|
meta?.consent === false ? 0 : 1,
|
|
meta?.recipe === undefined ? null : JSON.stringify(meta.recipe),
|
|
);
|
|
// 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,
|
|
consent: meta?.consent !== false,
|
|
};
|
|
}
|
|
|
|
// Re-saving a photo the caller already owns: the pixels are replaced in place
|
|
// and the look the row carried steps into its history, newest first and capped
|
|
// — the only way back to it, since the bytes it described are gone. A row that
|
|
// is not theirs, or not there, comes back undefined.
|
|
export function replacePhoto(
|
|
userId: number,
|
|
id: number,
|
|
file: string,
|
|
mime: string,
|
|
bytes: number,
|
|
meta?: PhotoMeta,
|
|
): MyPhoto | undefined {
|
|
const row = db
|
|
.prepare(
|
|
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history
|
|
FROM photos WHERE id = ? AND user_id = ?`,
|
|
)
|
|
.get(id, userId) as MyPhotoRow | undefined;
|
|
if (!row) return undefined;
|
|
const before = toMyPhoto(row);
|
|
// Only a save that carried a new look is a version of anything, and the log
|
|
// is capped on the way in so the column can never grow past it.
|
|
const history =
|
|
meta?.recipe === undefined || before.recipe === null
|
|
? before.history
|
|
: [before.recipe, ...before.history].slice(0, PHOTO_HISTORY_MAX);
|
|
db.prepare(
|
|
`UPDATE photos
|
|
SET file = ?, mime = ?, bytes = ?, tag = ?, title = ?, meta = ?, consent = ?, recipe = ?, history = ?
|
|
WHERE id = ?`,
|
|
).run(
|
|
file,
|
|
mime,
|
|
bytes,
|
|
meta?.tag ?? null,
|
|
meta?.title ?? null,
|
|
meta?.meta ?? null,
|
|
meta?.consent === false ? 0 : 1,
|
|
meta?.recipe === undefined ? JSON.stringify(before.recipe) : JSON.stringify(meta.recipe),
|
|
JSON.stringify(history),
|
|
id,
|
|
);
|
|
return toMyPhoto(
|
|
db
|
|
.prepare(
|
|
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe, photos.history AS history FROM photos WHERE id = ?`,
|
|
)
|
|
.get(id) as MyPhotoRow,
|
|
);
|
|
}
|
|
|
|
// The uploader's own toggle: may this photo show on the landing strip?
|
|
export function setPhotoConsent(userId: number, id: number, consent: boolean): boolean {
|
|
return (
|
|
db.prepare('UPDATE photos SET consent = ? WHERE id = ? AND user_id = ?').run(consent ? 1 : 0, id, userId)
|
|
.changes > 0
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// --- analytics -------------------------------------------------------------
|
|
// One row per page view or feature click. Nothing that identifies a visitor is
|
|
// stored: the address is turned into a salted hash (enough to count uniques)
|
|
// and into a coarse place at insert time, then dropped. See `createEvent`.
|
|
export type EventKind = 'view' | 'click';
|
|
|
|
export interface EventInput {
|
|
kind: EventKind;
|
|
path: string;
|
|
target: string | null;
|
|
visitor: string;
|
|
userId: number | null;
|
|
country: string | null;
|
|
region: string | null;
|
|
city: string | null;
|
|
browser: string | null;
|
|
os: string | null;
|
|
device: string | null;
|
|
}
|
|
|
|
export function createEvent(e: EventInput): void {
|
|
db.prepare(
|
|
`INSERT INTO events (at, kind, path, target, visitor, user_id, country, region, city, browser, os, device)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
new Date().toISOString(),
|
|
e.kind,
|
|
e.path,
|
|
e.target,
|
|
e.visitor,
|
|
e.userId,
|
|
e.country,
|
|
e.region,
|
|
e.city,
|
|
e.browser,
|
|
e.os,
|
|
e.device,
|
|
);
|
|
}
|
|
|
|
// One grouped count, in the shape every chart on the stats page wants.
|
|
export interface EventBucket {
|
|
key: string;
|
|
n: number;
|
|
}
|
|
|
|
export interface EventStats {
|
|
days: number;
|
|
totals: { views: number; clicks: number; visitors: number };
|
|
series: { date: string; views: number; clicks: number }[];
|
|
pages: EventBucket[];
|
|
targets: EventBucket[];
|
|
countries: EventBucket[];
|
|
regions: EventBucket[];
|
|
cities: EventBucket[];
|
|
browsers: EventBucket[];
|
|
systems: EventBucket[];
|
|
devices: EventBucket[];
|
|
}
|
|
|
|
// The columns a group-by may name. An allowlist, not interpolation: the value
|
|
// reaches a SQL string, so it must never come from the request unchecked.
|
|
const BUCKET_COLUMN = {
|
|
pages: 'path',
|
|
targets: 'target',
|
|
countries: 'country',
|
|
regions: 'region',
|
|
cities: 'city',
|
|
browsers: 'browser',
|
|
systems: 'os',
|
|
devices: 'device',
|
|
} as const;
|
|
|
|
export function eventStats(days: number, limit = 12): EventStats {
|
|
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
const grouped = (column: string, kind: EventKind | null): EventBucket[] =>
|
|
db
|
|
.prepare(
|
|
`SELECT ${column} AS key, COUNT(*) AS n FROM events
|
|
WHERE at >= ? AND ${column} IS NOT NULL AND ${column} <> ''
|
|
AND (? IS NULL OR kind = ?)
|
|
GROUP BY ${column} ORDER BY n DESC, key ASC LIMIT ?`,
|
|
)
|
|
.all(since, kind, kind, limit) as EventBucket[];
|
|
|
|
const totals = db
|
|
.prepare(
|
|
`SELECT
|
|
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
|
|
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks,
|
|
COUNT(DISTINCT visitor) AS visitors
|
|
FROM events WHERE at >= ?`,
|
|
)
|
|
.get(since) as { views: number | null; clicks: number | null; visitors: number };
|
|
|
|
// One point per calendar day (UTC), including days with no traffic, so the
|
|
// chart's x-axis is a real timeline and not just the days that had hits.
|
|
const seen = new Map<string, { date: string; views: number; clicks: number }>();
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
const date = new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10);
|
|
seen.set(date, { date, views: 0, clicks: 0 });
|
|
}
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT substr(at, 1, 10) AS date,
|
|
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
|
|
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks
|
|
FROM events WHERE at >= ? GROUP BY date`,
|
|
)
|
|
.all(since) as { date: string; views: number; clicks: number }[];
|
|
for (const row of rows) if (seen.has(row.date)) seen.set(row.date, row);
|
|
|
|
return {
|
|
days,
|
|
totals: { views: totals.views ?? 0, clicks: totals.clicks ?? 0, visitors: totals.visitors },
|
|
series: [...seen.values()],
|
|
pages: grouped(BUCKET_COLUMN.pages, 'view'),
|
|
targets: grouped(BUCKET_COLUMN.targets, 'click'),
|
|
countries: grouped(BUCKET_COLUMN.countries, 'view'),
|
|
regions: grouped(BUCKET_COLUMN.regions, 'view'),
|
|
cities: grouped(BUCKET_COLUMN.cities, 'view'),
|
|
browsers: grouped(BUCKET_COLUMN.browsers, 'view'),
|
|
systems: grouped(BUCKET_COLUMN.systems, 'view'),
|
|
devices: grouped(BUCKET_COLUMN.devices, 'view'),
|
|
};
|
|
}
|