diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index ca70428..f01ab63 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1658,9 +1658,21 @@ export const UploadImageResponse = z.object({ ImageName: z.string().describe('The bucket key; the img worker serves the object by it'), }) -/** `DELETE /api/images/v1/deletesaved` JSON body. */ +/** `POST /api/images/v1/deletesaved` JSON body. */ export const DeleteImageRequest = z.object({ ImageName: z.string() }) +/** `POST /api/images/v2/modifyaccessibility` JSON body. */ +export const ModifyImageAccessibilityRequest = z.object({ + ImageName: z.string(), + Accessibility: z.int().describe('0 private, 1 public'), +}) + +/** `POST /api/images/v1/modifydescription` JSON body. */ +export const ModifyImageDescriptionRequest = z.object({ + ImageName: z.string(), + Description: z.string().nullable().describe('The new caption; null or empty clears it'), +}) + /** * `POST /api/images/v5/cheered/bulk` form body — the saved-image ids to report cheer state * for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 7339502..eb1a210 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -12,7 +12,9 @@ import { getPlayerFeed, getSlideshowImages, SavedImageType, + setImageAccessibility, setImageCheer, + setImageDescription, SLIDESHOW_LIMIT, SLIDESHOW_MAX_LIMIT, toImageMetadata, @@ -35,6 +37,8 @@ import { json, JsonArray, jsonBody, + ModifyImageAccessibilityRequest, + ModifyImageDescriptionRequest, pageParams, PhotoTaggingSettingRequest, PhotoTaggingSettingResponse, @@ -308,8 +312,9 @@ export const imageRoutes = new Hono({ strict: false }) // Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the // image up by name, refuses unless the caller took it (PlayerId), then removes the // metadata row (and its cheers) and the object from R2. 404 for an unknown image, - // 403 for someone else's. - .delete( + // 403 for someone else's. A POST, not a DELETE: the client posts the JSON body to + // this path when a player deletes a photo from their gallery. + .post( '/api/images/v1/deletesaved', describeRoute({ tags: ['Images'], @@ -349,6 +354,99 @@ export const imageRoutes = new Hono({ strict: false }) } ) + // Make one of the caller's photos public or private ({ ImageName, Accessibility }). + // Auth-gated and owner-only, like deletesaved; additionally refuses an image whose + // accessibility moderation has locked. Answers the same `{ success: true }` ack. + .post( + '/api/images/v2/modifyaccessibility', + describeRoute({ + tags: ['Images'], + summary: 'Make one of the caller’s photos public or private', + description: + 'Looks the image up by name and refuses unless the caller took it (or its ' + + 'accessibility is locked), then sets `Accessibility` on the stored record: 0 ' + + 'private, 1 public.', + security: AUTHED, + requestBody: jsonBody(ModifyImageAccessibilityRequest, 'The image and its new accessibility'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: json(ErrorResponse, 'No ImageName, or Accessibility not 0 or 1'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorResponse, 'Not the caller’s image, or its accessibility is locked'), + 404: { description: 'No image by that name' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as { + ImageName?: unknown + Accessibility?: unknown + } | null + const imageName = typeof body?.ImageName === 'string' ? body.ImageName : '' + if (imageName === '') return c.json({ error: 'ImageName is required' }, 400) + const accessibility = body?.Accessibility + if (accessibility !== 0 && accessibility !== 1) { + return c.json({ error: 'Accessibility must be 0 or 1' }, 400) + } + + const image = await getImageByName(c.env.DB, imageName) + if (!image) return c.notFound() + if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403) + if (image.AccessibilityLocked) return c.json({ error: 'Accessibility is locked' }, 403) + + await setImageAccessibility(c.env.DB, image, accessibility) + return c.json({ success: true }) + } + ) + + // Set the caption on one of the caller's photos ({ ImageName, Description }). + // Auth-gated and owner-only, like deletesaved. An empty or null Description clears + // the caption (stored as null, the same as an upload without one). + .post( + '/api/images/v1/modifydescription', + describeRoute({ + tags: ['Images'], + summary: 'Set the caption on one of the caller’s photos', + description: + 'Looks the image up by name and refuses unless the caller took it, then sets ' + + '`Description` on the stored record. Null or empty clears it.', + security: AUTHED, + requestBody: jsonBody(ModifyImageDescriptionRequest, 'The image and its new caption'), + responses: { + 200: json(SuccessResponse, 'Updated'), + 400: json(ErrorResponse, 'No ImageName, or Description not a string'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(ErrorResponse, 'Not the caller’s image'), + 404: { description: 'No image by that name' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as { + ImageName?: unknown + Description?: unknown + } | null + const imageName = typeof body?.ImageName === 'string' ? body.ImageName : '' + if (imageName === '') return c.json({ error: 'ImageName is required' }, 400) + const raw = body?.Description ?? null + if (raw !== null && typeof raw !== 'string') { + return c.json({ error: 'Description must be a string' }, 400) + } + const description = raw === null || raw.trim() === '' ? null : raw + + const image = await getImageByName(c.env.DB, imageName) + if (!image) return c.notFound() + if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403) + + await setImageDescription(c.env.DB, image, description) + return c.json({ success: true }) + } + ) + // A room's photo feed — the public images taken in that room. `sort` orders the // feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType // (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 8dec08e..ca06d98 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -6955,7 +6955,7 @@ describe('images', () => { expect(JSON.parse(row!.data).profileImage).toBe(ImageName) }) - test('DELETE /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => { + test('POST /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => { const ImageName = 'sharecamera/2026-07-17/delete-me.jpg' await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3])) await env.DB.prepare('INSERT INTO image (data) VALUES (?1)') @@ -6983,7 +6983,7 @@ describe('images', () => { const del = (headers: Record) => exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, { - method: 'DELETE', + method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify({ ImageName }), }) @@ -6995,7 +6995,7 @@ describe('images', () => { // Unknown image → 404. const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, { - method: 'DELETE', + method: 'POST', headers: { 'Content-Type': 'application/json', ...(await bearer('42')) }, body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }), }) @@ -7011,6 +7011,113 @@ describe('images', () => { expect(cheers!.n).toBe(0) }) + /** Seed one image row owned by the default bearer account (42). */ + const seedOwnedImage = async ( + Id: number, + ImageName: string, + extra: Record = {} + ) => + env.DB.prepare('INSERT INTO image (data) VALUES (?1)') + .bind( + JSON.stringify({ + Id, + Type: 1, + Accessibility: 1, + AccessibilityLocked: false, + ImageName, + Description: null, + PlayerId: 42, + TaggedPlayerIds: [], + RoomId: null, + PlayerEventId: null, + CreatedAt: new Date().toISOString(), + CheerCount: 0, + CommentCount: 0, + ...extra, + }) + ) + .run() + + const postImageJson = (path: string, headers: Record, body: unknown) => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }) + + test('POST /api/images/v2/modifyaccessibility flips the owner’s image and gates on the owner', async () => { + const ImageName = 'sharecamera/2026-09-13/flip-me.jpg' + await seedOwnedImage(8101, ImageName) + const path = '/api/images/v2/modifyaccessibility' + + // No token → 401; a different account → 403; the image is untouched. + expect((await postImageJson(path, {}, { ImageName, Accessibility: 0 })).status).toBe(401) + expect( + (await postImageJson(path, await bearer('43'), { ImageName, Accessibility: 0 })).status + ).toBe(403) + expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1) + + // Unknown image → 404; a bad accessibility or no name → 400. + const owner = await bearer('42') + expect( + (await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Accessibility: 0 })) + .status + ).toBe(404) + expect((await postImageJson(path, owner, { ImageName, Accessibility: 7 })).status).toBe(400) + expect((await postImageJson(path, owner, { Accessibility: 0 })).status).toBe(400) + + // Owner → 200 and the record is now private; back to 1 makes it public again. + const res = await postImageJson(path, owner, { ImageName, Accessibility: 0 }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(0) + expect((await postImageJson(path, owner, { ImageName, Accessibility: 1 })).status).toBe(200) + expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1) + + // A locked image refuses even its owner. + const locked = 'sharecamera/2026-09-13/locked.jpg' + await seedOwnedImage(8102, locked, { AccessibilityLocked: true, Accessibility: 0 }) + expect((await postImageJson(path, owner, { ImageName: locked, Accessibility: 1 })).status).toBe( + 403 + ) + expect((await getImageByName(env.DB, locked))!.Accessibility).toBe(0) + }) + + test('POST /api/images/v1/modifydescription sets the owner’s caption and gates on the owner', async () => { + const ImageName = 'sharecamera/2026-09-13/caption-me.jpg' + await seedOwnedImage(8103, ImageName) + const path = '/api/images/v1/modifydescription' + + // No token → 401; a different account → 403; the caption is untouched. + expect((await postImageJson(path, {}, { ImageName, Description: 'x' })).status).toBe(401) + expect( + (await postImageJson(path, await bearer('43'), { ImageName, Description: 'x' })).status + ).toBe(403) + expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull() + + // Unknown image → 404; a non-string description or no name → 400. + const owner = await bearer('42') + expect( + (await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Description: 'x' })) + .status + ).toBe(404) + expect((await postImageJson(path, owner, { ImageName, Description: 5 })).status).toBe(400) + expect((await postImageJson(path, owner, { Description: 'x' })).status).toBe(400) + + // Owner → 200 and the caption is stored; the rest of the record is untouched. + const res = await postImageJson(path, owner, { ImageName, Description: 'tet' }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true }) + const after = await getImageByName(env.DB, ImageName) + expect(after!.Description).toBe('tet') + expect(after!.Accessibility).toBe(1) + expect(after!.PlayerId).toBe(42) + + // An empty description clears the caption back to null. + expect((await postImageJson(path, owner, { ImageName, Description: '' })).status).toBe(200) + expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull() + }) + test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => { const fd = new FormData() fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' })) @@ -9550,7 +9657,6 @@ describe('openapi', () => { ) expect([...documented].sort()).toEqual([ 'DELETE /api/customAvatarItems/v1/{id}', - 'DELETE /api/images/v1/deletesaved', 'DELETE /api/playerevents/v2/delete/{eventId}', 'GET /api/CircuitChipLists/{list}', 'GET /api/PlayerReporting/v1/moderationBlockDetails', @@ -9663,6 +9769,9 @@ describe('openapi', () => { 'POST /api/customAvatarItems/v1/{id}/report', 'POST /api/gamesight/event', 'POST /api/images/v1/cheer', + 'POST /api/images/v1/deletesaved', + 'POST /api/images/v1/modifydescription', + 'POST /api/images/v2/modifyaccessibility', 'POST /api/images/v4/uploadsaved', 'POST /api/images/v5/cheered/bulk', 'POST /api/inventions/v1/cheer', diff --git a/packages/domain/src/images-db.ts b/packages/domain/src/images-db.ts index b3d1e69..a5a3288 100644 --- a/packages/domain/src/images-db.ts +++ b/packages/domain/src/images-db.ts @@ -295,6 +295,44 @@ export async function deleteImage(db: D1Database, image: SavedImage): Promise { + await db + .prepare( + "UPDATE image SET data = json_set(data, '$.Accessibility', CAST(?2 AS INTEGER)) WHERE id = ?1" + ) + .bind(image.Id, accessibility) + .run() +} + +/** + * Set an image's `Description` in place on the stored record; `null` clears it. + * Authorization (the caller must be the image's `PlayerId`) is the route's job + * (see modifydescription). + */ +export async function setImageDescription( + db: D1Database, + image: SavedImage, + description: string | null +): Promise { + // json_set with a bound NULL stores SQL NULL, not JSON null, so spell the null out. + const sql = + description === null + ? "UPDATE image SET data = json_set(data, '$.Description', json('null')) WHERE id = ?1" + : "UPDATE image SET data = json_set(data, '$.Description', ?2) WHERE id = ?1" + const stmt = db.prepare(sql) + await (description === null ? stmt.bind(image.Id) : stmt.bind(image.Id, description)).run() +} + /** * The public images taken in a room, for the room's photo feed. Only publicly * accessible images (Accessibility === 1) are returned. `filter` narrows by