an attempt to make a lightweight, easily self-hostable, scoped bluesky appview
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// }