~james/core

core/web/src/lib/api/strings.ts -rw-r--r-- 3.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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);
};