diff --git a/docker/.env.example b/docker/.env.example index c1a7b16..9df20f2 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -5,3 +5,19 @@ WEB_PORT=8090 # Comma-separated emails allowed to moderate the landing strip (/admin). # Leave empty to make nobody an admin. ADMIN_EMAILS= + +# The mail relay that sends the address-verification link. A new account is a +# guest until it follows that link, so a deployment without a relay can only +# ever hand out guest access. +# +# Leave SMTP_HOST empty and the link is written to the api container's log +# instead (`docker compose logs api`), which is enough for local work. +SMTP_HOST= +# 587 upgrades to TLS (STARTTLS); 465 is TLS from the first byte. Set +# SMTP_SECURE=true to force the latter on an unusual port. +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +# What the mail says it is from. Defaults to SMTP_USER, then a noreply address. +SMTP_FROM= +SMTP_SECURE= diff --git a/docker/backend/package-lock.json b/docker/backend/package-lock.json index 21fdcfe..b894ad7 100644 --- a/docker/backend/package-lock.json +++ b/docker/backend/package-lock.json @@ -9,11 +9,13 @@ "version": "1.0.0", "dependencies": { "better-sqlite3": "^12.11.1", - "fastify": "^5.12.5" + "fastify": "^5.12.5", + "nodemailer": "^7.0.13" }, "devDependencies": { "@types/better-sqlite3": "^9.6.0", "@types/node": "^22.20.3", + "@types/nodemailer": "^8.0.2", "tsx": "^4.23.13", "typescript": "^5.9.3" } @@ -597,6 +599,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -1178,6 +1190,15 @@ "node": ">=10" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", diff --git a/docker/backend/package.json b/docker/backend/package.json index 7fece42..973a2f7 100644 --- a/docker/backend/package.json +++ b/docker/backend/package.json @@ -12,11 +12,13 @@ }, "dependencies": { "better-sqlite3": "^12.11.1", - "fastify": "^5.12.5" + "fastify": "^5.12.5", + "nodemailer": "^7.0.13" }, "devDependencies": { "@types/better-sqlite3": "^9.6.0", "@types/node": "^22.20.3", + "@types/nodemailer": "^8.0.2", "tsx": "^4.23.13", "typescript": "^5.9.3" } diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 62f0967..6802550 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -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; diff --git a/docker/backend/src/mailer.ts b/docker/backend/src/mailer.ts new file mode 100644 index 0000000..0935373 --- /dev/null +++ b/docker/backend/src/mailer.ts @@ -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)}`)); +} diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index b893d9f..5710ee7 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -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 , 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); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index df5983a..a56c359 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -77,6 +77,30 @@ function actor() { }; } +// A fresh signup proves nothing until the address it gave is confirmed: an +// unverified account is served at the guest tier (see the PRO gate below). The +// suite cannot read the mail, but the token is in the throwaway database and +// the link is the API's own route, so it is followed here for the accounts that +// are exercising something other than the gate. +const Database = (await import('better-sqlite3')).default; +function tokenFor(email) { + const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true }); + const row = db + .prepare('SELECT token FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)') + .get(email); + db.close(); + return row?.token; +} +async function followVerifyLink(email) { + const res = await fetch(`${BASE}/auth/verify?token=${tokenFor(email)}`, { redirect: 'manual' }); + if (res.status !== 303) throw new Error(`verify link for ${email} answered ${res.status}`); +} +async function activeSignup(a, email) { + const res = await a.signup(email); + await followVerifyLink(email); + return res; +} + // Run the sources, not a possibly stale build: the point of this suite is to // test the code as written. const tsx = join(ROOT, 'node_modules/.bin/tsx'); @@ -121,9 +145,9 @@ try { const adminSignup = await admin.signup(ADMIN_EMAIL); check('admin account signs up', adminSignup.status === 201, `got ${adminSignup.status}`); - const userSignup = await user.signup(`contributor${stamp}@test.local`); + const userSignup = await activeSignup(user, `contributor${stamp}@test.local`); check('contributor account signs up', userSignup.status === 201, `got ${userSignup.status}`); - await other.signup(`other${stamp}@test.local`); + await activeSignup(other, `other${stamp}@test.local`); const cookie = userSignup.setCookie; check('session cookie is HttpOnly', /HttpOnly/i.test(cookie), cookie); @@ -157,6 +181,50 @@ try { const ownMe = await user.req('/auth/me'); check('/auth/me reports the signed-in account', ownMe.body?.user?.email === `contributor${stamp}@test.local`, JSON.stringify(ownMe.body)); + // ---- the PRO gate: an unproven address is a guest ----------------------- + // Signing up is not what earns the tier — the address is. Until its link is + // followed the account is a guest with a name: every write and every personal + // listing answers 403, which is what tells the studio to ask for the mail + // rather than for a password. + const jsonHdr = { 'content-type': 'application/json' }; + const unproven = actor(); + const unprovenEmail = `unproven${stamp}@test.local`; + const unprovenSignup = await unproven.signup(unprovenEmail); + check('a fresh signup is unverified', unprovenSignup.body?.user?.verified === false, JSON.stringify(unprovenSignup.body)); + check( + 'an unverified account may still ask for its link', + (await unproven.req('/auth/resend-verification', { method: 'POST' })).status === 200, + ); + check('an unverified account cannot upload', (await unproven.upload(PNG, 'image/png')).status === 403); + check('an unverified account cannot list a folder', (await unproven.req('/photos/mine')).status === 403); + check( + 'an unverified account cannot save a recipe', + (await unproven.req('/recipes', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ name: 'x', recipe: {} }) })).status === 403, + ); + check('an unverified account cannot wear an avatar', (await unproven.avatar(PNG, 'image/png')).status === 403); + check('a signed-out caller still gets a 401, not a 403', (await actor().req('/photos/mine')).status === 401); + + const unknownLink = await fetch(`${BASE}/auth/verify?token=${'0'.repeat(64)}`, { redirect: 'manual' }); + check( + 'an unknown link verifies nothing', + unknownLink.status === 303 && unknownLink.headers.get('location')?.endsWith('/?verified=0'), + String(unknownLink.headers.get('location')), + ); + + const link = tokenFor(unprovenEmail); + check('signup leaves one verification link in the database', typeof link === 'string' && link.length === 64, String(link)); + const followed = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' }); + check( + 'the mailed link verifies the account', + followed.status === 303 && followed.headers.get('location')?.endsWith('/?verified=1'), + String(followed.headers.get('location')), + ); + check('the account is verified from then on', (await unproven.req('/auth/me')).body?.user?.verified === true); + check('a verified account may upload', (await unproven.upload(PNG, 'image/png')).status === 201); + const replay = await fetch(`${BASE}/auth/verify?token=${link}`, { redirect: 'manual' }); + check('a spent link cannot be followed twice', replay.headers.get('location')?.endsWith('/?verified=0'), String(replay.headers.get('location'))); + check('the allowlisted admin needs no letter', (await admin.req('/auth/me')).body?.user?.verified === true); + // ---- rate limiting ------------------------------------------------------ const brute = actor(); const bruteEmail = `brute${stamp}@test.local`; @@ -228,7 +296,7 @@ try { ); const stranger = actor(); - await stranger.signup(`stranger${stamp}@test.local`); + await activeSignup(stranger, `stranger${stamp}@test.local`); check("a fresh account's folder is empty", ((await stranger.req('/photos/mine')).body?.photos ?? []).length === 0); // The strip's own labels ride the query string: the body is the image. @@ -389,7 +457,7 @@ try { // ---- quota -------------------------------------------------------------- const quota = actor(); - await quota.signup(`quota${stamp}@test.local`); + await activeSignup(quota, `quota${stamp}@test.local`); let last = 0; for (let i = 0; i < 13; i++) last = (await quota.upload(PNG, 'image/png')).status; check('uploads are capped per account', last === 429, `13th upload: ${last}`); @@ -409,7 +477,7 @@ try { const edit = (a, body) => a.req('/auth/me', { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) }); const member = actor(); - await member.signup(`profile${stamp}@test.local`); + await activeSignup(member, `profile${stamp}@test.local`); const anonEdit = await edit(actor(), { password: 'another-secret-1', currentPassword: 'supersecret1' }); check('a profile edit needs a session', anonEdit.status === 401, `got ${anonEdit.status}`); const badCurrent = await edit(member, { password: 'another-secret-1', currentPassword: 'not-the-password' }); @@ -422,6 +490,12 @@ try { const newEmail = `renamed${stamp}@test.local`; const renamed = await edit(member, { email: newEmail, currentPassword: 'supersecret1' }); check('an admin-visible profile edit changes the email', renamed.status === 200 && renamed.body?.user?.email === newEmail, JSON.stringify(renamed.body)); + // The tier follows the address that earned it: the new one is unproven until + // its own letter is followed, so the account drops back to the guest tier. + check('a changed address is unproven again', renamed.body?.user?.verified === false, JSON.stringify(renamed.body)); + check('a changed address loses the writes', (await member.req('/recipes')).status === 403); + await followVerifyLink(newEmail); + check('following the new letter restores the tier', (await member.req('/auth/me')).body?.user?.verified === true); const login = (email, password) => actor().req('/auth/login', { method: 'POST', headers: JSON_HDR, body: JSON.stringify({ email, password }) }); check('the account logs in under the new email', (await login(newEmail, 'supersecret1')).status === 200); @@ -479,7 +553,7 @@ try { // ---- moderation: block, remove, delete an account ----------------------- const target = actor(); - await target.signup(`moderated${stamp}@test.local`); + await activeSignup(target, `moderated${stamp}@test.local`); const targetId = (await target.req('/auth/me')).body?.user?.id; const targetPhoto = (await target.upload(PNG, 'image/png')).body?.photo; const targetPhotoUrl = `http://127.0.0.1:${PORT}/api/photos/${targetPhoto?.id}/file`; diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2ad15eb..d704c81 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,15 @@ services: # Who may moderate the contributed strip: a comma-separated email # allowlist. Empty means nobody is an admin, which is the safe default. ADMIN_EMAILS: ${ADMIN_EMAILS:-} + # The relay that mails the "prove this address" link. Leave SMTP_HOST + # empty and the link is written to this container's log instead — a dev + # box needs no mail server, and the operator can read / copy it. + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-} + SMTP_SECURE: ${SMTP_SECURE:-} volumes: # SQLite (WAL) lives on the host so a rebuild never loses accounts. - ./data:/data diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 39c0a27..9ef4660 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -32,7 +32,10 @@ server { set $api_upstream http://api:3000; proxy_pass $api_upstream$request_uri; proxy_http_version 1.1; - proxy_set_header Host $host; + # $http_host, not $host: keep the port when the visitor hits this container + # directly (localhost:8090), since the API builds the verification link from + # the forwarded host. + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Hand the API the scheme the visitor actually used (TLS is terminated by diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index cda119c..4ec7f13 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -59,6 +59,12 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'wallframe-landscape', label: 'frame.wallLand' }, ]; +// The printed frames are PRO, the plain export is not — the same split the +// phone build draws (see src/utils/entitlement.ts there). Everything else the +// gate owns lives where it is used: the GPS stamp in wmChips, the HDF effect in +// paramDefs (its own `pro` flag), and the export tier in TIERS. +const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe', 'wallframe-landscape']; + // FREE draws the frame on the canvas and lets the user drag its corners, so // every ratio is offered here — no ratio is applied until APPLY. const CROPS: CropRatio[] = ['none', 'free', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9']; @@ -329,6 +335,11 @@ export function Workspace() { const [exporting, setExporting] = useState(false); const [savingPhoto, setSavingPhoto] = useState(false); const [user, setUser] = useState(null); + // PRO is the old "signed in", and then some: the account has to have proven + // the address it signed up with. Until it does, this build serves it exactly + // like a guest — watermarked 2048px export, no saving, no PRO tools. An + // allowlisted admin is trusted by construction and counts as verified. + const pro = !!user?.verified; const [saved, setSaved] = useState([]); // Bumped by RESET while CREATE is open: the form above the button listens for // it and drops back to its defaults. @@ -351,11 +362,30 @@ export function Workspace() { // null = closed. /app?auth=1 opens the studio's dialog straight on the sign-in // tab (sign-in is the default; ?auth=signup asks for the other one); the // landing page runs the same dialog in place instead of linking here. - const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() => { + // `verify` is the third face of the same dialog: the account exists but its + // address is unproven, so the only thing left to do is open the letter. + const [authMode, setAuthMode] = useState<'login' | 'signup' | 'verify' | null>(() => { const asked = new URLSearchParams(window.location.search).get('auth'); return asked ? (asked === 'signup' ? 'signup' : 'login') : null; }); + // What every PRO-only entry point calls instead of returning silently: a + // guest is sent to the dialog for an account, an unverified account to the + // same dialog for its letter. + const promptPro = useCallback(() => setAuthMode(user ? 'verify' : 'login'), [user]); + + // The banner's own button. The address already has one live link, so this + // replaces it — the API caps how often, and answers either way. + const [resent, setResent] = useState(false); + const resendVerification = useCallback(async () => { + try { + await api.resendVerification(); + setResent(true); + } catch (err) { + setError(t('err.generic', { msg: String(err) })); + } + }, [t]); + const renderToken = useRef(0); // Which previewMax the bytes in previewBytes were made at, so the upgrade // effect does not redo the load-time 1600px copy. @@ -395,14 +425,16 @@ export function Workspace() { }, []); useEffect(() => { - if (!user) { + // PRO, not merely signed in: those two listings are the API's own, and it + // answers an unverified account with a 403 (see requirePro there). + if (!pro) { setSaved([]); setMyPhotos([]); return; } api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([])); api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([])); - }, [user]); + }, [pro]); // --- preview render ------------------------------------------------------- useEffect(() => { @@ -702,12 +734,12 @@ export function Workspace() { ); const saveRecipe = useCallback(() => { - if (!user) { - setAuthMode('login'); + if (!pro) { + promptPro(); return; } setNaming('recipe'); - }, [user]); + }, [pro, promptPro]); // A recipe name is only free while this account has not spent it. Checked on // the field rather than at the store: the list is already here, whole. @@ -734,8 +766,8 @@ export function Workspace() { // asked to sign in first, like every other write in the studio. const favoriteRecipe = useCallback( async (entry: Recipe) => { - if (!user) { - setAuthMode('login'); + if (!pro) { + promptPro(); return; } try { @@ -746,7 +778,7 @@ export function Workspace() { setError(t('err.generic', { msg: String(err) })); } }, - [user, t] + [pro, promptPro, t] ); // The star reads the id the dragged chip carried back to its recipe. @@ -772,14 +804,14 @@ export function Workspace() { }, [favoriteRecipe, recipe]); // CREATE tab: a new recipe is applied at once and listed under RECIPES. A - // signed-in account also stores it; a guest's copy stays in `created`, which - // is exactly what "forgotten when you leave" means. + // PRO account also stores it; a guest's — or an unverified account's — copy + // stays in `created`, which is exactly what "forgotten when you leave" means. const addCreatedRecipe = useCallback( async (draft: RecipeDraft) => { const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true }; setCreated((list) => [entry, ...list]); applyRecipe(entry); - if (!user) return; + if (!pro) return; try { const stored = await api.createRecipe(entry.name, entry); setSaved((list) => [stored.recipe, ...list]); @@ -787,7 +819,7 @@ export function Workspace() { setError(t('err.generic', { msg: String(err) })); } }, - [user, applyRecipe, t] + [pro, applyRecipe, t] ); // CREATE edits the photo live: each knob lands on the render as it is typed so @@ -798,11 +830,14 @@ export function Workspace() { }, []); // The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the - // app's own file, byte-identical). Signed in only — a guest's recipe is never - // written to disk. + // app's own file, byte-identical). PRO only — a guest's recipe, or an + // unverified account's, is never written to disk. const exportRecipeFile = useCallback( (draft: RecipeDraft) => { - if (!user) return; + if (!pro) { + promptPro(); + return; + } const xml = exportRecipeXml({ ...draft, id: 'share' }); download( new TextEncoder().encode(xml), @@ -810,7 +845,7 @@ export function Workspace() { 'application/xml' ); }, - [user] + [pro, promptPro] ); // IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved @@ -868,12 +903,28 @@ export function Workspace() { [recipe, frameId, cropEditing, useGeotag] ); + // The look the stage is holding, judged against the PRO list. The chips keep + // a guest from picking one, but a look can arrive without them: an imported + // .recipe, or a photo saved before the gate existed. The FILE is where the + // gate bites — the preview is free, exactly as it is on the phone. + const proLookInUse = useCallback( + () => + PRO_FRAMES.includes(frameId) || + useGeotag || + (recipe.adjustments.hdf ?? 0) > 0, + [frameId, useGeotag, recipe.adjustments.hdf] + ); + const exportPhoto = useCallback(async () => { if (!engine || !source) return; + if (!pro && proLookInUse()) { + promptPro(); + return; + } setExporting(true); setError(null); try { - const tier = TIERS[user ? 'user' : 'guest']; + const tier = TIERS[pro ? 'user' : 'guest']; const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes; const out = await renderPhoto({ source: src, @@ -924,15 +975,15 @@ export function Workspace() { } finally { setExporting(false); } - }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, user, t, strip, photoSettings]); + }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]); // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. // The upload is left clean (no burned strip) so the landing reel can draw its // own `.lp-shot` frame around it; the labels are stored alongside instead. // The 12-photo cap is the API's, mirrored here so the button can say no. const savePhotoToFolder = useCallback(async (photoName?: string) => { - if (!user) { - setAuthMode('login'); + if (!pro) { + promptPro(); return; } if (!engine || !source) { @@ -1008,7 +1059,7 @@ export function Workspace() { } finally { setSavingPhoto(false); } - }, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); + }, [pro, promptPro, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); // Reopen one of this account's saved frames: the bytes come back as a File // through the same door an upload uses, then the stored look goes on top. @@ -1134,15 +1185,18 @@ export function Workspace() { const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => defs.map((def) => { const value = def.get(recipe.adjustments); + const locked = !!def.pro && !pro; return { key: def.key, label: def.label, value: def.display(value), active: openParam === def.key, amberValue: openParam !== def.key && value !== def.defaultValue, - // The web build is the demo: HDF EFFECT is part of what it shows off, - // so no knob is held back here. The PRO gate stays a phone-build rule. - onClick: () => toggleParam(def.key), + // The phone greys a PRO knob out; the web keeps it clickable and says + // why instead, because the fix here is an action (prove the address) + // rather than a purchase. + pro: !!def.pro, + onClick: locked ? promptPro : () => toggleParam(def.key), }; }); @@ -1417,8 +1471,13 @@ export function Workspace() { key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, + pro: true, disabled: !gps && !(gpsManual.lat && gpsManual.lng), onClick: () => { + if (!pro) { + promptPro(); + return; + } remember(); setUseGeotag(!useGeotag); }, @@ -1469,6 +1528,16 @@ export function Workspace() { }, }); + // Shown where an unverified account would otherwise just see an empty list. + // The list is not empty because the account has nothing — it is shut until + // the address is proven, and saying so is the whole point of the chip. + const verifyChip = (): ChipDef => ({ + key: 'verify-now', + label: t('pro.verifyChip'), + pro: true, + onClick: promptPro, + }); + const framesRow = (): ChipDef[] => [ // NO FRAME is not a chip of its own: clicking the frame already on the // photo takes it off again, so the row carries one entry per frame. @@ -1476,7 +1545,12 @@ export function Workspace() { key: `fr:${frame.id}`, label: t(frame.label), active: frameId === frame.id, + pro: PRO_FRAMES.includes(frame.id), onClick: () => { + if (PRO_FRAMES.includes(frame.id) && !pro) { + promptPro(); + return; + } setOpenParam(null); setOpenGroup(null); remember(); @@ -1545,6 +1619,7 @@ export function Workspace() { // IMPORT is a chip inside the RECIPES strip. ]; case 'favorited': + if (user && !pro) return [verifyChip()]; return saved.length === 0 ? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }] : saved.map((entry) => ({ @@ -1564,6 +1639,7 @@ export function Workspace() { // the landing strip — the owner takes it off again with that switch, or // the curator with the admin slot. const rows: ChipDef[] = []; + if (user && !pro) return [verifyChip()]; if (myPhotos.length === 0) { rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} }); } else { @@ -1747,6 +1823,24 @@ export function Workspace() { }} /> + {/* A signed-in account whose address is unproven is served as a guest, so + the studio says so once, at the top, instead of letting each locked + chip explain it on its own. */} + {user && !pro ? ( +
+ {t('pro.unverifiedBar', { email: user.email })} + +
+ ) : null} +
@@ -1757,7 +1851,7 @@ export function Workspace() { void addCreatedRecipe(draft)} @@ -2041,6 +2135,7 @@ export function Workspace() { {authMode ? ( setAuthMode(null)} onDone={async () => { setAuthMode(null); diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index cd1a3f4..2b56fd9 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -180,7 +180,9 @@ export function Landing() { const [temp, setTemp] = useState(0); const [sat, setSat] = useState(100); const [con, setCon] = useState(100); - const [toast, setToast] = useState(null); + // `lead` is the short bold tag in front of the message; the marketing lines + // share "Coming soon —", the verification ones carry their own. + const [toast, setToast] = useState<{ lead: string; text: string } | null>(null); const [user, setUser] = useState(null); const [photos, setPhotos] = useState([]); const [ratings, setRatings] = useState>({}); @@ -197,8 +199,13 @@ export function Landing() { const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0]; - const showToast = useCallback((text: Txt | string) => { - setToast(typeof text === 'string' ? text : c(text)); + const toastLead: Txt = { en: 'Coming soon —', vi: 'Sắp có —' }; + + const showToast = useCallback((text: Txt | string, lead?: Txt) => { + setToast({ + lead: c(lead ?? toastLead), + text: typeof text === 'string' ? text : c(text), + }); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 3400); }, [c]); @@ -219,6 +226,32 @@ export function Landing() { reload(); }, [reload]); + // The verification link in the letter redirects here with `?verified=1` (or + // `0` for a stale or unknown token). Say which it was, re-read the session so + // a just-proven account comes back PRO, and drop the flag so a reload does + // not repeat the toast. + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const flag = params.get('verified'); + if (flag === null) return; + params.delete('verified'); + const rest = params.toString(); + window.history.replaceState(null, '', `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`); + if (flag === '1') { + showToast( + { en: 'Your email is confirmed — the studio is unlocked.', vi: 'Đã xác thực email — studio đã mở khoá.' }, + { en: 'Verified —', vi: 'Đã xác thực —' }, + ); + reload(); + } else { + showToast( + { en: 'That verification link is invalid or has expired.', vi: 'Liên kết xác thực không hợp lệ hoặc đã hết hạn.' }, + { en: 'Not verified —', vi: 'Chưa xác thực —' }, + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // One vote. The visitor may change their mind: the API upserts under the same // address hash, so a second click replaces the first rather than stacking. A // vote that never lands is silently dropped — it is not worth a toast. @@ -949,6 +982,7 @@ export function Landing() { {auth ? ( setAuth(null)} onDone={() => { setAuth(null); @@ -959,7 +993,7 @@ export function Landing() { /> ) : null} - {toast ?
{c({ en: 'Coming soon —', vi: 'Sắp có —' })} {toast}
: null} + {toast ?
{toast.lead} {toast.text}
: null}
); } diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 8579910..da41521 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -16,6 +16,9 @@ export interface User { // True when the account is on the API's ADMIN_EMAILS allowlist. The server // re-checks it on every admin route; this only drives what the UI offers. admin?: boolean; + // Signed-in accounts only unlock the PRO tier once their address is proven; + // an unverified one is served exactly like a guest. Admins count as verified. + verified?: boolean; // A ready-to-use picture URL (`/api/users//avatar?v=`), or null. // The version segment is the file's own name, so a replacement is never // served from cache. @@ -197,6 +200,9 @@ export const api = { login: (email: string, password: string) => call<{ user: User }>('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }), logout: () => call('/auth/logout', { method: 'POST' }), + // Mail the verification link to the signed-in address again. Works while + // unverified (that is the whole point); 429 once the hourly cap is spent. + resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }), listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'), createRecipe: (name: string, recipe: Recipe) => diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 30d8d36..22f7f82 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -109,6 +109,19 @@ export const en: Dict = { 'auth.continueDemo': 'Keep using the demo', 'auth.busy': 'Working…', 'auth.loggedInAs': 'Signed in as {email}', + 'auth.verifyTitle': 'Verify your email', + 'auth.verifyBody': 'We sent a verification link to {email}. Open it to unlock the PRO features.', + 'auth.verifyProHint': 'Until then the account works exactly like a guest: exports stay 2048px with a watermark, and nothing can be saved.', + 'auth.verifySent': 'Verification email sent again.', + 'auth.verifiedDone': 'I have verified', + 'auth.signupVerifyHint': 'After signing up we email you a verification link — open it to unlock the PRO features.', + + // The PRO gate. Signed in but unproven is served as a guest, so the studio + // needs one place that says why and one way to ask for the letter again. + 'pro.verifyChip': 'VERIFY EMAIL TO USE PRO', + 'pro.unverifiedBar': 'Account {email} is not verified — it works like a guest.', + 'pro.resend': 'RESEND VERIFICATION EMAIL', + 'pro.resent': 'SENT', 'save.title': 'Save recipe', 'save.name': 'Recipe name', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 484e969..1a7b790 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -117,6 +117,19 @@ export const vi = { 'auth.continueDemo': 'Tiếp tục dùng thử', 'auth.busy': 'Đang xử lý…', 'auth.loggedInAs': 'Đã đăng nhập: {email}', + 'auth.verifyTitle': 'Xác thực email', + 'auth.verifyBody': 'Đã gửi một liên kết xác thực tới {email}. Mở liên kết đó để dùng được các tính năng PRO.', + 'auth.verifyProHint': 'Khi chưa xác thực, tài khoản dùng y như khách: ảnh xuất ra tối đa 2048px kèm watermark và không lưu được gì.', + 'auth.verifySent': 'Đã gửi lại email xác thực.', + 'auth.verifiedDone': 'Tôi đã xác thực xong', + 'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một email xác thực — mở liên kết trong đó để bật các tính năng PRO.', + + // The PRO gate. Signed in but unproven is served as a guest, so the studio + // needs one place that says why and one way to ask for the letter again. + 'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO', + 'pro.unverifiedBar': 'Tài khoản {email} chưa xác thực email — đang dùng như khách.', + 'pro.resend': 'GỬI LẠI EMAIL XÁC THỰC', + 'pro.resent': 'ĐÃ GỬI', 'save.title': 'Lưu công thức', 'save.name': 'Tên công thức', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index b390dd5..52abdc9 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -195,6 +195,23 @@ } input[type="range"] { width: 100%; accent-color: var(--accent); } .hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; } +.hint.ok { color: var(--accent); } + +/* --- PRO gate bar ------------------------------------------------------- */ +/* One line between the header and the workspace, shown only while a signed-in + account has not proven its address — it is served as a guest until it does. */ +.verify-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; + padding: 6px 12px; + background: var(--accent-soft); + border-bottom: 1px solid var(--accent-line); + font-size: 12px; +} +.verify-bar .btn { padding: 3px 10px; font-size: 11px; } /* --- CREATE RECIPES form (the phone's RecipeCreateModal, unrolled) ------- */ .create-form { diff --git a/docker/frontend/src/ui/AuthModal.tsx b/docker/frontend/src/ui/AuthModal.tsx index dc24fa2..4d2b08e 100644 --- a/docker/frontend/src/ui/AuthModal.tsx +++ b/docker/frontend/src/ui/AuthModal.tsx @@ -4,29 +4,53 @@ import { api } from '../api'; // Email + password only: the cookie is set by the API container, this dialog // just drives it. The demo is always one click away — the app is usable signed -// out, and only export resolution / saving are gated (see config/tiers.ts). +// out, and only the PRO half is gated (see config/tiers.ts). +// +// Three faces, one form. `verify` is the third: the account exists but the +// address it gave has not been confirmed, so until the visitor opens the link +// it is served exactly like a guest. A fresh signup lands on that face by +// itself — telling someone their account is ready when it is not is the one +// thing this dialog must not do. export function AuthModal({ initialMode = 'login', + email, onClose, onDone, }: { - initialMode?: 'login' | 'signup'; + initialMode?: 'login' | 'signup' | 'verify'; + // The account already signed in, when the studio opens the verify face from + // its own banner. + email?: string; onClose: () => void; onDone: () => void; }) { const { t } = useI18n(); - const [mode, setMode] = useState<'login' | 'signup'>(initialMode); - const [email, setEmail] = useState(''); + const [mode, setMode] = useState<'login' | 'signup' | 'verify'>(initialMode); + const [address, setAddress] = useState(''); const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [sent, setSent] = useState(false); const submit = async (e: React.FormEvent) => { e.preventDefault(); setBusy(true); setError(null); try { - await (mode === 'login' ? api.login(email, password) : api.signup(email, password)); + if (mode === 'verify') { + await api.resendVerification(); + setSent(true); + return; + } + const { user } = await (mode === 'login' + ? api.login(address, password) + : api.signup(address, password)); + setAddress(user.email); + if (!user.verified) { + if (mode === 'signup') setSent(true); // signup mails the first link itself + setMode('verify'); + return; + } onDone(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -35,6 +59,26 @@ export function AuthModal({ } }; + if (mode === 'verify') { + const who = email || address; + return ( +
e.target === e.currentTarget && onClose()}> +
+

{t('auth.verifyTitle')}

+

{t('auth.verifyBody', { email: who })}

+

{t('auth.verifyProHint')}

+ {sent ?

{t('auth.verifySent')}

: null} + {error ?

{error}

: null} + + + +
+
+ ); + } + return (
e.target === e.currentTarget && onClose()}>
@@ -45,8 +89,8 @@ export function AuthModal({ required autoComplete="email" placeholder={t('auth.email')} - value={email} - onChange={(e) => setEmail(e.target.value)} + value={address} + onChange={(e) => setAddress(e.target.value)} /> setPassword(e.target.value)} />

{t('auth.passwordHint')}

+ {mode === 'signup' ?

{t('auth.signupVerifyHint')}

: null} {error ?

{error}

: null}