import { error } from "@sveltejs/kit"; import { ok } from "@atcute/client"; import { isBlob } from "@atcute/lexicons/interfaces"; import { mainSchema as uploadBlobSchema } from "@atcute/atproto/types/repo/uploadBlob"; import type { Did, ResourceUri } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient } from "#lib/auth/agent.ts"; import { awaitDeletedRecord, awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts"; import type { BobbinContext } from "#lib/api/client.ts"; import { resolveMiniDoc } from "#lib/api/identity.ts"; import { orNull } from "#lib/api/load.ts"; import { getString, type RecordView, type StringRecord } from "#lib/api/records.ts"; import { cidOf, nsidOf, recordKeyOf } from "#lib/api/syntax.ts"; import { rkeyFromUri } from "#lib/api/uri.ts"; import { deleteRecord, putRecord, type WrittenRecord } from "#lib/api/write.ts"; import { fetchPublic, readTextCapped } from "#lib/publicFetch.ts"; const STRING_COLLECTION = nsidOf("org.tangled.snippet.string"); export interface StringSubject { did: Did; handle: string; pds: string; uri: ResourceUri; record: RecordView; } export const loadStringSubject = async ( ctx: BobbinContext, handle: string, rkey: string ): Promise => { const doc = await orNull(resolveMiniDoc(ctx, handle)); if (!doc) error(404, "String not found"); const uri = `at://${doc.did}/${STRING_COLLECTION}/${rkey}` as ResourceUri; const record = await orNull(getString(ctx, uri)); if (!record) error(404, "String not found"); return { did: doc.did as Did, handle: doc.handle, pds: doc.pds, uri, record }; }; /** Past this a string's contents aren't fetched: a pds could otherwise stream us anything. */ const MAX_CONTENTS_BYTES = 10 * 1024 * 1024; const CONTENTS_TIMEOUT_MS = 10_000; // bobbin indexes the record only; the content blob lives on the owner's pds. that pds is // whatever the owner's did document says, so the fetch only goes to a public https host // (redirects included), gives up after a while and stops reading past MAX_CONTENTS_BYTES export const fetchStringContents = async ( fetch: typeof globalThis.fetch, { did, pds, record }: StringSubject ): Promise => { let url: URL; try { url = new URL("/xrpc/com.atproto.sync.getBlob", pds); } catch { error(502, "String contents unavailable"); } url.searchParams.set("did", did); const { content } = record.value.snippet; url.searchParams.set("cid", isBlob(content) ? content.ref.$link : content.cid); try { const res = await fetchPublic(fetch, url, { signal: AbortSignal.timeout(CONTENTS_TIMEOUT_MS) }); if (!res.ok) throw new Error(`getBlob: ${res.status}`); return await readTextCapped(res, MAX_CONTENTS_BYTES); } catch { error(502, "String contents unavailable"); } }; export const uploadStringContents = async ( agent: OAuthUserAgent, contents: string ): Promise => { const input = new Blob([contents], { type: "text/plain" }); const { blob } = await ok(createClient(agent).call(uploadBlobSchema, { input })); return blob; }; export const putString = async ( agent: OAuthUserAgent, bobbin: BobbinContext, rkey: string, record: StringRecord, swapRecord?: string ): Promise => { const written = await putRecord( agent, STRING_COLLECTION, recordKeyOf(rkey), record, swapRecord ? cidOf(swapRecord) : undefined ); await awaitIndexedOrSaved(bobbin, written); return written; }; export const deleteString = async ( agent: OAuthUserAgent, bobbin: BobbinContext, uri: ResourceUri, swapRecord?: string ): Promise => { await deleteRecord( agent, STRING_COLLECTION, rkeyFromUri(uri), swapRecord ? cidOf(swapRecord) : undefined ); return awaitDeletedRecord(bobbin, uri); };