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