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 ? (
+
@@ -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 ?
);
}
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 (
+