an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
1import ky from "npm:ky";
2import QuickLRU from "npm:quick-lru";
3import { createHash } from "node:crypto";
4import { config } from "../config.ts";
5import * as ATPAPI from "npm:@atproto/api";
6
7const cache = new QuickLRU({ maxSize: 10000 });
8
9function simpleHashAuth(auth: string): string {
10 return createHash("sha256").update(auth).digest("hex");
11}
12export async function cachedFetch(url: string, auth?: string) {
13 const cacheKey = auth ? `${url}|${simpleHashAuth(auth)}` : url;
14 if (cache.has(cacheKey)) return cache.get(cacheKey);
15
16 const data = await ky
17 .get(url, {
18 headers: {
19 Authorization: `${auth}`,
20 },
21 })
22 .json();
23
24 cache.set(cacheKey, data);
25 return data;
26}
27
28export type SlingshotMiniDoc = {
29 did: string;
30 handle: string;
31 pds: string;
32 signing_key: string;
33};
34let preferences: any = undefined;
35
36export function searchParamsToJson(
37 params: URLSearchParams
38): Record<string, unknown> {
39 const result: Record<string, string | string[]> = {};
40
41 for (const [key, value] of params.entries()) {
42 if (result.hasOwnProperty(key)) {
43 const existing = result[key];
44 if (Array.isArray(existing)) {
45 existing.push(value);
46 } else {
47 result[key] = [existing, value];
48 }
49 } else {
50 result[key] = value;
51 }
52 }
53
54 return result;
55}
56
57export async function resolveIdentity(
58 actor: string
59): Promise<SlingshotMiniDoc> {
60 const url = `${config.slingshot}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${actor}`;
61 return (await cachedFetch(url)) as SlingshotMiniDoc;
62}
63export async function getRecord({
64 pds,
65 did,
66 collection,
67 rkey,
68}: {
69 pds: string;
70 did: string;
71 collection: string;
72 rkey: string;
73}): Promise<{ cursor?: string; records: GetRecord[] }> {
74 const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`;
75 const result = (await cachedFetch(url)) as {
76 cursor?: string;
77 records: GetRecord[];
78 };
79 return result as {
80 cursor?: string;
81 records: {
82 uri: string;
83 cid: string;
84 value: ATPAPI.AppBskyFeedPost.Record;
85 }[];
86 };
87}
88
89export async function listPostRecords({
90 pds,
91 did,
92 limit = 50,
93 cursor,
94}: {
95 pds: string;
96 did: string;
97 limit: number;
98 cursor?: string;
99}): Promise<{
100 cursor?: string;
101 records: { uri: string; cid: string; value: ATPAPI.AppBskyFeedPost.Record }[];
102}> {
103 const url = `${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${
104 cursor ? `&cursor=${cursor}` : ""
105 }`;
106 const result = (await cachedFetch(url)) as {
107 cursor?: string;
108 records: GetRecord[];
109 };
110 return result as {
111 cursor?: string;
112 records: {
113 uri: string;
114 cid: string;
115 value: ATPAPI.AppBskyFeedPost.Record;
116 }[];
117 };
118}
119
120// async function getProfileRecord(did: string): Promise<ATPAPI.AppBskyActorProfile.Record> {
121// const url = `${slingshoturl}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self`;
122// const result = await cachedFetch(url) as GetRecord;
123// return result.value as ATPAPI.AppBskyActorProfile.Record;
124// }
125
126export function buildBlobUrl(
127 pds: string,
128 did: string,
129 cid: string
130): string | undefined {
131 if (!pds || !did || !cid) return undefined;
132 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;
133}
134
135export type ConstellationDistinctDids = {
136 total: number;
137 linking_dids: string[];
138 cursor: string;
139};
140export type GetRecord = {
141 uri: string;
142 cid: string;
143 value: Record<string, unknown>;
144};
145
146export function didWebToHttps(did: string) {
147 if (!did.startsWith("did:web:")) return null;
148 const parts = did.slice("did:web:".length).split(":");
149 const [domain, ...path] = parts;
150 return `https://${domain}${path.length ? "/" + path.join("/") : ""}`;
151}
152
153export async function getSlingshotRecord(
154 did: string,
155 collection: string,
156 rkey: string
157): Promise<GetRecord> {
158 const identity = await resolveIdentity(did);
159 //const url = `${config.slingshot}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`;
160 const url = `${identity.pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=${collection}&rkey=${rkey}`;
161 const result = (await cachedFetch(url)) as GetRecord;
162 return result as GetRecord;
163}
164
165export async function getUniqueCount({
166 did,
167 collection,
168 path,
169}: {
170 did: string;
171 collection: string;
172 path: string;
173}): Promise<number> {
174 const url = `${config.constellation}/links/count/distinct-dids?target=${did}&collection=${collection}&path=${path}`;
175 const result = (await cachedFetch(url)) as ConstellationDistinctDids;
176 return result.total;
177}
178
179
180export function withCors(headers: HeadersInit = {}) {
181 return {
182 "Access-Control-Allow-Origin": "*",
183 ...headers,
184 };
185}