1054 lines
42 KiB
TypeScript
1054 lines
42 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { randomBytes, randomInt, 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;
|
|
|
|
// Everything the deployment owns lives here: the SQLite file and the two media
|
|
// folders. Exported because the backup/restore routes walk the same root.
|
|
export 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 TABLE IF NOT EXISTS ratings (
|
|
key TEXT NOT NULL,
|
|
visitor TEXT NOT NULL,
|
|
stars INTEGER NOT NULL,
|
|
at TEXT NOT NULL,
|
|
PRIMARY KEY (key, visitor)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS email_verifications (
|
|
token TEXT PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
-- The six digits the same letter carries, so a visitor can prove the address
|
|
-- without leaving the page they signed up on. NULL for a row minted before
|
|
-- codes existed; attempts counts only the guesses at it.
|
|
code TEXT,
|
|
attempts INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE TABLE IF NOT EXISTS places (
|
|
key TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications(user_id);
|
|
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.
|
|
// A photo may sit in several at once — each section draws its own random pick
|
|
// out of its set on every page load. Sitting in none of them is the curator's
|
|
// "take it off the landing, keep the row": the owner still has it in MY PHOTOS.
|
|
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 holds the set as a comma list. Ids it does not know — the retired
|
|
// single-slot `off`, anything hand-edited — are dropped, so a row can always be
|
|
// read back as a set the landing page understands.
|
|
export const parseSlots = (raw: string | null | undefined): PhotoSlot[] =>
|
|
(raw ?? '').split(',').filter(isPhotoSlot);
|
|
export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
|
|
[...new Set(slots.filter(isPhotoSlot))].join(',');
|
|
|
|
// 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.
|
|
// It first held one slot; the rename keeps every existing row readable, since
|
|
// a bare `strip` parses as a one-member set and the old `off` as the empty one.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'slots')) {
|
|
if (cols.some((c) => c.name === 'slot')) db.exec(`ALTER TABLE photos RENAME COLUMN slot TO slots`);
|
|
else db.exec(`ALTER TABLE photos ADD COLUMN slots 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 address has to be proven before the account is worth anything: an
|
|
// unverified signup is a guest with a name (see publicUser/requirePro). The
|
|
// column arrives long after the first accounts did, and they were all real —
|
|
// they signed up while a valid address was the only door — so the same edit
|
|
// that adds the column marks them verified. Only signups from here on start
|
|
// unproven.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'email_verified')) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
|
|
db.exec(`UPDATE users SET email_verified = 1`);
|
|
}
|
|
}
|
|
|
|
// PRO, the operator's own switch: the admin ticks it in the users table and the
|
|
// account reads the studio's PRO features from then on, proven address or not.
|
|
// It only ever adds to what a verified account already has — unticking it
|
|
// cannot take the tier away from an address that has been proven.
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'pro')) {
|
|
db.exec(`ALTER TABLE users ADD COLUMN pro INTEGER NOT NULL DEFAULT 0`);
|
|
}
|
|
}
|
|
|
|
// The mailed code, added once visitors were expected to prove an address
|
|
// without leaving the page. Rows minted before it keep working as links: their
|
|
// `code` is NULL, which no typed guess can match (see verifyEmailCode).
|
|
{
|
|
const cols = db.prepare('PRAGMA table_info(email_verifications)').all() as { name: string }[];
|
|
if (!cols.some((c) => c.name === 'code')) {
|
|
db.exec(`ALTER TABLE email_verifications ADD COLUMN code TEXT`);
|
|
}
|
|
if (!cols.some((c) => c.name === 'attempts')) {
|
|
db.exec(`ALTER TABLE email_verifications ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0`);
|
|
}
|
|
}
|
|
|
|
// 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".
|
|
// `emailVerified` is 0/1 from SQLite; the route layer turns it into the
|
|
// `verified` the client reads.
|
|
// `pro` is the admin's own 0/1 grant — the "Activated Pro" box in the users
|
|
// table, and the only way an unproven address reaches the PRO tier.
|
|
// `createdAt` is the signup instant, which the PRO cutoff reads (see server.ts).
|
|
export type User = {
|
|
id: number;
|
|
email: string;
|
|
avatar: string | null;
|
|
blocked: number;
|
|
deletedAt: string | null;
|
|
emailVerified: number;
|
|
pro: number;
|
|
createdAt: 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 createdAt = now();
|
|
const info = db
|
|
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
|
|
.run(email, hashPassword(password), createdAt);
|
|
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0, pro: 0, createdAt };
|
|
} 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, email_verified AS emailVerified, pro, created_at AS createdAt, 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, email_verified AS emailVerified, pro, created_at AS createdAt FROM users WHERE id = ?')
|
|
.get(id) as User | undefined;
|
|
}
|
|
|
|
// ---- proving the address ---------------------------------------------------
|
|
// One live token per account: minting a new one drops the old, so a re-sent
|
|
// mail is the only link that works and the table cannot grow past the user
|
|
// count. 24 hours is long enough to find the mail in a spam folder.
|
|
export const VERIFY_TTL_S = 24 * 60 * 60;
|
|
// The typed code is the same proof over a shorter clock: six digits are worth
|
|
// guessing for a quarter of an hour, not for a day. It rides the same row —
|
|
// the link's `expires_at` and the code's `created_at + CODE_TTL_S` are two
|
|
// clocks over one row, which is why the code needs no expiry column of its own.
|
|
export const CODE_TTL_S = 15 * 60;
|
|
// Five guesses at a million, then the row is spent: the count is what keeps a
|
|
// six-digit secret from being walked through a hundred requests a second.
|
|
export const MAX_CODE_ATTEMPTS = 5;
|
|
|
|
export interface Verification {
|
|
token: string;
|
|
code: string;
|
|
}
|
|
|
|
export function createEmailVerification(userId: number): Verification {
|
|
const token = randomBytes(32).toString('hex');
|
|
// randomInt, not Math.random: the code is the one value here worth predicting.
|
|
const code = String(randomInt(0, 1_000_000)).padStart(6, '0');
|
|
const at = now();
|
|
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
|
|
db.prepare(
|
|
'INSERT INTO email_verifications (token, code, user_id, expires_at, created_at, attempts) VALUES (?, ?, ?, ?, ?, 0)',
|
|
).run(token, code, userId, new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(), at);
|
|
return { token, code };
|
|
}
|
|
|
|
// The account the token proves, or null when it is unknown or expired — the
|
|
// caller cannot tell the two apart, and neither can an attacker. A used token
|
|
// is spent either way.
|
|
export function verifyEmailToken(token: string): number | null {
|
|
const row = db
|
|
.prepare('SELECT user_id AS userId, expires_at AS expiresAt FROM email_verifications WHERE token = ?')
|
|
.get(token) as { userId: number; expiresAt: string } | undefined;
|
|
if (!row) return null;
|
|
db.prepare('DELETE FROM email_verifications WHERE token = ?').run(token);
|
|
if (row.expiresAt <= now()) return null;
|
|
db.prepare('UPDATE users SET email_verified = 1 WHERE id = ?').run(row.userId);
|
|
return row.userId;
|
|
}
|
|
|
|
// What a typed code did. 'stale' covers both "no live code" and "too old", so
|
|
// the caller learns nothing about which — the two are one answer, "ask for
|
|
// another letter". 'locked' is the spent-attempts state, which the visitor can
|
|
// only leave by having a new code mailed.
|
|
export type CodeResult = 'ok' | 'bad' | 'stale' | 'locked';
|
|
|
|
export function verifyEmailCode(userId: number, code: string): CodeResult {
|
|
const row = db
|
|
.prepare('SELECT code, attempts, created_at AS createdAt FROM email_verifications WHERE user_id = ?')
|
|
.get(userId) as { code: string | null; attempts: number; createdAt: string } | undefined;
|
|
if (!row || !row.code) return 'stale';
|
|
if (new Date(row.createdAt).getTime() + CODE_TTL_S * 1000 <= Date.now()) return 'stale';
|
|
if (row.attempts >= MAX_CODE_ATTEMPTS) return 'locked';
|
|
// The attempt is counted before the answer is given, so an interrupted
|
|
// request cannot hand back a guess nobody paid for.
|
|
db.prepare('UPDATE email_verifications SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
|
|
if (code.length !== row.code.length || !timingSafeEqual(Buffer.from(code), Buffer.from(row.code))) return 'bad';
|
|
// One row is both proofs, so the code being spent spends the link with it.
|
|
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
|
|
db.prepare('UPDATE users SET email_verified = 1 WHERE id = ?').run(userId);
|
|
return 'ok';
|
|
}
|
|
|
|
export function deleteEmailVerifications(userId: number): void {
|
|
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
|
|
}
|
|
|
|
// 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;
|
|
// Every landing section this photo is allowed to appear in; empty means it
|
|
// is curated off the landing page entirely.
|
|
slots: 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;
|
|
// Whether the row still carries the look that made it — the only thing the
|
|
// QR card can hand out (see the preset route in server.ts). A bool, not the
|
|
// recipe itself: the public listing has no business shipping looks.
|
|
hasPreset: 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;
|
|
// The name of the look the photo was saved with, when it still carries one —
|
|
// the curator's A→Z ordering key. Null for a photo uploaded without a look.
|
|
recipeName: string | null;
|
|
};
|
|
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.slots AS slots,
|
|
photos.tag AS tag, photos.title AS title, photos.meta AS meta,
|
|
photos.consent AS consent, (photos.recipe IS NOT NULL) AS hasPreset`;
|
|
|
|
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
|
|
// `slots` comes back as the stored comma list, turned into a set on the way out.
|
|
type PhotoRow = Omit<Photo, 'consent' | 'slots' | 'hasPreset'> & {
|
|
consent: number;
|
|
slots: string | null;
|
|
hasPreset: number;
|
|
};
|
|
type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
|
|
const toPhoto = (row: PhotoRow): Photo => ({
|
|
...row,
|
|
consent: row.consent === 1,
|
|
hasPreset: row.hasPreset === 1,
|
|
slots: parseSlots(row.slots),
|
|
});
|
|
// 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,
|
|
photos.recipe AS recipe, 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; recipe: string | null })[]
|
|
).map((row) => {
|
|
const recipe = parseJson(row.recipe) as { name?: unknown } | null;
|
|
return {
|
|
...toPhoto(row),
|
|
userId: row.userId,
|
|
email: row.email,
|
|
mime: row.mime,
|
|
bytes: row.bytes,
|
|
recipeName: typeof recipe?.name === 'string' ? recipe.name : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
pro: number;
|
|
emailVerified: number;
|
|
};
|
|
|
|
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, users.pro AS pro,
|
|
users.email_verified AS emailVerified, 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;
|
|
}
|
|
|
|
// The PRO grant, ticked or unticked from the users table. No session sweep: the
|
|
// tier is read off the row on every request, so the next one already sees it.
|
|
export function setUserPro(id: number, pro: boolean): boolean {
|
|
const info = db.prepare('UPDATE users SET pro = ? WHERE id = ?').run(pro ? 1 : 0, 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 ratings WHERE key IN (SELECT 'photo:' || id FROM photos WHERE user_id = ?)`).run(id);
|
|
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);
|
|
db.prepare('DELETE FROM email_verifications 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 {
|
|
// A new address is an unproven one: the flag goes back to 0 and the caller
|
|
// mails a fresh link, so the tier can never outlive the address that
|
|
// earned it.
|
|
db.prepare('UPDATE users SET email = ?, email_verified = 0 WHERE id = ?').run(email, id);
|
|
deleteEmailVerifications(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 starts in the community strip; the curator may put it in
|
|
// any of the live sections too, or take it off the landing.
|
|
return {
|
|
id: Number(info.lastInsertRowid),
|
|
createdAt: ts,
|
|
slots: ['strip'],
|
|
tag: meta?.tag ?? null,
|
|
title: meta?.title ?? null,
|
|
meta: meta?.meta ?? null,
|
|
consent: meta?.consent !== false,
|
|
hasPreset: meta?.recipe !== undefined,
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// The same row, owner-scoped: the routes that serve or write the editable base
|
|
// behind a saved render answer only to the account that made the photo.
|
|
export function photoFileOwned(userId: number, id: number): { file: string; mime: string } | undefined {
|
|
return db.prepare('SELECT file, mime FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as
|
|
| { file: string; mime: string }
|
|
| undefined;
|
|
}
|
|
|
|
// The QR card's payload: the stored look, as raw JSON, and where the photo is
|
|
// allowed to show. The route decides who may read it (a curated `qr` slot), so
|
|
// this returns the row as stored, recipe included.
|
|
export function photoPreset(id: number): { recipe: string | null; slots: PhotoSlot[] } | undefined {
|
|
const row = db.prepare('SELECT recipe, slots FROM photos WHERE id = ?').get(id) as
|
|
| { recipe: string | null; slots: string | null }
|
|
| undefined;
|
|
return row && { recipe: row.recipe, slots: parseSlots(row.slots) };
|
|
}
|
|
|
|
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);
|
|
db.prepare('DELETE FROM ratings WHERE key = ?').run(`photo:${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);
|
|
db.prepare('DELETE FROM ratings WHERE key = ?').run(`photo:${id}`);
|
|
return row.file;
|
|
}
|
|
|
|
// Curating, not moderating: where this photo is allowed to surface. The whole
|
|
// set arrives at once — the admin's checkboxes are the only writer.
|
|
export function setPhotoSlots(id: number, slots: readonly PhotoSlot[]): boolean {
|
|
return db.prepare('UPDATE photos SET slots = ? WHERE id = ?').run(serializeSlots(slots), 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();
|
|
// The votes go with the rows; the built-in reel's own keys are left alone.
|
|
db.prepare(`DELETE FROM ratings WHERE key LIKE 'photo:%'`).run();
|
|
return files;
|
|
}
|
|
|
|
// --- ratings ---------------------------------------------------------------
|
|
// One row per (subject, visitor): a viewer's vote on a film-strip frame. The
|
|
// subject is `photo:<id>` for a contribution or `look:<TAG>` for a built-in
|
|
// look, so every frame on the strip is rated the same way. `visitor` is the
|
|
// salted-address hash the counter already uses, which is what makes a vote
|
|
// one-per-visitor without an account and without storing anything identifying.
|
|
// Reverse-geocode cache, keyed by the coordinate rounded to ~11m. An empty
|
|
// string is a coordinate the geocoder answered about and had no name for, which
|
|
// is a real answer and is cached like any other; `undefined` means "never
|
|
// asked" — the caller then makes the call.
|
|
export function findPlace(key: string): string | undefined {
|
|
const row = db.prepare('SELECT name FROM places WHERE key = ?').get(key) as { name: string } | undefined;
|
|
return row?.name;
|
|
}
|
|
|
|
export function savePlace(key: string, name: string): void {
|
|
db.prepare(
|
|
`INSERT INTO places (key, name, at) VALUES (?, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET name = excluded.name, at = excluded.at`,
|
|
).run(key, name, new Date().toISOString());
|
|
}
|
|
|
|
export type Rating = { avg: number; n: number; mine: number };
|
|
|
|
export function rateLook(key: string, visitor: string, stars: number): void {
|
|
db.prepare(
|
|
`INSERT INTO ratings (key, visitor, stars, at) VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(key, visitor) DO UPDATE SET stars = excluded.stars, at = excluded.at`,
|
|
).run(key, visitor, stars, new Date().toISOString());
|
|
}
|
|
|
|
// What a window of votes left on one photo — the landing's award column reads
|
|
// the best of these. `at` is the window's most recent vote, the last tie-break.
|
|
export type PhotoTally = { id: number; avg: number; n: number; at: string };
|
|
|
|
// The best-rated consenting photos a window of votes names, most deserving
|
|
// first. Only `photo:` subjects count: a vote on a built-in look is not a
|
|
// contribution and has no frame to show. The key carries the id, so the join is
|
|
// a cast of everything past `photo:` — a key that casts to no row simply drops
|
|
// out of the join. Order: the mean, then how many votes stand behind it, then
|
|
// the most recent one, so a tie is settled by evidence and two loads of the page
|
|
// cannot disagree.
|
|
export function topRatedPhotos(since: string, limit: number): PhotoTally[] {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT photos.id AS id, AVG(ratings.stars) AS avg, COUNT(*) AS n, MAX(ratings.at) AS at
|
|
FROM ratings JOIN photos ON photos.id = CAST(substr(ratings.key, 7) AS INTEGER)
|
|
WHERE ratings.key LIKE 'photo:%' AND ratings.at >= ? AND photos.consent = 1
|
|
GROUP BY photos.id
|
|
ORDER BY avg DESC, n DESC, at DESC, id DESC
|
|
LIMIT ?`,
|
|
)
|
|
.all(since, limit) as PhotoTally[];
|
|
return rows.map((r) => ({ id: r.id, avg: Math.round(r.avg * 100) / 100, n: r.n, at: r.at }));
|
|
}
|
|
|
|
// Every subject's tally, keyed by subject. `mine` is this visitor's own vote, 0
|
|
// when they have not rated it — the landing draws the star row from it.
|
|
export function ratingsFor(visitor: string): Record<string, Rating> {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT key, AVG(stars) AS avg, COUNT(*) AS n,
|
|
MAX(CASE WHEN visitor = ? THEN stars END) AS mine
|
|
FROM ratings GROUP BY key`,
|
|
)
|
|
.all(visitor) as { key: string; avg: number; n: number; mine: number | null }[];
|
|
const out: Record<string, Rating> = {};
|
|
for (const r of rows) out[r.key] = { avg: Math.round(r.avg * 100) / 100, n: r.n, mine: r.mine ?? 0 };
|
|
return out;
|
|
}
|
|
|
|
// --- 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();
|
|
// Real visits only: the crawler and headless-reading rows the counter used to
|
|
// keep are filtered here as well as at the door, so the numbers are traffic
|
|
// and not a scan of the page by something with no one behind it.
|
|
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 COALESCE(device, '') <> 'bot'
|
|
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 >= ? AND COALESCE(device, '') <> 'bot'`,
|
|
)
|
|
.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 >= ? AND COALESCE(device, '') <> 'bot' 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'),
|
|
};
|
|
}
|