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, Handle, 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";
import type { Fetch } from "#lib/fetch.ts";
const STRING_COLLECTION = nsidOf("org.tangled.snippet.string");
export interface StringSubject {
did: Did;
handle: Handle;
pds: string;
uri: ResourceUri;
record: RecordView<StringRecord>;
}
export const loadStringSubject = async (
ctx: BobbinContext,
handle: string,
rkey: string
): Promise<StringSubject> => {
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: Fetch,
{ did, pds, record }: StringSubject
): Promise<string> => {
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<StringRecord["snippet"]["content"]> => {
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<WrittenRecord> => {
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<boolean> => {
await deleteRecord(
agent,
STRING_COLLECTION,
rkeyFromUri(uri),
swapRecord ? cidOf(swapRecord) : undefined
);
return awaitDeletedRecord(bobbin, uri);
};