mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -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'),
|
||||
})
|
||||
|
||||
/** `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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user