web: moderate accounts, accept 12MB uploads, put SAVE under CREATE
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE. Blocked = cannot sign in (sessions swept), removed = hidden from the strip and cannot sign in, both reversible; DELETE drops the account with its photos and recipes and unlinks the files. An allowlisted account is never a target, so an admin cannot moderate or delete itself. - Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and the browser shrinks an oversized still before sending it (2048px JPEG, avatars 512px) so the declared type still matches the sniffed bytes. - The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab, labelled SAVE RECIPES.
This commit is contained in:
+92
-12
@@ -6,9 +6,13 @@ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
export const SESSION_COOKIE = 'rc_session';
|
||||
export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days
|
||||
export const MAX_RECIPE_BYTES = 256 * 1024;
|
||||
// Under nginx's `client_max_body_size 4m`, so an over-limit upload is rejected
|
||||
// with our JSON error instead of nginx's HTML 413.
|
||||
export const MAX_PHOTO_BYTES = 3 * 1024 * 1024;
|
||||
// A phone's 12MP JPEG lands around 4-8MB, so 3MB rejected real photos with a
|
||||
// 413. The client downscales to 2048px before uploading (see shrinkForUpload),
|
||||
// which keeps normal uploads well under this; the cap stays generous for a
|
||||
// full-size PNG or a photo that arrived from elsewhere. Under nginx's
|
||||
// `client_max_body_size 16m`, so an over-limit upload is still rejected with
|
||||
// our JSON error instead of nginx's HTML 413.
|
||||
export const MAX_PHOTO_BYTES = 12 * 1024 * 1024;
|
||||
export const MAX_PHOTOS_PER_USER = 12;
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
@@ -86,8 +90,28 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
|
||||
}
|
||||
}
|
||||
|
||||
// Moderation state, added after the first accounts existed:
|
||||
// blocked — may not sign in (or stay signed in); the row is kept whole.
|
||||
// deleted_at — "removed" from the site: hidden from the strip, cannot sign
|
||||
// in, but restorable. A hard DELETE is the separate, final act.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'blocked')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0`);
|
||||
}
|
||||
if (!cols.some((c) => c.name === 'deleted_at')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN deleted_at TEXT`);
|
||||
}
|
||||
}
|
||||
|
||||
// `avatar` is the stored file name, or null for "no picture".
|
||||
export type User = { id: number; email: string; avatar: string | null };
|
||||
export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
avatar: string | null;
|
||||
blocked: number;
|
||||
deletedAt: string | null;
|
||||
};
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -123,7 +147,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 };
|
||||
return { id: Number(info.lastInsertRowid), email, avatar: null, blocked: 0, deletedAt: null };
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
|
||||
throw err;
|
||||
@@ -132,12 +156,16 @@ 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, password_hash FROM users WHERE email = ?')
|
||||
.prepare(
|
||||
'SELECT id, email, avatar, blocked, deleted_at AS deletedAt, 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 FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
return db
|
||||
.prepare('SELECT id, email, avatar, blocked, deleted_at AS deletedAt FROM users WHERE id = ?')
|
||||
.get(id) as User | undefined;
|
||||
}
|
||||
|
||||
// Swaps the picture and hands back the file it replaced, so the caller can
|
||||
@@ -176,7 +204,10 @@ export function sessionUser(token: string): User | undefined {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(row.token); // lazy cleanup
|
||||
return undefined;
|
||||
}
|
||||
return findUserById(row.userId);
|
||||
const user = findUserById(row.userId);
|
||||
// Belt to the braces of the session sweep in setUserBlocked/setUserRemoved.
|
||||
if (!user || user.blocked || user.deletedAt) return undefined;
|
||||
return user;
|
||||
}
|
||||
|
||||
export function deleteSession(token: string): void {
|
||||
@@ -231,7 +262,12 @@ export type AdminPhoto = Photo & { userId: number; email: string; mime: string;
|
||||
|
||||
export function listPhotos(): Photo[] {
|
||||
return db
|
||||
.prepare('SELECT id, created_at AS createdAt, slot FROM photos ORDER BY id DESC')
|
||||
.prepare(
|
||||
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot
|
||||
FROM photos JOIN users ON users.id = photos.user_id
|
||||
WHERE users.deleted_at IS NULL
|
||||
ORDER BY photos.id DESC`,
|
||||
)
|
||||
.all() as Photo[];
|
||||
}
|
||||
|
||||
@@ -247,14 +283,24 @@ export function listPhotosWithOwner(): AdminPhoto[] {
|
||||
.all() as AdminPhoto[];
|
||||
}
|
||||
|
||||
// Admin listing: one row per account with how many photos it owns.
|
||||
export type AdminUser = { id: number; email: string; createdAt: string; photos: number; avatar: string | null };
|
||||
// Admin listing: one row per account with how many photos it owns. Blocked and
|
||||
// removed accounts stay listed — a removed one has to be findable to restore it.
|
||||
export type AdminUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
photos: number;
|
||||
avatar: string | null;
|
||||
blocked: number;
|
||||
deletedAt: string | null;
|
||||
};
|
||||
|
||||
export function listUsersWithCounts(): AdminUser[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
|
||||
users.avatar AS avatar, COUNT(photos.id) AS photos
|
||||
users.avatar AS avatar, users.blocked AS blocked,
|
||||
users.deleted_at AS deletedAt, COUNT(photos.id) AS photos
|
||||
FROM users LEFT JOIN photos ON photos.user_id = users.id
|
||||
GROUP BY users.id
|
||||
ORDER BY users.id`,
|
||||
@@ -262,6 +308,40 @@ export function listUsersWithCounts(): AdminUser[] {
|
||||
.all() as AdminUser[];
|
||||
}
|
||||
|
||||
// ---- moderation -------------------------------------------------------------
|
||||
// Blocking and removing both drop the account's live sessions: the state has to
|
||||
// take effect on the next request, not whenever the cookie happens to expire.
|
||||
export function setUserBlocked(id: number, blocked: boolean): boolean {
|
||||
const info = db.prepare('UPDATE users SET blocked = ? WHERE id = ?').run(blocked ? 1 : 0, id);
|
||||
if (info.changes > 0 && blocked) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
export function setUserRemoved(id: number, removed: boolean): boolean {
|
||||
const info = db
|
||||
.prepare('UPDATE users SET deleted_at = ? WHERE id = ?')
|
||||
.run(removed ? now() : null, id);
|
||||
if (info.changes > 0 && removed) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
// The final act: the row and everything hanging off it. Returns the files the
|
||||
// caller has to unlink — the rows are the only index of what is on disk.
|
||||
export function deleteUser(id: number): { photos: string[]; avatar: string | null } | undefined {
|
||||
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as
|
||||
| { avatar: string | null }
|
||||
| undefined;
|
||||
if (!row) return undefined;
|
||||
const photos = (db.prepare('SELECT file FROM photos WHERE user_id = ?').all(id) as { file: string }[]).map(
|
||||
(r) => r.file,
|
||||
);
|
||||
if (db.prepare('DELETE FROM users WHERE id = ?').run(id).changes === 0) return undefined;
|
||||
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);
|
||||
return { photos, avatar: row.avatar };
|
||||
}
|
||||
|
||||
// Profile edits. The email column is UNIQUE, so a taken address comes back as
|
||||
// false rather than a thrown constraint; the password uses the same hash the
|
||||
// sign-up path writes.
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
deletePhoto,
|
||||
deleteRecipe,
|
||||
deleteSession,
|
||||
deleteUser,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
isPhotoSlot,
|
||||
listPhotos,
|
||||
listPhotosWithOwner,
|
||||
@@ -30,7 +32,9 @@ import {
|
||||
sessionUser,
|
||||
setPhotoSlot,
|
||||
setUserAvatar,
|
||||
setUserBlocked,
|
||||
setUserPassword,
|
||||
setUserRemoved,
|
||||
updateRecipe,
|
||||
updateUserEmail,
|
||||
userAvatar,
|
||||
@@ -233,8 +237,12 @@ app.post('/api/auth/login', async (req, reply) => {
|
||||
const row = findUserByEmail(email);
|
||||
const ok = verifyPassword(b.password, row?.password_hash ?? DUMMY_HASH);
|
||||
if (!row || !ok) return reply.status(401).send({ error: 'invalid credentials' });
|
||||
// Moderation answers after the password check, so the state of an account is
|
||||
// not something an attacker can probe without its credentials.
|
||||
if (row.blocked) return reply.status(403).send({ error: 'account blocked' });
|
||||
if (row.deletedAt) return reply.status(403).send({ error: 'account removed' });
|
||||
setSession(req, reply, createSession(row.id));
|
||||
return reply.status(200).send({ user: publicUser({ id: row.id, email: row.email, avatar: row.avatar }) });
|
||||
return reply.status(200).send({ user: publicUser(row) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
@@ -280,7 +288,7 @@ 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({ id: user.id, email, avatar: user.avatar }) });
|
||||
return reply.status(200).send({ user: publicUser({ ...user, email }) });
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (req, reply) => {
|
||||
@@ -456,10 +464,65 @@ app.get('/api/admin/users', async (req, reply) => {
|
||||
...u,
|
||||
avatar: u.avatar ? `/api/users/${u.id}/avatar?v=${u.avatar.split('.')[0]}` : null,
|
||||
admin: ADMIN_EMAILS.has(u.email),
|
||||
blocked: !!u.blocked,
|
||||
removed: !!u.deletedAt,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Moderation of an account. `blocked` stops it signing in; `removed` takes it
|
||||
// (and its photos) off the site while staying restorable. Both are reversible,
|
||||
// which is why they share one route — the hard delete is the DELETE below.
|
||||
// An allowlisted account is never a target: the allowlist is the only source of
|
||||
// admin privilege, so this also makes "delete yourself" impossible.
|
||||
function moderatable(reply: FastifyReply, id: number): number | null {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
reply.status(404).send({ error: 'user not found' });
|
||||
return null;
|
||||
}
|
||||
const target = findUserById(id);
|
||||
if (!target) {
|
||||
reply.status(404).send({ error: 'user not found' });
|
||||
return null;
|
||||
}
|
||||
if (ADMIN_EMAILS.has(target.email.toLowerCase())) {
|
||||
reply.status(403).send({ error: 'cannot modify an admin account' });
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {
|
||||
const user = admin(req);
|
||||
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
const id = moderatable(reply, Number(req.params.id));
|
||||
if (id === null) return reply;
|
||||
const b = bodyOf(req);
|
||||
if (!b) return reply.status(400).send({ error: 'invalid body' });
|
||||
if (b.blocked !== undefined) {
|
||||
if (typeof b.blocked !== 'boolean') return reply.status(400).send({ error: 'invalid blocked' });
|
||||
setUserBlocked(id, b.blocked);
|
||||
}
|
||||
if (b.removed !== undefined) {
|
||||
if (typeof b.removed !== 'boolean') return reply.status(400).send({ error: 'invalid removed' });
|
||||
setUserRemoved(id, b.removed);
|
||||
}
|
||||
const row = listUsersWithCounts().find((u) => u.id === id);
|
||||
return reply.status(200).send({ user: { ...row, blocked: !!row?.blocked, removed: !!row?.deletedAt } });
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/admin/users/:id', async (req, reply) => {
|
||||
const user = admin(req);
|
||||
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
const id = moderatable(reply, Number(req.params.id));
|
||||
if (id === null) return reply;
|
||||
const removed = deleteUser(id);
|
||||
if (!removed) return reply.status(404).send({ error: 'user not found' });
|
||||
for (const file of removed.photos) unlink(file);
|
||||
if (removed.avatar) unlinkAvatar(removed.avatar);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get('/api/admin/photos', async (req, reply) => {
|
||||
const user = admin(req);
|
||||
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
|
||||
@@ -187,7 +187,7 @@ try {
|
||||
);
|
||||
check('empty body is refused', (await user.upload(Buffer.alloc(0), 'image/png')).status === 400);
|
||||
|
||||
const tooBig = Buffer.concat([PNG, Buffer.alloc(4 * 1024 * 1024)]);
|
||||
const tooBig = Buffer.concat([PNG, Buffer.alloc(13 * 1024 * 1024)]);
|
||||
check('an over-limit body is refused', (await user.upload(tooBig, 'image/png')).status === 413);
|
||||
|
||||
const created = await user.upload(PNG, 'image/png');
|
||||
@@ -345,6 +345,57 @@ try {
|
||||
const memberRow = listedUsers.find((u) => u.id === memberId);
|
||||
check('the user list carries each account’s picture', memberRow?.avatar === replacedUrl, JSON.stringify(memberRow));
|
||||
|
||||
// ---- moderation: block, remove, delete an account -----------------------
|
||||
const target = actor();
|
||||
await target.signup(`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`;
|
||||
const moderate = (a, id, body) => a.req(`/admin/users/${id}`, { method: 'PATCH', headers: JSON_HDR, body: JSON.stringify(body) });
|
||||
check('a fresh account is moderatable', Number.isInteger(targetId) && Number.isInteger(targetPhoto?.id), `${targetId}/${targetPhoto?.id}`);
|
||||
|
||||
const anonModerate = await moderate(actor(), targetId, { blocked: true });
|
||||
check('moderating needs a session', anonModerate.status === 401, `got ${anonModerate.status}`);
|
||||
const memberModerate = await moderate(member, targetId, { blocked: true });
|
||||
check('a member cannot moderate', memberModerate.status === 403, `got ${memberModerate.status}`);
|
||||
const selfModerate = await moderate(admin, adminRow?.id, { blocked: true });
|
||||
check('an allowlisted account cannot be moderated', selfModerate.status === 403, `got ${selfModerate.status}`);
|
||||
const ghostModerate = await moderate(admin, 999999, { blocked: true });
|
||||
check('an unknown account is a 404', ghostModerate.status === 404, `got ${ghostModerate.status}`);
|
||||
const badModerate = await moderate(admin, targetId, { blocked: 'yes' });
|
||||
check('a moderation body is type-checked', badModerate.status === 400, `got ${badModerate.status}`);
|
||||
|
||||
const blocked = await moderate(admin, targetId, { blocked: true });
|
||||
check('an admin blocks an account', blocked.status === 200 && blocked.body?.user?.blocked === true, JSON.stringify(blocked.body));
|
||||
const blockedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
|
||||
check('a blocked account cannot sign in', blockedLogin.status === 403 && blockedLogin.body?.error === 'account blocked', JSON.stringify(blockedLogin.body));
|
||||
check('a blocked session stops being a user', (await target.req('/auth/me')).body?.user === null, JSON.stringify((await target.req('/auth/me')).body));
|
||||
const blockedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
|
||||
check('the list flags a blocked account', blockedRow?.blocked === true, JSON.stringify(blockedRow));
|
||||
|
||||
const unblocked = await moderate(admin, targetId, { blocked: false });
|
||||
check('an admin unblocks an account', unblocked.status === 200 && unblocked.body?.user?.blocked === false, JSON.stringify(unblocked.body));
|
||||
check('an unblocked account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
|
||||
check('its photo is back on the strip', ((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
|
||||
|
||||
const removed = await moderate(admin, targetId, { removed: true });
|
||||
check('an admin removes an account', removed.status === 200 && removed.body?.user?.removed === true, JSON.stringify(removed.body));
|
||||
const removedLogin = await login(`moderated${stamp}@test.local`, 'supersecret1');
|
||||
check('a removed account cannot sign in', removedLogin.status === 403 && removedLogin.body?.error === 'account removed', JSON.stringify(removedLogin.body));
|
||||
check('a removed account’s photos leave the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === targetPhoto?.id));
|
||||
const removedRow = ((await admin.req('/admin/users')).body?.users ?? []).find((u) => u.id === targetId);
|
||||
check('the list flags a removed account', removedRow?.removed === true, JSON.stringify(removedRow));
|
||||
|
||||
const restored = await moderate(admin, targetId, { removed: false });
|
||||
check('an admin restores an account', restored.status === 200 && restored.body?.user?.removed === false, JSON.stringify(restored.body));
|
||||
check('a restored account signs in again', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 200);
|
||||
|
||||
const hardDelete = await admin.req(`/admin/users/${targetId}`, { method: 'DELETE' });
|
||||
check('an admin deletes an account outright', hardDelete.status === 204, `got ${hardDelete.status}`);
|
||||
check('a deleted account leaves the list', !((await admin.req('/admin/users')).body?.users ?? []).some((u) => u.id === targetId));
|
||||
check('a deleted account cannot sign in', (await login(`moderated${stamp}@test.local`, 'supersecret1')).status === 401);
|
||||
check('a deleted account’s photo file is unlinked', (await fetch(targetPhotoUrl)).status === 404);
|
||||
|
||||
// ---- pre-existing guarantees still hold ---------------------------------
|
||||
const foreignRecipe = await user.req('/recipes/1', { method: 'DELETE' });
|
||||
check("another account's recipe is not deletable", foreignRecipe.status === 404, `got ${foreignRecipe.status}`);
|
||||
|
||||
@@ -39,7 +39,8 @@ server {
|
||||
# Nginx Proxy Manager in front of this container) so it can mark the session
|
||||
# cookie Secure. Falls back to nothing when that header is absent.
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
client_max_body_size 4m;
|
||||
# Room for a full-size upload (the API caps photo bodies at 12m itself).
|
||||
client_max_body_size 16m;
|
||||
}
|
||||
|
||||
# SPA fallback: /app, /login and /signup are all index.html.
|
||||
|
||||
@@ -116,6 +116,22 @@ export function Admin() {
|
||||
setTab('pictures');
|
||||
};
|
||||
|
||||
// Block/unblock and remove/restore share one route; the list is reloaded
|
||||
// because either flag also changes what the built-in viewer would show.
|
||||
const setUserState = (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
|
||||
run(async () => {
|
||||
await api.adminSetUser(id, patch);
|
||||
await refreshPhotos();
|
||||
});
|
||||
|
||||
const deleteUser = (u: AdminUser) => {
|
||||
if (!window.confirm(t('adm.userDeleteConfirm', { email: u.email }))) return;
|
||||
return run(async () => {
|
||||
await api.adminDeleteUser(u.id);
|
||||
await refreshPhotos();
|
||||
});
|
||||
};
|
||||
|
||||
const shown = owner ? photos.filter((p) => p.email === owner) : photos;
|
||||
|
||||
return (
|
||||
@@ -270,7 +286,11 @@ export function Admin() {
|
||||
<span className="adm-face adm-face-empty">{(u.email[0] ?? '?').toUpperCase()}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.email.split('@')[0]}</td>
|
||||
<td>
|
||||
{u.email.split('@')[0]}
|
||||
{u.blocked ? <span className="adm-badge adm-badge-warn">{t('adm.userBlocked')}</span> : null}
|
||||
{u.removed ? <span className="adm-badge adm-badge-warn">{t('adm.userRemoved')}</span> : null}
|
||||
</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
<span className={`adm-badge${u.admin ? ' on' : ''}`}>
|
||||
@@ -288,6 +308,40 @@ export function Admin() {
|
||||
>
|
||||
{t('adm.viewPhotos')}
|
||||
</button>
|
||||
{/* An admin account is the API's own privilege
|
||||
source, so it is never a moderation target and
|
||||
offers no buttons here. */}
|
||||
{u.admin ? null : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key={`adm-user-block-${u.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void setUserState(u.id, { blocked: !u.blocked })}
|
||||
>
|
||||
{u.blocked ? t('adm.userUnblock') : t('adm.userBlock')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key={`adm-user-remove-${u.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void setUserState(u.id, { removed: !u.removed })}
|
||||
>
|
||||
{u.removed ? t('adm.userRestore') : t('adm.userRemove')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`adm-user-delete-${u.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void deleteUser(u)}
|
||||
>
|
||||
{t('adm.userDelete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
+24
-11
@@ -1023,8 +1023,9 @@ export function Workspace() {
|
||||
setShowRecipes((v) => !v);
|
||||
},
|
||||
},
|
||||
// ponytail: no CREATE chip — saving goes through the top bar. IMPORT
|
||||
// is a chip inside the RECIPES strip.
|
||||
// ponytail: no CREATE chip — the save button lives under the CREATE
|
||||
// RECIPES tab (see the create column). IMPORT is a chip inside the
|
||||
// RECIPES strip.
|
||||
];
|
||||
case 'favorited':
|
||||
return saved.length === 0
|
||||
@@ -1114,7 +1115,6 @@ export function Workspace() {
|
||||
onUndo={undoLast}
|
||||
onReset={reset}
|
||||
onExport={exportPhoto}
|
||||
onSave={saveRecipe}
|
||||
onAuth={() => setAuthMode('login')}
|
||||
onSignup={() => setAuthMode('signup')}
|
||||
onLogout={async () => {
|
||||
@@ -1131,14 +1131,27 @@ export function Workspace() {
|
||||
<div className={`col col-main${tab === 'create' ? ' col-create' : ''}`} data-col="main">
|
||||
<ChipRow chips={[resetChip()]} />
|
||||
{tab === 'create' ? (
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
onPreview={previewCreatedRecipe}
|
||||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||||
onExport={exportRecipeFile}
|
||||
/>
|
||||
<>
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
onPreview={previewCreatedRecipe}
|
||||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||||
onExport={exportRecipeFile}
|
||||
/>
|
||||
{/* The studio's own save lives here now, under the CREATE tab:
|
||||
it stores the recipe standing on the stage, where the panel
|
||||
above stores the draft being built. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="save-recipes"
|
||||
onClick={() => void saveRecipe()}
|
||||
>
|
||||
{t('save.recipes')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<ChipRow chips={chips} />
|
||||
)}
|
||||
|
||||
+34
-12
@@ -1,6 +1,14 @@
|
||||
// Thin wrapper over the API container. Same origin in production (nginx proxies
|
||||
// /api), the Vite dev server proxies it too — so no base URL, no CORS.
|
||||
import type { Recipe } from '../shared/types';
|
||||
import { shrinkForUpload } from './engine/imageOps';
|
||||
|
||||
// Upload caps. The API + nginx both refuse oversized bodies, so shrink in the
|
||||
// browser first; the server then sniffs the bytes and requires the declared
|
||||
// content-type to match, which is why the type follows the re-encode below.
|
||||
const MAX_PHOTO_UPLOAD = 12 * 1024 * 1024;
|
||||
const MAX_PHOTO_DIM = 2048;
|
||||
const MAX_AVATAR_DIM = 512;
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
@@ -52,6 +60,10 @@ export interface AdminUser {
|
||||
photos: number;
|
||||
admin: boolean;
|
||||
avatar: string | null;
|
||||
// Moderation state: blocked = cannot sign in; removed = pulled off the site
|
||||
// but restorable (a hard delete drops the row entirely).
|
||||
blocked: boolean;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
async function call<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -78,6 +90,20 @@ async function readJson(res: Response): Promise<{ error?: string } & Record<stri
|
||||
}
|
||||
}
|
||||
|
||||
// POST a picture as raw bytes, after shrinking it under the server's caps.
|
||||
// Declared type stays the original unless the bytes were re-encoded to JPEG —
|
||||
// the server compares it against the sniffed magic number.
|
||||
async function uploadBytes(url: string, file: File, maxDim: number, maxBytes: number): Promise<Response> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const out = await shrinkForUpload(bytes, maxDim, maxBytes);
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': out === bytes ? file.type || 'image/jpeg' : 'image/jpeg' },
|
||||
body: out as unknown as BodyInit,
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// null user = signed out; the API answers 200 either way.
|
||||
me: () => call<{ user: User | null }>('/auth/me'),
|
||||
@@ -98,12 +124,7 @@ export const api = {
|
||||
// request, so no multipart framing and no extra dependency.
|
||||
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
|
||||
uploadPhoto: async (file: File) => {
|
||||
const res = await fetch('/api/photos', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': file.type },
|
||||
body: file,
|
||||
});
|
||||
const res = await uploadBytes('/api/photos', file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as unknown as { photo: Photo };
|
||||
@@ -118,6 +139,12 @@ export const api = {
|
||||
adminSetPhotoSlot: (id: number, slot: PhotoSlot) =>
|
||||
call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }),
|
||||
|
||||
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
|
||||
// the final act and takes the account's photos and recipes with it.
|
||||
adminSetUser: (id: number, patch: { blocked?: boolean; removed?: boolean }) =>
|
||||
call<{ user: AdminUser }>(`/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
adminDeleteUser: (id: number) => call<void>(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Own profile. The API wants `currentPassword` on every edit, even an email-only one.
|
||||
updateProfile: (body: { email?: string; password?: string; currentPassword: string }) =>
|
||||
call<{ user: User }>('/auth/me', { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
@@ -125,12 +152,7 @@ export const api = {
|
||||
// The profile picture, raw bytes like a photo. Replacing it deletes the old
|
||||
// file, so the returned User carries a new `?v=` and nothing goes stale.
|
||||
uploadAvatar: async (file: File) => {
|
||||
const res = await fetch('/api/auth/avatar', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'content-type': file.type },
|
||||
body: file,
|
||||
});
|
||||
const res = await uploadBytes('/api/auth/avatar', file, MAX_AVATAR_DIM, MAX_PHOTO_UPLOAD);
|
||||
const body = await readJson(res);
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
return body as unknown as { user: User };
|
||||
|
||||
@@ -31,6 +31,43 @@ export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Uploads are capped by the API (and by nginx in front of it), so a phone's
|
||||
// 12MP JPEG has to shrink before it goes up — the same downscale the preview
|
||||
// uses. The ORIGINAL bytes come back untouched (same reference) when the photo
|
||||
// already fits both the pixel and the byte budget, which is what keeps a small
|
||||
// PNG's declared content-type honest instead of silently turning it into JPEG.
|
||||
export async function shrinkForUpload(
|
||||
bytes: Uint8Array,
|
||||
maxDim: number,
|
||||
maxBytes: number
|
||||
): Promise<Uint8Array> {
|
||||
try {
|
||||
return await shrinkOrKeep(bytes, maxDim, maxBytes);
|
||||
} catch {
|
||||
// Undecodable here (corrupt or exotic): hand it to the server unchanged and
|
||||
// let its magic-number sniff give the real answer.
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
async function shrinkOrKeep(bytes: Uint8Array, maxDim: number, maxBytes: number): Promise<Uint8Array> {
|
||||
const resized = await resizedJpeg(bytes, maxDim);
|
||||
if (resized !== bytes) return resized;
|
||||
if (bytes.length <= maxBytes) return bytes;
|
||||
// Already inside maxDim yet still over the byte cap: force one JPEG re-encode.
|
||||
const bitmap = await createImageBitmap(new Blob([bytes as BlobPart]));
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return bytes;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
|
||||
// Returns null when the photo has none — the caller then offers manual entry.
|
||||
export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
|
||||
|
||||
@@ -103,7 +103,7 @@ export const en: Dict = {
|
||||
|
||||
'save.title': 'Save recipe',
|
||||
'save.name': 'Recipe name',
|
||||
'save.save': 'SAVE',
|
||||
'save.recipes': 'SAVE RECIPES',
|
||||
'save.saved': 'Saved "{name}"',
|
||||
'save.needLogin': 'Log in to save a recipe.',
|
||||
|
||||
@@ -159,6 +159,15 @@ export const en: Dict = {
|
||||
'adm.newPassword': 'New password',
|
||||
'adm.save': 'SAVE',
|
||||
'adm.saved': 'Profile saved.',
|
||||
'adm.userActions': 'Actions',
|
||||
'adm.userBlock': 'BLOCK',
|
||||
'adm.userUnblock': 'UNBLOCK',
|
||||
'adm.userRemove': 'REMOVE',
|
||||
'adm.userRestore': 'RESTORE',
|
||||
'adm.userDelete': 'DELETE',
|
||||
'adm.userBlocked': 'Blocked',
|
||||
'adm.userRemoved': 'Removed',
|
||||
'adm.userDeleteConfirm': 'Permanently delete the account {email} with all of its photos and recipes? This cannot be undone.',
|
||||
|
||||
'profile.title': 'My profile',
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export const vi = {
|
||||
|
||||
'save.title': 'Lưu công thức',
|
||||
'save.name': 'Tên công thức',
|
||||
'save.save': 'LƯU',
|
||||
'save.recipes': 'LƯU CÔNG THỨC',
|
||||
'save.saved': 'Đã lưu "{name}"',
|
||||
'save.needLogin': 'Đăng nhập để lưu công thức.',
|
||||
|
||||
@@ -162,6 +162,15 @@ export const vi = {
|
||||
'adm.newPassword': 'Mật khẩu mới',
|
||||
'adm.save': 'LƯU',
|
||||
'adm.saved': 'Đã lưu hồ sơ.',
|
||||
'adm.userActions': 'Thao tác',
|
||||
'adm.userBlock': 'KHOÁ',
|
||||
'adm.userUnblock': 'MỞ KHOÁ',
|
||||
'adm.userRemove': 'GỠ',
|
||||
'adm.userRestore': 'KHÔI PHỤC',
|
||||
'adm.userDelete': 'XOÁ HẲN',
|
||||
'adm.userBlocked': 'Đã khoá',
|
||||
'adm.userRemoved': 'Đã gỡ',
|
||||
'adm.userDeleteConfirm': 'Xoá vĩnh viễn tài khoản {email} cùng toàn bộ ảnh và công thức của nó? Không hoàn tác được.',
|
||||
|
||||
'profile.title': 'Hồ sơ của tôi',
|
||||
|
||||
|
||||
@@ -400,6 +400,11 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
}
|
||||
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.adm-filter { display: flex; align-items: center; gap: 8px; }
|
||||
/* Moderation state next to the account name, and the row's own actions: the
|
||||
three controls sit side by side and the pane scrolls when it runs out. */
|
||||
.adm-badge-warn { margin-left: 6px; color: var(--danger); font-size: 11px; }
|
||||
.adm-table td:last-child { white-space: nowrap; }
|
||||
.adm-table td:last-child .btn { margin-right: 6px; }
|
||||
.adm-form { display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
|
||||
.field-label { font-size: 11.5px; color: var(--text-dim); }
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export function TopBar({
|
||||
onUndo,
|
||||
onReset,
|
||||
onExport,
|
||||
onSave,
|
||||
onAuth,
|
||||
onSignup,
|
||||
onLogout,
|
||||
@@ -26,7 +25,6 @@ export function TopBar({
|
||||
onUndo: () => void;
|
||||
onReset: () => void;
|
||||
onExport: () => void;
|
||||
onSave: () => void;
|
||||
onAuth: () => void;
|
||||
onSignup: () => void;
|
||||
onLogout: () => void;
|
||||
@@ -57,7 +55,6 @@ export function TopBar({
|
||||
|
||||
<button type="button" className="btn ghost" disabled={!canUndo} onClick={onUndo}>{t('act.undo')}</button>
|
||||
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
|
||||
<button type="button" className="btn" onClick={onSave}>{t('save.save')}</button>
|
||||
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
|
||||
{exporting ? t('act.exporting') : t('act.export')}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user