delete image endpoint + r2 deletion

This commit is contained in:
Devin Zuczek
2026-07-17 17:59:33 -04:00
parent 9065bf5e54
commit bbd702faf1
3 changed files with 95 additions and 1 deletions
+13
View File
@@ -181,6 +181,19 @@ export async function getImageByName(db: D1Database, name: string): Promise<Save
return row ? (JSON.parse(row.data) as SavedImage) : null
}
/**
* Delete an image's metadata row plus any per-player interactions (cheers) recorded
* against it, in one batch — the row keyed by ImageName (the R2 key), its interactions
* by the image's `Id`. Authorization and removing the object from R2 are the caller's
* responsibility (see the deletesaved route).
*/
export async function deleteImage(db: D1Database, image: SavedImage): Promise<void> {
await db.batch([
db.prepare('DELETE FROM image WHERE image_name = ?1').bind(image.ImageName),
db.prepare('DELETE FROM image_interaction WHERE saved_image_id = ?1').bind(image.Id),
])
}
/**
* The public images taken in a room, for the room's photo feed. Only publicly
* accessible images (Accessibility === 1) are returned. `filter` narrows by
+25
View File
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
import {
createImage,
deleteImage,
getCheeredImageIds,
getImageByName,
getImagesByPlayer,
@@ -101,6 +102,30 @@ export const imageRoutes = new Hono<App>({ strict: false })
return c.json({ ImageName: name })
})
// 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('/api/images/v1/deletesaved', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as { ImageName?: unknown } | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 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)
// Drop the metadata (and cheers) first, then the object. An R2 delete is
// idempotent, so a missing object is fine.
await deleteImage(c.env.DB, image)
await c.env.IMAGES.delete(imageName)
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.
+57 -1
View File
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../api.app'
import { createImage, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
@@ -1297,6 +1297,62 @@ describe('images', () => {
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
})
test('DELETE /api/images/v1/deletesaved removes the owners 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)')
.bind(
JSON.stringify({
Id: 8100,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 42, // owned by the default bearer account
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 1,
CommentCount: 0,
})
)
.run()
await env.DB.prepare(
'INSERT INTO image_interaction (player_id, saved_image_id, cheered) VALUES (99, 8100, 1)'
).run()
const del = (headers: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ ImageName }),
})
// No token → 401; a different account → 403 (still present afterwards).
expect((await del({})).status).toBe(401)
expect((await del(await bearer('43'))).status).toBe(403)
expect(await getImageByName(env.DB, ImageName)).not.toBeNull()
// Unknown image → 404.
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
})
expect(unknown.status).toBe(404)
// Owner → 200, and the row, its cheers, and the R2 object are all gone.
expect((await del(await bearer('42'))).status).toBe(200)
expect(await getImageByName(env.DB, ImageName)).toBeNull()
expect(await env.IMAGES.get(ImageName)).toBeNull()
const cheers = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = 8100'
).first<{ n: number }>()
expect(cheers!.n).toBe(0)
})
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' }))