web: PRO needs a proven address — email verification gates the studio

A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
This commit is contained in:
2026-09-20 07:39:03 +07:00
parent efe578f61c
commit 52b672deec
16 changed files with 633 additions and 74 deletions
+70 -4
View File
@@ -82,6 +82,13 @@ CREATE TABLE IF NOT EXISTS ratings (
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
);
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);
@@ -139,6 +146,20 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
}
}
// 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`);
}
}
// 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
@@ -180,12 +201,15 @@ export const PHOTO_HISTORY_MAX = 3;
}
// `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.
export type User = {
id: number;
email: string;
avatar: string | null;
blocked: number;
deletedAt: string | null;
emailVerified: number;
};
export type Recipe = {
id: number;
@@ -222,7 +246,7 @@ export function createUser(email: string, password: string): User | null {
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 };
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0 };
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
throw err;
@@ -232,17 +256,54 @@ export function createUser(email: string, password: string): User | null {
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 = ?',
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, 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 = ?')
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified 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;
export function createEmailVerification(userId: number): string {
const token = randomBytes(32).toString('hex');
const at = now();
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
db.prepare('INSERT INTO email_verifications (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)').run(
token,
userId,
new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(),
at,
);
return token;
}
// 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;
}
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 {
@@ -515,6 +576,7 @@ export function deleteUser(id: number): { photos: string[]; avatar: string | nul
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 };
}
@@ -523,7 +585,11 @@ export function deleteUser(id: number): { photos: string[]; avatar: string | nul
// sign-up path writes.
export function updateUserEmail(id: number, email: string): boolean {
try {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
// 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;
+58
View File
@@ -0,0 +1,58 @@
import nodemailer, { type Transporter } from 'nodemailer';
// The API sends exactly one kind of mail: the link that proves an address. The
// relay is declared in the deployment's .env, because a mail server is
// infrastructure, not a constant.
//
// With no SMTP_HOST there is nothing to connect to, so the link is written to
// the log instead. That keeps a dev box — or this repo's own test suite — able
// to finish a signup without a mail server, and whoever reads the log is
// already the person running the database.
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim();
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_USER = (process.env.SMTP_USER ?? '').trim();
const SMTP_PASS = process.env.SMTP_PASS ?? '';
const SMTP_FROM = (process.env.SMTP_FROM ?? '').trim() || SMTP_USER || 'no-reply@recipescam.local';
// 465 is TLS from the first byte; 587 starts in the clear and upgrades. Only a
// port the operator actually chose should be second-guessed.
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465;
export const mailConfigured = SMTP_HOST !== '';
const transporter: Transporter | null = mailConfigured
? nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
// A relay that never answers must not hold a request open.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
})
: null;
// Both languages, because the account's language is not known before it exists.
const body = (url: string) =>
[
'RecipesCam — xác thực địa chỉ email / verify your email address',
'',
url,
'',
'Liên kết hết hạn sau 24 giờ. Nếu bạn không đăng ký, hãy bỏ qua thư này.',
'The link expires in 24 hours. If you did not sign up, ignore this mail.',
].join('\r\n');
// Fire and forget: the account already exists, so a relay that is slow, out of
// quota or misconfigured may not fail the signup that asked for it. The owner
// can ask for another link from the studio; the operator sees the error here.
export function sendVerificationMail(to: string, url: string, log: (msg: string) => void): void {
if (!transporter) {
log(`[verify] SMTP not configured — verification link for ${to}: ${url}`);
return;
}
transporter
.sendMail({ from: SMTP_FROM, to, subject: 'RecipesCam — verify your email', text: body(url) })
.then(() => log(`[verify] link sent to ${to}`))
.catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`));
}
+111 -24
View File
@@ -1,4 +1,5 @@
import { recipeFile } from './recipeFile';
import { sendVerificationMail } from './mailer';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -11,6 +12,7 @@ import {
SESSION_MAX_AGE_S,
DUMMY_HASH,
countPhotos,
createEmailVerification,
createEvent,
createPhoto,
createRecipe,
@@ -48,6 +50,7 @@ import {
updateRecipe,
updateUserEmail,
userAvatar,
verifyEmailToken,
verifyPassword,
type PhotoMeta,
type Recipe,
@@ -76,9 +79,17 @@ const ADMIN_EMAILS = new Set(
);
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
// What an account is worth. A signup proves nothing until the address it gave
// is confirmed, so an unverified account is a guest with a name: the PRO tier,
// its own listings and every write stay shut. Admins come from the
// deployment's own allowlist — trusted by construction, so no letter is needed
// and a broken relay cannot lock the operator out of their own site.
const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user);
// The public shape of an account. `admin` is the allowlist's answer, so the
// client can decide whether to offer /admin without a second round trip — and
// the server still enforces it on every admin route below.
// `verified` is the studio's PRO gate: true only for a proven address.
// `avatar` is a URL the client can drop straight into an <img>, or null when
// the account never picked a picture. The `v` is the stored file's own name, so
// the URL changes with the picture and can be cached hard.
@@ -86,6 +97,7 @@ const publicUser = (user: User) => ({
id: user.id,
email: user.email,
admin: isAdmin(user),
verified: isVerified(user),
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
});
@@ -225,6 +237,42 @@ function auth(req: FastifyRequest): User | undefined {
return token ? sessionUser(token) : undefined;
}
// The gate every personal route takes instead of `auth`. Two different
// refusals, because the studio acts on them differently: 401 sends a guest to
// the sign-in dialog, 403 asks a signed-in account to open its mail.
function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
if (!isVerified(user)) {
void reply.status(403).send({ error: 'email not verified' });
return undefined;
}
return user;
}
// The verification link has to work from wherever the visitor actually
// arrived — the deployment's domain, an IP:port, localhost in development.
// nginx forwards the original Host and scheme, so the request already knows
// both; the header is a chain, and the first hop is the one the browser used.
function originOf(req: FastifyRequest): string {
const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v)?.split(',')[0].trim();
const host = first(req.headers['x-forwarded-host']) || req.headers.host || '';
const proto = first(req.headers['x-forwarded-proto']) || req.protocol || 'http';
return host ? `${proto}://${host}` : '';
}
// Mints the single live token and hands the link to the mailer. The URL is the
// API's own route, so a click needs no page of its own (see the redirect
// there). A relay that cannot send is not an error here: the link is in the
// log, and the account can ask again.
function sendVerification(req: FastifyRequest, user: User): void {
const token = createEmailVerification(user.id);
sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, (msg) => req.log.info(msg));
}
// ---- analytics ------------------------------------------------------------
// The page counter. It stores nothing that identifies a visitor: the address
// becomes a salted hash (enough to count uniques) and a coarse place, then it
@@ -379,10 +427,40 @@ app.post('/api/auth/signup', async (req, reply) => {
if (findUserByEmail(creds.email)) return reply.status(409).send({ error: 'email already registered' });
const user = createUser(creds.email, creds.password);
if (!user) return reply.status(409).send({ error: 'email already registered' });
// The session is granted anyway. An unverified account is served at the guest
// tier, but it is a guest that can see the banner saying so and ask for its
// link again — which needs to be somebody.
sendVerification(req, user);
setSession(req, reply, createSession(user.id));
return reply.status(201).send({ user: publicUser(user) });
});
// Where the mail link lands. A plain GET, no session required: the visitor may
// well open it in another browser, or on the phone that owns the address. It
// answers with a redirect rather than JSON for the same reason — the landing
// page is what a browser should show. A bad or expired token is not an error
// page, it is the same page saying the link did not work.
app.get('/api/auth/verify', async (req, reply) => {
const raw = (req.query as { token?: unknown } | undefined)?.token;
const userId = typeof raw === 'string' && raw.length <= 128 ? verifyEmailToken(raw) : null;
return reply.redirect(`${originOf(req)}/?verified=${userId ? 1 : 0}`, 303);
});
// The banner's own button. Capped like signup and keyed on the address, so the
// route is not a way to mail a stranger repeatedly.
const allowResend = limiter(3, 60 * 60_000);
app.post('/api/auth/resend-verification', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (isVerified(user)) return reply.status(200).send({ ok: true, verified: true });
if (!allowResend(user.email)) return tooMany(reply);
sendVerification(req, user);
return reply.status(200).send({ ok: true });
});
app.post('/api/auth/login', async (req, reply) => {
const b = bodyOf(req);
if (!b || typeof b.email !== 'string' || typeof b.password !== 'string')
@@ -419,6 +497,8 @@ app.get('/api/auth/me', async (req, reply) => {
// password is required either way, so a stolen cookie alone cannot lock the
// owner out — and the login limiter caps guesses at it.
app.patch('/api/auth/me', async (req, reply) => {
// `auth`, not `requirePro`: editing your own profile is how an unverified
// account fixes a mistyped address, so this route stays open to it.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (!allowLogin(user.email)) return tooMany(reply);
@@ -430,12 +510,19 @@ app.patch('/api/auth/me', async (req, reply) => {
return reply.status(403).send({ error: 'invalid password' });
let email = user.email;
let emailVerified = user.emailVerified;
if (b.email !== undefined) {
const next = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
if (!next || next.length > MAX_EMAIL || !EMAIL_RE.test(next)) return reply.status(400).send({ error: 'invalid email' });
if (next !== user.email && !updateUserEmail(user.id, next))
return reply.status(409).send({ error: 'email already registered' });
email = next;
if (next !== user.email) {
if (!updateUserEmail(user.id, next)) return reply.status(409).send({ error: 'email already registered' });
email = next;
// The tier follows the address that earned it: a new one is unproven
// until its own link is followed, so the flag goes back to 0 (the update
// cleared the row) and a letter goes out.
emailVerified = 0;
sendVerification(req, { ...user, email });
}
}
if (b.password !== undefined) {
const password = typeof b.password === 'string' ? b.password : '';
@@ -443,18 +530,18 @@ app.patch('/api/auth/me', async (req, reply) => {
return reply.status(400).send({ error: `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters` });
setUserPassword(user.id, password);
}
return reply.status(200).send({ user: publicUser({ ...user, email }) });
return reply.status(200).send({ user: publicUser({ ...user, email, emailVerified }) });
});
app.get('/api/recipes', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
return reply.status(200).send({ recipes: listRecipes(user.id) });
});
app.post('/api/recipes', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const b = bodyOf(req);
const payload = b && recipePayload(b);
if (typeof payload === 'string' || !payload)
@@ -464,8 +551,8 @@ app.post('/api/recipes', async (req, reply) => {
});
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
const b = bodyOf(req);
@@ -478,8 +565,8 @@ app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
});
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
if (!deleteRecipe(user.id, id)) return reply.status(404).send({ error: 'recipe not found' });
@@ -538,14 +625,14 @@ app.get('/api/photos', async () => ({ photos: listPhotos() }));
// The caller's own folder — the count the studio's SAVE PHOTO shows comes from
// here, and the admin drill-down reads the same rows through /admin/photos.
app.get('/api/photos/mine', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const body = req.body;
@@ -575,8 +662,8 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -605,8 +692,8 @@ app.put<{ Params: { id: string } }>(
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
// under a server-generated name. The picture it replaces goes with it.
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const body = req.body;
@@ -693,8 +780,8 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req,
// consent), and only their own row is reachable — the user_id in the WHERE is
// the authorisation.
app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const body = (req.body ?? {}) as { consent?: unknown };
@@ -708,8 +795,8 @@ app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
// dropped when the caller owns it (or curates the whole strip), and the file
// goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink.
app.delete<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const file = isAdmin(user) ? deletePhoto(id) : deletePhotoOf(user.id, id);