Tier model: registered=basic (recipes, 12 photos, QR), PRO=verified or admin-activated (HSL, tools/gradient mask, RAW, export > source longest edge); admin users table gets Activated Pro column

This commit is contained in:
2026-09-30 11:50:28 +07:00
parent 35d8d57984
commit 74c0b2747f
10 changed files with 264 additions and 122 deletions
+59 -27
View File
@@ -52,6 +52,7 @@ import {
setUserAvatar,
setUserBlocked,
setUserPassword,
setUserPro,
setUserRemoved,
topRatedPhotos,
updateRecipe,
@@ -87,17 +88,26 @@ 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.
// What an account is worth. Being signed in is the basic tier and nothing more
// is required of it (see requireMember): the account has its own recipes and
// its own photo folder. This is the proof of address, which the PRO tier still
// carries on top. 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 PRO tier itself: a proven address, an admin, or a grant the operator
// ticked in the users table. The grant only ever adds — unticking an account
// that has already proven its address leaves it PRO, because the address is
// still the stronger proof of the two.
const isPro = (user: User) => isVerified(user) || user.pro === 1;
// 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.
// `verified` is the proof of address (the client offers the resend/verify
// routes off it); `pro` is the studio's own gate, which is that proof or the
// operator's grant.
// `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.
@@ -106,6 +116,7 @@ const publicUser = (user: User) => ({
email: user.email,
admin: isAdmin(user),
verified: isVerified(user),
pro: isPro(user),
avatar: user.avatar ? `/api/users/${user.id}/avatar?v=${user.avatar.split('.')[0]}` : null,
});
@@ -253,17 +264,29 @@ 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.
// The gate every personal route takes instead of `auth`: the basic tier is
// simply being signed in — recipes, the photo folder, an export of the photo
// being edited. One refusal, because the studio acts on it the one way: 401
// sends a guest to the sign-in dialog.
function requireMember(req: FastifyRequest, reply: FastifyReply): User | undefined {
const user = auth(req);
if (!user) {
void reply.status(401).send({ error: 'unauthorized' });
return undefined;
}
return user;
}
// The PRO tier's own gate, on the routes that are the tier: the geocoder is the
// one left (every other route that used to take it is the basic tier's now).
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' });
if (!isPro(user)) {
void reply.status(403).send({ error: 'pro required' });
return undefined;
}
return user;
@@ -498,7 +521,7 @@ app.get('/api/auth/verify', async (req, reply) => {
// fresh code costs one of the three resends an hour, so five guesses per code
// is the budget an attacker has either way — and the cap lives with the secret.
app.post('/api/auth/verify-code', async (req, reply) => {
// `auth`, not `requirePro`: the whole point of the route is the account that
// `auth`, not `requireMember`: 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' });
@@ -518,7 +541,7 @@ app.post('/api/auth/verify-code', async (req, reply) => {
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
// `auth`, not `requireMember`: 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' });
@@ -564,7 +587,7 @@ 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
// `auth`, not `requireMember`: 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' });
@@ -601,13 +624,13 @@ app.patch('/api/auth/me', async (req, reply) => {
});
app.get('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
return reply.status(200).send({ recipes: listRecipes(user.id) });
});
app.post('/api/recipes', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const b = bodyOf(req);
const payload = b && recipePayload(b);
@@ -618,7 +641,7 @@ app.post('/api/recipes', async (req, reply) => {
});
app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(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' });
@@ -632,7 +655,7 @@ app.put<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
});
app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(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' });
@@ -692,7 +715,7 @@ 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 = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
return reply.status(200).send({ photos: listPhotosByUser(user.id) });
});
@@ -712,7 +735,7 @@ app.get('/api/place', async (req, reply) => {
});
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -743,7 +766,7 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(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' });
@@ -773,7 +796,7 @@ 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 = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
@@ -849,7 +872,7 @@ app.put<{ Params: { id: string } }>(
'/api/photos/:id/base',
{ bodyLimit: MAX_PHOTO_BYTES + 8192 },
async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
if (!allowUpload(String(user.id))) return tooMany(reply);
const id = Number(req.params.id);
@@ -874,7 +897,7 @@ app.put<{ Params: { id: string } }>(
);
app.get<{ Params: { id: string } }>('/api/photos/:id/base', async (req, reply) => {
const user = requirePro(req, reply);
const user = requireMember(req, reply);
if (!user) return;
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'not_found' });
@@ -921,7 +944,7 @@ 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 = requirePro(req, reply);
const user = requireMember(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' });
@@ -936,7 +959,7 @@ 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 = requirePro(req, reply);
const user = requireMember(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' });
@@ -988,6 +1011,9 @@ app.get('/api/admin/users', async (req, reply) => {
admin: ADMIN_EMAILS.has(u.email),
blocked: !!u.blocked,
removed: !!u.deletedAt,
// An allowlisted account is PRO by construction; its stored box is not
// what decides, so the table shows the truth of `isPro`.
pro: !!u.pro || ADMIN_EMAILS.has(u.email.toLowerCase()),
})),
});
});
@@ -1029,8 +1055,14 @@ app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply)
if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' });
setUserRemoved(id, b.removed);
}
if (b.pro !== undefined) {
if (typeof b.pro !== 'boolean') return reply.status(400).send({ error: 'invalid pro' });
setUserPro(id, b.pro);
}
const row = listUsersWithCounts().find((u) => u.id === id);
return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } });
return reply
.status(200)
.send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt, pro: !!row?.pro } });
});
app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {