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