your personal website on atproto - mirror blento.app
at improve-oauth 464 lines 12 kB view raw
1import { 2 parseResourceUri, 3 type ActorIdentifier, 4 type Did, 5 type Handle, 6 type ResourceUri 7} from '@atcute/lexicons'; 8import { user } from './auth.svelte'; 9import type { AllowedCollection } from './settings'; 10import { 11 CompositeDidDocumentResolver, 12 CompositeHandleResolver, 13 DohJsonHandleResolver, 14 PlcDidDocumentResolver, 15 WebDidDocumentResolver, 16 WellKnownHandleResolver 17} from '@atcute/identity-resolver'; 18import { Client, simpleFetchHandler } from '@atcute/client'; 19import { type AppBskyActorDefs } from '@atcute/bluesky'; 20 21export type Collection = `${string}.${string}.${string}`; 22import * as TID from '@atcute/tid'; 23 24/** 25 * Parses an AT Protocol URI into its components. 26 * @param uri - The AT URI to parse (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 27 * @returns An object containing the repo, collection, and rkey or undefined if not an AT uri 28 */ 29export function parseUri(uri: string) { 30 const parts = parseResourceUri(uri); 31 if (!parts.ok) return; 32 return parts.value; 33} 34 35/** 36 * Resolves a handle to a DID using DNS and HTTP methods. 37 * @param handle - The handle to resolve (e.g., "alice.bsky.social") 38 * @returns The DID associated with the handle 39 */ 40export async function resolveHandle({ handle }: { handle: Handle }) { 41 const handleResolver = new CompositeHandleResolver({ 42 methods: { 43 dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 44 http: new WellKnownHandleResolver() 45 } 46 }); 47 48 const data = await handleResolver.resolve(handle); 49 return data; 50} 51 52const didResolver = new CompositeDidDocumentResolver({ 53 methods: { 54 plc: new PlcDidDocumentResolver(), 55 web: new WebDidDocumentResolver() 56 } 57}); 58 59/** 60 * Gets the PDS (Personal Data Server) URL for a given DID. 61 * @param did - The DID to look up 62 * @returns The PDS service endpoint URL 63 * @throws If no PDS is found in the DID document 64 */ 65export async function getPDS(did: Did) { 66 const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); 67 if (!doc.service) throw new Error('No PDS found'); 68 for (const service of doc.service) { 69 if (service.id === '#atproto_pds') { 70 return service.serviceEndpoint.toString(); 71 } 72 } 73} 74 75/** 76 * Fetches a detailed Bluesky profile for a user. 77 * @param data - Optional object with did and client 78 * @param data.did - The DID to fetch the profile for (defaults to current user) 79 * @param data.client - The client to use (defaults to public Bluesky API) 80 * @returns The profile data or undefined if not found 81 */ 82export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { 83 data ??= {}; 84 data.did ??= user.did; 85 86 if (!data.did) throw new Error('Error getting detailed profile: no did'); 87 88 data.client ??= new Client({ 89 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 90 }); 91 92 const response = await data.client.get('app.bsky.actor.getProfile', { 93 params: { actor: data.did } 94 }); 95 96 if (!response.ok) return; 97 98 return response.data; 99} 100 101/** 102 * Creates an AT Protocol client for a user's PDS. 103 * @param did - The DID of the user 104 * @returns A client configured for the user's PDS 105 * @throws If the PDS cannot be found 106 */ 107export async function getClient({ did }: { did: Did }) { 108 const pds = await getPDS(did); 109 if (!pds) throw new Error('PDS not found'); 110 111 const client = new Client({ 112 handler: simpleFetchHandler({ service: pds }) 113 }); 114 115 return client; 116} 117 118/** 119 * Lists records from a repository collection with pagination support. 120 * @param did - The DID of the repository (defaults to current user) 121 * @param collection - The collection to list records from 122 * @param cursor - Pagination cursor for continuing from a previous request 123 * @param limit - Maximum number of records to return (default 100, set to 0 for all records) 124 * @param client - The client to use (defaults to user's PDS client) 125 * @returns An array of records from the collection 126 */ 127export async function listRecords({ 128 did, 129 collection, 130 cursor, 131 limit = 100, 132 client 133}: { 134 did?: Did; 135 collection: `${string}.${string}.${string}`; 136 cursor?: string; 137 limit?: number; 138 client?: Client; 139}) { 140 did ??= user.did; 141 if (!collection) { 142 throw new Error('Missing parameters for listRecords'); 143 } 144 if (!did) { 145 throw new Error('Missing did for getRecord'); 146 } 147 148 client ??= await getClient({ did }); 149 150 const allRecords = []; 151 152 let currentCursor = cursor; 153 do { 154 const response = await client.get('com.atproto.repo.listRecords', { 155 params: { 156 repo: did, 157 collection, 158 limit: !limit || limit > 100 ? 100 : limit, 159 cursor: currentCursor 160 } 161 }); 162 163 if (!response.ok) { 164 return allRecords; 165 } 166 167 allRecords.push(...response.data.records); 168 currentCursor = response.data.cursor; 169 } while (currentCursor && (!limit || allRecords.length < limit)); 170 171 return allRecords; 172} 173 174/** 175 * Fetches a single record from a repository. 176 * @param did - The DID of the repository (defaults to current user) 177 * @param collection - The collection the record belongs to 178 * @param rkey - The record key (defaults to "self") 179 * @param client - The client to use (defaults to user's PDS client) 180 * @returns The record data 181 */ 182export async function getRecord({ 183 did, 184 collection, 185 rkey = 'self', 186 client 187}: { 188 did?: Did; 189 collection: Collection; 190 rkey?: string; 191 client?: Client; 192}) { 193 did ??= user.did; 194 195 if (!collection) { 196 throw new Error('Missing parameters for getRecord'); 197 } 198 if (!did) { 199 throw new Error('Missing did for getRecord'); 200 } 201 202 client ??= await getClient({ did }); 203 204 const record = await client.get('com.atproto.repo.getRecord', { 205 params: { 206 repo: did, 207 collection, 208 rkey 209 } 210 }); 211 212 return JSON.parse(JSON.stringify(record.data)); 213} 214 215/** 216 * Creates or updates a record in the current user's repository. 217 * Only accepts collections that are configured in permissions. 218 * @param collection - The collection to write to (must be in permissions.collections) 219 * @param rkey - The record key (defaults to "self") 220 * @param record - The record data to write 221 * @returns The response from the PDS 222 * @throws If the user is not logged in 223 */ 224export async function putRecord({ 225 collection, 226 rkey = 'self', 227 record 228}: { 229 collection: AllowedCollection; 230 rkey?: string; 231 record: Record<string, unknown>; 232}) { 233 if (!user.client || !user.did) throw new Error('No rpc or did'); 234 235 const response = await user.client.post('com.atproto.repo.putRecord', { 236 input: { 237 collection, 238 repo: user.did, 239 rkey, 240 record: { 241 ...record 242 } 243 } 244 }); 245 246 return response; 247} 248 249/** 250 * Deletes a record from the current user's repository. 251 * Only accepts collections that are configured in permissions. 252 * @param collection - The collection the record belongs to (must be in permissions.collections) 253 * @param rkey - The record key (defaults to "self") 254 * @returns True if the deletion was successful 255 * @throws If the user is not logged in 256 */ 257export async function deleteRecord({ 258 collection, 259 rkey = 'self' 260}: { 261 collection: AllowedCollection; 262 rkey: string; 263}) { 264 if (!user.client || !user.did) throw new Error('No profile or rpc or did'); 265 266 const response = await user.client.post('com.atproto.repo.deleteRecord', { 267 input: { 268 collection, 269 repo: user.did, 270 rkey 271 } 272 }); 273 274 return response.ok; 275} 276 277/** 278 * Uploads a blob to the current user's PDS. 279 * @param blob - The blob data to upload 280 * @returns The blob metadata including ref, mimeType, and size, or undefined on failure 281 * @throws If the user is not logged in 282 */ 283export async function uploadBlob({ blob }: { blob: Blob }) { 284 if (!user.did || !user.client) throw new Error("Can't upload blob: Not logged in"); 285 286 const blobResponse = await user.client.post('com.atproto.repo.uploadBlob', { 287 params: { 288 repo: user.did 289 }, 290 input: blob 291 }); 292 293 if (!blobResponse?.ok) return; 294 295 const blobInfo = blobResponse?.data.blob as { 296 $type: 'blob'; 297 ref: { 298 $link: string; 299 }; 300 mimeType: string; 301 size: number; 302 }; 303 304 return blobInfo; 305} 306 307/** 308 * Gets metadata about a repository. 309 * @param client - The client to use 310 * @param did - The DID of the repository (defaults to current user) 311 * @returns Repository metadata or undefined on failure 312 */ 313export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { 314 did ??= user.did; 315 if (!did) { 316 throw new Error('Error describeRepo: No did'); 317 } 318 client ??= await getClient({ did }); 319 320 const repo = await client.get('com.atproto.repo.describeRepo', { 321 params: { 322 repo: did 323 } 324 }); 325 if (!repo.ok) return; 326 327 return repo.data; 328} 329 330/** 331 * Constructs a URL to fetch a blob directly from a user's PDS. 332 * @param did - The DID of the user who owns the blob 333 * @param blob - The blob reference object 334 * @returns The URL to fetch the blob 335 */ 336export async function getBlobURL({ 337 did, 338 blob 339}: { 340 did: Did; 341 blob: { 342 $type: 'blob'; 343 ref: { 344 $link: string; 345 }; 346 }; 347}) { 348 const pds = await getPDS(did); 349 return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; 350} 351 352/** 353 * Constructs a Bluesky CDN URL for an image blob. 354 * @param did - The DID of the user who owns the blob (defaults to current user) 355 * @param blob - The blob reference object 356 * @returns The CDN URL for the image in webp format 357 */ 358export function getCDNImageBlobUrl({ 359 did, 360 blob 361}: { 362 did?: string; 363 blob: { 364 $type: 'blob'; 365 ref: { 366 $link: string; 367 }; 368 }; 369}) { 370 did ??= user.did; 371 372 return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; 373} 374 375/** 376 * Searches for actors with typeahead/autocomplete functionality. 377 * @param q - The search query 378 * @param limit - Maximum number of results (default 10) 379 * @param host - The API host to use (defaults to public Bluesky API) 380 * @returns An object containing matching actors and the original query 381 */ 382export async function searchActorsTypeahead( 383 q: string, 384 limit: number = 10, 385 host?: string 386): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { 387 host ??= 'https://public.api.bsky.app'; 388 389 const client = new Client({ 390 handler: simpleFetchHandler({ service: host }) 391 }); 392 393 const response = await client.get('app.bsky.actor.searchActorsTypeahead', { 394 params: { 395 q, 396 limit 397 } 398 }); 399 400 if (!response.ok) return { actors: [], q }; 401 402 return { actors: response.data.actors, q }; 403} 404 405/** 406 * Return a TID based on current time 407 * 408 * @returns TID for current time 409 */ 410export function createTID() { 411 return TID.now(); 412} 413 414export async function getAuthorFeed(data?: { 415 did?: Did; 416 client?: Client; 417 filter?: string; 418 limit?: number; 419}) { 420 data ??= {}; 421 data.did ??= user.did; 422 423 if (!data.did) throw new Error('Error getting detailed profile: no did'); 424 425 data.client ??= new Client({ 426 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 427 }); 428 429 const response = await data.client.get('app.bsky.feed.getAuthorFeed', { 430 params: { actor: data.did, filter: data.filter ?? 'posts_with_media', limit: data.limit || 100 } 431 }); 432 433 if (!response.ok) return; 434 435 return response.data; 436} 437 438/** 439 * Fetches posts by their AT URIs. 440 * @param uris - Array of AT URIs (e.g., "at://did:plc:xyz/app.bsky.feed.post/abc123") 441 * @param client - The client to use (defaults to public Bluesky API) 442 * @returns Array of posts or undefined on failure 443 */ 444export async function getPosts(data: { uris: string[]; client?: Client }) { 445 data.client ??= new Client({ 446 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 447 }); 448 449 const response = await data.client.get('app.bsky.feed.getPosts', { 450 params: { uris: data.uris as ResourceUri[] } 451 }); 452 453 if (!response.ok) return; 454 455 return response.data.posts; 456} 457 458export function getHandleOrDid(profile: AppBskyActorDefs.ProfileViewDetailed): ActorIdentifier { 459 if (profile.handle && profile.handle !== 'handle.invalid') { 460 return profile.handle; 461 } else { 462 return profile.did; 463 } 464}