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