mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[api] add img delete and modify
This commit is contained in:
+13
-1
@@ -1658,9 +1658,21 @@ export const UploadImageResponse = z.object({
|
|||||||
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
|
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() })
|
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
|
* `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
|
* for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
getPlayerFeed,
|
getPlayerFeed,
|
||||||
getSlideshowImages,
|
getSlideshowImages,
|
||||||
SavedImageType,
|
SavedImageType,
|
||||||
|
setImageAccessibility,
|
||||||
setImageCheer,
|
setImageCheer,
|
||||||
|
setImageDescription,
|
||||||
SLIDESHOW_LIMIT,
|
SLIDESHOW_LIMIT,
|
||||||
SLIDESHOW_MAX_LIMIT,
|
SLIDESHOW_MAX_LIMIT,
|
||||||
toImageMetadata,
|
toImageMetadata,
|
||||||
@@ -35,6 +37,8 @@ import {
|
|||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
|
ModifyImageAccessibilityRequest,
|
||||||
|
ModifyImageDescriptionRequest,
|
||||||
pageParams,
|
pageParams,
|
||||||
PhotoTaggingSettingRequest,
|
PhotoTaggingSettingRequest,
|
||||||
PhotoTaggingSettingResponse,
|
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
|
// 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
|
// 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,
|
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
|
||||||
// 403 for someone else's.
|
// 403 for someone else's. A POST, not a DELETE: the client posts the JSON body to
|
||||||
.delete(
|
// this path when a player deletes a photo from their gallery.
|
||||||
|
.post(
|
||||||
'/api/images/v1/deletesaved',
|
'/api/images/v1/deletesaved',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Images'],
|
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
|
// 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
|
// 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.
|
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||||
|
|||||||
@@ -6955,7 +6955,7 @@ describe('images', () => {
|
|||||||
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
|
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'
|
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
|
||||||
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
|
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
|
||||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||||
@@ -6983,7 +6983,7 @@ describe('images', () => {
|
|||||||
|
|
||||||
const del = (headers: Record<string, string>) =>
|
const del = (headers: Record<string, string>) =>
|
||||||
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||||
method: 'DELETE',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...headers },
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
body: JSON.stringify({ ImageName }),
|
body: JSON.stringify({ ImageName }),
|
||||||
})
|
})
|
||||||
@@ -6995,7 +6995,7 @@ describe('images', () => {
|
|||||||
|
|
||||||
// Unknown image → 404.
|
// Unknown image → 404.
|
||||||
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||||
method: 'DELETE',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
|
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
|
||||||
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
|
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
|
||||||
})
|
})
|
||||||
@@ -7011,6 +7011,113 @@ describe('images', () => {
|
|||||||
expect(cheers!.n).toBe(0)
|
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 () => {
|
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
|
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([
|
expect([...documented].sort()).toEqual([
|
||||||
'DELETE /api/customAvatarItems/v1/{id}',
|
'DELETE /api/customAvatarItems/v1/{id}',
|
||||||
'DELETE /api/images/v1/deletesaved',
|
|
||||||
'DELETE /api/playerevents/v2/delete/{eventId}',
|
'DELETE /api/playerevents/v2/delete/{eventId}',
|
||||||
'GET /api/CircuitChipLists/{list}',
|
'GET /api/CircuitChipLists/{list}',
|
||||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
@@ -9663,6 +9769,9 @@ describe('openapi', () => {
|
|||||||
'POST /api/customAvatarItems/v1/{id}/report',
|
'POST /api/customAvatarItems/v1/{id}/report',
|
||||||
'POST /api/gamesight/event',
|
'POST /api/gamesight/event',
|
||||||
'POST /api/images/v1/cheer',
|
'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/v4/uploadsaved',
|
||||||
'POST /api/images/v5/cheered/bulk',
|
'POST /api/images/v5/cheered/bulk',
|
||||||
'POST /api/inventions/v1/cheer',
|
'POST /api/inventions/v1/cheer',
|
||||||
|
|||||||
@@ -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
|
* The public images taken in a room, for the room's photo feed. Only publicly
|
||||||
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
||||||
|
|||||||
Reference in New Issue
Block a user