From 41d0b0543ad050f314963f2ee18edfdc5325d897 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 2 Oct 2026 18:10:08 -0400 Subject: [PATCH] [api] add image moderation and v6 endpoint --- apps/api/migrations/0034_report_image.sql | 16 +++ apps/api/src/openapi.ts | 23 ---- apps/api/src/reports-db.ts | 32 ++++-- apps/api/src/routes/images.ts | 104 ++++++++++++++++--- apps/api/src/test/integration/api.test.ts | 121 +++++++++++++++++----- apps/www/src/client/Moderation.tsx | 4 +- packages/domain/src/images-db.ts | 59 ++--------- 7 files changed, 241 insertions(+), 118 deletions(-) create mode 100644 apps/api/migrations/0034_report_image.sql diff --git a/apps/api/migrations/0034_report_image.sql b/apps/api/migrations/0034_report_image.sql new file mode 100644 index 0000000..3a1161e --- /dev/null +++ b/apps/api/migrations/0034_report_image.sql @@ -0,0 +1,16 @@ +-- Reporting a saved IMAGE (`POST /api/images/v1/{id}/report`) reuses the report table, as +-- the event, invention, custom-avatar-item and chat-message reports beside it do: same +-- fields, same moderation life. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- `image_id` names the reported image (`image.id`). NULL on every other kind of report, and +-- mutually exclusive with `event_id`, `invention_id`, `custom_avatar_item_id` and +-- `chat_message_id`. +-- +-- The request has no body, so the row is three facts: the reporter (the caller), the image, +-- and `reported_player_id` — the image's `PlayerId`, who took it, read from the image. +-- `report_category` stays 0 and `details` NULL: the client sends neither. +-- +-- NOT indexed, like the other kind columns: written on every report of this kind and read by +-- nothing. Add an index with the query that needs it. + +ALTER TABLE report ADD COLUMN image_id INTEGER; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 02db2f5..b9469e9 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1596,29 +1596,6 @@ export const SavedImageDto = z.object({ CommentCount: z.int(), }) -/** - * `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same - * row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) - * but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and - * `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the - * other two. - */ -export const ImageMetadataDto = z.object({ - SavedImageId: z.int(), - ImageName: z.string().describe('The bucket key the img worker serves it back by'), - PlayerId: z.int(), - RoomId: z.int().describe('0 when the photo was not taken in a room'), - PlayerEventId: z.int().describe('0 when it belongs to no event'), - ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'), - Description: z.string().describe('Empty string, never null'), - Accessibility: z.int(), - AccessibilityLocked: z.boolean(), - SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'), - CreatedAt: z.string(), - CheerCount: z.int(), - CommentCount: z.int(), -}) - /** * The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`, * `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed diff --git a/apps/api/src/reports-db.ts b/apps/api/src/reports-db.ts index f9e151a..99731bf 100644 --- a/apps/api/src/reports-db.ts +++ b/apps/api/src/reports-db.ts @@ -8,8 +8,8 @@ * * The `api` worker owns this schema/migration (migrations/0004_report.sql, * 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql, - * 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and - * 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash + * 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql, + * 0025_report_chat_message.sql and 0034_report_image.sql, applied under its own `migrations_table` so it doesn't clash * with the other workers' migrations that share the database). * * The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` — @@ -24,7 +24,9 @@ * CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and * `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind: * `chat_message_id`, with the message's SENDER as the reported player — see - * `POST /api/chatreport/createChatReport`. The four id columns are mutually exclusive; a row + * `POST /api/chatreport/createChatReport`. A reported IMAGE is the fifth: `image_id`, with + * the player who took it as the reported player — see `POST /api/images/v1/{id}/report`. + * The five id columns are mutually exclusive; a row * with none of them is an ordinary player report. They are separate columns rather than one * polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key. * @@ -42,9 +44,10 @@ /** * Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql + * 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql + - * 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql). + * 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql + + * 0034_report_image.sql). * - * None of `event_id`, `invention_id`, `custom_avatar_item_id` or `chat_message_id` is + * None of `event_id`, `invention_id`, `custom_avatar_item_id`, `chat_message_id` or `image_id` is * indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them, * and the reads that do exist go by player or by the ban flag. 0011's partial index over * `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that @@ -71,7 +74,8 @@ export const SCHEMA_DDL: string[] = [ banned_at TEXT, chat_message_id INTEGER, unbanned_by_player_id INTEGER, - unbanned_at TEXT + unbanned_at TEXT, + image_id INTEGER )`, `CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`, `CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`, @@ -150,6 +154,13 @@ export interface ReportRow { unbanned_by_player_id: number | null /** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */ unbanned_at: string | null + /** + * The saved image (photo) this report is against, or NULL for any other kind — mutually + * exclusive with the four id columns above. See `POST /api/images/v1/{id}/report`: the + * request has no body, so `reported_player_id` is the image's `PlayerId` (who took it), + * read from the image, and there is no category or description to store. + */ + image_id: number | null } /** @@ -175,6 +186,8 @@ export interface NewReport { customAvatarItemId?: string | null /** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */ chatMessageId?: number | null + /** Set only when reporting an IMAGE; never set alongside the four above. */ + imageId?: number | null } /** Record a submitted report, returning the stored row (with its assigned id). */ @@ -184,8 +197,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise() // RETURNING always yields the inserted row; the non-null assert keeps the caller diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 35d7e38..ffe2121 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -5,6 +5,7 @@ import { createImage, deleteImage, getCheeredImageIds, + getImageById, getImageByName, getImagesByIds, getImagesByPlayer, @@ -19,7 +20,6 @@ import { setImageDescription, SLIDESHOW_LIMIT, SLIDESHOW_MAX_LIMIT, - toImageMetadata, toImagesPlayer, } from '@repo/domain' @@ -33,7 +33,6 @@ import { ErrorResponse, form, idParam, - ImageMetadataDto, ImagesPlayerDto, intQuery, json, @@ -47,11 +46,13 @@ import { SavedImageDto, SlideshowResponse, stringQuery, + SuccessErrorEnvelope, SuccessResponse, UNAUTHORIZED_RESPONSE, UploadImageRequest, UploadImageResponse, } from '../openapi' +import { createReport } from '../reports-db' import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit' import type { Context } from 'hono' @@ -642,23 +643,24 @@ export const imageRoutes = new Hono({ strict: false }) } ) - // Image metadata by filename. Returns the stored SavedImage record, or 404 when - // there's no metadata row for that name. + // Image metadata by filename, or 404 when there's no metadata row for that name. + // + // Serves the RAW 13-key `SavedImage`, the same type the client decodes `v6/:id` into. + // This once answered a projection of its own (`SavedImageId`/`SavedImageType`, a + // `ClubId`, no `TaggedPlayerIds`, nulls as 0/""): the client's DTO has `Id` and `Type` + // and no `ClubId`, so it was silently dropping most of that. .get( '/api/images/v6', describeRoute({ tags: ['Images'], summary: 'Image metadata by filename', description: - 'An image’s metadata for a bucket key. 404s when the object exists but has no ' + - 'metadata row.\n\n' + - 'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' + - 'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' + - '`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' + - '`""`. Three shapes of one row; keep them straight.', + 'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' + + '`v6/{id}` and `v5/bulk` serve, with `Description`, `RoomId` and `PlayerEventId` ' + + 'null where the row holds null. 404s when the object exists but has no metadata row.', parameters: [stringQuery('name', 'The image name (bucket key); required')], responses: { - 200: json(ImageMetadataDto, 'The image’s metadata'), + 200: json(SavedImageDto, 'The image record'), 400: json(ErrorResponse, 'No name given'), 404: { description: 'No metadata for that name' }, }, @@ -667,7 +669,85 @@ export const imageRoutes = new Hono({ strict: false }) const name = c.req.query('name') ?? '' if (name === '') return c.json({ error: 'name is required' }, 400) const image = await getImageByName(c.env.DB, name) - return image ? c.json(toImageMetadata(image)) : c.notFound() + return image ? c.json(image) : c.notFound() + } + ) + + // One image by id — the client opening a single photo it holds only the id of. + // + // Serves the RAW 13-key `SavedImage` (`Id`/`Type`, `TaggedPlayerIds`, nulls left null), + // which is the type the client decodes this route into — the same record `v6?name=` + // serves, and `v5/bulk` a list of. NOT the `ImagesPlayer` projection of the player lists. + // + // Public, or the caller's own: ids are sequential, so anyone else's private photo is a + // 404 here, the same as an id that doesn't exist. + .get( + '/api/images/v6/:id{[0-9]+}', + describeRoute({ + tags: ['Images'], + summary: 'One image by id', + description: + 'The stored `SavedImage` record for an image id — the raw record, as `v5/bulk` ' + + 'serves, with `Description`, `RoomId` and `PlayerEventId` null where the row holds ' + + 'null. A private image is served only to the player who took it; to anyone else ' + + 'it is a 404, like an unknown id.', + parameters: [idParam('id', 'Image id')], + responses: { + 200: json(SavedImageDto, 'The image record'), + 404: { description: 'No such image, or not the caller’s to see' }, + }, + }), + async (c) => { + const image = await getImageById(c.env.DB, Number.parseInt(c.req.param('id'), 10)) + if (image === null) return c.notFound() + if (image.Accessibility !== 1 && image.PlayerId !== (await authedId(c))) return c.notFound() + return c.json(image) + } + ) + + // Report a saved image. Stored in the `report` table the player, event, invention, item + // and chat reports use — same moderation life — with `image_id` set. See + // migrations/0034_report_image.sql. + // + // The request has NO body: the path names the image and the token names the reporter, + // and that is the whole report. The reported player is whoever took the image, read from + // the image. The `{ success, error: "" }` reply is the envelope the other reports use, + // which is an ASSUMPTION here: what the client does with this response has not been + // observed. + .post( + '/api/images/v1/:id{[0-9]+}/report', + describeRoute({ + tags: ['Images', 'Moderation'], + summary: 'Report a photo', + description: + 'Files a report against a saved image, as a row in the same `report` table the ' + + 'player, event, invention, custom-avatar-item and chat reports go to. There is no ' + + 'request body: the row is the reporter (the caller), the image (`image_id`), and ' + + '`reported_player_id` — the image’s `PlayerId`, who took it. No category or ' + + 'description is stored, since none is sent. Nothing dedupes the rows, and an ' + + 'unknown image is refused rather than filed against nobody.', + security: AUTHED, + parameters: [idParam('id', 'Image id')], + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 401: UNAUTHORIZED_RESPONSE, + 404: json(SuccessErrorEnvelope, 'No such image'), + }, + }), + async (c) => { + const reporterId = await authedId(c) + if (reporterId === null) return unauthorized(c) + + const imageId = Number.parseInt(c.req.param('id'), 10) + const image = await getImageById(c.env.DB, imageId) + if (image === null) return c.json({ success: false, error: 'No such image' }, 404) + + await createReport(c.env.DB, { + reporterPlayerId: reporterId, + reportedPlayerId: image.PlayerId, + imageId, + }) + return c.json({ success: true, error: '' }) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b31c43f..8f7f945 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -6681,11 +6681,11 @@ describe('images', () => { // A metadata row was created, and it's readable by name via /api/images/v6. const meta = (await ( await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`) - ).json()) as { ImageName: string; PlayerId: number; SavedImageId: number; CheerCount: number } + ).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number } expect(meta.ImageName).toBe(ImageName) expect(meta.PlayerId).toBe(42) - // `SavedImageId`, not `Id` — v6 renames like the player lists do. - expect(typeof meta.SavedImageId).toBe('number') + // `Id`, not `SavedImageId` — v6 serves the raw record, unlike the player lists. + expect(typeof meta.Id).toBe('number') expect(meta.CheerCount).toBe(0) }) @@ -7030,7 +7030,7 @@ describe('images', () => { expect(entries.filter((e) => e.IsCheered)).toHaveLength(1) }) - test('GET /api/images/v6 serves the metadata projection, nothing nullable', async () => { + test('GET /api/images/v6 serves the raw SavedImage by name', async () => { const img = await createImage(env.DB, { imageName: 'v6shape.jpg', playerId: 7301, @@ -7038,24 +7038,98 @@ describe('images', () => { }) const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`) expect(res.status).toBe(200) + // The client's 13-key DTO: `Id` and `Type`, `TaggedPlayerIds`, nulls left null — the + // same record `v6/:id` serves. expect(await res.json()).toEqual({ - SavedImageId: img.Id, + Id: img.Id, ImageName: 'v6shape.jpg', + Description: null, PlayerId: 7301, - // Nulls on the row come out as 0 / "" — the client's DTO has no null to put there. - RoomId: 0, - PlayerEventId: 0, - ClubId: 0, - Description: '', + RoomId: null, + PlayerEventId: null, Accessibility: 1, AccessibilityLocked: false, - SavedImageType: 1, + Type: 1, CreatedAt: img.CreatedAt, + TaggedPlayerIds: [], CheerCount: 0, CommentCount: 0, }) }) + test('POST /api/images/v1/:id/report files a bodiless report against the photo’s author', async () => { + const img = await createImage(env.DB, { imageName: 'reported.jpg', playerId: 7310 }) + const report = async (id: number, sub?: string) => + exports.default.fetch(`${ORIGIN}/api/images/v1/${id}/report`, { + method: 'POST', + headers: sub ? await bearer(sub) : undefined, + }) + + expect((await report(img.Id)).status).toBe(401) + const unknown = await report(99999999, '7311') + expect(unknown.status).toBe(404) + expect(await unknown.json()).toEqual({ success: false, error: 'No such image' }) + + const res = await report(img.Id, '7311') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + + const { results } = await env.DB.prepare('SELECT * FROM report WHERE image_id = ?1') + .bind(img.Id) + .all() + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + reporter_player_id: 7311, + reported_player_id: 7310, + image_id: img.Id, + report_category: 0, + details: null, + event_id: null, + invention_id: null, + custom_avatar_item_id: null, + chat_message_id: null, + }) + }) + + test('GET /api/images/v6/:id serves the raw SavedImage, private ones to their owner only', async () => { + const img = await createImage(env.DB, { imageName: 'v6byid.jpg', playerId: 7302 }) + const get = async (id: number, sub?: string) => + exports.default.fetch( + `${ORIGIN}/api/images/v6/${id}`, + sub ? { headers: await bearer(sub) } : undefined + ) + + // The 13-key record, nulls left null. + const res = await get(img.Id) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + Id: img.Id, + ImageName: 'v6byid.jpg', + Description: null, + PlayerId: 7302, + RoomId: null, + PlayerEventId: null, + Accessibility: 1, + AccessibilityLocked: false, + Type: 1, + CreatedAt: img.CreatedAt, + TaggedPlayerIds: [], + CheerCount: 0, + CommentCount: 0, + }) + expect((await get(99999999)).status).toBe(404) + + // Made private, it is its owner's alone — ids are sequential. + await env.DB.prepare( + "UPDATE image SET data = json_set(data, '$.Accessibility', 0) WHERE id = ?1" + ) + .bind(img.Id) + .run() + expect((await get(img.Id)).status).toBe(404) + expect((await get(img.Id, '7303')).status).toBe(404) + expect((await get(img.Id, '7302')).status).toBe(200) + }) + test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => { expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400) expect( @@ -7087,23 +7161,20 @@ describe('images', () => { const meta = (await ( await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`) ).json()) as { - SavedImageType: number + Type: number RoomId: number Accessibility: number - PlayerEventId: number - ClubId: number - Description: string + PlayerEventId: number | null + TaggedPlayerIds: number[] } - expect(meta.SavedImageType).toBe(1) + expect(meta.Type).toBe(1) expect(meta.RoomId).toBe(777) expect(meta.Accessibility).toBe(2) - // v6 carries no TaggedPlayerIds — the upload still records them, which the stored row - // below proves. Nothing on this projection is nullable: a "none" event reads 0, not - // null, and the club (which nothing here sets) reads 0 too. - expect(meta).not.toHaveProperty('TaggedPlayerIds') - expect(meta.PlayerEventId).toBe(0) - expect(meta.ClubId).toBe(0) - expect(meta.Description).toBe('') + // v6 serves the stored record as it is: the tagged players ride along, a "none" + // event is null rather than 0, and there is no `ClubId`. + expect(meta.TaggedPlayerIds).toEqual([5, 6]) + expect(meta.PlayerEventId).toBeNull() + expect(meta).not.toHaveProperty('ClubId') // The tagged players and the null event id, as actually stored. const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1') @@ -7114,7 +7185,7 @@ describe('images', () => { PlayerEventId: number | null } expect(stored.TaggedPlayerIds).toEqual([5, 6]) - // playerEventId 0 means "none" → stored as null, and serialized back out as 0. + // playerEventId 0 means "none" → stored as null. expect(stored.PlayerEventId).toBeNull() }) @@ -10011,6 +10082,7 @@ describe('openapi', () => { 'GET /api/images/v5/cheered/bulk', 'GET /api/images/v5/player/{playerId}', 'GET /api/images/v6', + 'GET /api/images/v6/{id}', 'GET /api/inventions/v1', 'GET /api/inventions/v1/details', 'GET /api/inventions/v1/featured', @@ -10095,6 +10167,7 @@ describe('openapi', () => { 'POST /api/images/v1/cheer', 'POST /api/images/v1/deletesaved', 'POST /api/images/v1/modifydescription', + 'POST /api/images/v1/{id}/report', 'POST /api/images/v2/modifyaccessibility', 'POST /api/images/v4/uploadsaved', 'POST /api/images/v5/cheered/bulk', diff --git a/apps/www/src/client/Moderation.tsx b/apps/www/src/client/Moderation.tsx index 3d8b2c5..03785e8 100644 --- a/apps/www/src/client/Moderation.tsx +++ b/apps/www/src/client/Moderation.tsx @@ -94,6 +94,7 @@ interface ReportRow { banned_by_player_id: number | null banned_at: string | null chat_message_id: number | null + image_id: number | null /** * Who lifted the ban that once stood on this row, and when — null until one is lifted, * and cleared again by a re-ban. A lift wipes the four ban columns, so this pair is the @@ -230,7 +231,7 @@ function LiftedBan({ report, names }: { report: ReportRow; names: Map { + const row = await db.prepare('SELECT data FROM image WHERE id = ?1').bind(id).first() + return row ? (JSON.parse(row.data) as SavedImage) : null +} + /** * Look up image records by name (the R2 key), returned keyed by ImageName. One query * for the whole set; names with no record are simply absent from the map. @@ -417,55 +427,6 @@ export async function getImagesByPlayer( .slice(skip, skip + take) } -/** - * An image's metadata as `GET /api/images/v6` serves it — the by-name lookup's shape. - * - * A THIRD projection of the same row, and deliberately not either of the other two: it - * renames like `ImagesPlayer` (`Id` → `SavedImageId`, `Type` → `SavedImageType`, no - * `TaggedPlayerIds`) but adds `ClubId`, and its numbers and strings are never null — - * `RoomId`, `PlayerEventId` and `ClubId` come out as 0 and `Description` as `""` where the - * row holds null. The reference's DTO declares them non-nullable, so a null is a decode - * failure rather than "none". - * - * `ClubId` is always 0: nothing here associates an image with a club. - */ -export interface ImageMetadata { - SavedImageId: number - ImageName: string - PlayerId: number - RoomId: number - PlayerEventId: number - ClubId: number - Description: string - Accessibility: number - AccessibilityLocked: boolean - SavedImageType: number - CreatedAt: string - CheerCount: number - CommentCount: number -} - -/** Project a stored image into the {@link ImageMetadata} shape `/api/images/v6` answers. */ -export function toImageMetadata(img: SavedImage): ImageMetadata { - return { - SavedImageId: img.Id, - ImageName: img.ImageName, - PlayerId: img.PlayerId, - // Null means "not taken in a room" / "no event"; the client's DTO has no null to put - // there, and 0 is the id it treats as none. - RoomId: img.RoomId ?? 0, - PlayerEventId: img.PlayerEventId ?? 0, - ClubId: 0, - Description: img.Description ?? '', - Accessibility: img.Accessibility, - AccessibilityLocked: img.AccessibilityLocked, - SavedImageType: img.Type, - CreatedAt: img.CreatedAt, - CheerCount: img.CheerCount, - CommentCount: img.CommentCount, - } -} - /** * The client-facing projection of a saved image for the player photo lists (the * reference's `ImagesPlayer`). Same data as the stored record, but the id and type