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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user