mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
delete image endpoint + r2 deletion
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 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)')
|
||||
.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' }))
|
||||
|
||||
Reference in New Issue
Block a user