diff --git a/package.json b/package.json index 510955abbf..5d92bc833f 100644 --- a/package.json +++ b/package.json @@ -333,11 +333,14 @@ "^multiformats/cid$": "/node_modules/multiformats/dist/src/cid.js", "^multiformats/bases/base32$": "/node_modules/multiformats/dist/src/bases/base32.js", "^multiformats/hashes/digest$": "/node_modules/multiformats/dist/src/hashes/digest.js", + "^multiformats/hashes/hasher$": "/node_modules/multiformats/dist/src/hashes/hasher.js", "^multiformats/hashes/sha2$": "/node_modules/multiformats/dist/src/hashes/sha2.js", "^uint8arrays/from-string$": "/node_modules/uint8arrays/dist/src/from-string.js", "^uint8arrays/to-string$": "/node_modules/uint8arrays/dist/src/to-string.js", "^unicode-segmenter/grapheme$": "/node_modules/unicode-segmenter/grapheme.cjs", - "^await-lock$": "/node_modules/await-lock/build/AwaitLock.js" + "^await-lock$": "/node_modules/await-lock/build/AwaitLock.js", + "^@ipld/dag-cbor$": "/node_modules/@ipld/dag-cbor/src/index.js", + "^cborg$": "/node_modules/cborg/cborg.js" }, "transformIgnorePatterns": [ "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match|@atproto/.*|@bsky.app/sdk|tlds|multiformats|uint8arrays|@ipld/.*|cborg|await-lock)" diff --git a/src/components/contacts/screens/GetContacts.tsx b/src/components/contacts/screens/GetContacts.tsx index 879864b162..d84e0d1ab0 100644 --- a/src/components/contacts/screens/GetContacts.tsx +++ b/src/components/contacts/screens/GetContacts.tsx @@ -8,16 +8,18 @@ import { AppBskyContactImportContacts, type Un$Typed, } from '@atproto/api' +import {type Client} from '@atproto/lex' import {msg, t} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQueryClient} from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {toLegacyBlobRef} from '#/lib/api/legacy-blob' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {findContactsStatusQueryKey} from '#/state/queries/find-contacts' -import {useAgent} from '#/state/session' +import {useAgent, usePdsClient} from '#/state/session' import { Context as OnboardingContext, type OnboardingAction, @@ -55,6 +57,7 @@ export function GetContacts({ const {_} = useLingui() const ax = useAnalytics() const agent = useAgent() + const pdsClient = usePdsClient() const insets = useSafeAreaInsets() const gutters = useGutters([0, 'wide']) const queryClient = useQueryClient() @@ -72,7 +75,7 @@ export function GetContacts({ */ if (context === 'Onboarding' && maybeOnboardingContext) { try { - await createProfileRecord(agent, maybeOnboardingContext) + await createProfileRecord(agent, pdsClient, maybeOnboardingContext) } catch (error) { logger.debug('Error creating profile record:', {safeMessage: error}) } @@ -326,6 +329,7 @@ function showPermissionDeniedAlert() { */ async function createProfileRecord( agent: AtpAgent, + pdsClient: Client, onboardingContext: { state: OnboardingState dispatch: React.Dispatch @@ -334,15 +338,17 @@ async function createProfileRecord( const profileStepResults = onboardingContext.state.profileStepResults const {imageUri, imageMime} = profileStepResults const blobPromise = - imageUri && imageMime ? uploadBlob(agent, imageUri, imageMime) : undefined + imageUri && imageMime + ? uploadBlob(pdsClient, imageUri, imageMime) + : undefined await agent.upsertProfile(async existing => { let next: Un$Typed = existing ?? {} if (blobPromise) { const res = await blobPromise - if (res.data.blob) { - next.avatar = res.data.blob + if (res.blob) { + next.avatar = toLegacyBlobRef(res.blob) } } diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 3b60b688bb..525e189ace 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -224,7 +224,6 @@ export function useUpsertLiveStatusMutation( ) { const ax = useAnalytics() const {currentAccount} = useSession() - const agent = useAgent() const pdsClient = usePdsClient() const queryClient = useQueryClient() const control = useDialogContext() @@ -244,15 +243,11 @@ export function useUpsertLiveStatusMutation( const img = await imageToThumb(linkMeta.image) if (img) { const blob = await uploadBlob( - agent, + pdsClient, img.source.path, img.source.mime, ) - /* - * `uploadBlob` still returns the legacy `BlobRef` class - * instance; it moves to the client with the blob pipeline. - */ - thumb = blob.data.blob as unknown as l.BlobRef + thumb = blob.blob } } catch (e: any) { ax.logger.error(`Failed to upload thumbnail for live status`, { diff --git a/src/lib/api/__tests__/computeCid.test.ts b/src/lib/api/__tests__/computeCid.test.ts new file mode 100644 index 0000000000..ce0adc4a85 --- /dev/null +++ b/src/lib/api/__tests__/computeCid.test.ts @@ -0,0 +1,121 @@ +/* + * The jest suite ships global manual mocks for `multiformats/cid` and + * `multiformats/hashes/hasher` (in root `__mocks__/`) so unrelated tests don't + * pull in real crypto. This test is precisely about the real CID hashing, so we + * opt back into the actual implementations here. + */ +jest.unmock('multiformats/cid') +jest.unmock('multiformats/hashes/hasher') + +import {BlobRef} from '@atproto/api' +import {CID} from 'multiformats/cid' + +import {computeCid} from '#/lib/api/computeCid' +import {type app, type com} from '#/lexicons' + +/* + * Golden-CID regression test for the composer post pipeline. + * + * `computeCid` hashes a post record in the client so a thread's later posts can + * reference earlier posts by CID before the server assigns them. The hash is + * byte-sensitive: any drift in how records (especially blobs) are serialized to + * DAG-CBOR silently produces the wrong CID and breaks reply chains with NO type + * error. These golden values MUST remain byte-identical - they gate the + * structural lex-blob shape check. + * + * The blob CID below is a fixed, deterministic CIDv1/raw/sha256 used purely as a + * stable fixture - it is not derived from any real upload. + */ +const BLOB_CID = 'bafkreieq5jui4j25lacwomsqgjeswwl3y5zcdrresptwgmfylxo2depppq' + +/** + * Build a post record with an image embed whose blob is the given value. + */ +function postWithImageBlob(blob: unknown): app.bsky.feed.post.Main { + return { + $type: 'app.bsky.feed.post', + createdAt: '2024-01-01T00:00:00.001Z', + text: 'post with image', + embed: { + $type: 'app.bsky.embed.images', + images: [ + { + image: blob, + alt: 'alt text', + aspectRatio: {width: 100, height: 200}, + }, + ], + }, + } as app.bsky.feed.post.Main +} + +describe('computeCid', () => { + it('case 1: plain post record with no blob', async () => { + const record: app.bsky.feed.post.Main = { + $type: 'app.bsky.feed.post', + createdAt: '2024-01-01T00:00:00.000Z', + text: 'hello world', + } + expect(await computeCid(record)).toBe( + 'bafyreieawtmh7hwfrqpamqkodza5r62bbfhsepe2iyustgxhgbhi6b2lfi', + ) + }) + + it('case 2: record whose embed carries a plain-JSON lex blob', async () => { + /* + * The blob shape lex `uploadBlob` returns: a plain object + * `{$type: 'blob', ref, mimeType, size}` with `ref` a parsed CID. The + * structural lex-blob guard passes it through `prepareForHashing` + * untouched and DAG-CBOR encodes its CID `ref` as a CID link. The golden + * CID below is the byte-identical value the pre-migration `BlobRef` class + * instance produced via `.ipld()`. + */ + const blob = { + $type: 'blob' as const, + ref: CID.parse(BLOB_CID), + mimeType: 'image/jpeg', + size: 12345, + } + expect(await computeCid(postWithImageBlob(blob))).toBe( + 'bafyreiem7g6vja66nebr7he4fshfnlyndyldbvle2n265oixscmepjcbii', + ) + }) + + it('case 2b: a legacy BlobRef instance hashes to the same CID', async () => { + /* + * The video pipeline still yields legacy `BlobRef` class instances, so + * `prepareForHashing` keeps its `instanceof` guard. Both branches must + * agree: this asserts the SAME golden CID as case 2. + */ + const blob = new BlobRef(CID.parse(BLOB_CID), 'image/jpeg', 12345) + expect(await computeCid(postWithImageBlob(blob))).toBe( + 'bafyreiem7g6vja66nebr7he4fshfnlyndyldbvle2n265oixscmepjcbii', + ) + }) + + it('case 3: three-post thread chains reply StrongRef CIDs', async () => { + const did = 'did:plc:abc123' + const base = new Date('2024-01-01T00:00:00.000Z') + const golden = [ + 'bafyreig62rxs34h5rvznfrracwkjlfgad5b25qxglp2hcziqdfas2nw2ee', + 'bafyreicxcj2tq5jrh5jcaczg3eli5cvxitgzu7kpu3fm5v3njq2byjxirq', + 'bafyreigvaswuhlpd7dllja2xrqswhqbruyv2kar7mvbn7gdm5ldzu6vkti', + ] + + let reply: app.bsky.feed.post.Main['reply'] | undefined + for (let i = 0; i < 3; i++) { + const now = new Date(base.getTime() + i) + const uri = `at://${did}/app.bsky.feed.post/rkey${i}` + const record = { + $type: 'app.bsky.feed.post', + createdAt: now.toISOString(), + text: `post ${i}`, + reply, + } as app.bsky.feed.post.Main + const cid = await computeCid(record) + expect(cid).toBe(golden[i]) + const ref = {cid, uri} as com.atproto.repo.strongRef.Main + reply = {root: reply?.root ?? ref, parent: ref} + } + }) +}) diff --git a/src/lib/api/computeCid.ts b/src/lib/api/computeCid.ts new file mode 100644 index 0000000000..18821793ae --- /dev/null +++ b/src/lib/api/computeCid.ts @@ -0,0 +1,148 @@ +import {BlobRef} from '@atproto/api' +import {sha256} from 'js-sha256' +import {CID} from 'multiformats/cid' +import * as Hasher from 'multiformats/hashes/hasher' + +import {type app} from '#/lexicons' + +/* + * Client-side CID computation for post records, extracted from the post + * pipeline so it can be unit-tested in isolation (importing the pipeline pulls + * in the native gallery/media chain). See `computeCid.test.ts` for the + * golden-CID regression fixtures that gate any change to this serialization. + */ + +// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`) +// are meant for Node.js, this is the cross-platform equivalent. +const mf_sha256 = Hasher.from({ + name: 'sha2-256', + code: 0x12, + encode: input => { + const digest = sha256.arrayBuffer(input) + return new Uint8Array(digest) + }, +}) + +export async function computeCid( + record: app.bsky.feed.post.Main, +): Promise { + /* + * Lazily loaded since it's only needed when posting a thread, and its + * `cborg` dependency is ~190KB that would otherwise be in the initial + * web bundle. + */ + const dcbor = await importDagCbor() + /* + * IMPORTANT: `prepareForHashing` prepares the record to be hashed by + * removing fields with undefined value, and converting blobs to the right + * IPLD representation. + */ + const prepared = prepareForHashing(record) + // 1. Encode the record into DAG-CBOR format + const encoded = dcbor.encode(prepared) + // 2. Hash the record in SHA-256 (code 0x12) + const digest = await mf_sha256.digest(encoded) + // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71) + const cid = CID.createV1(0x71, digest) + // 4. Get the Base32 representation of the CID (`b` prefix) + return cid.toString() +} + +/** + * True for a plain-JSON lexicon blob, the shape lex `uploadBlob` returns + * (`{$type: 'blob', ref, mimeType, size}` with `ref` a parsed CID). Lex blobs + * are plain objects, not class instances. + */ +function isLexBlob(v: unknown): boolean { + if (v == null || typeof v !== 'object') return false + const o = v as Record + return o.$type === 'blob' && 'ref' in o && 'mimeType' in o +} + +/* + * Returns a transformed version of the object for use in DAG-CBOR. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function prepareForHashing(v: any): any { + /* + * A plain-JSON lex blob is already in the right IPLD shape (its `ref` is a + * parsed CID that DAG-CBOR encodes as a CID link), so pass it through + * untouched. + */ + if (isLexBlob(v)) { + return v + } + + /* + * The video pipeline still reads its blob off the legacy agent + * (`app.bsky.video.getJobStatus` in composer `state/video`), which yields a + * `BlobRef` class instance. `ipld()` gives the same IPLD shape a lex blob + * already has, so both branches hash identically. Drop this guard once the + * video client is migrated. + */ + if (v instanceof BlobRef) { + return v.ipld() + } + + // Walk through arrays + if (Array.isArray(v)) { + let pure = true + const mapped = v.map(value => { + if (value !== (value = prepareForHashing(value))) { + pure = false + } + return value + }) + return pure ? v : mapped + } + + // Walk through plain objects + if (isPlainObject(v)) { + const rec = v as Record + const obj: Record = {} + let pure = true + for (const key in rec) { + let value = rec[key] + // `value` is undefined + if (value === undefined) { + pure = false + continue + } + /* + * `prepareForHashing` returned a value that's different from what we had + * before + */ + if (value !== (value = prepareForHashing(value))) { + pure = false + } + obj[key] = value + } + // Return as is if we haven't needed to tamper with anything + return pure ? v : obj + } + return v +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isPlainObject(v: any): boolean { + if (typeof v !== 'object' || v === null) { + return false + } + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + +/** + * Load `@ipld/dag-cbor` on demand. The dynamic `import()` lets web bundlers + * emit it (and its ~190KB `cborg` dependency) as a separate chunk that only + * loads when posting a thread. Under jest (which runs without + * `--experimental-vm-modules`) dynamic import throws, so we fall back to a + * lazy `require`, which resolves through the test moduleNameMapper. + */ +function importDagCbor(): Promise { + if (process.env.NODE_ENV === 'test') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return Promise.resolve(require('@ipld/dag-cbor')) + } + return import('@ipld/dag-cbor') +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index aee481669e..e52d4ec4fc 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,28 +1,14 @@ -import { - type $Typed, - type AppBskyEmbedExternal, - type AppBskyEmbedGallery, - type AppBskyEmbedImages, - type AppBskyEmbedRecord, - type AppBskyEmbedRecordWithMedia, - type AppBskyEmbedVideo, - AppBskyFeedPost, - type AtpAgent, - BlobRef, - ChatBskyGroupDefs, - type ComAtprotoLabelDefs, - type ComAtprotoRepoApplyWrites, - type ComAtprotoRepoStrongRef, -} from '@atproto/api' +import {type AtpAgent, ChatBskyGroupDefs} from '@atproto/api' import {TID} from '@atproto/common-web' -import {type Client} from '@atproto/lex' -import {toDatetimeString} from '@atproto/syntax' +import {type $Typed, type Client} from '@atproto/lex' +import { + type AtUriString, + toDatetimeString, + type UriString, +} from '@atproto/syntax' import {RichText} from '@bsky.app/sdk/richtext' import {t} from '@lingui/core/macro' import {type QueryClient} from '@tanstack/react-query' -import {sha256} from 'js-sha256' -import {CID} from 'multiformats/cid' -import * as Hasher from 'multiformats/hashes/hasher' import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {isNetworkError} from '#/lib/strings/errors' @@ -42,8 +28,11 @@ import { type PostDraft, type ThreadDraft, } from '#/view/com/composer/state/composer' +import {app, com} from '#/lexicons' import * as bsky from '#/types/bsky' import {createGIFDescription} from '../gif-alt-text' +import {computeCid} from './computeCid' +import {fromLegacyBlobRef} from './legacy-blob' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -54,13 +43,23 @@ interface PostOpts { onStateChange?: (state: string) => void langs?: string[] /* - * Facet/mention resolution is an appview job - it resolves handles through - * the appview, and the public fallback keeps it working when logged out. - * The rest of this pipeline still writes through the agent. + * Facet/mention resolution and reply-root lookup are appview jobs - they + * resolve handles and read posts through the appview, and the public + * fallback keeps facet detection working when logged out. */ appviewClient: Client + /* + * The repo write itself (applyWrites) plus every record blob (images, + * gallery items, link thumbnails, video captions) goes to the account's own + * PDS, never the appview. + */ + pdsClient: Client } +/** + * The `agent` is only still here for the link/gif resolvers in `resolve.ts`, + * which read through it; drop the parameter when those move to the clients. + */ export async function post( agent: AtpAgent, queryClient: QueryClient, @@ -70,12 +69,12 @@ export async function post( opts.onStateChange?.(t`Processing...`) let replyPromise: - | Promise - | AppBskyFeedPost.Record['reply'] + | Promise + | app.bsky.feed.post.Main['reply'] | undefined if (opts.replyTo) { // Not awaited to avoid waterfalls. - replyPromise = resolveReply(agent, opts.replyTo) + replyPromise = resolveReply(opts.appviewClient, opts.replyTo) } // add top 3 languages from user preferences if langs is provided @@ -84,8 +83,8 @@ export async function post( langs = opts.langs.slice(0, 3) } - const did = agent.assertDid - const writes: $Typed[] = [] + const did = opts.pdsClient.assertDid + const writes: com.atproto.repo.applyWrites.$InputBody['writes'] = [] const uris: string[] = [] let now = new Date() @@ -98,11 +97,12 @@ export async function post( const rtPromise = resolveRT(opts.appviewClient, draft.richtext) const embedPromise = resolveEmbed( agent, + opts.pdsClient, queryClient, draft, opts.onStateChange, ) - let labels: $Typed | undefined + let labels: $Typed | undefined if (draft.labels.length) { labels = { $type: 'com.atproto.label.defs#selfLabels', @@ -115,17 +115,17 @@ export async function post( now.setMilliseconds(now.getMilliseconds() + 1) tid = TID.next(tid) const rkey = tid.toString() - const uri = `at://${did}/app.bsky.feed.post/${rkey}` + const uri = `at://${did}/app.bsky.feed.post/${rkey}` as AtUriString uris.push(uri) const rt = await rtPromise const embed = await embedPromise const reply = await replyPromise - const record: AppBskyFeedPost.Record = { + const record: app.bsky.feed.post.Main = { // IMPORTANT: $type has to exist, CID is calculated with the `$type` field // present and will produce the wrong CID if you omit it. $type: 'app.bsky.feed.post', - createdAt: now.toISOString(), + createdAt: toDatetimeString(now), text: rt.text, facets: rt.facets, reply, @@ -164,7 +164,7 @@ export async function post( value: { ...thread.postgate, $type: 'app.bsky.feed.postgate', - createdAt: now.toISOString(), + createdAt: toDatetimeString(now), post: uri, }, }) @@ -182,8 +182,8 @@ export async function post( } try { - await agent.com.atproto.repo.applyWrites({ - repo: agent.assertDid, + await opts.pdsClient.call(com.atproto.repo.applyWrites, { + repo: did, writes: writes, validate: true, }) @@ -224,9 +224,9 @@ export class ReplyDeletedError extends Error { } } -async function resolveReply(agent: AtpAgent, replyTo: string) { - const {data} = await agent.app.bsky.feed.getPosts({ - uris: [replyTo], +async function resolveReply(appviewClient: Client, replyTo: string) { + const data = await appviewClient.call(app.bsky.feed.getPosts, { + uris: [replyTo as AtUriString], }) const parentPost = data.posts[0] if (!parentPost) { @@ -237,14 +237,9 @@ async function resolveReply(agent: AtpAgent, replyTo: string) { uri: parentPost.uri, cid: parentPost.cid, } - let rootRef = parentRef + let rootRef: com.atproto.repo.strongRef.Main = parentRef - if ( - bsky.dangerousIsType( - parentPost.record, - AppBskyFeedPost.isRecord, - ) - ) { + if (bsky.isType(app.bsky.feed.post, parentPost.record)) { if (parentPost.record.reply) { rootRef = parentPost.record.reply.root } @@ -258,21 +253,14 @@ async function resolveReply(agent: AtpAgent, replyTo: string) { async function resolveEmbed( agent: AtpAgent, + pdsClient: Client, queryClient: QueryClient, draft: PostDraft, onStateChange: ((state: string) => void) | undefined, -): Promise< - | $Typed - | $Typed - | $Typed - | $Typed - | $Typed - | $Typed - | undefined -> { +): Promise { if (draft.embed.quote) { const [resolvedMedia, resolvedQuote] = await Promise.all([ - resolveMedia(agent, queryClient, draft.embed, onStateChange), + resolveMedia(agent, pdsClient, queryClient, draft.embed, onStateChange), resolveRecord(agent, queryClient, draft.embed.quote.uri), ]) if (resolvedMedia) { @@ -292,6 +280,7 @@ async function resolveEmbed( } const resolvedMedia = await resolveMedia( agent, + pdsClient, queryClient, draft.embed, onStateChange, @@ -308,7 +297,12 @@ async function resolveEmbed( if (resolvedLink.type === 'record') { return { $type: 'app.bsky.embed.record', - record: resolvedLink.record, + /* + * `resolve.ts` is still legacy-typed - its strong refs and URIs carry + * unbranded strings. Assert at the boundary until it moves to the + * clients. + */ + record: resolvedLink.record as com.atproto.repo.strongRef.Main, } } } @@ -317,14 +311,15 @@ async function resolveEmbed( async function resolveMedia( agent: AtpAgent, + pdsClient: Client, queryClient: QueryClient, embedDraft: EmbedDraft, onStateChange: ((state: string) => void) | undefined, ): Promise< - | $Typed - | $Typed - | $Typed - | $Typed + | $Typed + | $Typed + | $Typed + | $Typed | undefined > { if (embedDraft.media?.type === 'images') { @@ -333,7 +328,7 @@ async function resolveMedia( count: imagesDraft.length, }) onStateChange?.(t`Uploading images...`) - const images: AppBskyEmbedImages.Image[] = await Promise.all( + const images: app.bsky.embed.images.Image[] = await Promise.all( imagesDraft.map(async (image, i) => { logger.debug(`Compressing image #${i}`) const {path, width, height, mime} = await compressImage( @@ -341,9 +336,9 @@ async function resolveMedia( IMAGE_SIZE_CONFIG_POSTS, ) logger.debug(`Uploading image #${i}`) - const res = await uploadBlob(agent, path, mime) + const res = await uploadBlob(pdsClient, path, mime) return { - image: res.data.blob, + image: res.blob, alt: image.alt, aspectRatio: {width, height}, } @@ -360,7 +355,7 @@ async function resolveMedia( count: imagesDraft.length, }) onStateChange?.(t`Uploading images...`) - const items: $Typed[] = await Promise.all( + const items: $Typed[] = await Promise.all( imagesDraft.map(async (image, i) => { logger.debug(`Compressing image #${i}`) const {path, width, height, mime} = await compressImage( @@ -368,10 +363,10 @@ async function resolveMedia( IMAGE_SIZE_CONFIG_POSTS, ) logger.debug(`Uploading image #${i}`) - const res = await uploadBlob(agent, path, mime) + const res = await uploadBlob(pdsClient, path, mime) return { $type: 'app.bsky.embed.gallery#image' as const, - image: res.data.blob, + image: res.blob, alt: image.alt, aspectRatio: {width, height}, } @@ -391,10 +386,10 @@ async function resolveMedia( videoDraft.captions .filter(caption => caption.lang !== '') .map(async caption => { - const {data} = await agent.uploadBlob(caption.file, { + const res = await pdsClient.uploadBlob(caption.file, { encoding: 'text/vtt', }) - return {lang: caption.lang, file: data.blob} + return {lang: caption.lang, file: res.body.blob} }), ) @@ -414,7 +409,11 @@ async function resolveMedia( return { $type: 'app.bsky.embed.video', - video: videoDraft.pendingPublish.blobRef, + /* + * The video pipeline still reads its blob off the legacy agent, so + * normalize it to the lex shape before it reaches the lex write. + */ + video: fromLegacyBlobRef(videoDraft.pendingPublish.blobRef), alt: videoDraft.altText || undefined, captions: captions.length === 0 ? undefined : captions, aspectRatio, @@ -429,17 +428,17 @@ async function resolveMedia( agent, gifDraft.gif, ) - let blob: BlobRef | undefined + let blob: app.bsky.embed.external.External['thumb'] if (resolvedGif.thumb) { onStateChange?.(t`Uploading link thumbnail...`) const {path, mime} = resolvedGif.thumb.source - const response = await uploadBlob(agent, path, mime) - blob = response.data.blob + const response = await uploadBlob(pdsClient, path, mime) + blob = response.blob } return { $type: 'app.bsky.embed.external', external: { - uri: resolvedGif.uri, + uri: resolvedGif.uri as UriString, title: resolvedGif.title, description: createGIFDescription(resolvedGif.title, gifDraft.alt), thumb: blob, @@ -453,21 +452,23 @@ async function resolveMedia( embedDraft.link.uri, ) if (resolvedLink.type === 'external') { - let blob: BlobRef | undefined + let blob: app.bsky.embed.external.External['thumb'] if (resolvedLink.thumb) { onStateChange?.(t`Uploading link thumbnail...`) const {path, mime} = resolvedLink.thumb.source - const response = await uploadBlob(agent, path, mime) - blob = response.data.blob + const response = await uploadBlob(pdsClient, path, mime) + blob = response.blob } return { $type: 'app.bsky.embed.external', external: { - uri: resolvedLink.uri, + uri: resolvedLink.uri as UriString, title: resolvedLink.title, description: resolvedLink.description, thumb: blob, - associatedRefs: resolvedLink.associatedRefs, + associatedRefs: resolvedLink.associatedRefs as + | com.atproto.repo.strongRef.Main[] + | undefined, }, } } @@ -478,7 +479,7 @@ async function resolveMedia( return { $type: 'app.bsky.embed.external', external: { - uri: resolvedLink.uri, + uri: resolvedLink.uri as UriString, title: resolvedLink.view.name, description: `${resolvedLink.view.memberCount}/${resolvedLink.view.memberLimit}`, }, @@ -488,101 +489,18 @@ async function resolveMedia( return undefined } +/* + * `resolve.ts` still resolves through the agent and returns legacy-typed refs; + * assert at the boundary until it moves to the clients. + */ async function resolveRecord( agent: AtpAgent, queryClient: QueryClient, uri: string, -): Promise { +): Promise { const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) if (resolvedLink.type !== 'record') { throw Error(t`Expected uri to resolve to a record`) } - return resolvedLink.record -} - -// The built-in hashing functions from multiformats (`multiformats/hashes/sha2`) -// are meant for Node.js, this is the cross-platform equivalent. -const mf_sha256 = Hasher.from({ - name: 'sha2-256', - code: 0x12, - encode: input => { - const digest = sha256.arrayBuffer(input) - return new Uint8Array(digest) - }, -}) - -async function computeCid(record: AppBskyFeedPost.Record): Promise { - /* - * Lazily loaded since it's only needed when posting a thread, and its - * `cborg` dependency is ~190KB that would otherwise be in the initial - * web bundle. - */ - const dcbor = await import('@ipld/dag-cbor') - // IMPORTANT: `prepareObject` prepares the record to be hashed by removing - // fields with undefined value, and converting BlobRef instances to the - // right IPLD representation. - const prepared = prepareForHashing(record) - // 1. Encode the record into DAG-CBOR format - const encoded = dcbor.encode(prepared) - // 2. Hash the record in SHA-256 (code 0x12) - const digest = await mf_sha256.digest(encoded) - // 3. Create a CIDv1, specifying DAG-CBOR as content (code 0x71) - const cid = CID.createV1(0x71, digest) - // 4. Get the Base32 representation of the CID (`b` prefix) - return cid.toString() -} - -// Returns a transformed version of the object for use in DAG-CBOR. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function prepareForHashing(v: any): any { - // IMPORTANT: BlobRef#ipld() returns the correct object we need for hashing, - // the API client will convert this for you but we're hashing in the client, - // so we need it *now*. - if (v instanceof BlobRef) { - return v.ipld() - } - - // Walk through arrays - if (Array.isArray(v)) { - let pure = true - const mapped = v.map(value => { - if (value !== (value = prepareForHashing(value))) { - pure = false - } - return value - }) - return pure ? v : mapped - } - - // Walk through plain objects - if (isPlainObject(v)) { - const obj: Record = {} - let pure = true - for (const key in v) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - let value = v[key] - // `value` is undefined - if (value === undefined) { - pure = false - continue - } - // `prepareObject` returned a value that's different from what we had before - if (value !== (value = prepareForHashing(value))) { - pure = false - } - obj[key] = value - } - // Return as is if we haven't needed to tamper with anything - return pure ? v : obj - } - return v -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isPlainObject(v: any): boolean { - if (typeof v !== 'object' || v === null) { - return false - } - const proto = Object.getPrototypeOf(v) - return proto === Object.prototype || proto === null + return resolvedLink.record as com.atproto.repo.strongRef.Main } diff --git a/src/lib/api/legacy-blob.ts b/src/lib/api/legacy-blob.ts new file mode 100644 index 0000000000..a98e375d20 --- /dev/null +++ b/src/lib/api/legacy-blob.ts @@ -0,0 +1,31 @@ +import {BlobRef} from '@atproto/api' +import {type BlobRef as LexBlobRef} from '@atproto/lex' + +/** + * Bridge a lex blob ref (the plain-JSON `{$type: 'blob', ref, mimeType, size}` + * that {@link uploadBlob} now returns) back to the legacy `BlobRef` class + * instance. + * + * Only needed where a blob is handed to a legacy agent write: the legacy + * lexicon blob validator checks `value instanceof BlobRef`, so a plain lex + * blob fails validation, and the legacy serializer would put the wrong shape + * on the wire. Drop each call as its write moves to the lex client. + */ +export function toLegacyBlobRef(blob: LexBlobRef): BlobRef { + return BlobRef.fromJsonRef(blob as Parameters[0]) +} + +/** + * Normalize a legacy `BlobRef` class instance to the plain-JSON lex blob shape. + * + * Required for any blob that reaches a lex write: the lex serializer walks + * plain objects, so a class instance goes on the wire with its internal + * `original` field and no `$type`. `ipld()` yields exactly the lex shape, and + * hashes identically (see `computeCid.test.ts` case 2b). + * + * Only the video pipeline still needs this - it reads its blob off the legacy + * agent (`app.bsky.video.getJobStatus`). Drop it when the video client moves. + */ +export function fromLegacyBlobRef(blob: BlobRef): LexBlobRef { + return blob.ipld() +} diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts index 0bee39b9b1..c679e15d84 100644 --- a/src/lib/api/upload-blob.ts +++ b/src/lib/api/upload-blob.ts @@ -1,38 +1,62 @@ import {copyAsync} from 'expo-file-system/legacy' -import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' +import {type BlobRef, type Client, type EncodingString} from '@atproto/lex' import {safeDeleteAsync} from '#/lib/media/manip' /** - * @param encoding Allows overriding the blob's type + * The blob-upload response body: `{blob}`. lex `Client.uploadBlob` returns the + * full XRPC response, so this helper unwraps `res.body` for callers. + */ +type UploadBlobResult = {blob: BlobRef} + +/** + * @param encoding Allows overriding the blob's type. Passed as the lex upload + * option (NEVER a content-type header - lex-client throws if the encoding is + * set via headers). */ export async function uploadBlob( - agent: AtpAgent, + client: Client, input: string | Blob, encoding?: string, -): Promise { +): Promise { if (typeof input === 'string' && input.startsWith('file:')) { const blob = await asBlob(input) - return agent.uploadBlob(blob, {encoding}) + return uploadBlobResult(client, blob, encoding) } if (typeof input === 'string' && input.startsWith('/')) { const blob = await asBlob(`file://${input}`) - return agent.uploadBlob(blob, {encoding}) + return uploadBlobResult(client, blob, encoding) } if (typeof input === 'string' && input.startsWith('data:')) { const blob = await fetch(input).then(r => r.blob()) - return agent.uploadBlob(blob, {encoding}) + return uploadBlobResult(client, blob, encoding) } if (input instanceof Blob) { - return agent.uploadBlob(input, {encoding}) + return uploadBlobResult(client, input, encoding) } throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) } +async function uploadBlobResult( + client: Client, + blob: Blob, + encoding?: string, +): Promise { + const res = await client.uploadBlob(blob, { + /* + * The lex encoding option is a branded mime string + * (`${string}/${string}`); callers pass a plain mime string, so assert the + * brand here. + */ + encoding: encoding as EncodingString | undefined, + }) + return {blob: res.body.blob} +} + async function asBlob(uri: string): Promise { return withSafeFile(uri, async safeUri => { // Note diff --git a/src/lib/api/upload-blob.web.ts b/src/lib/api/upload-blob.web.ts index d74e834647..0a58ad42f1 100644 --- a/src/lib/api/upload-blob.web.ts +++ b/src/lib/api/upload-blob.web.ts @@ -1,28 +1,42 @@ -import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' +import {type BlobRef, type Client, type EncodingString} from '@atproto/lex' + +/** + * The blob-upload response body: `{blob}`. lex `Client.uploadBlob` returns the + * full XRPC response, so this helper unwraps `res.body` for callers. + */ +type UploadBlobResult = {blob: BlobRef} /** * @note It is recommended, on web, to use the `file` instance of the file * selector input element, rather than a `data:` URL, to avoid * loading the file into memory. `File` extends `Blob` "file" instances can * be passed directly to this function. + * + * @param encoding Passed as the lex upload option (NEVER a content-type header + * - lex-client throws if the encoding is set via headers). */ export async function uploadBlob( - agent: AtpAgent, + client: Client, input: string | Blob, encoding?: string, -): Promise { +): Promise { + /* + * The lex encoding option is a branded mime string (`${string}/${string}`); + * callers pass a plain mime string, so assert the brand here. + */ + const enc = encoding as EncodingString | undefined if ( typeof input === 'string' && (input.startsWith('data:') || input.startsWith('blob:')) ) { const blob = await fetch(input).then(r => r.blob()) - return agent.uploadBlob(blob, {encoding}) + const res = await client.uploadBlob(blob, {encoding: enc}) + return {blob: res.body.blob} } if (input instanceof Blob) { - return agent.uploadBlob(input, { - encoding, - }) + const res = await client.uploadBlob(input, {encoding: enc}) + return {blob: res.body.blob} } throw new TypeError(`Invalid uploadBlob input: ${typeof input}`) diff --git a/src/screens/Onboarding/StepFinished/index.tsx b/src/screens/Onboarding/StepFinished/index.tsx index 6f1dea3a4f..706a160003 100644 --- a/src/screens/Onboarding/StepFinished/index.tsx +++ b/src/screens/Onboarding/StepFinished/index.tsx @@ -15,6 +15,7 @@ import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {toLegacyBlobRef} from '#/lib/api/legacy-blob' import { BSKY_APP_ACCOUNT_DID, DISCOVER_SAVED_FEED, @@ -153,7 +154,7 @@ export function StepFinished() { const {imageUri, imageMime} = profileStepResults const blobPromise = imageUri && imageMime - ? uploadBlob(agent, imageUri, imageMime) + ? uploadBlob(pdsClient, imageUri, imageMime) : undefined await agent.upsertProfile(async existing => { @@ -161,8 +162,8 @@ export function StepFinished() { if (blobPromise) { const res = await blobPromise - if (res.data.blob) { - next.avatar = res.data.blob + if (res.blob) { + next.avatar = toLegacyBlobRef(res.blob) } } diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index ec551bc8e5..eef0dac585 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -1,5 +1,5 @@ import {type AppBskyGraphDefs} from '@atproto/api' -import {type $Typed, type Client, type l} from '@atproto/lex' +import {type $Typed, type Client} from '@atproto/lex' import { type AtIdentifierString, AtUri, @@ -56,7 +56,6 @@ export interface ListCreateMutateParams { export function useListCreateMutation() { const {currentAccount} = useSession() const queryClient = useQueryClient() - const agent = useAgent() const appviewClient = useAppviewClient() const pdsClient = usePdsClient() return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>( @@ -86,12 +85,8 @@ export function useListCreateMutation() { createdAt: toDatetimeString(new Date()), } if (avatar) { - const blobRes = await uploadBlob(agent, avatar.path, avatar.mime) - /* - * `uploadBlob` still returns the legacy `BlobRef` class instance; - * it moves to the client with the rest of the blob pipeline. - */ - record.avatar = blobRes.data.blob as unknown as l.BlobRef + const blobRes = await uploadBlob(pdsClient, avatar.path, avatar.mime) + record.avatar = blobRes.blob } const res = await pdsClient.create(app.bsky.graph.list, record) @@ -120,7 +115,6 @@ export interface ListMetadataMutateParams { } export function useListMetadataMutation() { const {currentAccount} = useSession() - const agent = useAgent() const appviewClient = useAppviewClient() const pdsClient = usePdsClient() const queryClient = useQueryClient() @@ -149,8 +143,8 @@ export function useListMetadataMutation() { record.description = description record.descriptionFacets = descriptionFacets if (avatar) { - const blobRes = await uploadBlob(agent, avatar.path, avatar.mime) - record.avatar = blobRes.data.blob as unknown as l.BlobRef + const blobRes = await uploadBlob(pdsClient, avatar.path, avatar.mime) + record.avatar = blobRes.blob } else if (avatar === null) { record.avatar = undefined } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index dea8d35eea..ae5d4b1ad6 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -7,7 +7,6 @@ import { type AppBskyGraphGetFollows, type AtpAgent, AtUri, - type ComAtprotoRepoUploadBlob, type Un$Typed, } from '@atproto/api' import { @@ -25,6 +24,7 @@ import { } from '@tanstack/react-query' import {uploadBlob} from '#/lib/api' +import {toLegacyBlobRef} from '#/lib/api/legacy-blob' import {until} from '#/lib/async/until' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' import {updateProfileShadow} from '#/state/cache/profile-shadow' @@ -149,6 +149,7 @@ interface ProfileUpdateParams { export function useProfileUpdateMutation() { const queryClient = useQueryClient() const agent = useAgent() + const pdsClient = usePdsClient() const updateProfileVerificationCache = useUpdateProfileVerificationCache() return useMutation({ mutationFn: async ({ @@ -158,22 +159,18 @@ export function useProfileUpdateMutation() { newUserBanner, checkCommitted, }) => { - let newUserAvatarPromise: - | Promise - | undefined + let newUserAvatarPromise: ReturnType | undefined if (newUserAvatar) { newUserAvatarPromise = uploadBlob( - agent, + pdsClient, newUserAvatar.path, newUserAvatar.mime, ) } - let newUserBannerPromise: - | Promise - | undefined + let newUserBannerPromise: ReturnType | undefined if (newUserBanner) { newUserBannerPromise = uploadBlob( - agent, + pdsClient, newUserBanner.path, newUserBanner.mime, ) @@ -191,13 +188,13 @@ export function useProfileUpdateMutation() { } if (newUserAvatarPromise) { const res = await newUserAvatarPromise - next.avatar = res.data.blob + next.avatar = toLegacyBlobRef(res.blob) } else if (newUserAvatar === null) { next.avatar = undefined } if (newUserBannerPromise) { const res = await newUserBannerPromise - next.banner = res.data.blob + next.banner = toLegacyBlobRef(res.blob) } else if (newUserBanner === null) { next.banner = undefined } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index c2e651556d..1388ce0c45 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -96,7 +96,12 @@ import { import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' import {resolveLinkQueryOptions} from '#/state/queries/resolve-link' -import {useAgent, useAppviewClient, useSession} from '#/state/session' +import { + useAgent, + useAppviewClient, + usePdsClient, + useSession, +} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' @@ -277,6 +282,7 @@ export const ComposePost = ({ : VIDEO_MAX_DURATION_MS const agent = useAgent() const client = useAppviewClient() + const pdsClient = usePdsClient() const queryClient = useQueryClient() const currentDid = currentAccount!.did const {closeComposer} = useComposerControls() @@ -1084,6 +1090,7 @@ export const ComposePost = ({ onStateChange: setPublishingStage, langs: currentLanguages, appviewClient: client, + pdsClient, }) ).uris[0] @@ -1279,6 +1286,7 @@ export const ComposePost = ({ ax, agent, client, + pdsClient, canPost, isPublishing, currentLanguages,