[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
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}