[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 330 lines 8.6 kB view raw
1import { Database } from "./db/index.ts"; 2import { DidDocument } from "@atp/identity"; 3import * as bytes from "@atp/bytes"; 4import * as so from "../lex/so.ts"; 5 6type ReplyRecord = so.sprk.feed.reply.Main; 7type ReplyRef = so.sprk.feed.reply.ReplyRef; 8 9export const getDescendents = async ( 10 db: Database, 11 opts: { 12 uri: string; 13 depth: number; // required, protects against cycles 14 }, 15) => { 16 const { uri, depth } = opts; 17 const descendents: Array<{ 18 uri: string; 19 depth: number; 20 cid: string; 21 creator: string; 22 sortAt: string; 23 }> = []; 24 25 // Get direct replies (depth 1) 26 const [directReplies, directCrosspostReplies] = await Promise.all([ 27 db.models.Reply.find({ 28 "reply.parent.uri": uri, 29 }).lean(), 30 db.models.CrosspostReply.find({ 31 "reply.parent.uri": uri, 32 }).lean(), 33 ]); 34 const directChildren = [...directReplies, ...directCrosspostReplies]; 35 36 for (const reply of directChildren) { 37 descendents.push({ 38 uri: reply.uri, 39 depth: 1, 40 cid: reply.cid, 41 creator: reply.authorDid, 42 sortAt: reply.createdAt, 43 }); 44 } 45 46 // Get nested replies (depth > 1) 47 if (depth > 1) { 48 const processedUris = new Set(directChildren.map((r) => r.uri)); 49 const toProcess = [ 50 ...directChildren.map((r) => ({ uri: r.uri, depth: 1 })), 51 ]; 52 53 while (toProcess.length > 0) { 54 const current = toProcess.shift()!; 55 if (current.depth >= depth) continue; 56 57 const [nestedReplies, nestedCrosspostReplies] = await Promise.all([ 58 db.models.Reply.find({ 59 "reply.parent.uri": current.uri, 60 }).lean(), 61 db.models.CrosspostReply.find({ 62 "reply.parent.uri": current.uri, 63 }).lean(), 64 ]); 65 const nestedChildren = [...nestedReplies, ...nestedCrosspostReplies]; 66 67 for (const reply of nestedChildren) { 68 if (processedUris.has(reply.uri)) continue; 69 processedUris.add(reply.uri); 70 71 descendents.push({ 72 uri: reply.uri, 73 depth: current.depth + 1, 74 cid: reply.cid, 75 creator: reply.authorDid, 76 sortAt: reply.createdAt, 77 }); 78 79 toProcess.push({ uri: reply.uri, depth: current.depth + 1 }); 80 } 81 } 82 } 83 84 return descendents; 85}; 86 87export const getAncestorsAndSelf = async ( 88 db: Database, 89 opts: { 90 uri: string; 91 parentHeight: number; // required, protects against cycles 92 }, 93) => { 94 const { uri, parentHeight } = opts; 95 const ancestors: Array<{ 96 uri: string; 97 height: number; 98 }> = []; 99 100 // Start with the current post 101 const [currentReply, currentCrosspostReply] = await Promise.all([ 102 db.models.Reply.findOne({ uri }).lean(), 103 db.models.CrosspostReply.findOne({ uri }).lean(), 104 ]); 105 const currentPost = currentReply || currentCrosspostReply; 106 if (!currentPost) return ancestors; 107 108 ancestors.push({ 109 uri: currentPost.uri, 110 height: 0, 111 }); 112 113 // Traverse up the reply chain 114 let currentUri = currentPost.reply?.parent?.uri; 115 let height = 1; 116 117 while (currentUri && height <= parentHeight) { 118 // Check if parent is a Post (root) or Reply 119 const [parentPost, parentReply, parentCrosspostReply] = await Promise.all([ 120 db.models.Post.findOne({ uri: currentUri }).lean(), 121 db.models.Reply.findOne({ uri: currentUri }).lean(), 122 db.models.CrosspostReply.findOne({ uri: currentUri }).lean(), 123 ]); 124 125 if (parentPost) { 126 // Found root post - add it and stop traversing 127 ancestors.push({ 128 uri: parentPost.uri, 129 height, 130 }); 131 break; 132 } else if (parentReply) { 133 // Found a reply - add it and continue traversing 134 ancestors.push({ 135 uri: parentReply.uri, 136 height, 137 }); 138 currentUri = parentReply.reply?.parent?.uri; 139 height++; 140 } else if (parentCrosspostReply) { 141 ancestors.push({ 142 uri: parentCrosspostReply.uri, 143 height, 144 }); 145 currentUri = parentCrosspostReply.reply?.parent?.uri; 146 height++; 147 } else { 148 // Parent not found - stop traversing 149 break; 150 } 151 } 152 153 return ancestors; 154}; 155 156export const invalidReplyRoot = ( 157 reply: ReplyRef, 158 parent: { 159 record: ReplyRecord; 160 invalidReplyRoot: boolean | null; 161 }, 162) => { 163 const replyRoot = reply.root.uri; 164 const replyParent = reply.parent.uri; 165 // if parent is not a valid reply, transitively this is not a valid one either 166 if (parent.invalidReplyRoot) { 167 return true; 168 } 169 // replying to root post: ensure the root looks correct 170 if (replyParent === replyRoot) { 171 return !!parent.record.reply; 172 } 173 // replying to a reply: ensure the parent is a reply for the same root post 174 return parent.record.reply?.root.uri !== replyRoot; 175}; 176 177const getDid = (doc: DidDocument) => doc.id; 178const getHandle = (doc: DidDocument) => 179 doc.alsoKnownAs?.find((aka) => aka.startsWith("at://"))?.replace("at://", ""); 180 181export const getResultFromDoc = (doc: DidDocument) => { 182 const keys: Record<string, { Type: string; PublicKeyMultibase: string }> = {}; 183 doc.verificationMethod?.forEach((method) => { 184 const id = method.id.split("#").at(1); 185 if (!id) return; 186 keys[id] = { 187 Type: method.type, 188 PublicKeyMultibase: method.publicKeyMultibase || "", 189 }; 190 }); 191 const services: Record<string, { Type: string; URL: string }> = {}; 192 doc.service?.forEach((service) => { 193 const id = service.id.split("#").at(1); 194 if (!id) return; 195 if (typeof service.serviceEndpoint !== "string") return; 196 services[id] = { 197 Type: service.type, 198 URL: service.serviceEndpoint, 199 }; 200 }); 201 return { 202 did: getDid(doc), 203 handle: getHandle(doc), 204 keys: new TextEncoder().encode(JSON.stringify(keys)), 205 services: new TextEncoder().encode(JSON.stringify(services)), 206 updated: new Date(), 207 }; 208}; 209 210export enum Code { 211 NotFound = "Not Found", 212 InvalidRequest = "Invalid Request", 213 InternalError = "Internal Error", 214} 215 216export class DataPlaneError extends Error { 217 public code: Code; 218 219 constructor(message: Code) { 220 super(); 221 this.name = "DataPlaneError"; 222 this.code = message; 223 } 224} 225 226export function isDataPlaneError(error: unknown, code?: Code): boolean { 227 return error instanceof DataPlaneError && (!code || error.code === code); 228} 229 230export const unpackIdentityServices = (services: string) => { 231 if (!services) return {}; 232 return JSON.parse(services) as UnpackedServices; 233}; 234 235export const unpackIdentityKeys = (keysBytes: Uint8Array) => { 236 const keysStr = bytes.toString(keysBytes, "utf8"); 237 if (!keysStr) return {}; 238 return JSON.parse(keysStr) as UnpackedKeys; 239}; 240 241export const getServiceEndpoint = ( 242 services: UnpackedServices, 243 opts: { id: string; type: string }, 244) => { 245 const endpoint = services[opts.id] && 246 services[opts.id].Type === opts.type && 247 validateUrl(services[opts.id].URL); 248 return endpoint || undefined; 249}; 250 251type UnpackedServices = Record<string, { Type: string; URL: string }>; 252 253type UnpackedKeys = Record< 254 string, 255 { Type: string; PublicKeyMultibase: string } 256>; 257 258const validateUrl = (urlStr: string): string | undefined => { 259 let url; 260 try { 261 url = new URL(urlStr); 262 } catch { 263 return undefined; 264 } 265 if (!["http:", "https:"].includes(url.protocol)) { 266 return undefined; 267 } else if (!url.hostname) { 268 return undefined; 269 } else { 270 return urlStr; 271 } 272}; 273 274// @NOTE: This type is not complete with all supported options. 275// Only the ones that we needed to apply custom logic on are currently present. 276export type PostSearchQuery = { 277 q: string; 278 author: string | undefined; 279}; 280 281export const parsePostSearchQuery = ( 282 qParam: string, 283 params?: { 284 author?: string; 285 }, 286): PostSearchQuery => { 287 // Accept individual params, but give preference to options embedded in `q`. 288 let author = params?.author; 289 290 const parts: string[] = []; 291 let curr = ""; 292 let quoted = false; 293 for (const c of qParam) { 294 if (c === " " && !quoted) { 295 curr.trim() && parts.push(curr); 296 curr = ""; 297 continue; 298 } 299 300 if (c === '"') { 301 quoted = !quoted; 302 } 303 curr += c; 304 } 305 curr.trim() && parts.push(curr); 306 307 const qParts: string[] = []; 308 for (const p of parts) { 309 const tokens = p.split(":"); 310 if (tokens[0] === "did") { 311 author = p; 312 } else if (tokens[0] === "author" || tokens[0] === "from") { 313 author = tokens[1]; 314 } else { 315 qParts.push(p); 316 } 317 } 318 319 return { 320 q: qParts.join(" "), 321 author, 322 }; 323}; 324 325// Helper function for composite time 326export function compositeTime(ts1?: string, ts2?: string): string | undefined { 327 if (!ts1) return ts2; 328 if (!ts2) return ts1; 329 return new Date(ts1) < new Date(ts2) ? ts1 : ts2; 330}