diff --git a/deno.lock b/deno.lock index 33fda59..9b6a843 100644 --- a/deno.lock +++ b/deno.lock @@ -18,6 +18,7 @@ "npm:@atproto/lex-cli@*": "0.9.1", "npm:@atproto/lexicon@*": "0.4.11", "npm:@atproto/xrpc-server@*": "0.9.1", + "npm:@atproto/xrpc@*": "0.7.1", "npm:@ipld/car@*": "5.4.2", "npm:@ipld/dag-cbor@*": "9.2.4", "npm:@types/express@4.17.15": "4.17.15", diff --git a/indexserver.ts b/indexserver.ts index 27bcf45..95dec3c 100644 --- a/indexserver.ts +++ b/indexserver.ts @@ -11,6 +11,7 @@ import { handleSpacedust, SpacedustLinkMessage } from "./index/spacedust.ts"; import { handleJetstream } from "./index/jetstream.ts"; import * as ATPAPI from "npm:@atproto/api"; import { AtUri } from "npm:@atproto/api"; +import * as IndexServerAPI from "./indexclient/index.ts"; export class IndexServerUserManager { private users = new Map(); @@ -350,6 +351,7 @@ export async function indexServerHandler(req: Request): Promise { ? pathname.slice("/xrpc/".length) : null; const searchParams = searchParamsToJson(url.searchParams); + console.log(JSON.stringify(searchParams, null, 2)); const jsonUntyped = searchParams; switch (xrpcMethod) { @@ -357,7 +359,19 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.AppBskyActorGetProfile.QueryParams; - const response: IndexServerTypes.AppBskyActorGetProfile.OutputSchema = {}; + const res = queryProfileView(jsonTyped.actor, "Detailed"); + if (!res) + return new Response( + JSON.stringify({ + error: "User not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + const response: IndexServerTypes.AppBskyActorGetProfile.OutputSchema = + res; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -366,9 +380,52 @@ export async function indexServerHandler(req: Request): Promise { case "app.bsky.actor.getProfiles": { const jsonTyped = jsonUntyped as IndexServerTypes.AppBskyActorGetProfiles.QueryParams; + + if (typeof jsonUntyped?.actors === "string" ) { + const res = queryProfileView(jsonUntyped.actors as string, "Detailed"); + if (!res) + return new Response( + JSON.stringify({ + error: "User not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = { + profiles: [res], + }; + + return new Response(JSON.stringify(response), { + headers: withCors({ "Content-Type": "application/json" }), + }); + } + + const res: ATPAPI.AppBskyActorDefs.ProfileViewDetailed[] = + jsonTyped.actors + .map((actor) => { + return queryProfileView(actor, "Detailed"); + }) + .filter( + (x): x is ATPAPI.AppBskyActorDefs.ProfileViewDetailed => + x !== undefined + ); + + if (!res) + return new Response( + JSON.stringify({ + error: "User not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); - const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = - {}; + const response: IndexServerTypes.AppBskyActorGetProfiles.OutputSchema = { + profiles: res, + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -377,9 +434,13 @@ export async function indexServerHandler(req: Request): Promise { case "app.bsky.feed.getActorFeeds": { const jsonTyped = jsonUntyped as IndexServerTypes.AppBskyFeedGetActorFeeds.QueryParams; + + const qresult = queryActorFeeds(jsonTyped.actor) const response: IndexServerTypes.AppBskyFeedGetActorFeeds.OutputSchema = - {}; + { + feeds: qresult + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -389,8 +450,25 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerator.QueryParams; + const qresult = queryFeedGenerator(jsonTyped.feed) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } + const response: IndexServerTypes.AppBskyFeedGetFeedGenerator.OutputSchema = - {}; + { + view: qresult, + isOnline: true, // lmao + isValid: true, // lmao + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -400,8 +478,23 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.AppBskyFeedGetFeedGenerators.QueryParams; + const qresult = queryFeedGenerators(jsonTyped.feeds) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } + const response: IndexServerTypes.AppBskyFeedGetFeedGenerators.OutputSchema = - {}; + { + feeds: qresult + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -429,9 +522,27 @@ export async function indexServerHandler(req: Request): Promise { case "party.whey.app.bsky.feed.getActorLikesPartial": { const jsonTyped = jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.QueryParams; + + // TODO: not partial yet, currently skips refs + + const qresult = queryActorLikes(jsonTyped.actor, jsonTyped.cursor) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } const response: IndexServerTypes.PartyWheyAppBskyFeedGetActorLikesPartial.OutputSchema = - {}; + { + feed: qresult.items as ATPAPI.$Typed[], + cursor: qresult.cursor + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -441,8 +552,26 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.QueryParams; + // TODO: not partial yet, currently skips refs + + const qresult = queryAuthorFeed(jsonTyped.actor, jsonTyped.cursor) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } + const response: IndexServerTypes.PartyWheyAppBskyFeedGetAuthorFeedPartial.OutputSchema = - {}; + { + feed: qresult.items as ATPAPI.$Typed[], + cursor: qresult.cursor + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -452,8 +581,25 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.QueryParams; + // TODO: not partial yet, currently skips refs + + const qresult = queryLikes(jsonTyped.uri) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } const response: IndexServerTypes.PartyWheyAppBskyFeedGetLikesPartial.OutputSchema = - {}; + { + // @ts-ignore whatever i dont care TODO: fix ts ignores + likes: qresult + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -463,8 +609,22 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.QueryParams; + // TODO: not partial yet, currently skips refs + + const qresult = queryPostThread(jsonTyped.uri) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } const response: IndexServerTypes.PartyWheyAppBskyFeedGetPostThreadPartial.OutputSchema = - {}; + qresult return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -474,8 +634,25 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.QueryParams; + // TODO: not partial yet, currently skips refs + + const qresult = queryQuotes(jsonTyped.uri) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } const response: IndexServerTypes.PartyWheyAppBskyFeedGetQuotesPartial.OutputSchema = - {}; + { + uri: jsonTyped.uri, + posts: qresult.map((feedviewpost)=>{return feedviewpost.post as ATPAPI.$Typed}) + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), @@ -485,24 +662,42 @@ export async function indexServerHandler(req: Request): Promise { const jsonTyped = jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.QueryParams; - const response: IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.OutputSchema = - {}; + // TODO: not partial yet, currently skips refs - return new Response(JSON.stringify(response), { - headers: withCors({ "Content-Type": "application/json" }), - }); - } - case "party.whey.app.bsky.feed.getListFeedPartial": { - const jsonTyped = - jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.QueryParams; - - const response: IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.OutputSchema = - {}; + const qresult = queryReposts(jsonTyped.uri) + if (!qresult) { + return new Response( + JSON.stringify({ + error: "Feed not found", + }), + { + status: 404, + headers: withCors({ "Content-Type": "application/json" }), + } + ); + } + const response: IndexServerTypes.PartyWheyAppBskyFeedGetRepostedByPartial.OutputSchema = + { + uri: jsonTyped.uri, + repostedBy: qresult as ATPAPI.$Typed[] + }; return new Response(JSON.stringify(response), { headers: withCors({ "Content-Type": "application/json" }), }); } + // TODO: too hard for now + // case "party.whey.app.bsky.feed.getListFeedPartial": { + // const jsonTyped = + // jsonUntyped as IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.QueryParams; + + // const response: IndexServerTypes.PartyWheyAppBskyFeedGetListFeedPartial.OutputSchema = + // {}; + + // return new Response(JSON.stringify(response), { + // headers: withCors({ "Content-Type": "application/json" }), + // }); + // } /* three more coming soon app.bsky.graph.getLists app.bsky.graph.getList @@ -607,10 +802,13 @@ export async function constellationAPIHandler(req: Request): Promise { const jsonUntyped = searchParams; if (!jsonUntyped.target) { - return new Response(JSON.stringify({ error: "Missing required parameter: target" }), { - status: 400, - headers: withCors({ "Content-Type": "application/json" }), - }); + return new Response( + JSON.stringify({ error: "Missing required parameter: target" }), + { + status: 400, + headers: withCors({ "Content-Type": "application/json" }), + } + ); } const did = isDid(searchParams.target) @@ -629,17 +827,22 @@ export async function constellationAPIHandler(req: Request): Promise { ); } - const limit = 16 //Math.min(parseInt(searchParams.limit || "50", 10), 100); + const limit = 16; //Math.min(parseInt(searchParams.limit || "50", 10), 100); const offset = parseInt(searchParams.cursor || "0", 10); switch (pathname) { case "/links": { const jsonTyped = jsonUntyped as linksQuery; if (!jsonTyped.collection || !jsonTyped.path) { - return new Response(JSON.stringify({ error: "Missing required parameters: collection, path" }), { - status: 400, - headers: withCors({ "Content-Type": "application/json" }), - }); + return new Response( + JSON.stringify({ + error: "Missing required parameters: collection, path", + }), + { + status: 400, + headers: withCors({ "Content-Type": "application/json" }), + } + ); } const field = `${jsonTyped.collection}:${jsonTyped.path.replace( @@ -749,7 +952,7 @@ export async function constellationAPIHandler(req: Request): Promise { .get(jsonTyped.target, jsonTyped.collection, field); const response: linksCountResponse = { - total: (result && result.total) ? result.total.toString() : "0", + total: result && result.total ? result.total.toString() : "0", }; return new Response(JSON.stringify(response), { @@ -780,7 +983,7 @@ export async function constellationAPIHandler(req: Request): Promise { .get(jsonTyped.target, jsonTyped.collection, field); const response: linksCountResponse = { - total: (result && result.total) ? result.total.toString() : "0", + total: result && result.total ? result.total.toString() : "0", }; return new Response(JSON.stringify(response), { @@ -904,6 +1107,61 @@ export function indexServerIndexer(ctx: indexHandlerContext) { case "app.bsky.feed.like": { return; } + case "app.bsky.actor.profile": { + console.log("bsky profuile"); + + try { + const stmt = db.prepare(` + INSERT OR IGNORE INTO app_bsky_actor_profile ( + uri, did, cid, rev, createdat, indexedat, json, + displayname, + description, + avatarcid, + avatarmime, + bannercid, + bannermime + ) VALUES (?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?) + `); + console.log({ + uri: ctx.aturi, + did: ctx.doer, + cid: ctx.cid, + rev: ctx.rev, + createdat: record.createdAt, + indexedat: Date.now(), + json: JSON.stringify(record), + displayname: record.displayName, + description: record.description, + avatarcid: uncid(record.avatar?.ref), + avatarmime: record.avatar?.mimeType, + bannercid: uncid(record.banner?.ref), + bannermime: record.banner?.mimeType, + }); + stmt.run( + ctx.aturi ?? null, + ctx.doer ?? null, + ctx.cid ?? null, + ctx.rev ?? null, + record.createdAt ?? null, + Date.now(), + JSON.stringify(record), + + record.displayName ?? null, + record.description ?? null, + uncid(record.avatar?.ref) ?? null, + record.avatar?.mimeType ?? null, + uncid(record.banner?.ref) ?? null, + record.banner?.mimeType ?? null, + // TODO please add pinned posts + + ); + } catch (err) { + console.error("stmt.run failed:", err); + } + return; + } case "app.bsky.feed.post": { console.log("bsky post"); const stmt = db.prepare(` @@ -997,6 +1255,7 @@ export function indexServerIndexer(ctx: indexHandlerContext) { } } +// user data function queryProfileView( did: string, type: "" @@ -1029,9 +1288,6 @@ function queryProfileView( `); const row = stmt.get(did) as ProfileRow; - const profileView = queryProfileView(did, "Basic"); - if (!row || !row.cid || !profileView || !row.json) return; - const value = JSON.parse(row.json) as ATPAPI.AppBskyActorProfile.Record; // simulate different types returned switch (type) { @@ -1039,14 +1295,16 @@ function queryProfileView( const result: ATPAPI.AppBskyActorDefs.ProfileView = { $type: "app.bsky.actor.defs#profileView", did: did, - handle: "@idiot.fuck", // TODO: Resolve user identity here for the handle + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle displayName: row.displayname ?? undefined, description: row.description ?? undefined, avatar: "https://google.com/", // create profile URL from resolved identity //associated?: ProfileAssociated, - indexedAt: new Date(row.indexedat).toString() ?? undefined, + indexedAt: row.createdat + ? new Date(row.createdat).toISOString() + : undefined, createdAt: row.createdat - ? new Date(row.createdat).toString() + ? new Date(row.createdat).toISOString() : undefined, //viewer?: ViewerState, //labels?: ComAtprotoLabelDefs.Label[], @@ -1059,12 +1317,12 @@ function queryProfileView( const result: ATPAPI.AppBskyActorDefs.ProfileViewBasic = { $type: "app.bsky.actor.defs#profileViewBasic", did: did, - handle: "@idiot.fuck", // TODO: Resolve user identity here for the handle + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle displayName: row.displayname ?? undefined, avatar: "https://google.com/", // create profile URL from resolved identity //associated?: ProfileAssociated, createdAt: row.createdat - ? new Date(row.createdat).toString() + ? new Date(row.createdat).toISOString() : undefined, //viewer?: ViewerState, //labels?: ComAtprotoLabelDefs.Label[], @@ -1101,23 +1359,24 @@ function queryProfileView( const postsResult = postsStmt.get(did) as { count: number }; const postsCount = postsResult?.count ?? 0; - // -- end of changes -- const result: ATPAPI.AppBskyActorDefs.ProfileViewDetailed = { $type: "app.bsky.actor.defs#profileViewDetailed", did: did, - handle: "@idiot.fuck", // TODO: Resolve user identity here for the handle + handle: "idiot.fuck.shit.example.com", // TODO: Resolve user identity here for the handle displayName: row.displayname ?? undefined, description: row.description ?? undefined, - avatar: "https://google.com/", // create profile URL from resolved identity + avatar: "https://google.com/", // TODO: create profile URL from resolved identity banner: "https://youtube.com/", // same here followersCount: followersCount, followsCount: followsCount, postsCount: postsCount, //associated?: ProfileAssociated, //joinedViaStarterPack?: // AppBskyGraphDefs.StarterPackViewBasic; - indexedAt: new Date(row.indexedat).toString() ?? undefined, + indexedAt: row.createdat + ? new Date(row.createdat).toISOString() + : undefined, createdAt: row.createdat - ? new Date(row.createdat).toString() + ? new Date(row.createdat).toISOString() : undefined, //viewer?: ViewerState, //labels?: ComAtprotoLabelDefs.Label[], @@ -1132,6 +1391,7 @@ function queryProfileView( } } +// post hydration function queryPostView( uri: string ): ATPAPI.AppBskyFeedDefs.PostView | undefined { @@ -1159,9 +1419,425 @@ function queryPostView( author: profileView, record: value, indexedAt: new Date(row.indexedat).toISOString(), - // These can be filled in later if you support them embed: value.embed, }; return post; } +function queryFeedViewPost( + uri: string +): ATPAPI.AppBskyFeedDefs.FeedViewPost | undefined { + + const post = queryPostView(uri) + if (!post) return; + + const feedviewpost: ATPAPI.AppBskyFeedDefs.FeedViewPost = { + $type: 'app.bsky.feed.defs#feedViewPost', + post: post, + //reply: ReplyRef, + //reason: , + }; + + return feedviewpost; +} + +interface BaseRow { + uri: string; + did: string; + cid: string | null; + rev: string | null; + createdat: number | null; + indexedat: number; + json: string | null; +} +interface GeneratorRow extends BaseRow { + displayname: string | null; + description: string | null; + avatarcid: string | null; +} +interface LikeRow extends BaseRow { + subject: string; +} +interface RepostRow extends BaseRow { + subject: string; +} +interface BacklinkRow { + srcuri: string; + srcdid: string; +} + +const FEED_LIMIT = 50; + +// user feedgens + +function queryActorFeeds(did: string): ATPAPI.AppBskyFeedDefs.GeneratorView[] { + if (!isRegisteredIndexUser(did)) return []; + const db = getDbForDid(did); + if (!db) return []; + + const stmt = db.prepare(` + SELECT uri, cid, did, json, indexedat + FROM app_bsky_feed_generator + WHERE did = ? + ORDER BY createdat DESC; + `); + + const rows = stmt.all(did) as unknown as GeneratorRow[]; + const creatorView = queryProfileView(did, "Basic"); + if (!creatorView) return []; + + return rows + .map((row) => { + try { + if (!row.json) return; + const record = JSON.parse( + row.json + ) as ATPAPI.AppBskyFeedGenerator.Record; + return { + $type: "app.bsky.feed.defs#generatorView", + uri: row.uri, + cid: row.cid, + did: row.did, + creator: creatorView, + displayName: record.displayName, + description: record.description, + descriptionFacets: record.descriptionFacets, + avatar: record.avatar, + likeCount: 0, // TODO: this should be easy + indexedAt: new Date(row.indexedat).toISOString(), + } as ATPAPI.AppBskyFeedDefs.GeneratorView; + } catch { + return undefined; + } + }) + .filter((v): v is ATPAPI.AppBskyFeedDefs.GeneratorView => !!v); +} + +function queryFeedGenerator( + uri: string +): ATPAPI.AppBskyFeedDefs.GeneratorView | undefined { + return queryFeedGenerators([uri])[0]; +} + +function queryFeedGenerators( + uris: string[] +): ATPAPI.AppBskyFeedDefs.GeneratorView[] { + const generators: ATPAPI.AppBskyFeedDefs.GeneratorView[] = []; + const urisByDid = new Map(); + + for (const uri of uris) { + try { + const { host: did } = new AtUri(uri); + if (!urisByDid.has(did)) { + urisByDid.set(did, []); + } + urisByDid.get(did)!.push(uri); + } catch { + } + } + + for (const [did, didUris] of urisByDid.entries()) { + if (!isRegisteredIndexUser(did)) continue; + const db = getDbForDid(did); + if (!db) continue; + + const placeholders = didUris.map(() => "?").join(","); + const stmt = db.prepare(` + SELECT uri, cid, did, json, indexedat + FROM app_bsky_feed_generator + WHERE uri IN (${placeholders}); + `); + + const rows = stmt.all(...didUris) as unknown as GeneratorRow[]; + if (rows.length === 0) continue; + + const creatorView = queryProfileView(did, ""); + if (!creatorView) continue; + + for (const row of rows) { + try { + if (!row.json || !row.cid ) continue; + const record = JSON.parse( + row.json + ) as ATPAPI.AppBskyFeedGenerator.Record; + generators.push({ + $type: "app.bsky.feed.defs#generatorView", + uri: row.uri, + cid: row.cid, + did: row.did, + creator: creatorView, + displayName: record.displayName, + description: record.description, + descriptionFacets: record.descriptionFacets, + avatar: record.avatar as string | undefined, + likeCount: 0, + indexedAt: new Date(row.indexedat).toISOString(), + }); + } catch {} + } + } + return generators; +} + +// user feeds + +function queryAuthorFeed( + did: string, + cursor?: string +): + | { + items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; + cursor: string | undefined; + } + | undefined { + if (!isRegisteredIndexUser(did)) return; + const db = getDbForDid(did); + if (!db) return; + + // TODO: implement this for real + let query = ` + SELECT uri, indexedat, cid + FROM app_bsky_feed_post + WHERE did = ? + `; + const params: (string | number)[] = [did]; + + if (cursor) { + const [indexedat, cid] = cursor.split("::"); + query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; + params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); + } + + query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; + + const stmt = db.prepare(query); + const rows = stmt.all(...params) as { + uri: string; + indexedat: number; + cid: string; + }[]; + + const items = rows + .map((row) => queryFeedViewPost(row.uri)) // TODO: for replies and repost i should inject the reason here + .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); + + const lastItem = rows[rows.length - 1]; + const nextCursor = lastItem + ? `${lastItem.indexedat}::${lastItem.cid}` + : undefined; + + return { items, cursor: nextCursor }; +} + +function queryListFeed( + uri: string, + cursor?: string +): + | { + items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; + cursor: string | undefined; + } + | undefined { + return { items: [], cursor: undefined }; +} + +function queryActorLikes( + did: string, + cursor?: string +): + | { + items: ATPAPI.AppBskyFeedDefs.FeedViewPost[]; + cursor: string | undefined; + } + | undefined { + if (!isRegisteredIndexUser(did)) return; + const db = getDbForDid(did); + if (!db) return; + + let query = ` + SELECT subject, indexedat, cid + FROM app_bsky_feed_like + WHERE did = ? + `; + const params: (string | number)[] = [did]; + + if (cursor) { + const [indexedat, cid] = cursor.split("::"); + query += ` AND (indexedat < ? OR (indexedat = ? AND cid < ?))`; + params.push(parseInt(indexedat, 10), parseInt(indexedat, 10), cid); + } + + query += ` ORDER BY indexedat DESC, cid DESC LIMIT ${FEED_LIMIT}`; + + const stmt = db.prepare(query); + const rows = stmt.all(...params) as { + subject: string; + indexedat: number; + cid: string; + }[]; + + const items = rows + .map((row) => queryFeedViewPost(row.subject)) + .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); + + const lastItem = rows[rows.length - 1]; + const nextCursor = lastItem + ? `${lastItem.indexedat}::${lastItem.cid}` + : undefined; + + return { items, cursor: nextCursor }; +} + +// post metadata + +function queryLikes( + uri: string +): ATPAPI.AppBskyFeedGetLikes.Like[] | undefined { + const postUri = new AtUri(uri); + const postAuthorDid = postUri.hostname; + if (!isRegisteredIndexUser(postAuthorDid)) return; + const db = getDbForDid(postAuthorDid); + if (!db) return; + + const stmt = db.prepare(` + SELECT b.srcdid, b.srcuri + FROM backlink_skeleton AS b + WHERE b.suburi = ? AND b.srccol = 'app_bsky_feed_like' + ORDER BY b.id DESC; + `); + + const rows = stmt.all(uri) as unknown as BacklinkRow[]; + + return rows + .map((row) => { + const actor = queryProfileView(row.srcdid, ""); + if (!actor) return; + + return { + // TODO write indexedAt for spacedust indexes + createdAt: new Date(Date.now()).toISOString(), + indexedAt: new Date(Date.now()).toISOString(), + actor: actor, + }; + }) + .filter((like): like is ATPAPI.AppBskyFeedGetLikes.Like => !!like); +} + +function queryReposts(uri: string): ATPAPI.AppBskyActorDefs.ProfileView[] { + const postUri = new AtUri(uri); + const postAuthorDid = postUri.hostname; + if (!isRegisteredIndexUser(postAuthorDid)) return []; + const db = getDbForDid(postAuthorDid); + if (!db) return []; + + const stmt = db.prepare(` + SELECT srcdid + FROM backlink_skeleton + WHERE suburi = ? AND srccol = 'app_bsky_feed_repost' + ORDER BY id DESC; + `); + + const rows = stmt.all(uri) as { srcdid: string }[]; + + return rows + .map((row) => queryProfileView(row.srcdid, "")) + .filter((p): p is ATPAPI.AppBskyActorDefs.ProfileView => !!p); +} + +function queryQuotes(uri: string): ATPAPI.AppBskyFeedDefs.FeedViewPost[] { + const postUri = new AtUri(uri); + const postAuthorDid = postUri.hostname; + if (!isRegisteredIndexUser(postAuthorDid)) return []; + const db = getDbForDid(postAuthorDid); + if (!db) return []; + + const stmt = db.prepare(` + SELECT srcuri + FROM backlink_skeleton + WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'quote' + ORDER BY id DESC; + `); + + const rows = stmt.all(uri) as { srcuri: string }[]; + + return rows + .map((row) => queryFeedViewPost(row.srcuri)) + .filter((p): p is ATPAPI.AppBskyFeedDefs.FeedViewPost => !!p); +} + +function queryPostThread( + uri: string +): ATPAPI.AppBskyFeedGetPostThread.OutputSchema | undefined { + const post = queryPostView(uri); + if (!post) { + return { + thread: { + $type: "app.bsky.feed.defs#notFoundPost", + uri: uri, + notFound: true, + } as ATPAPI.$Typed + } + } + + const thread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { + $type: "app.bsky.feed.defs#threadViewPost", + post: post, + replies: [], + }; + + let current = thread; + while ((current.post.record.reply as any)?.parent?.uri) { + const parentUri = (current.post.record.reply as any)?.parent?.uri; + const parentPost = queryPostView(parentUri); + if (!parentPost) break; + + const parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { + $type: "app.bsky.feed.defs#threadViewPost", + post: parentPost, + replies: [current as ATPAPI.$Typed], + }; + current.parent = parentThread as ATPAPI.$Typed; + current = parentThread; + } + + const seenUris = new Set(); + const fetchReplies = ( + parentThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost + ) => { + if (seenUris.has(parentThread.post.uri)) return; + seenUris.add(parentThread.post.uri); + + const parentUri = new AtUri(parentThread.post.uri); + const parentAuthorDid = parentUri.hostname; + const db = getDbForDid(parentAuthorDid); + if (!db) return; + + const stmt = db.prepare(` + SELECT srcuri + FROM backlink_skeleton + WHERE suburi = ? AND srccol = 'app_bsky_feed_post' AND srcfield = 'replyparent' + `); + const replyRows = stmt.all(parentThread.post.uri) as { srcuri: string }[]; + + const replies = replyRows + .map((row) => queryPostView(row.srcuri)) + .filter((p): p is ATPAPI.AppBskyFeedDefs.PostView => !!p); + + for (const replyPost of replies) { + const replyThread: ATPAPI.AppBskyFeedDefs.ThreadViewPost = { + $type: "app.bsky.feed.defs#threadViewPost", + post: replyPost, + parent: parentThread as ATPAPI.$Typed, + replies: [], + }; + parentThread.replies?.push(replyThread as ATPAPI.$Typed); + fetchReplies(replyThread); + } + }; + + fetchReplies(thread); + + const returned = thread as ATPAPI.$Typed + + return { thread: returned }; +} diff --git a/main.ts b/main.ts index 601b9c2..994e4ed 100644 --- a/main.ts +++ b/main.ts @@ -149,6 +149,8 @@ Deno.serve( console.log(`request for "${pathname}"`) const constellation = pathname.startsWith("/links") + // return await viewServerHandler(req) + if (constellation) { return await constellationAPIHandler(req); } diff --git a/utils/dbuser.ts b/utils/dbuser.ts index 8e4fc02..b1c551b 100644 --- a/utils/dbuser.ts +++ b/utils/dbuser.ts @@ -33,6 +33,7 @@ export function setupUserDb(db: Database) { avatarmime TEXT, bannercid TEXT, bannermime TEXT + -- TODO please add pinned posts ); ${createIndexINE} idx_actor_profile_did ON app_bsky_actor_profile(did); @@ -142,6 +143,7 @@ export function setupUserDb(db: Database) { suburi TEXT, subdid TEXT, subcol TEXT + -- TODO please add indexedAt ); ${createIndexINE} idx_backlink_subdid_mod ON backlink_skeleton(subdid, srcdid); ${createIndexINE} idx_backlink_suburi_mod ON backlink_skeleton(suburi, srcdid);