[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 const parentReply = await db.models.Reply.findOne({ uri: currentUri })
101 .lean();
102 if (!parentReply) break;
103
104 ancestors.push({
105 uri: parentReply.uri,
106 height,
107 });
108
109 currentUri = parentReply.reply?.parent?.uri;
110 height++;
111 }
112
113 return ancestors;
114};
115
116export const invalidReplyRoot = (
117 reply: ReplyRef,
118 parent: {
119 record: ReplyRecord;
120 invalidReplyRoot: boolean | null;
121 },
122) => {
123 const replyRoot = reply.root.uri;
124 const replyParent = reply.parent.uri;
125 // if parent is not a valid reply, transitively this is not a valid one either
126 if (parent.invalidReplyRoot) {
127 return true;
128 }
129 // replying to root post: ensure the root looks correct
130 if (replyParent === replyRoot) {
131 return !!parent.record.reply;
132 }
133 // replying to a reply: ensure the parent is a reply for the same root post
134 return parent.record.reply?.root.uri !== replyRoot;
135};
136
137const getDid = (doc: DidDocument) => doc.id;
138const getHandle = (doc: DidDocument) =>
139 doc.alsoKnownAs?.find((aka) => aka.startsWith("at://"))?.replace("at://", "");
140
141export const getResultFromDoc = (doc: DidDocument) => {
142 const keys: Record<string, { Type: string; PublicKeyMultibase: string }> = {};
143 doc.verificationMethod?.forEach((method) => {
144 const id = method.id.split("#").at(1);
145 if (!id) return;
146 keys[id] = {
147 Type: method.type,
148 PublicKeyMultibase: method.publicKeyMultibase || "",
149 };
150 });
151 const services: Record<string, { Type: string; URL: string }> = {};
152 doc.service?.forEach((service) => {
153 const id = service.id.split("#").at(1);
154 if (!id) return;
155 if (typeof service.serviceEndpoint !== "string") return;
156 services[id] = {
157 Type: service.type,
158 URL: service.serviceEndpoint,
159 };
160 });
161 return {
162 did: getDid(doc),
163 handle: getHandle(doc),
164 keys: new TextEncoder().encode(JSON.stringify(keys)),
165 services: new TextEncoder().encode(JSON.stringify(services)),
166 updated: new Date(),
167 };
168};
169
170export enum Code {
171 NotFound = "Not Found",
172 InvalidRequest = "Invalid Request",
173 InternalError = "Internal Error",
174}
175
176export class DataPlaneError extends Error {
177 public code: Code;
178
179 constructor(message: Code) {
180 super();
181 this.name = "DataPlaneError";
182 this.code = message;
183 }
184}
185
186export function isDataPlaneError(error: unknown, code?: Code): boolean {
187 return error instanceof DataPlaneError && (!code || error.code === code);
188}
189
190export const unpackIdentityServices = (services: string) => {
191 if (!services) return {};
192 return JSON.parse(services) as UnpackedServices;
193};
194
195export const unpackIdentityKeys = (keysBytes: Uint8Array) => {
196 const keysStr = bytes.toString(keysBytes, "utf8");
197 if (!keysStr) return {};
198 return JSON.parse(keysStr) as UnpackedKeys;
199};
200
201export const getServiceEndpoint = (
202 services: UnpackedServices,
203 opts: { id: string; type: string },
204) => {
205 const endpoint = services[opts.id] &&
206 services[opts.id].Type === opts.type &&
207 validateUrl(services[opts.id].URL);
208 return endpoint || undefined;
209};
210
211type UnpackedServices = Record<string, { Type: string; URL: string }>;
212
213type UnpackedKeys = Record<
214 string,
215 { Type: string; PublicKeyMultibase: string }
216>;
217
218const validateUrl = (urlStr: string): string | undefined => {
219 let url;
220 try {
221 url = new URL(urlStr);
222 } catch {
223 return undefined;
224 }
225 if (!["http:", "https:"].includes(url.protocol)) {
226 return undefined;
227 } else if (!url.hostname) {
228 return undefined;
229 } else {
230 return urlStr;
231 }
232};
233
234// @NOTE: This type is not complete with all supported options.
235// Only the ones that we needed to apply custom logic on are currently present.
236export type PostSearchQuery = {
237 q: string;
238 author: string | undefined;
239};
240
241export const parsePostSearchQuery = (
242 qParam: string,
243 params?: {
244 author?: string;
245 },
246): PostSearchQuery => {
247 // Accept individual params, but give preference to options embedded in `q`.
248 let author = params?.author;
249
250 const parts: string[] = [];
251 let curr = "";
252 let quoted = false;
253 for (const c of qParam) {
254 if (c === " " && !quoted) {
255 curr.trim() && parts.push(curr);
256 curr = "";
257 continue;
258 }
259
260 if (c === '"') {
261 quoted = !quoted;
262 }
263 curr += c;
264 }
265 curr.trim() && parts.push(curr);
266
267 const qParts: string[] = [];
268 for (const p of parts) {
269 const tokens = p.split(":");
270 if (tokens[0] === "did") {
271 author = p;
272 } else if (tokens[0] === "author" || tokens[0] === "from") {
273 author = tokens[1];
274 } else {
275 qParts.push(p);
276 }
277 }
278
279 return {
280 q: qParts.join(" "),
281 author,
282 };
283};
284
285// Helper function for composite time
286export function compositeTime(ts1?: string, ts2?: string): string | undefined {
287 if (!ts1) return ts2;
288 if (!ts2) return ts1;
289 return new Date(ts1) < new Date(ts2) ? ts1 : ts2;
290}