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

A signed-in account is served exactly like a guest until it opens the
verification link: watermarked 2048px export, no saving, no PRO frames,
GPS stamp or HDF. SMTP is declared in .env; with SMTP_HOST unset the link
goes to the container log. Allowlisted admins count as verified.
This commit is contained in:
2026-09-20 07:39:03 +07:00
parent efe578f61c
commit 52b672deec
16 changed files with 633 additions and 74 deletions
+16
View File
@@ -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=
+22 -1
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+70 -4
View File
@@ -82,6 +82,13 @@ CREATE TABLE IF NOT EXISTS ratings (
at TEXT NOT NULL,
PRIMARY KEY (key, visitor)
);
CREATE TABLE IF NOT EXISTS email_verifications (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
@@ -139,6 +146,20 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
}
}
// The address has to be proven before the account is worth anything: an
// unverified signup is a guest with a name (see publicUser/requirePro). The
// column arrives long after the first accounts did, and they were all real —
// they signed up while a valid address was the only door — so the same edit
// that adds the column marks them verified. Only signups from here on start
// unproven.
{
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'email_verified')) {
db.exec(`ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0`);
db.exec(`UPDATE users SET email_verified = 1`);
}
}
// The strip's own labels, added after the first contributions were on disk: the
// tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title
// and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are
@@ -180,12 +201,15 @@ export const PHOTO_HISTORY_MAX = 3;
}
// `avatar` is the stored file name, or null for "no picture".
// `emailVerified` is 0/1 from SQLite; the route layer turns it into the
// `verified` the client reads.
export type User = {
id: number;
email: string;
avatar: string | null;
blocked: number;
deletedAt: string | null;
emailVerified: number;
};
export type Recipe = {
id: number;
@@ -222,7 +246,7 @@ export function createUser(email: string, password: string): User | null {
const info = db
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
.run(email, hashPassword(password), now());
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null };
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null, emailVerified: 0 };
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
throw err;
@@ -232,17 +256,54 @@ export function createUser(email: string, password: string): User | null {
export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
return db
.prepare(
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, password_hash FROM users WHERE email = ?',
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified, password_hash FROM users WHERE email = ?',
)
.get(email) as (User & { password_hash: string }) | undefined;
}
export function findUserById(id: number): User | undefined {
return db
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt FROM users WHERE id = ?')
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt, email_verified AS emailVerified FROM users WHERE id = ?')
.get(id) as User | undefined;
}
// ---- proving the address ---------------------------------------------------
// One live token per account: minting a new one drops the old, so a re-sent
// mail is the only link that works and the table cannot grow past the user
// count. 24 hours is long enough to find the mail in a spam folder.
export const VERIFY_TTL_S = 24 * 60 * 60;
export function createEmailVerification(userId: number): string {
const token = randomBytes(32).toString('hex');
const at = now();
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
db.prepare('INSERT INTO email_verifications (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)').run(
token,
userId,
new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(),
at,
);
return token;
}
// The account the token proves, or null when it is unknown or expired — the
// caller cannot tell the two apart, and neither can an attacker. A used token
// is spent either way.
export function verifyEmailToken(token: string): number | null {
const row = db
.prepare('SELECT user_id AS userId, expires_at AS expiresAt FROM email_verifications WHERE token = ?')
.get(token) as { userId: number; expiresAt: string } | undefined;
if (!row) return null;
db.prepare('DELETE FROM email_verifications WHERE token = ?').run(token);
if (row.expiresAt <= now()) return null;
db.prepare('UPDATE users SET email_verified = 1 WHERE id = ?').run(row.userId);
return row.userId;
}
export function deleteEmailVerifications(userId: number): void {
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId);
}
// Swaps the picture and hands back the file it replaced, so the caller can
// unlink it — the row is the only index of what is on disk.
export function setUserAvatar(id: number, file: string): string | null {
@@ -515,6 +576,7 @@ export function deleteUser(id: number): { photos: string[]; avatar: string | nul
db.prepare('DELETE FROM photos WHERE user_id = ?').run(id);
db.prepare('DELETE FROM recipes WHERE user_id = ?').run(id);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(id);
return { photos, avatar: row.avatar };
}
@@ -523,7 +585,11 @@ export function deleteUser(id: number): { photos: string[]; avatar: string | nul
// sign-up path writes.
export function updateUserEmail(id: number, email: string): boolean {
try {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
// A new address is an unproven one: the flag goes back to 0 and the caller
// mails a fresh link, so the tier can never outlive the address that
// earned it.
db.prepare('UPDATE users SET email = ?, email_verified = 0 WHERE id = ?').run(email, id);
deleteEmailVerifications(id);
return true;
} catch (err) {
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return false;
+58
View File
@@ -0,0 +1,58 @@
import nodemailer, { type Transporter } from 'nodemailer';
// The API sends exactly one kind of mail: the link that proves an address. The
// relay is declared in the deployment's .env, because a mail server is
// infrastructure, not a constant.
//
// With no SMTP_HOST there is nothing to connect to, so the link is written to
// the log instead. That keeps a dev box — or this repo's own test suite — able
// to finish a signup without a mail server, and whoever reads the log is
// already the person running the database.
const SMTP_HOST = (process.env.SMTP_HOST ?? '').trim();
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_USER = (process.env.SMTP_USER ?? '').trim();
const SMTP_PASS = process.env.SMTP_PASS ?? '';
const SMTP_FROM = (process.env.SMTP_FROM ?? '').trim() || SMTP_USER || 'no-reply@recipescam.local';
// 465 is TLS from the first byte; 587 starts in the clear and upgrades. Only a
// port the operator actually chose should be second-guessed.
const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465;
export const mailConfigured = SMTP_HOST !== '';
const transporter: Transporter | null = mailConfigured
? nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_SECURE,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
// A relay that never answers must not hold a request open.
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
})
: null;
// Both languages, because the account's language is not known before it exists.
const body = (url: string) =>
[
'RecipesCam — xác thực địa chỉ email / verify your email address',
'',
url,
'',
'Liên kết hết hạn sau 24 giờ. Nếu bạn không đăng ký, hãy bỏ qua thư này.',
'The link expires in 24 hours. If you did not sign up, ignore this mail.',
].join('\r\n');
// Fire and forget: the account already exists, so a relay that is slow, out of
// quota or misconfigured may not fail the signup that asked for it. The owner
// can ask for another link from the studio; the operator sees the error here.
export function sendVerificationMail(to: string, url: string, log: (msg: string) => void): void {
if (!transporter) {
log(`[verify] SMTP not configured — verification link for ${to}: ${url}`);
return;
}
transporter
.sendMail({ from: SMTP_FROM, to, subject: 'RecipesCam — verify your email', text: body(url) })
.then(() => log(`[verify] link sent to ${to}`))
.catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`));
}
+111 -24
View File
@@ -1,4 +1,5 @@
import { recipeFile } from './recipeFile';
import { sendVerificationMail } from './mailer';
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import { createHash, randomBytes } from 'node:crypto';
import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -11,6 +12,7 @@ import {
SESSION_MAX_AGE_S,
DUMMY_HASH,
countPhotos,
createEmailVerification,
createEvent,
createPhoto,
createRecipe,
@@ -48,6 +50,7 @@ import {
updateRecipe,
updateUserEmail,
userAvatar,
verifyEmailToken,
verifyPassword,
type PhotoMeta,
type Recipe,
@@ -76,9 +79,17 @@ const ADMIN_EMAILS = new Set(
);
const isAdmin = (user: User) => ADMIN_EMAILS.has(user.email.toLowerCase());
// What an account is worth. A signup proves nothing until the address it gave
// is confirmed, so an unverified account is a guest with a name: the PRO tier,
// its own listings and every write stay shut. Admins come from the
// deployment's own allowlist — trusted by construction, so no letter is needed
// and a broken relay cannot lock the operator out of their own site.
const isVerified = (user: User) => user.emailVerified === 1 || isAdmin(user);
// The public shape of an account. `admin` is the allowlist's answer, so the
// client can decide whether to offer /admin without a second round trip — and
// the server still enforces it on every admin route below.
// `verified` is the studio's PRO gate: true only for a proven address.
// `avatar` is a URL the client can drop straight into an <img>, or null when
// the account never picked a picture. The `v` is the stored file's own name, so
// the URL changes with the picture and can be cached hard.
@@ -86,6 +97,7 @@ const publicUser = (user: User) => ({
id: user.id,
email: user.email,
admin: isAdmin(user),
verified: isVerified(user),
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
});
@@ -225,6 +237,42 @@ function auth(req: FastifyRequest): User | undefined {
return token ? sessionUser(token) : undefined;
}
// The gate every personal route takes instead of `auth`. Two different
// refusals, because the studio acts on them differently: 401 sends a guest to
// the sign-in dialog, 403 asks a signed-in account to open its mail.
function requirePro(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
if (!isVerified(user)) {
void reply.status(403).send({ error: 'email not verified' });
return undefined;
}
return user;
}
// The verification link has to work from wherever the visitor actually
// arrived — the deployment's domain, an IP:port, localhost in development.
// nginx forwards the original Host and scheme, so the request already knows
// both; the header is a chain, and the first hop is the one the browser used.
function originOf(req: FastifyRequest): string {
const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v)?.split(',')[0].trim();
const host = first(req.headers['x-forwarded-host']) || req.headers.host || '';
const proto = first(req.headers['x-forwarded-proto']) || req.protocol || 'http';
return host ? `${proto}://${host}` : '';
}
// Mints the single live token and hands the link to the mailer. The URL is the
// API's own route, so a click needs no page of its own (see the redirect
// there). A relay that cannot send is not an error here: the link is in the
// log, and the account can ask again.
function sendVerification(req: FastifyRequest, user: User): void {
const token = createEmailVerification(user.id);
sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, (msg) => req.log.info(msg));
}
// ---- analytics ------------------------------------------------------------
// The page counter. It stores nothing that identifies a visitor: the address
// becomes a salted hash (enough to count uniques) and a coarse place, then it
@@ -379,10 +427,40 @@ app.post('/api/auth/signup', async (req, reply) => {
if (findUserByEmail(creds.email)) return reply.status(409).send({ error: 'email already registered' });
const user = createUser(creds.email, creds.password);
if (!user) return reply.status(409).send({ error: 'email already registered' });
// The session is granted anyway. An unverified account is served at the guest
// tier, but it is a guest that can see the banner saying so and ask for its
// link again — which needs to be somebody.
sendVerification(req, user);
setSession(req, reply, createSession(user.id));
return reply.status(201).send({ user: publicUser(user) });
});
// Where the mail link lands. A plain GET, no session required: the visitor may
// well open it in another browser, or on the phone that owns the address. It
// answers with a redirect rather than JSON for the same reason — the landing
// page is what a browser should show. A bad or expired token is not an error
// page, it is the same page saying the link did not work.
app.get('/api/auth/verify', async (req, reply) => {
const raw = (req.query as { token?: unknown } | undefined)?.token;
const userId = typeof raw === 'string' && raw.length <= 128 ? verifyEmailToken(raw) : null;
return reply.redirect(`${originOf(req)}/?verified=${userId ? 1 : 0}`, 303);
});
// The banner's own button. Capped like signup and keyed on the address, so the
// route is not a way to mail a stranger repeatedly.
const allowResend = limiter(3, 60 * 60_000);
app.post('/api/auth/resend-verification', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// has not passed the gate yet.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (isVerified(user)) return reply.status(200).send({ ok: true, verified: true });
if (!allowResend(user.email)) return tooMany(reply);
sendVerification(req, user);
return reply.status(200).send({ ok: true });
});
app.post('/api/auth/login', async (req, reply) => {
const b = bodyOf(req);
if (!b || typeof b.email !== 'string' || typeof b.password !== 'string')
@@ -419,6 +497,8 @@ app.get('/api/auth/me', async (req, reply) => {
// password is required either way, so a stolen cookie alone cannot lock the
// owner out — and the login limiter caps guesses at it.
app.patch('/api/auth/me', async (req, reply) => {
// `auth`, not `requirePro`: editing your own profile is how an unverified
// account fixes a mistyped address, so this route stays open to it.
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
if (!allowLogin(user.email)) return tooMany(reply);
@@ -430,12 +510,19 @@ app.patch('/api/auth/me', async (req, reply) => {
return reply.status(403).send({ error: 'invalid password' });
let email = user.email;
let emailVerified = user.emailVerified;
if (b.email !== undefined) {
const next = typeof b.email === 'string' ? b.email.trim().toLowerCase() : '';
if (!next || next.length > MAX_EMAIL || !EMAIL_RE.test(next)) return reply.status(400).send({ error: 'invalid email' });
if (next !== user.email && !updateUserEmail(user.id, next))
return reply.status(409).send({ error: 'email already registered' });
email = next;
if (next !== user.email) {
if (!updateUserEmail(user.id, next)) return reply.status(409).send({ error: 'email already registered' });
email = next;
// The tier follows the address that earned it: a new one is unproven
// until its own link is followed, so the flag goes back to 0 (the update
// cleared the row) and a letter goes out.
emailVerified = 0;
sendVerification(req, { ...user, email });
}
}
if (b.password !== undefined) {
const password = typeof b.password === 'string' ? b.password : '';
@@ -443,18 +530,18 @@ app.patch('/api/auth/me', async (req, reply) => {
return reply.status(400).send({ error: `password must be ${MIN_PASSWORD}-${MAX_PASSWORD} characters` });
setUserPassword(user.id, password);
}
return reply.status(200).send({ user: publicUser({ ...user, email }) });
return reply.status(200).send({ user: publicUser({ ...user, email, emailVerified }) });
});
app.get('/api/recipes', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
return reply.status(200).send({ recipes: listRecipes(user.id) });
});
app.post('/api/recipes', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const b = bodyOf(req);
const payload = b && recipePayload(b);
if (typeof payload === 'string' || !payload)
@@ -464,8 +551,8 @@ app.post('/api/recipes', async (req, reply) => {
});
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
const b = bodyOf(req);
@@ -478,8 +565,8 @@ app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
});
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'recipe not found' });
if (!deleteRecipe(user.id, id)) return reply.status(404).send({ error: 'recipe not found' });
@@ -538,14 +625,14 @@ app.get('/api/photos', async () => ({ photos: listPhotos() }));
// The caller's own folder — the count the studio's SAVE PHOTO shows comes from
// here, and the admin drill-down reads the same rows through /admin/photos.
app.get('/api/photos/mine', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const body = req.body;
@@ -575,8 +662,8 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -605,8 +692,8 @@ app.put<{ Params: { id: string } }>(
// A profile picture is the same deal as a photo: raw bytes, sniffed, written
// under a server-generated name. The picture it replaces goes with it.
app.post('/api/auth/avatar', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const body = req.body;
@@ -693,8 +780,8 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/preset.recipe', async (req,
// consent), and only their own row is reachable — the user_id in the WHERE is
// the authorisation.
app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const body = (req.body ?? {}) as { consent?: unknown };
@@ -708,8 +795,8 @@ app.patch<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
// dropped when the caller owns it (or curates the whole strip), and the file
// goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink.
app.delete<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => {
const user = auth(req);
if (!user) return reply.status(401).send({ error: 'unauthorized' });
const user = requirePro(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
const file = isAdmin(user) ? deletePhoto(id) : deletePhotoOf(user.id, id);
+80 -6
View File
@@ -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`;
+9
View File
@@ -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
+4 -1
View File
@@ -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
+121 -26
View File
@@ -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<User | null>(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<SavedRecipe[]>([]);
// 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 ? (
<div className="verify-bar" role="status">
<span>{t('pro.unverifiedBar', { email: user.email })}</span>
<button
type="button"
className="btn"
data-key="verify-resend"
disabled={resent}
onClick={() => void resendVerification()}
>
{resent ? t('pro.resent') : t('pro.resend')}
</button>
</div>
) : null}
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} />
@@ -1757,7 +1851,7 @@ export function Workspace() {
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
signedIn={pro}
resetToken={createReset}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
@@ -2041,6 +2135,7 @@ export function Workspace() {
{authMode ? (
<AuthModal
initialMode={authMode}
email={user?.email}
onClose={() => setAuthMode(null)}
onDone={async () => {
setAuthMode(null);
+38 -4
View File
@@ -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<string | null>(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<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [ratings, setRatings] = useState<Record<string, Rating>>({});
@@ -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 ? (
<AuthModal
initialMode={auth}
email={user?.email}
onClose={() => setAuth(null)}
onDone={() => {
setAuth(null);
@@ -959,7 +993,7 @@ export function Landing() {
/>
) : null}
{toast ? <div className="lp-toast" role="status"><b>{c({ en: 'Coming soon —', vi: 'Sắp có —' })}</b> {toast}</div> : null}
{toast ? <div className="lp-toast" role="status"><b>{toast.lead}</b> {toast.text}</div> : null}
</div>
);
}
+6
View File
@@ -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/<id>/avatar?v=<file>`), 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<void>('/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) =>
+13
View File
@@ -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',
+13
View File
@@ -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',
+17
View File
@@ -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 {
+52 -7
View File
@@ -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<string | null>(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 (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<form className="modal" onSubmit={submit}>
<h2>{t('auth.verifyTitle')}</h2>
<p className="hint">{t('auth.verifyBody', { email: who })}</p>
<p className="hint">{t('auth.verifyProHint')}</p>
{sent ? <p className="hint ok">{t('auth.verifySent')}</p> : null}
{error ? <p className="err">{error}</p> : null}
<button type="submit" className="btn primary" data-key="verify-resend" disabled={busy}>
{busy ? t('auth.busy') : t('pro.resend')}
</button>
<button type="button" className="btn" onClick={onDone}>{t('auth.verifiedDone')}</button>
<button type="button" className="btn ghost" onClick={onClose}>{t('auth.continueDemo')}</button>
</form>
</div>
);
}
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<form className="modal" onSubmit={submit}>
@@ -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)}
/>
<input
className="field"
@@ -59,6 +103,7 @@ export function AuthModal({
onChange={(e) => setPassword(e.target.value)}
/>
<p className="hint">{t('auth.passwordHint')}</p>
{mode === 'signup' ? <p className="hint">{t('auth.signupVerifyHint')}</p> : null}
{error ? <p className="err">{error}</p> : null}
<button type="submit" className="btn primary" disabled={busy}>
{busy ? t('auth.busy') : mode === 'login' ? t('nav.login') : t('nav.signup')}