diff --git a/backend/src/activitypub/objects/image.ts b/backend/src/activitypub/objects/image.ts index c6b3a21..0fb1df1 100644 --- a/backend/src/activitypub/objects/image.ts +++ b/backend/src/activitypub/objects/image.ts @@ -4,7 +4,9 @@ import type { Actor } from 'wildebeest/backend/src/activitypub/actors' export const IMAGE = 'Image' // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image -export interface Image extends objects.Document {} +export interface Image extends objects.Document { + description?: string +} export async function createImage(domain: string, db: D1Database, actor: Actor, properties: any): Promise { const actorId = new URL(actor.id) diff --git a/backend/src/activitypub/objects/index.ts b/backend/src/activitypub/objects/index.ts index a4f1c73..be4f40d 100644 --- a/backend/src/activitypub/objects/index.ts +++ b/backend/src/activitypub/objects/index.ts @@ -156,6 +156,16 @@ export async function updateObject(db: D1Database, properties: any, id: URL): Pr return true } +export async function updateObjectProperty(db: D1Database, obj: APObject, key: string, value: string) { + const { success, error } = await db + .prepare(`UPDATE objects SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`) + .bind(value, obj.id.toString()) + .run() + if (!success) { + throw new Error('SQL error: ' + error) + } +} + export async function getObjectById(db: D1Database, id: string | URL): Promise { return getObjectBy(db, 'id', id.toString()) } diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts index e62c26a..b402830 100644 --- a/backend/src/errors/index.ts +++ b/backend/src/errors/index.ts @@ -42,6 +42,10 @@ export function statusNotFound(id: string): Response { return generateErrorResponse('Resource not found', 404, `Status "${id}" not found`) } +export function mediaNotFound(id: string): Response { + return generateErrorResponse('Resource not found', 404, `Media "${id}" not found`) +} + export function exceededLimit(detail: string): Response { return generateErrorResponse('Limit exceeded', 400, detail) } diff --git a/backend/test/mastodon/media.spec.ts b/backend/test/mastodon/media.spec.ts index c7a5f9e..e47ad8d 100644 --- a/backend/test/mastodon/media.spec.ts +++ b/backend/test/mastodon/media.spec.ts @@ -1,4 +1,6 @@ import * as media from 'wildebeest/functions/api/v2/media' +import { createImage } from 'wildebeest/backend/src/activitypub/objects/image' +import * as media_id from 'wildebeest/functions/api/v2/media/[id]' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' import { strict as assert } from 'node:assert/strict' import { makeDB, assertJSON, isUrlValid } from '../utils' @@ -56,5 +58,32 @@ describe('Mastodon APIs', () => { assert.equal(obj.type, 'Image') assert.equal(obj[originalActorIdSymbol], connectedActor.id.toString()) }) + + test('update image description', async () => { + const db = await makeDB() + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const properties = { + url: 'https://cloudflare.com/image.jpg', + description: 'foo bar', + } + const image = await createImage(domain, db, connectedActor, properties) + + const request = new Request('https://' + domain, { + method: 'PUT', + body: JSON.stringify({ description: 'new foo bar' }), + headers: { + 'content-type': 'application/json', + }, + }) + + const res = await media_id.handleRequestPut(db, image[mastodonIdSymbol]!, request) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.description, 'new foo bar') + + const newImage = (await objects.getObjectByMastodonId(db, image[mastodonIdSymbol]!)) as any + assert.equal(newImage.description, 'new foo bar') + }) }) }) diff --git a/functions/api/v2/media/[id].ts b/functions/api/v2/media/[id].ts new file mode 100644 index 0000000..eeef301 --- /dev/null +++ b/functions/api/v2/media/[id].ts @@ -0,0 +1,73 @@ +// https://docs.joinmastodon.org/methods/media/#update + +import { getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects' +import { mastodonIdSymbol } from 'wildebeest/backend/src/activitypub/objects' +import { cors } from 'wildebeest/backend/src/utils/cors' +import type { MediaAttachment } from 'wildebeest/backend/src/types/media' +import type { Image } from 'wildebeest/backend/src/activitypub/objects/image' +import { readBody } from 'wildebeest/backend/src/utils/body' +import type { UUID } from 'wildebeest/backend/src/types' +import type { Env } from 'wildebeest/backend/src/types/env' +import type { ContextData } from 'wildebeest/backend/src/types/context' +import * as errors from 'wildebeest/backend/src/errors' +import { updateObjectProperty } from 'wildebeest/backend/src/activitypub/objects' + +export const onRequestPut: PagesFunction = async ({ params, env, request }) => { + return handleRequestPut(env.DATABASE, params.id as UUID, request) +} + +type UpdateMedia = { + description?: string +} + +export async function handleRequestPut(db: D1Database, id: UUID, request: Request): Promise { + // Update the image properties + { + const image = (await getObjectByMastodonId(db, id)) as Image + if (image === null) { + return errors.mediaNotFound(id) + } + + const body = await readBody(request) + + if (body.description !== undefined) { + await updateObjectProperty(db, image, 'description', body.description) + } + } + + // reload the image for fresh state + const image = (await getObjectByMastodonId(db, id)) as Image + + const res: MediaAttachment = { + id: image[mastodonIdSymbol]!, + url: image.url, + preview_url: image.url, + type: 'image', + meta: { + original: { + width: 640, + height: 480, + size: '640x480', + aspect: 1.3333333333333333, + }, + small: { + width: 461, + height: 346, + size: '461x346', + aspect: 1.3323699421965318, + }, + focus: { + x: -0.27, + y: 0.51, + }, + }, + description: image.description || '', + blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}', + } + + const headers = { + ...cors(), + 'content-type': 'application/json; charset=utf-8', + } + return new Response(JSON.stringify(res), { headers }) +}