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:
Generated
+22
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user