From 7e07ed6fe0b80f24c5c1c660d2c993094f4e1dc7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 15 Aug 2026 00:02:35 +0100 Subject: [PATCH] fix: normalise blob refs to json lex encoding --- server/api/admin/blobs.post.ts | 4 +++- server/utils/admin/ama-record.ts | 5 ++++- server/utils/admin/crud.ts | 16 +++++++++++++--- shared/cms/blob.ts | 10 ++++++---- test/unit/blob.spec.ts | 8 ++++++++ 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/server/api/admin/blobs.post.ts b/server/api/admin/blobs.post.ts index d84c1e24..b5d59836 100644 --- a/server/api/admin/blobs.post.ts +++ b/server/api/admin/blobs.post.ts @@ -1,3 +1,5 @@ +import { lexToJson } from '@atproto/lex' + import { requireAdminClient } from '../../utils/admin/client' /** @@ -26,7 +28,7 @@ export default defineEventHandler(async event => { probeAspectRatio(bytes, contentType), ]) return { - blob: res.body.blob, + blob: lexToJson(res.body.blob), ...(aspectRatio ? { aspectRatio } : {}), } }) diff --git a/server/utils/admin/ama-record.ts b/server/utils/admin/ama-record.ts index 0f493c16..f827ccc1 100644 --- a/server/utils/admin/ama-record.ts +++ b/server/utils/admin/ama-record.ts @@ -274,7 +274,10 @@ export async function prepareAmaImage ( const cid = cidFromBlob(body.image) if (!cid) { - throw createError({ statusCode: 422, statusMessage: 'Invalid AMA image blob.' }) + throw createError({ + statusCode: 422, + statusMessage: `Invalid AMA image blob: ${JSON.stringify(body.image)}`, + }) } const service = useRuntimeConfig(event).public.atproto.service diff --git a/server/utils/admin/crud.ts b/server/utils/admin/crud.ts index 875d7991..b25ce607 100644 --- a/server/utils/admin/crud.ts +++ b/server/utils/admin/crud.ts @@ -1,5 +1,5 @@ import type { H3Event } from 'h3' -import { LexValidationError, XrpcResponseError, jsonToLex } from '@atproto/lex' +import { LexValidationError, XrpcResponseError, jsonToLex, lexToJson } from '@atproto/lex' import type { AtUriString, CidString, CreateOptions, DeleteOptions, GetOptions, Infer, JsonValue, PutOptions, RecordSchema } from '@atproto/lex' import { requireAdminClient } from './client' @@ -18,6 +18,16 @@ function rkeyFromUri (uri: string): string { return uri.split('/').pop() ?? '' } +/** + * Record values hold lex representations (a blob ref's `ref` is a `Cid` + * instance), which `JSON.stringify` would emit as DAG-JSON (`{ '/': cid }`). + * The admin UI posts values straight back to us, and `writeRecord` decodes + * them with `jsonToLex`, which only accepts the `{ $link: cid }` form. + */ +function toJsonValue (value: Infer): Infer { + return lexToJson(value) as Infer +} + function assertRkey (rkey: string | undefined): asserts rkey is string { if (!rkey || rkey === 'undefined' || rkey === 'null') { throw createError({ statusCode: 400, statusMessage: 'Missing or invalid rkey.' }) @@ -36,7 +46,7 @@ export async function listAdminRecords ( rkey: rkeyFromUri(r.uri), uri: r.uri, cid: r.cid, - value: r.value as Infer, + value: toJsonValue(r.value as Infer), }) } if (options.sortBy) { @@ -67,7 +77,7 @@ export async function getAdminRecord ( const { client, did } = await requireAdminClient(event) try { const res = await client.get(schema, { repo: did, rkey } as unknown as GetOptions) - return { rkey, uri: res.uri, cid: res.cid ?? '', value: res.value } + return { rkey, uri: res.uri, cid: res.cid ?? '', value: toJsonValue(res.value) } } catch (err) { if (err instanceof XrpcResponseError && err.status === 404) { diff --git a/shared/cms/blob.ts b/shared/cms/blob.ts index 4733b4c5..8aa6559c 100644 --- a/shared/cms/blob.ts +++ b/shared/cms/blob.ts @@ -1,18 +1,20 @@ /** - * Extract the base32 CID string from a blob ref. Accepts the IPLD-JSON - * form (`{ ref: { $link: cid } }`) and a `BlobRef` instance with a `CID` - * `ref`. + * Extract the base32 CID string from a blob ref. Accepts the IPLD-JSON form + * (`{ ref: { $link: cid } }`), the DAG-JSON form (`{ ref: { '/': cid } }`) + * produced when a `CID` instance is serialised with `JSON.stringify`, the + * legacy untyped form (`{ cid }`), and a `BlobRef` instance with a `CID` `ref`. */ export function cidFromBlob (blob: unknown): string | null { if (!blob || typeof blob !== 'object') return null const b = blob as Record - const ref = b.ref + const ref = b.ref ?? b.cid if (!ref) return null if (typeof ref === 'string') return ref if (typeof ref === 'object') { const r = ref as Record if (typeof r.$link === 'string') return r.$link + if (typeof r['/'] === 'string') return r['/'] const s = String(ref) if (s && s !== '[object Object]') return s } diff --git a/test/unit/blob.spec.ts b/test/unit/blob.spec.ts index 422ef0fa..705c9d6e 100644 --- a/test/unit/blob.spec.ts +++ b/test/unit/blob.spec.ts @@ -6,6 +6,14 @@ describe('cidFromBlob', () => { expect(cidFromBlob({ $type: 'blob', ref: { $link: 'bafyabc' }, mimeType: 'image/png', size: 10 })).toBe('bafyabc') }) + it('reads the DAG-JSON blob shape', () => { + expect(cidFromBlob({ $type: 'blob', ref: { '/': 'bafyabc' }, mimeType: 'image/png', size: 10 })).toBe('bafyabc') + }) + + it('reads the legacy untyped blob shape', () => { + expect(cidFromBlob({ cid: 'bafyabc', mimeType: 'image/png' })).toBe('bafyabc') + }) + it('returns null for non-blob input', () => { expect(cidFromBlob(null)).toBeNull() expect(cidFromBlob('nope')).toBeNull() -- 2.51.2