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