28313bb81c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
4.7 KiB
TypeScript
149 lines
4.7 KiB
TypeScript
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<string> {
|
|
/*
|
|
* 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<string, unknown>
|
|
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<string, unknown>
|
|
const obj: Record<string, unknown> = {}
|
|
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<typeof import('@ipld/dag-cbor')> {
|
|
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')
|
|
}
|