an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
at main 46 kB view raw
1import ky from "npm:ky"; 2import { isGeneratorView } from "./indexserver/types/app/bsky/feed/defs.ts"; 3import * as ViewServerTypes from "./utils/viewservertypes.ts"; 4import * as ATPAPI from "npm:@atproto/api"; 5import { 6 searchParamsToJson, 7 resolveIdentity, 8 buildBlobUrl, 9 cachedFetch, 10 didWebToHttps, 11 getSlingshotRecord, 12 withCors, 13} from "./utils/server.ts"; 14import QuickLRU from "npm:quick-lru"; 15import { validateRecord } from "./utils/records.ts"; 16import { indexHandlerContext } from "./index/types.ts"; 17import { Database } from "jsr:@db/sqlite@0.11"; 18import { JetstreamManager, SpacedustManager } from "./utils/sharders.ts"; 19import { SpacedustLinkMessage } from "./index/spacedust.ts"; 20import { setupUserDb } from "./utils/dbuser.ts"; 21import { config } from "./config.ts"; 22import { AtUri } from "npm:@atproto/api"; 23import { CID } from "../../Library/Caches/deno/npm/registry.npmjs.org/multiformats/9.9.0/cjs/src/cid.js"; 24import { uncid } from "./indexserver.ts"; 25import { getAuthenticatedDid } from "./utils/auth.ts"; 26 27const temporarydevelopmentblockednotiftypes: ATPAPI.AppBskyNotificationListNotifications.Notification["reason"][] = [ 28 //'like', 29 //'repost', 30 //'follow', 31 //'mention', 32 //'reply', 33 //'quote', 34 //'starterpack-joined', 35 //'liked-via-repost', 36 //'repost-via-repost', 37 ]; 38 39export interface ViewServerConfig { 40 baseDbPath: string; 41 systemDbPath: string; 42} 43 44interface BaseRow { 45 uri: string; 46 did: string; 47 cid: string | null; 48 rev: string | null; 49 createdat: number | null; 50 indexedAt: number; 51 json: string | null; 52} 53interface GeneratorRow extends BaseRow { 54 displayname: string | null; 55 description: string | null; 56 avatarcid: string | null; 57} 58interface LikeRow extends BaseRow { 59 subject: string; 60} 61interface RepostRow extends BaseRow { 62 subject: string; 63} 64interface BacklinkRow { 65 srcuri: string; 66 srcdid: string; 67} 68 69const FEED_LIMIT = 50; 70 71export class ViewServer { 72 private config: ViewServerConfig; 73 public userManager: ViewServerUserManager; 74 public systemDB: Database; 75 76 constructor(config: ViewServerConfig) { 77 this.config = config; 78 79 // We will initialize the system DB and user manager here 80 this.systemDB = new Database(this.config.systemDbPath); 81 // TODO: We need to setup the system DB schema if it's new 82 83 this.userManager = new ViewServerUserManager(this); // Pass the server instance 84 } 85 86 public start() { 87 // This is where we'll kick things off, like the cold start 88 this.userManager.coldStart(this.systemDB); 89 console.log("viewServer started."); 90 } 91 92 async unspeccedGetRegisteredUsers(): Promise<{ 93 did: string; 94 role: string; 95 registrationdate: string; 96 onboardingstatus: string; 97 pfp?: string; 98 displayname: string; 99 handle: string; 100 }[]|undefined> { 101 const stmt = this.systemDB.prepare(` 102 SELECT * 103 FROM users; 104 `); 105 const result = stmt.all() as 106 { 107 did: string; 108 role: string; 109 registrationdate: string; 110 onboardingstatus: string; 111 }[]; 112 const hydrated = await Promise.all( result.map(async (user)=>{ 113 const identity = await resolveIdentity(user.did); 114 const profile = (await getSlingshotRecord(identity.did,"app.bsky.actor.profile","self")).value as ATPAPI.AppBskyActorProfile.Record; 115 const avatarcid = uncid(profile.avatar?.ref); 116 const avatar = avatarcid 117 ? buildBlobUrl(identity.pds, identity.did, avatarcid) 118 : undefined; 119 return {...user,handle: identity.handle,pfp: avatar, displayname:profile.displayName ?? identity.handle } 120 })) 121 //const exists = result !== undefined; 122 return hydrated; 123 } 124 125 async viewServerHandler(req: Request): Promise<Response> { 126 const url = new URL(req.url); 127 const pathname = url.pathname; 128 const bskyUrl = `https://api.bsky.app${pathname}${url.search}`; 129 const hasAuth = req.headers.has("authorization"); 130 const xrpcMethod = pathname.startsWith("/xrpc/") 131 ? pathname.slice("/xrpc/".length) 132 : null; 133 const searchParams = searchParamsToJson(url.searchParams); 134 const jsonUntyped = searchParams; 135 136 let tempauthdid: string | undefined = undefined; 137 try { 138 tempauthdid = (await getAuthenticatedDid(req)) ?? undefined; 139 } catch (_e) { 140 // nothing lol 141 } 142 const authdid = tempauthdid 143 ? this.handlesDid(tempauthdid) 144 ? tempauthdid 145 : undefined 146 : undefined; 147 console.log("authed:", authdid); 148 149 if (xrpcMethod === "app.bsky.unspecced.getTrendingTopics") { 150 // const jsonTyped = 151 // jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.QueryParams; 152 153 const faketopics: ATPAPI.AppBskyUnspeccedDefs.TrendingTopic[] = [ 154 { 155 $type: "app.bsky.unspecced.defs#trendingTopic", 156 topic: "Git Repo", 157 displayName: "Git Repo", 158 description: "Git Repo", 159 link: "https://tangled.sh/@whey.party/skylite", 160 }, 161 { 162 $type: "app.bsky.unspecced.defs#trendingTopic", 163 topic: "this View Server url", 164 displayName: "this View Server url", 165 description: "this View Server url", 166 link: config.viewServer.host, 167 }, 168 { 169 $type: "app.bsky.unspecced.defs#trendingTopic", 170 topic: "this social-app fork url", 171 displayName: "this social-app fork url", 172 description: "this social-app fork url", 173 link: "https://github.com/rimar1337/social-app/tree/publicappview-colorable", 174 }, 175 { 176 $type: "app.bsky.unspecced.defs#trendingTopic", 177 topic: "whey dot party", 178 displayName: "whey dot party", 179 description: "whey dot party", 180 link: "https://whey.party/", 181 }, 182 ]; 183 184 const response: ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.OutputSchema = 185 { 186 topics: faketopics, 187 suggested: faketopics, 188 }; 189 190 return new Response(JSON.stringify(response), { 191 headers: withCors({ "Content-Type": "application/json" }), 192 }); 193 } 194 195 //if (xrpcMethod !== 'app.bsky.actor.getPreferences' && xrpcMethod !== 'app.bsky.notification.listNotifications') { 196 if ( 197 !hasAuth 198 // (!hasAuth || 199 // xrpcMethod === "app.bsky.labeler.getServices" || 200 // xrpcMethod === "app.bsky.unspecced.getConfig") && 201 // xrpcMethod !== "app.bsky.notification.putPreferences" 202 ) { 203 return new Response( 204 JSON.stringify({ 205 error: "XRPCNotSupported", 206 message: 207 "(no auth) HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported", 208 }), 209 { 210 status: 404, 211 headers: withCors({ "Content-Type": "application/json" }), 212 } 213 ); 214 //return await sendItToApiBskyApp(req); 215 } 216 if ( 217 // !hasAuth || 218 xrpcMethod === "app.bsky.labeler.getServices" || 219 xrpcMethod === "app.bsky.unspecced.getConfig" //&& 220 //xrpcMethod !== "app.bsky.notification.putPreferences" 221 ) { 222 return new Response( 223 JSON.stringify({ 224 error: "XRPCNotSupported", 225 message: 226 "(getservices / getconfig) HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported", 227 }), 228 { 229 status: 404, 230 headers: withCors({ "Content-Type": "application/json" }), 231 } 232 ); 233 //return await sendItToApiBskyApp(req); 234 } 235 236 const authDID = "did:plc:mn45tewwnse5btfftvd3powc"; //getAuthenticatedDid(req); 237 238 switch (xrpcMethod) { 239 case "app.bsky.feed.getFeedGenerators": { 240 const jsonTyped = 241 jsonUntyped as ViewServerTypes.AppBskyFeedGetFeedGenerators.QueryParams; 242 243 const feeds: ATPAPI.AppBskyFeedDefs.GeneratorView[] = ( 244 await Promise.all( 245 jsonTyped.feeds.map(async (feed) => { 246 try { 247 const did = new ATPAPI.AtUri(feed).hostname; 248 const rkey = new ATPAPI.AtUri(feed).rkey; 249 const identity = await resolveIdentity(did); 250 const feedgetRecord = await getSlingshotRecord( 251 identity.did, 252 "app.bsky.feed.generator", 253 rkey 254 ); 255 const profile = ( 256 await getSlingshotRecord( 257 identity.did, 258 "app.bsky.actor.profile", 259 "self" 260 ) 261 ).value as ATPAPI.AppBskyActorProfile.Record; 262 const anyprofile = profile as any; 263 const value = 264 feedgetRecord.value as ATPAPI.AppBskyFeedGenerator.Record; 265 266 return { 267 $type: "app.bsky.feed.defs#generatorView", 268 uri: feed, 269 cid: feedgetRecord.cid, 270 did: identity.did, 271 creator: /*AppBskyActorDefs.ProfileView*/ { 272 $type: "app.bsky.actor.defs#profileView", 273 did: identity.did, 274 handle: identity.handle, 275 displayName: profile.displayName, 276 description: profile.description, 277 avatar: buildBlobUrl( 278 identity.pds, 279 identity.did, 280 anyprofile.avatar.ref["$link"] 281 ), 282 //associated?: ProfileAssociated 283 //indexedAt?: string 284 //createdAt?: string 285 //viewer?: ViewerState 286 //labels?: ComAtprotoLabelDefs.Label[] 287 //verification?: VerificationState 288 //status?: StatusView 289 }, 290 displayName: value.displayName, 291 description: value.description, 292 //descriptionFacets?: AppBskyRichtextFacet.Main[] 293 avatar: buildBlobUrl( 294 identity.pds, 295 identity.did, 296 (value as any).avatar.ref["$link"] 297 ), 298 //likeCount?: number 299 //acceptsInteractions?: boolean 300 //labels?: ComAtprotoLabelDefs.Label[] 301 //viewer?: GeneratorViewerState 302 contentMode: value.contentMode, 303 indexedAt: new Date().toISOString(), 304 }; 305 } catch (err) { 306 return undefined; 307 } 308 }) 309 ) 310 ).filter(isGeneratorView); 311 312 const response: ViewServerTypes.AppBskyFeedGetFeedGenerators.OutputSchema = 313 { 314 feeds: feeds ? feeds : [], 315 }; 316 317 return new Response(JSON.stringify(response), { 318 headers: withCors({ "Content-Type": "application/json" }), 319 }); 320 } 321 case "app.bsky.feed.getFeed": { 322 const jsonTyped = 323 jsonUntyped as ViewServerTypes.AppBskyFeedGetFeed.QueryParams; 324 const cursor = jsonTyped.cursor; 325 const feed = jsonTyped.feed; 326 const limit = jsonTyped.limit; 327 const proxyauth = req.headers.get("authorization") || ""; 328 329 const did = new ATPAPI.AtUri(feed).hostname; 330 const rkey = new ATPAPI.AtUri(feed).rkey; 331 const identity = await resolveIdentity(did); 332 const feedgetRecord = ( 333 await getSlingshotRecord( 334 identity.did, 335 "app.bsky.feed.generator", 336 rkey 337 ) 338 ).value as ATPAPI.AppBskyFeedGenerator.Record; 339 340 const skeleton = (await cachedFetch( 341 `${didWebToHttps( 342 feedgetRecord.did 343 )}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${jsonTyped.feed}${ 344 cursor ? `&cursor=${cursor}` : "" 345 }${limit ? `&limit=${limit}` : ""}`, 346 proxyauth 347 )) as ATPAPI.AppBskyFeedGetFeedSkeleton.OutputSchema; 348 349 const nextcursor = skeleton.cursor; 350 const dbgrqstid = skeleton.reqId; 351 const uriarray = skeleton.feed; 352 353 // Step 1: Chunk into 25 max 354 const chunks = []; 355 for (let i = 0; i < uriarray.length; i += 25) { 356 chunks.push(uriarray.slice(i, i + 25)); 357 } 358 359 // Step 2: Hydrate via getPosts 360 const hydratedPosts: ATPAPI.AppBskyFeedDefs.FeedViewPost[] = []; 361 362 for (const chunk of chunks) { 363 const searchParams = new URLSearchParams(); 364 for (const uri of chunk.map((item) => item.post)) { 365 searchParams.append("uris", uri); 366 } 367 368 const postResp = await ky 369 // TODO aaaaaa dont do this please use the new getServiceEndpointFromIdentity() 370 .get(`https://api.bsky.app/xrpc/app.bsky.feed.getPosts`, { 371 // headers: { 372 // Authorization: proxyauth, 373 // }, 374 searchParams, 375 }) 376 .json<ATPAPI.AppBskyFeedGetPosts.OutputSchema>(); 377 378 for (const post of postResp.posts) { 379 const matchingSkeleton = uriarray.find( 380 (item) => item.post === post.uri 381 ); 382 if (matchingSkeleton) { 383 //post.author.handle = post.author.handle + ".percent40.api.bsky.app"; // or any logic to modify it 384 hydratedPosts.push({ 385 post, 386 reason: matchingSkeleton.reason, 387 //reply: matchingSkeleton, 388 }); 389 } 390 } 391 } 392 393 // Step 3: Compose final response 394 const response: ViewServerTypes.AppBskyFeedGetFeed.OutputSchema = { 395 feed: hydratedPosts, 396 cursor: nextcursor, 397 }; 398 399 return new Response(JSON.stringify(response), { 400 headers: withCors({ "Content-Type": "application/json" }), 401 }); 402 } 403 case "app.bsky.actor.getProfile": { 404 const jsonTyped = 405 jsonUntyped as ViewServerTypes.AppBskyActorGetProfile.QueryParams; 406 407 const response: ViewServerTypes.AppBskyActorGetProfile.OutputSchema = 408 ((await this.resolveGetProfiles([jsonTyped.actor])) ?? [])[0]; 409 410 return new Response(JSON.stringify(response), { 411 headers: withCors({ "Content-Type": "application/json" }), 412 }); 413 } 414 415 case "app.bsky.actor.getProfiles": { 416 const jsonhalfTyped = 417 jsonUntyped as ViewServerTypes.AppBskyActorGetProfiles.QueryParams; 418 const actors = jsonhalfTyped.actors as string[] | string 419 const queryactors = Array.isArray(actors) 420 ? actors 421 : [actors]; 422 //console.log("queryactors:",jsonTyped.actors) 423 const response: ViewServerTypes.AppBskyActorGetProfiles.OutputSchema = { 424 profiles: (await this.resolveGetProfiles(queryactors)) ?? [], 425 }; 426 427 return new Response(JSON.stringify(response), { 428 headers: withCors({ "Content-Type": "application/json" }), 429 }); 430 } 431 case "app.bsky.feed.getAuthorFeed": { 432 const jsonTyped = 433 jsonUntyped as ViewServerTypes.AppBskyFeedGetAuthorFeed.QueryParams; 434 435 const userindexservice = ""; 436 const isbskyfallback = true; 437 if (isbskyfallback) { 438 return this.sendItToApiBskyApp(req); 439 } 440 441 const response: ViewServerTypes.AppBskyFeedGetAuthorFeed.OutputSchema = 442 {}; 443 444 return new Response(JSON.stringify(response), { 445 headers: withCors({ "Content-Type": "application/json" }), 446 }); 447 } 448 case "app.bsky.feed.getPostThread": { 449 const jsonTyped = 450 jsonUntyped as ViewServerTypes.AppBskyFeedGetPostThread.QueryParams; 451 452 const userindexservice = ""; 453 const isbskyfallback = true; 454 if (isbskyfallback) { 455 return this.sendItToApiBskyApp(req); 456 } 457 458 const response: ViewServerTypes.AppBskyFeedGetPostThread.OutputSchema = 459 {}; 460 461 return new Response(JSON.stringify(response), { 462 headers: withCors({ "Content-Type": "application/json" }), 463 }); 464 } 465 case "app.bsky.unspecced.getPostThreadV2": { 466 const jsonTyped = 467 jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetPostThreadV2.QueryParams; 468 469 const userindexservice = ""; 470 const isbskyfallback = true; 471 if (isbskyfallback) { 472 return this.sendItToApiBskyApp(req); 473 } 474 475 const response: ViewServerTypes.AppBskyUnspeccedGetPostThreadV2.OutputSchema = 476 {}; 477 478 return new Response(JSON.stringify(response), { 479 headers: withCors({ "Content-Type": "application/json" }), 480 }); 481 } 482 483 // case "app.bsky.actor.getProfile": { 484 // const jsonTyped = 485 // jsonUntyped as ViewServerTypes.AppBskyActorGetProfile.QueryParams; 486 487 // const response: ViewServerTypes.AppBskyActorGetProfile.OutputSchema= {}; 488 489 // return new Response(JSON.stringify(response), { 490 // headers: withCors({ "Content-Type": "application/json" }), 491 // }); 492 // } 493 // case "app.bsky.actor.getProfiles": { 494 // const jsonTyped = jsonUntyped as ViewServerTypes.AppBskyActorGetProfiles.QueryParams; 495 496 // const response: ViewServerTypes.AppBskyActorGetProfiles.OutputSchema = {}; 497 498 // return new Response(JSON.stringify(response), { 499 // headers: withCors({ "Content-Type": "application/json" }), 500 // }); 501 // } 502 // case "whatever": { 503 // const jsonTyped = jsonUntyped as ViewServerTypes.AppBskyFeedGetAuthorFeed.QueryParams; 504 505 // const response: ViewServerTypes.AppBskyFeedGetAuthorFeed.OutputSchema = {} 506 507 // return new Response(JSON.stringify(response), { 508 // headers: withCors({ "Content-Type": "application/json" }), 509 // }); 510 // } 511 case "app.bsky.notification.listNotifications": { 512 if (!authdid) return new Response("Not Found", { status: 404 }); 513 const jsonTyped = 514 jsonUntyped as ViewServerTypes.AppBskyNotificationListNotifications.QueryParams; 515 516 const response: ViewServerTypes.AppBskyNotificationListNotifications.OutputSchema = 517 await this.queryNotificationsList(authdid, jsonTyped.cursor, jsonTyped.limit); 518 519 return new Response(JSON.stringify(response), { 520 headers: withCors({ "Content-Type": "application/json" }), 521 }); 522 } 523 case "app.bsky.feed.getPosts": { 524 const jsonTyped = 525 jsonUntyped as ViewServerTypes.AppBskyFeedGetPosts.QueryParams; 526 const inputUris = Array.isArray(jsonTyped.uris) 527 ? jsonTyped.uris 528 : [jsonTyped.uris]; 529 const response: ViewServerTypes.AppBskyFeedGetPosts.OutputSchema = { 530 posts: await this.resolveGetPosts(inputUris), 531 }; 532 533 return new Response(JSON.stringify(response), { 534 headers: withCors({ "Content-Type": "application/json" }), 535 }); 536 } 537 538 case "app.bsky.unspecced.getConfig": { 539 const jsonTyped = 540 jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetConfig.QueryParams; 541 542 const response: ViewServerTypes.AppBskyUnspeccedGetConfig.OutputSchema = 543 { 544 checkEmailConfirmed: true, 545 liveNow: [ 546 { 547 $type: "app.bsky.unspecced.getConfig#liveNowConfig", 548 did: "did:plc:mn45tewwnse5btfftvd3powc", 549 domains: ["local3768forumtest.whey.party"], 550 }, 551 ], 552 }; 553 554 return new Response(JSON.stringify(response), { 555 headers: withCors({ "Content-Type": "application/json" }), 556 }); 557 } 558 case "app.bsky.graph.getLists": { 559 const jsonTyped = 560 jsonUntyped as ViewServerTypes.AppBskyGraphGetLists.QueryParams; 561 562 const response: ViewServerTypes.AppBskyGraphGetLists.OutputSchema = { 563 lists: [], 564 }; 565 566 return new Response(JSON.stringify(response), { 567 headers: withCors({ "Content-Type": "application/json" }), 568 }); 569 } 570 //https://shimeji.us-east.host.bsky.network/xrpc/app.bsky.unspecced.getTrendingTopics?limit=14 571 case "app.bsky.unspecced.getTrendingTopics": { 572 const jsonTyped = 573 jsonUntyped as ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.QueryParams; 574 575 const faketopics: ATPAPI.AppBskyUnspeccedDefs.TrendingTopic[] = [ 576 { 577 $type: "app.bsky.unspecced.defs#trendingTopic", 578 topic: "Git Repo", 579 displayName: "Git Repo", 580 description: "Git Repo", 581 link: "https://tangled.sh/@whey.party/skylite", 582 }, 583 { 584 $type: "app.bsky.unspecced.defs#trendingTopic", 585 topic: "Red Dwarf Lite", 586 displayName: "Red Dwarf Lite", 587 description: "Red Dwarf Lite", 588 link: "https://reddwarf.whey.party/", 589 }, 590 { 591 $type: "app.bsky.unspecced.defs#trendingTopic", 592 topic: "whey dot party", 593 displayName: "whey dot party", 594 description: "whey dot party", 595 link: "https://whey.party/", 596 }, 597 ]; 598 599 const response: ViewServerTypes.AppBskyUnspeccedGetTrendingTopics.OutputSchema = 600 { 601 topics: faketopics, 602 suggested: faketopics, 603 }; 604 605 return new Response(JSON.stringify(response), { 606 headers: withCors({ "Content-Type": "application/json" }), 607 }); 608 } 609 default: { 610 return new Response( 611 JSON.stringify({ 612 error: "XRPCNotSupported", 613 message: 614 "(default) HEY hello there my name is whey dot party and you have used my custom appview that is very cool but have you considered that XRPC Not Supported", 615 }), 616 { 617 status: 404, 618 headers: withCors({ "Content-Type": "application/json" }), 619 } 620 ); 621 } 622 } 623 624 // return new Response("Not Found", { status: 404 }); 625 } 626 627 async sendItToApiBskyApp(req: Request): Promise<Response> { 628 const url = new URL(req.url); 629 const pathname = url.pathname; 630 const searchParams = searchParamsToJson(url.searchParams); 631 let reqBody: undefined | string; 632 let jsonbody: undefined | Record<string, unknown>; 633 if (req.body) { 634 const body = await req.json(); 635 jsonbody = body; 636 // console.log( 637 // `called at euh reqreqreqreq: ${pathname}\n\n${JSON.stringify(body)}` 638 // ); 639 reqBody = JSON.stringify(body, null, 2); 640 } 641 const bskyUrl = `https://public.api.bsky.app${pathname}${url.search}`; 642 console.log("request", searchParams); 643 const proxyHeaders = new Headers(req.headers); 644 645 // Remove Authorization and set browser-like User-Agent 646 proxyHeaders.delete("authorization"); 647 proxyHeaders.delete("Access-Control-Allow-Origin"), 648 proxyHeaders.set( 649 "user-agent", 650 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" 651 ); 652 proxyHeaders.set("Access-Control-Allow-Origin", "*"); 653 654 const proxyRes = await fetch(bskyUrl, { 655 method: req.method, 656 headers: proxyHeaders, 657 body: ["GET", "HEAD"].includes(req.method.toUpperCase()) 658 ? undefined 659 : reqBody, 660 }); 661 662 const resBody = await proxyRes.text(); 663 664 // console.log( 665 // "← Response:", 666 // JSON.stringify(await JSON.parse(resBody), null, 2) 667 // ); 668 669 return new Response(resBody, { 670 status: proxyRes.status, 671 headers: proxyRes.headers, 672 }); 673 } 674 675 viewServerIndexer(ctx: indexHandlerContext) { 676 const record = validateRecord(ctx.value); 677 switch (record?.$type) { 678 case "app.bsky.feed.like": { 679 return; 680 } 681 default: { 682 // what the hell 683 return; 684 } 685 } 686 } 687 688 /** 689 * please do not use this, use openDbForDid() instead 690 * @param did 691 * @returns 692 */ 693 internalCreateDbForDid(did: string): Database { 694 const path = `${this.config.baseDbPath}/${did}.sqlite`; 695 const db = new Database(path); 696 // TODO maybe split the user db schema between view server and index server 697 setupUserDb(db); 698 //await db.exec(/* CREATE IF NOT EXISTS statements */); 699 return db; 700 } 701 public handlesDid(did: string): boolean { 702 return this.userManager.handlesDid(did); 703 } 704 705 async resolveGetPosts( 706 uris: string[] 707 ): Promise<ATPAPI.AppBskyFeedDefs.PostView[]> { 708 const grouped: Record<string, string[]> = {}; 709 710 // Group URIs by resolved endpoint 711 for (const uri of uris) { 712 const did = new AtUri(uri).host; 713 const endpoint = await getSkyliteEndpoint(did); 714 if (!endpoint) continue; 715 716 if (!grouped[endpoint]) { 717 grouped[endpoint] = []; 718 } 719 grouped[endpoint].push(uri); 720 } 721 722 const postviews: ATPAPI.AppBskyFeedDefs.PostView[] = []; 723 724 // Fetch posts per endpoint 725 for (const [endpoint, urisForEndpoint] of Object.entries(grouped)) { 726 const query = urisForEndpoint 727 .map((u) => `uris=${encodeURIComponent(u)}`) 728 .join("&"); 729 730 const url = `${endpoint}/xrpc/app.bsky.feed.getPosts?${query}`; 731 const resp = await fetch(url); 732 if (!resp.ok) { 733 throw new Error( 734 `Failed to fetch posts from ${endpoint} for uris=${urisForEndpoint.join( 735 "," 736 )}` 737 ); 738 } 739 740 const raw = 741 (await resp.json()) as ATPAPI.AppBskyFeedGetPosts.OutputSchema; 742 postviews.push(...raw.posts); 743 } 744 745 return postviews; 746 } 747 748 async resolveGetProfiles( 749 dids: string[] 750 ): Promise<ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] | undefined> { 751 const profiles: ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] = []; 752 753 for (const did of dids) { 754 const endpoint = await getSkyliteEndpoint(did); 755 const url = `${endpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent( 756 did 757 )}`; 758 const resp = await fetch(url); 759 if (!resp.ok) 760 throw new Error(`Failed to fetch profile for ${did} via ${url}`); 761 762 const raw = 763 (await resp.json()) as ATPAPI.AppBskyActorGetProfile.OutputSchema; 764 profiles.push(raw); 765 } 766 767 return profiles; 768 } 769 async queryNotificationsList( 770 did: string, 771 cursor?: string, 772 limit?: number 773 ): Promise<ATPAPI.AppBskyNotificationListNotifications.OutputSchema> { 774 if (!this.handlesDid(did)) { 775 return { notifications: [] }; 776 } 777 const db = this.userManager.getDbForDid(did); 778 if (!db) { 779 return { notifications: [] }; 780 } 781 782 const NOTIFS_LIMIT = limit ?? 30; 783 const offset = cursor ? parseInt(cursor, 10) : 0; 784 785 const mapReason = ( 786 field: string 787 ): 788 | ATPAPI.AppBskyNotificationListNotifications.Notification["reason"] 789 | undefined => { 790 switch (field) { 791 //'like' | 'repost' | 'follow' | 'mention' | 'reply' | 'quote' | 'starterpack-joined' | 'verified' | 'unverified' | 'like-via-repost' | 'repost-via-repost' | 792 case "app.bsky.feed.like:subject.uri": 793 return "like"; 794 case "app.bsky.feed.like:via.uri": 795 return "liked-via-repost"; 796 case "app.bsky.feed.repost:subject.uri": 797 return "repost"; 798 case "app.bsky.feed.repost:via.uri": 799 return "repost-via-repost"; 800 case "app.bsky.feed.post:reply.root.uri": 801 return "reply"; 802 case "app.bsky.feed.post:reply.parent.uri": 803 return "reply"; 804 case "app.bsky.feed.post:embed.media.record.record.uri": 805 return "quote"; 806 case "app.bsky.feed.post:embed.record.uri": 807 return "quote"; 808 //case"app.bsky.feed.threadgate:post": return "threadgate subject 809 //case"app.bsky.feed.threadgate:hiddenReplies": return "threadgate items (array) 810 case "app.bsky.feed.post:facets.features.did": 811 return "mention"; 812 //case"app.bsky.graph.block:subject": return "blocks 813 case "app.bsky.graph.follow:subject": 814 return "follow"; 815 //case"app.bsky.graph.listblock:subject": return "list item (blocks) 816 //case"app.bsky.graph.listblock:list": return "blocklist mention (might not exist) 817 //case"app.bsky.graph.listitem:subject": return "list item (blocks) 818 //"app.bsky.graph.listitem:list": return "list mention 819 // case "like": return "like"; 820 // case "repost": return "repost"; 821 // case "follow": return "follow"; 822 // case "replyparent": return "reply"; 823 // case "replyroot": return "reply"; 824 // case "mention": return "mention"; 825 default: 826 return undefined; 827 } 828 }; 829 830 // --- Build Query --- 831 let query = ` 832 SELECT srcuri, suburi, srcfield, indexedAt 833 FROM backlink_skeleton 834 WHERE 835 -- Find actions targeting the user's content or profile 836 (suburi LIKE ? OR suburi = ?) 837 -- Exclude notifications from the user themselves 838 AND srcuri NOT LIKE ? 839 ORDER BY indexedAt DESC, srcuri DESC 840 LIMIT ? OFFSET ? 841 `; 842 const params: (string | number)[] = [ 843 `at://${did}/%`, 844 did, 845 `at://${did}/%`, 846 NOTIFS_LIMIT, 847 offset 848 ]; 849 850 // if (cursor) { 851 // const [indexedAt, srcuri] = cursor.split("::"); 852 // if (indexedAt && srcuri && !Number.isNaN(+indexedAt)) { 853 // query += ` AND (indexedAt < ? OR (indexedAt = ? AND srcuri < ?))`; 854 // params.push(+indexedAt, +indexedAt, srcuri); 855 // } 856 // } 857 858 // query += ` ORDER BY indexedAt DESC, srcuri DESC LIMIT ${NOTIFS_LIMIT}`; 859 860 // --- Fetch and Process --- 861 const stmt = db.prepare(query); 862 const rows = stmt.all(...params) as { 863 srcuri: string; 864 suburi: string; // might be uri, might be just a did 865 srcfield: string; 866 indexedAt: number; 867 }[]; 868 869 const notificationPromises = rows.map(async (row) => { 870 try { 871 const reason = mapReason(row.srcfield); 872 // i have a hunch that follow notifs are crashing the client 873 if (!reason || temporarydevelopmentblockednotiftypes.includes(reason)) { 874 return null; 875 } 876 // Skip if it's a backlink type we don't have a notification for 877 if (!reason) return null; 878 879 const srcURI = new AtUri(row.srcuri); 880 const [authorRes, recordRes] = await Promise.allSettled([ 881 this.resolveProfileView(srcURI.host, ""), 882 getSlingshotRecord(srcURI.host, srcURI.collection, srcURI.rkey), 883 ]); 884 885 const author = authorRes.status === "fulfilled" ? authorRes.value : null; 886 const getrecord = recordRes.status === "fulfilled" ? recordRes.value : null; 887 888 const reasonsubject = 889 row.suburi.startsWith("at://") ? row.suburi : `at://${row.suburi}`; 890 // If we can't resolve the author or the record, we can't form a valid notification 891 if (!author || !getrecord || !reason || !reasonsubject) return null; 892 893 author.viewer = { 894 "muted": false, 895 "blockedBy": false, 896 //"following": 897 } // TODO: proper mutes and blocks here 898 899 if (!getrecord?.value?.$type) getrecord.value.$type = srcURI.collection 900 return { 901 uri: row.srcuri, 902 cid: getrecord.cid, 903 author: author, 904 reason: reason, 905 // The reasonSubject is the URI of the post that was liked, reposted, or replied to 906 reasonSubject: reasonsubject, 907 record: getrecord.value, 908 isRead: false, // Placeholder for read-state logic 909 indexedAt: new Date(row.indexedAt).toISOString(), 910 labels: [], // Placeholder for label logic 911 } as ATPAPI.AppBskyNotificationListNotifications.Notification; 912 } catch (e) {console.log("error:",e)} 913 }); 914 915 const seen = new Set<string>(); 916 const notifications = (await Promise.all(notificationPromises)) 917 .filter((n): n is ATPAPI.AppBskyNotificationListNotifications.Notification => { 918 if (!n) return false; 919 const key = `${n.uri}:${n.reason}:${n.reasonSubject}`; 920 if (seen.has(key)) return false; 921 seen.add(key); 922 return true; 923 }); 924 925 // --- Create next cursor --- 926 const nextCursor:number = Number(offset) + Number(limit ?? 0) 927 // const lastItem = rows[rows.length - 1]; 928 // const nextCursor = lastItem 929 // ? `${lastItem.indexedAt}::${lastItem.srcuri}` 930 // : undefined; 931 932 return { 933 cursor: `${nextCursor}`,//nextCursor, 934 notifications: notifications, 935 priority:false, 936 seenAt: new Date().toISOString() 937 }; 938 } 939 async resolveProfileView( 940 did: string, 941 type: "" 942 ): Promise<ATPAPI.AppBskyActorDefs.ProfileView | undefined>; 943 async resolveProfileView( 944 did: string, 945 type: "Basic" 946 ): Promise<ATPAPI.AppBskyActorDefs.ProfileViewBasic | undefined>; 947 async resolveProfileView( 948 did: string, 949 type: "Detailed" 950 ): Promise<ATPAPI.AppBskyActorDefs.ProfileViewDetailed | undefined>; 951 async resolveProfileView( 952 did: string, 953 type: "" | "Basic" | "Detailed" 954 ): Promise< 955 | ATPAPI.AppBskyActorDefs.ProfileView 956 | ATPAPI.AppBskyActorDefs.ProfileViewBasic 957 | ATPAPI.AppBskyActorDefs.ProfileViewDetailed 958 | undefined 959 > { 960 const record = ( 961 await getSlingshotRecord(did, "app.bsky.actor.profile", "self") 962 ).value as ATPAPI.AppBskyActorProfile.Record; 963 964 const identity = await resolveIdentity(did); 965 const avatarcid = uncid(record.avatar?.ref); 966 const avatar = avatarcid 967 ? buildBlobUrl(identity.pds, identity.did, avatarcid) 968 : undefined; 969 const bannercid = uncid(record.banner?.ref); 970 const banner = bannercid 971 ? buildBlobUrl(identity.pds, identity.did, bannercid) 972 : undefined; 973 // simulate different types returned 974 switch (type) { 975 case "": { 976 const result: ATPAPI.AppBskyActorDefs.ProfileView = { 977 $type: "app.bsky.actor.defs#profileView", 978 did: did, 979 handle: identity.handle, 980 displayName: record.displayName ?? identity.handle, 981 description: record.description ?? undefined, 982 avatar: avatar, // create profile URL from resolved identity 983 //associated?: ProfileAssociated, 984 indexedAt: record.createdAt 985 ? new Date(record.createdAt).toISOString() 986 : undefined, 987 createdAt: record.createdAt 988 ? new Date(record.createdAt).toISOString() 989 : undefined, 990 //viewer?: ViewerState, 991 //labels?: ComAtprotoLabelDefs.Label[], 992 //verification?: VerificationState, 993 //status?: StatusView, 994 }; 995 return result; 996 } 997 case "Basic": { 998 const result: ATPAPI.AppBskyActorDefs.ProfileViewBasic = { 999 $type: "app.bsky.actor.defs#profileViewBasic", 1000 did: did, 1001 handle: identity.handle, 1002 displayName: record.displayName ?? identity.handle, 1003 avatar: avatar, // create profile URL from resolved identity 1004 //associated?: ProfileAssociated, 1005 createdAt: record.createdAt 1006 ? new Date(record.createdAt).toISOString() 1007 : undefined, 1008 //viewer?: ViewerState, 1009 //labels?: ComAtprotoLabelDefs.Label[], 1010 //verification?: VerificationState, 1011 //status?: StatusView, 1012 }; 1013 return result; 1014 } 1015 case "Detailed": { 1016 const response: ViewServerTypes.AppBskyActorGetProfile.OutputSchema = 1017 ((await this.resolveGetProfiles([did])) ?? [])[0]; 1018 return response; 1019 } 1020 default: 1021 throw new Error("Invalid type"); 1022 } 1023 } 1024} 1025 1026export class ViewServerUserManager { 1027 public viewServer: ViewServer; 1028 1029 constructor(viewServer: ViewServer) { 1030 this.viewServer = viewServer; 1031 } 1032 1033 public users = new Map<string, UserViewServer>(); 1034 public handlesDid(did: string): boolean { 1035 return this.users.has(did); 1036 } 1037 1038 /*async*/ addUser(did: string) { 1039 if (this.users.has(did)) return; 1040 const instance = new UserViewServer(this, did); 1041 //await instance.initialize(); 1042 this.users.set(did, instance); 1043 } 1044 1045 // async handleRequest({ 1046 // did, 1047 // route, 1048 // req, 1049 // }: { 1050 // did: string; 1051 // route: string; 1052 // req: Request; 1053 // }) { 1054 // if (!this.users.has(did)) await this.addUser(did); 1055 // const user = this.users.get(did)!; 1056 // return await user.handleHttpRequest(route, req); 1057 // } 1058 1059 removeUser(did: string) { 1060 const instance = this.users.get(did); 1061 if (!instance) return; 1062 /*await*/ instance.shutdown(); 1063 this.users.delete(did); 1064 } 1065 1066 getDbForDid(did: string): Database | null { 1067 if (!this.users.has(did)) { 1068 return null; 1069 } 1070 return this.users.get(did)?.db ?? null; 1071 } 1072 1073 coldStart(db: Database) { 1074 const rows = db.prepare("SELECT did FROM users").all(); 1075 for (const row of rows) { 1076 this.addUser(row.did); 1077 } 1078 } 1079} 1080 1081class UserViewServer { 1082 public viewServerUserManager: ViewServerUserManager; 1083 did: string; 1084 db: Database; // | undefined; 1085 jetstream: JetstreamManager; // | undefined; 1086 spacedust: SpacedustManager; // | undefined; 1087 1088 constructor(viewServerUserManager: ViewServerUserManager, did: string) { 1089 this.did = did; 1090 this.viewServerUserManager = viewServerUserManager; 1091 this.db = this.viewServerUserManager.viewServer.internalCreateDbForDid( 1092 this.did 1093 ); 1094 // should probably put the params of exactly what were listening to here 1095 this.jetstream = new JetstreamManager((msg) => { 1096 console.log("Received Jetstream message: ", msg); 1097 1098 const op = msg.commit.operation; 1099 const doer = msg.did; 1100 const rev = msg.commit.rev; 1101 const aturi = `${msg.did}/${msg.commit.collection}/${msg.commit.rkey}`; 1102 const value = msg.commit.record; 1103 1104 if (!doer || !value) return; 1105 this.viewServerUserManager.viewServer.viewServerIndexer({ 1106 op, 1107 doer, 1108 cid: msg.commit.cid, 1109 rev, 1110 aturi, 1111 value, 1112 indexsrc: `jetstream-${op}`, 1113 db: this.db, 1114 }); 1115 }); 1116 this.jetstream.start({ 1117 // for realsies pls get from db or something instead of this shit 1118 wantedDids: [ 1119 this.did, 1120 // "did:plc:mn45tewwnse5btfftvd3powc", 1121 // "did:plc:yy6kbriyxtimkjqonqatv2rb", 1122 // "did:plc:zzhzjga3ab5fcs2vnsv2ist3", 1123 // "did:plc:jz4ibztn56hygfld6j6zjszg", 1124 ], 1125 wantedCollections: [ 1126 // View server only needs some of the things related to user views mutes, not all of them 1127 //"app.bsky.actor.profile", 1128 //"app.bsky.feed.generator", 1129 //"app.bsky.feed.like", 1130 //"app.bsky.feed.post", 1131 //"app.bsky.feed.repost", 1132 "app.bsky.feed.threadgate", // mod 1133 "app.bsky.graph.block", // mod 1134 "app.bsky.graph.follow", // graphing 1135 //"app.bsky.graph.list", 1136 "app.bsky.graph.listblock", // mod 1137 //"app.bsky.graph.listitem", 1138 "app.bsky.notification.declaration", // mod 1139 ], 1140 }); 1141 //await connectToJetstream(this.did, this.db); 1142 this.spacedust = new SpacedustManager((msg: SpacedustLinkMessage) => { 1143 console.log("Received Spacedust message: ", msg); 1144 const operation = msg.link.operation; 1145 1146 const sourceURI = new ATPAPI.AtUri(msg.link.source_record); 1147 const srcUri = msg.link.source_record; 1148 const srcDid = sourceURI.host; 1149 const srcField = msg.link.source; 1150 const srcCol = sourceURI.collection; 1151 const subjectURI = new ATPAPI.AtUri(msg.link.subject); 1152 const subUri = msg.link.subject; 1153 const subDid = subjectURI.host; 1154 const subCol = subjectURI.collection; 1155 1156 if (operation === "delete") { 1157 this.db.run( 1158 `DELETE FROM backlink_skeleton 1159 WHERE srcuri = ? AND srcfield = ? AND suburi = ?`, 1160 [srcUri, srcField, subUri] 1161 ); 1162 } else if (operation === "create") { 1163 this.db.run( 1164 `INSERT OR REPLACE INTO backlink_skeleton ( 1165 srcuri, 1166 srcdid, 1167 srcfield, 1168 srccol, 1169 suburi, 1170 subdid, 1171 subcol, 1172 indexedAt 1173 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 1174 [ 1175 srcUri, // full AT URI of the source record 1176 srcDid, // did: of the source 1177 srcField, // e.g., "reply.parent.uri" or "facets.features.did" 1178 srcCol, // e.g., "app.bsky.feed.post" 1179 subUri, // full AT URI of the subject (linked record) 1180 subDid, // did: of the subject 1181 subCol, // subject collection (can be inferred or passed) 1182 Date.now(), 1183 ] 1184 ); 1185 } 1186 }); 1187 this.spacedust.start({ 1188 wantedSources: [ 1189 // view server keeps all of this because notifications are a thing 1190 "app.bsky.feed.like:subject.uri", // like 1191 "app.bsky.feed.like:via.uri", // liked repost 1192 "app.bsky.feed.repost:subject.uri", // repost 1193 "app.bsky.feed.repost:via.uri", // reposted repost 1194 "app.bsky.feed.post:reply.root.uri", // thread OP 1195 "app.bsky.feed.post:reply.parent.uri", // direct parent 1196 "app.bsky.feed.post:embed.media.record.record.uri", // quote with media 1197 "app.bsky.feed.post:embed.record.uri", // quote without media 1198 "app.bsky.feed.threadgate:post", // threadgate subject 1199 "app.bsky.feed.threadgate:hiddenReplies", // threadgate items (array) 1200 "app.bsky.feed.post:facets.features.did", // facet item (array): mention 1201 "app.bsky.graph.block:subject", // blocks 1202 "app.bsky.graph.follow:subject", // follow 1203 "app.bsky.graph.listblock:subject", // list item (blocks) 1204 "app.bsky.graph.listblock:list", // blocklist mention (might not exist) 1205 "app.bsky.graph.listitem:subject", // list item (blocks) 1206 "app.bsky.graph.listitem:list", // list mention 1207 ], 1208 // should be getting from DB but whatever right 1209 wantedSubjects: [ 1210 // as noted i dont need to write down each post, just the user to listen to ! 1211 // hell yeah 1212 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybv7b6ic2h", 1213 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvybws4avc2h", 1214 // "at://did:plc:mn45tewwnse5btfftvd3powc/app.bsky.feed.post/3lvvkcxcscs2h", 1215 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3l63ogxocq42f", 1216 // "at://did:plc:yy6kbriyxtimkjqonqatv2rb/app.bsky.feed.post/3lw3wamvflu23", 1217 ], 1218 wantedSubjectDids: [ 1219 this.did, 1220 //"did:plc:mn45tewwnse5btfftvd3powc", 1221 //"did:plc:yy6kbriyxtimkjqonqatv2rb", 1222 //"did:plc:zzhzjga3ab5fcs2vnsv2ist3", 1223 //"did:plc:jz4ibztn56hygfld6j6zjszg", 1224 ], 1225 instant: ["true"] 1226 }); 1227 //await connectToConstellation(this.did, this.db); 1228 } 1229 1230 // initialize() { 1231 1232 // } 1233 1234 // async handleHttpRequest(route: string, req: Request): Promise<Response> { 1235 // if (route === "posts") { 1236 // const posts = await this.queryPosts(); 1237 // return new Response(JSON.stringify(posts), { 1238 // headers: { "content-type": "application/json" }, 1239 // }); 1240 // } 1241 1242 // return new Response("Unknown route", { status: 404 }); 1243 // } 1244 1245 // private async queryPosts() { 1246 // return this.db.run( 1247 // "SELECT * FROM posts ORDER BY created_at DESC LIMIT 100" 1248 // ); 1249 // } 1250 1251 shutdown() { 1252 this.jetstream.stop(); 1253 this.spacedust.stop(); 1254 this.db.close?.(); 1255 } 1256} 1257 1258async function getServiceEndpointFromIdentity( 1259 did: string, 1260 kind: "skylite_index" | "bsky_appview" 1261): Promise<string | null> { 1262 //const identity = await resolveIdentity(did); 1263 //const declUrl = `${identity.pds}/xrpc/com.atproto.repo.getRecord?repo=${identity.did}&collection=party.whey.skylite.declaration&rkey=self`; 1264 1265 //const data = (await cachedFetch(declUrl)) as any; 1266 const data = await getSlingshotRecord(did,"party.whey.skylite.declaration","self") as any; 1267 //if (!resp.ok) throw new Error(`Failed to fetch declaration for ${did}`); 1268 //const data = await resp.json(); 1269 1270 const svc = data?.value?.service?.find((s: any) => s.id === `#${kind}`); 1271 return svc?.serviceEndpoint ?? null; 1272} 1273 1274const cache = new QuickLRU({ maxSize: 10000 }); 1275 1276async function getSkyliteEndpoint(did: string): Promise<string | null> { 1277 if (cache.has(did)) return cache.get(did) as string; 1278 for (const resolver of config.viewServer.indexPriority) { 1279 try { 1280 const [prefix, suffix] = resolver.split("#") as [ 1281 "user" | `did:web:${string}`, 1282 "skylite_index" | "bsky_appview" 1283 ]; 1284 if (prefix === "user") { 1285 return await getServiceEndpointFromIdentity(did, suffix); 1286 } else if (prefix.startsWith("did:web:")) { 1287 // map did:web:foo.com -> https://foo.com 1288 return prefix.replace("did:web:", "https://"); 1289 } 1290 } catch (err) { 1291 // continue to next resolver 1292 continue; 1293 } 1294 } 1295 return null; //throw new Error(`No endpoint found for ${resolver}`); 1296} 1297 1298// export interface Notification { 1299// $type?: 'app.bsky.notification.listNotifications#notification'; 1300// uri: string; 1301// cid: string; 1302// author: AppBskyActorDefs.ProfileView; 1303// /** The reason why this notification was delivered - e.g. your post was liked, or you received a new follower. */ 1304// reason: 'like' | 'repost' | 'follow' | 'mention' | 'reply' | 'quote' | 'starterpack-joined' | 'verified' | 'unverified' | 'like-via-repost' | 'repost-via-repost' | (string & {}); 1305// reasonSubject?: string; 1306// record: { 1307// [_ in string]: unknown; 1308// }; 1309// isRead: boolean; 1310// indexedAt: string; 1311// labels?: ComAtprotoLabelDefs.Label[]; 1312// }