[api] add img delete and modify

This commit is contained in:
Devin Zuczek
2026-09-29 23:44:47 -04:00
parent ef5bfece59
commit a5ae46fac4
4 changed files with 264 additions and 7 deletions
+13 -1
View File
@@ -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
+100 -2
View File
@@ -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<App>({ 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<App>({ 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.
+113 -4
View File
@@ -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<string, string>) =>
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<string, unknown> = {}
) =>
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<string, string>, 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',
+38
View File
@@ -295,6 +295,44 @@ export async function deleteImage(db: D1Database, image: SavedImage): Promise<vo
])
}
/**
* Set an image's `Accessibility` (0 private, 1 public) in place on the stored record.
* Authorization — the caller must be the image's `PlayerId`, and a locked image
* (`AccessibilityLocked`, set by moderation) refuses — is the route's job
* (see modifyaccessibility).
*/
export async function setImageAccessibility(
db: D1Database,
image: SavedImage,
accessibility: number
): Promise<void> {
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<void> {
// 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