From bbd702faf160afc20364fa75df097dc076d4a668 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 17 Jul 2026 17:59:33 -0400 Subject: [PATCH] delete image endpoint + r2 deletion --- apps/api/src/images-db.ts | 13 +++++ apps/api/src/routes/images.ts | 25 ++++++++++ apps/api/src/test/integration/api.test.ts | 58 ++++++++++++++++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index 45c5740..c3711cd 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -181,6 +181,19 @@ export async function getImageByName(db: D1Database, name: string): Promise { + 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 diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 7abd401..2aa8bae 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { createImage, + deleteImage, getCheeredImageIds, getImageByName, getImagesByPlayer, @@ -101,6 +102,30 @@ export const imageRoutes = new Hono({ 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. diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 833c6b0..b481cae 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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) => + 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' }))