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
+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);