mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[api] add image moderation and v6 endpoint
This commit is contained in:
@@ -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;
|
||||||
@@ -1596,29 +1596,6 @@ export const SavedImageDto = z.object({
|
|||||||
CommentCount: z.int(),
|
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`,
|
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
|
||||||
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
|
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
*
|
*
|
||||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||||
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.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
|
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql,
|
||||||
* 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash
|
* 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).
|
* with the other workers' migrations that share the database).
|
||||||
*
|
*
|
||||||
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
|
* 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
|
* 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:
|
* `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
|
* `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
|
* 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.
|
* 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 +
|
* 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 +
|
* 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,
|
* 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
|
* 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
|
* `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,
|
banned_at TEXT,
|
||||||
chat_message_id INTEGER,
|
chat_message_id INTEGER,
|
||||||
unbanned_by_player_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_reported ON report (reported_player_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_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
|
unbanned_by_player_id: number | null
|
||||||
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
|
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
|
||||||
unbanned_at: string | null
|
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
|
customAvatarItemId?: string | null
|
||||||
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
|
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
|
||||||
chatMessageId?: number | null
|
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). */
|
/** 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<Re
|
|||||||
`INSERT INTO report (
|
`INSERT INTO report (
|
||||||
reporter_player_id, reported_player_id, report_category, details,
|
reporter_player_id, reported_player_id, report_category, details,
|
||||||
height_reporter, height_reported, room_id, room_instance_type, created_at,
|
height_reporter, height_reported, room_id, room_instance_type, created_at,
|
||||||
event_id, invention_id, custom_avatar_item_id, chat_message_id
|
event_id, invention_id, custom_avatar_item_id, chat_message_id, image_id
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||||
RETURNING *`
|
RETURNING *`
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
@@ -201,7 +214,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
|||||||
input.eventId ?? null,
|
input.eventId ?? null,
|
||||||
input.inventionId ?? null,
|
input.inventionId ?? null,
|
||||||
input.customAvatarItemId ?? null,
|
input.customAvatarItemId ?? null,
|
||||||
input.chatMessageId ?? null
|
input.chatMessageId ?? null,
|
||||||
|
input.imageId ?? null
|
||||||
)
|
)
|
||||||
.first<ReportRow>()
|
.first<ReportRow>()
|
||||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createImage,
|
createImage,
|
||||||
deleteImage,
|
deleteImage,
|
||||||
getCheeredImageIds,
|
getCheeredImageIds,
|
||||||
|
getImageById,
|
||||||
getImageByName,
|
getImageByName,
|
||||||
getImagesByIds,
|
getImagesByIds,
|
||||||
getImagesByPlayer,
|
getImagesByPlayer,
|
||||||
@@ -19,7 +20,6 @@ import {
|
|||||||
setImageDescription,
|
setImageDescription,
|
||||||
SLIDESHOW_LIMIT,
|
SLIDESHOW_LIMIT,
|
||||||
SLIDESHOW_MAX_LIMIT,
|
SLIDESHOW_MAX_LIMIT,
|
||||||
toImageMetadata,
|
|
||||||
toImagesPlayer,
|
toImagesPlayer,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
idParam,
|
idParam,
|
||||||
ImageMetadataDto,
|
|
||||||
ImagesPlayerDto,
|
ImagesPlayerDto,
|
||||||
intQuery,
|
intQuery,
|
||||||
json,
|
json,
|
||||||
@@ -47,11 +46,13 @@ import {
|
|||||||
SavedImageDto,
|
SavedImageDto,
|
||||||
SlideshowResponse,
|
SlideshowResponse,
|
||||||
stringQuery,
|
stringQuery,
|
||||||
|
SuccessErrorEnvelope,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
UploadImageRequest,
|
UploadImageRequest,
|
||||||
UploadImageResponse,
|
UploadImageResponse,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
import { createReport } from '../reports-db'
|
||||||
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
@@ -642,23 +643,24 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
|
// Image metadata by filename, or 404 when there's no metadata row for that name.
|
||||||
// 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(
|
.get(
|
||||||
'/api/images/v6',
|
'/api/images/v6',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Images'],
|
tags: ['Images'],
|
||||||
summary: 'Image metadata by filename',
|
summary: 'Image metadata by filename',
|
||||||
description:
|
description:
|
||||||
'An image’s metadata for a bucket key. 404s when the object exists but has no ' +
|
'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' +
|
||||||
'metadata row.\n\n' +
|
'`v6/{id}` and `v5/bulk` serve, with `Description`, `RoomId` and `PlayerEventId` ' +
|
||||||
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' +
|
'null where the row holds null. 404s when the object exists but has no metadata row.',
|
||||||
'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.',
|
|
||||||
parameters: [stringQuery('name', 'The image name (bucket key); required')],
|
parameters: [stringQuery('name', 'The image name (bucket key); required')],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(ImageMetadataDto, 'The image’s metadata'),
|
200: json(SavedImageDto, 'The image record'),
|
||||||
400: json(ErrorResponse, 'No name given'),
|
400: json(ErrorResponse, 'No name given'),
|
||||||
404: { description: 'No metadata for that name' },
|
404: { description: 'No metadata for that name' },
|
||||||
},
|
},
|
||||||
@@ -667,7 +669,85 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
const name = c.req.query('name') ?? ''
|
const name = c.req.query('name') ?? ''
|
||||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||||
const image = await getImageByName(c.env.DB, name)
|
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: '' })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6681,11 +6681,11 @@ describe('images', () => {
|
|||||||
// A metadata row was created, and it's readable by name via /api/images/v6.
|
// A metadata row was created, and it's readable by name via /api/images/v6.
|
||||||
const meta = (await (
|
const meta = (await (
|
||||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
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.ImageName).toBe(ImageName)
|
||||||
expect(meta.PlayerId).toBe(42)
|
expect(meta.PlayerId).toBe(42)
|
||||||
// `SavedImageId`, not `Id` — v6 renames like the player lists do.
|
// `Id`, not `SavedImageId` — v6 serves the raw record, unlike the player lists.
|
||||||
expect(typeof meta.SavedImageId).toBe('number')
|
expect(typeof meta.Id).toBe('number')
|
||||||
expect(meta.CheerCount).toBe(0)
|
expect(meta.CheerCount).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -7030,7 +7030,7 @@ describe('images', () => {
|
|||||||
expect(entries.filter((e) => e.IsCheered)).toHaveLength(1)
|
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, {
|
const img = await createImage(env.DB, {
|
||||||
imageName: 'v6shape.jpg',
|
imageName: 'v6shape.jpg',
|
||||||
playerId: 7301,
|
playerId: 7301,
|
||||||
@@ -7038,24 +7038,98 @@ describe('images', () => {
|
|||||||
})
|
})
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
|
||||||
expect(res.status).toBe(200)
|
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({
|
expect(await res.json()).toEqual({
|
||||||
SavedImageId: img.Id,
|
Id: img.Id,
|
||||||
ImageName: 'v6shape.jpg',
|
ImageName: 'v6shape.jpg',
|
||||||
|
Description: null,
|
||||||
PlayerId: 7301,
|
PlayerId: 7301,
|
||||||
// Nulls on the row come out as 0 / "" — the client's DTO has no null to put there.
|
RoomId: null,
|
||||||
RoomId: 0,
|
PlayerEventId: null,
|
||||||
PlayerEventId: 0,
|
|
||||||
ClubId: 0,
|
|
||||||
Description: '',
|
|
||||||
Accessibility: 1,
|
Accessibility: 1,
|
||||||
AccessibilityLocked: false,
|
AccessibilityLocked: false,
|
||||||
SavedImageType: 1,
|
Type: 1,
|
||||||
CreatedAt: img.CreatedAt,
|
CreatedAt: img.CreatedAt,
|
||||||
|
TaggedPlayerIds: [],
|
||||||
CheerCount: 0,
|
CheerCount: 0,
|
||||||
CommentCount: 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 () => {
|
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((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
|
||||||
expect(
|
expect(
|
||||||
@@ -7087,23 +7161,20 @@ describe('images', () => {
|
|||||||
const meta = (await (
|
const meta = (await (
|
||||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
||||||
).json()) as {
|
).json()) as {
|
||||||
SavedImageType: number
|
Type: number
|
||||||
RoomId: number
|
RoomId: number
|
||||||
Accessibility: number
|
Accessibility: number
|
||||||
PlayerEventId: number
|
PlayerEventId: number | null
|
||||||
ClubId: number
|
TaggedPlayerIds: number[]
|
||||||
Description: string
|
|
||||||
}
|
}
|
||||||
expect(meta.SavedImageType).toBe(1)
|
expect(meta.Type).toBe(1)
|
||||||
expect(meta.RoomId).toBe(777)
|
expect(meta.RoomId).toBe(777)
|
||||||
expect(meta.Accessibility).toBe(2)
|
expect(meta.Accessibility).toBe(2)
|
||||||
// v6 carries no TaggedPlayerIds — the upload still records them, which the stored row
|
// v6 serves the stored record as it is: the tagged players ride along, a "none"
|
||||||
// below proves. Nothing on this projection is nullable: a "none" event reads 0, not
|
// event is null rather than 0, and there is no `ClubId`.
|
||||||
// null, and the club (which nothing here sets) reads 0 too.
|
expect(meta.TaggedPlayerIds).toEqual([5, 6])
|
||||||
expect(meta).not.toHaveProperty('TaggedPlayerIds')
|
expect(meta.PlayerEventId).toBeNull()
|
||||||
expect(meta.PlayerEventId).toBe(0)
|
expect(meta).not.toHaveProperty('ClubId')
|
||||||
expect(meta.ClubId).toBe(0)
|
|
||||||
expect(meta.Description).toBe('')
|
|
||||||
|
|
||||||
// The tagged players and the null event id, as actually stored.
|
// 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')
|
const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||||
@@ -7114,7 +7185,7 @@ describe('images', () => {
|
|||||||
PlayerEventId: number | null
|
PlayerEventId: number | null
|
||||||
}
|
}
|
||||||
expect(stored.TaggedPlayerIds).toEqual([5, 6])
|
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()
|
expect(stored.PlayerEventId).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -10011,6 +10082,7 @@ describe('openapi', () => {
|
|||||||
'GET /api/images/v5/cheered/bulk',
|
'GET /api/images/v5/cheered/bulk',
|
||||||
'GET /api/images/v5/player/{playerId}',
|
'GET /api/images/v5/player/{playerId}',
|
||||||
'GET /api/images/v6',
|
'GET /api/images/v6',
|
||||||
|
'GET /api/images/v6/{id}',
|
||||||
'GET /api/inventions/v1',
|
'GET /api/inventions/v1',
|
||||||
'GET /api/inventions/v1/details',
|
'GET /api/inventions/v1/details',
|
||||||
'GET /api/inventions/v1/featured',
|
'GET /api/inventions/v1/featured',
|
||||||
@@ -10095,6 +10167,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
'POST /api/images/v1/deletesaved',
|
'POST /api/images/v1/deletesaved',
|
||||||
'POST /api/images/v1/modifydescription',
|
'POST /api/images/v1/modifydescription',
|
||||||
|
'POST /api/images/v1/{id}/report',
|
||||||
'POST /api/images/v2/modifyaccessibility',
|
'POST /api/images/v2/modifyaccessibility',
|
||||||
'POST /api/images/v4/uploadsaved',
|
'POST /api/images/v4/uploadsaved',
|
||||||
'POST /api/images/v5/cheered/bulk',
|
'POST /api/images/v5/cheered/bulk',
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ interface ReportRow {
|
|||||||
banned_by_player_id: number | null
|
banned_by_player_id: number | null
|
||||||
banned_at: string | null
|
banned_at: string | null
|
||||||
chat_message_id: number | 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,
|
* 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
|
* 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<number, Pu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What kind of thing a report is against. The four id columns are mutually exclusive and
|
* What kind of thing a report is against. The five id columns are mutually exclusive and
|
||||||
* a row with none of them is an ordinary player report (see reports-db) — which is the
|
* a row with none of them is an ordinary player report (see reports-db) — which is the
|
||||||
* only way to tell the kinds apart, so it is worth a column of its own.
|
* only way to tell the kinds apart, so it is worth a column of its own.
|
||||||
*/
|
*/
|
||||||
@@ -239,6 +240,7 @@ function reportKind(report: ReportRow): string {
|
|||||||
if (report.invention_id !== null) return 'Invention'
|
if (report.invention_id !== null) return 'Invention'
|
||||||
if (report.custom_avatar_item_id !== null) return 'Avatar item'
|
if (report.custom_avatar_item_id !== null) return 'Avatar item'
|
||||||
if (report.chat_message_id !== null) return 'Chat message'
|
if (report.chat_message_id !== null) return 'Chat message'
|
||||||
|
if (report.image_id != null) return 'Photo'
|
||||||
return 'Player'
|
return 'Player'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,16 @@ export async function getImageByName(db: D1Database, name: string): Promise<Save
|
|||||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up one image record by id, or null. Whatever its accessibility — unlike
|
||||||
|
* {@link getImagesByIds} this does not filter, so the caller decides who may see a
|
||||||
|
* private one.
|
||||||
|
*/
|
||||||
|
export async function getImageById(db: D1Database, id: number): Promise<SavedImage | null> {
|
||||||
|
const row = await db.prepare('SELECT data FROM image WHERE id = ?1').bind(id).first<ImageRow>()
|
||||||
|
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up image records by name (the R2 key), returned keyed by ImageName. One query
|
* 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.
|
* 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)
|
.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
|
* 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
|
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
||||||
|
|||||||
Reference in New Issue
Block a user