social bookmarking for atproto
1/*
2 * clippr: a social bookmarking service for the AT Protocol
3 * Copyright (c) 2025 clippr contributors.
4 * SPDX-License-Identifier: AGPL-3.0-only
5 */
6
7import type { CommitEvent } from "@skyware/jetstream";
8import { Database } from "../db/database.js";
9import { clipsTable, tagsTable, usersTable } from "../db/schema.js";
10import { is } from "@atcute/lexicons";
11import {
12 SocialClipprActorProfile,
13 SocialClipprFeedClip,
14 SocialClipprFeedTag,
15} from "@clipprjs/lexicons";
16import Logger from "../logger.js";
17import { isBlob } from "@atcute/lexicons/interfaces";
18import { validateClip, validateProfile, validateTag } from "./validator.js";
19import { convertDidToString } from "./converters.js";
20import { hashString } from "../hasher.js";
21import { and, eq } from "drizzle-orm";
22import type { TagRef } from "../api/types.js";
23
24const db = Database.getInstance().getDb();
25
26/// Converts a microsecond Unix date to a Date object, for type reasons.
27function convertMicroToDate(micro: number): Date {
28 return new Date(micro / 1000);
29}
30
31export async function handleClip(
32 event: CommitEvent<`social.clippr.${string}`>,
33): Promise<void> {
34 if (event.commit.operation === "delete") {
35 await db
36 .delete(clipsTable)
37 .where(
38 and(
39 eq(clipsTable.did, event.did),
40 eq(clipsTable.recordKey, event.commit.rkey),
41 ),
42 );
43 Logger.verbose(`Deleted clip: ${event.did}/${event.commit.rkey}`, event);
44 return;
45 }
46
47 if (event.commit.record.$type !== "social.clippr.feed.clip") {
48 Logger.verbose(
49 `Mismatched type for incoming clip record (${event.did}/${event.commit.rkey})`,
50 event.commit.record,
51 );
52 }
53
54 if (!is(SocialClipprFeedClip.mainSchema, event.commit.record)) {
55 Logger.verbose(
56 `Invalid schema for incoming clip record (${event.did}/${event.commit.rkey})`,
57 event.commit.record,
58 );
59 return;
60 }
61
62 const record: SocialClipprFeedClip.Main = {
63 $type: "social.clippr.feed.clip",
64 createdAt: event.commit.record.createdAt,
65 description: event.commit.record.description,
66 languages: event.commit.record.languages,
67 notes: event.commit.record.notes,
68 tags: event.commit.record.tags,
69 title: event.commit.record.title,
70 unlisted: event.commit.record.unlisted,
71 unread: event.commit.record.unread,
72 url: event.commit.record.url,
73 };
74
75 // xxh64, NOT xxh3 learned that the hard way
76 const urlHash: string = await hashString(record.url);
77
78 if (urlHash !== event.commit.rkey) {
79 Logger.verbose(
80 `Record key hash (${event.commit.rkey}) does not match hash of URL (${urlHash}) in incoming clip record (${event.did})`,
81 event.commit.record,
82 );
83 return;
84 }
85
86 if (!(await validateClip(record))) return;
87
88 if (event.commit.operation === "update") {
89 await db
90 .update(clipsTable)
91 .set({
92 did: convertDidToString(event.did),
93 cid: event.commit.cid,
94 timestamp: convertMicroToDate(event.time_us),
95 recordKey: event.commit.rkey,
96 createdAt: new Date(record.createdAt),
97 indexedAt: new Date(),
98 url: record.url,
99 title: record.title,
100 description: record.description,
101 tags: record.tags as TagRef[] | undefined,
102 notes: record.notes,
103 unlisted: record.unlisted,
104 unread: record.unread,
105 languages: record.languages,
106 })
107 .where(
108 and(
109 eq(clipsTable.did, event.did),
110 eq(clipsTable.recordKey, event.commit.rkey),
111 ),
112 );
113 Logger.verbose(`Updated clip: ${event.did}/${event.commit.rkey}`, event);
114 return;
115 }
116
117 await db.insert(clipsTable).values({
118 // @ts-expect-error Weird type error despite being a normal string.
119 did: convertDidToString(event.did),
120 cid: event.commit.cid,
121 timestamp: convertMicroToDate(event.time_us),
122 recordKey: event.commit.rkey,
123 createdAt: new Date(record.createdAt),
124 indexedAt: new Date(),
125 url: record.url,
126 title: record.title,
127 description: record.description,
128 tags: record.tags,
129 notes: record.notes,
130 unlisted: record.unlisted,
131 unread: record.unread,
132 languages: record.languages,
133 });
134
135 Logger.verbose(`Indexed new clip: ${event.did}/${event.commit.rkey}`, event);
136}
137
138export async function handleTag(
139 event: CommitEvent<`social.clippr.${string}`>,
140): Promise<void> {
141 if (event.commit.operation === "delete") {
142 await db
143 .delete(tagsTable)
144 .where(
145 and(
146 eq(tagsTable.did, event.did),
147 eq(tagsTable.recordKey, event.commit.rkey),
148 ),
149 );
150 Logger.verbose(`Deleted tag: ${event.did}/${event.commit.rkey}`, event);
151 return;
152 }
153
154 if (event.commit.record.$type !== "social.clippr.feed.tag") {
155 Logger.verbose(
156 `Mismatched type for incoming tag record (${event.did}/${event.commit.rkey})`,
157 event.commit.record,
158 );
159 return;
160 }
161
162 if (!is(SocialClipprFeedTag.mainSchema, event.commit.record)) {
163 Logger.verbose(
164 `Invalid schema for incoming tag record (${event.did}/${event.commit.rkey})`,
165 event.commit.record,
166 );
167 return;
168 }
169
170 const record: SocialClipprFeedTag.Main = {
171 $type: "social.clippr.feed.tag",
172 createdAt: event.commit.record.createdAt,
173 name: event.commit.record.name,
174 color: event.commit.record.color,
175 description: event.commit.record.description,
176 };
177
178 if (record.name !== event.commit.rkey) {
179 Logger.verbose(
180 `Record key does not match name of incoming tag record (${event.did}/${event.commit.rkey})`,
181 event.commit.record,
182 );
183 return;
184 }
185
186 // Independent validations
187 if (!(await validateTag(record))) {
188 return;
189 }
190
191 if (event.commit.operation === "update") {
192 await db
193 .update(tagsTable)
194 .set({
195 timestamp: convertMicroToDate(event.time_us),
196 did: convertDidToString(event.did),
197 cid: event.commit.cid,
198 recordKey: event.commit.rkey,
199 name: record.name,
200 description: record.description,
201 color: record.color,
202 createdAt: new Date(record.createdAt),
203 indexedAt: new Date(),
204 })
205 .where(
206 and(
207 eq(tagsTable.did, event.did),
208 eq(tagsTable.recordKey, event.commit.rkey),
209 ),
210 );
211 Logger.verbose(`Updated tag: ${event.did}/${event.commit.rkey}`, event);
212 return;
213 }
214
215 await db.insert(tagsTable).values({
216 timestamp: convertMicroToDate(event.time_us),
217 did: convertDidToString(event.did),
218 cid: event.commit.cid,
219 recordKey: event.commit.rkey,
220 name: record.name,
221 description: record.description,
222 color: record.color,
223 createdAt: new Date(record.createdAt),
224 indexedAt: new Date(),
225 });
226
227 Logger.verbose(`Indexed new tag: ${event.did}/${event.commit.rkey}`, event);
228}
229
230export async function handleProfile(
231 event: CommitEvent<`social.clippr.${string}`>,
232): Promise<void> {
233 if (event.commit.operation === "delete") {
234 await db.delete(usersTable).where(eq(usersTable.did, event.did));
235 Logger.verbose(`Deleted profile: ${event.did}`, event);
236 return;
237 }
238
239 if (event.commit.record.$type !== "social.clippr.actor.profile") {
240 Logger.verbose(
241 `Mismatched type for incoming profile record (${event.did})`,
242 event.commit.record,
243 );
244 return;
245 }
246
247 if (!is(SocialClipprActorProfile.mainSchema, event.commit.record)) {
248 Logger.verbose(
249 `Invalid schema for incoming profile record (${event.did})`,
250 event.commit.record,
251 );
252 return;
253 }
254
255 const record: SocialClipprActorProfile.Main = {
256 $type: "social.clippr.actor.profile",
257 createdAt: event.commit.record.createdAt,
258 displayName: event.commit.record.displayName,
259 description: event.commit.record.description || undefined,
260 avatar: event.commit.record.avatar || undefined,
261 };
262
263 if (event.commit.rkey !== "self") {
264 Logger.verbose(
265 `Record key of incoming profile record does not match 'self' (${event.did})`,
266 event.commit.record,
267 );
268 return;
269 }
270
271 // This needs to be here so the avatar can be recognized as a proper blob.
272 if (record.avatar) {
273 if (!isBlob(record.avatar)) {
274 Logger.verbose(
275 `Avatar in incoming profile record is not a blob (${event.did})`,
276 record,
277 );
278 return;
279 }
280
281 if (record.avatar.mimeType.match(/^image\/(png|jpeg)$/i) === null) {
282 Logger.verbose(
283 `Avatar in incoming profile record is not a PNG or JPEG (${event.did})`,
284 record,
285 );
286 return;
287 }
288
289 if (record.avatar.ref?.$link === undefined) {
290 Logger.verbose(
291 `Avatar in incoming profile record has no link to blob (${event.did})`,
292 record,
293 );
294 return;
295 }
296
297 if (record.avatar.size > 1000000) {
298 Logger.verbose(
299 `Avatar in incoming profile record is too large (${event.did})`,
300 record,
301 );
302 return;
303 }
304 }
305
306 // Independent validations
307 if (!(await validateProfile(record))) {
308 return;
309 }
310
311 if (event.commit.operation === "update") {
312 await db
313 .update(usersTable)
314 .set({
315 did: convertDidToString(event.did),
316 cid: event.commit.cid,
317 timestamp: convertMicroToDate(event.time_us),
318 createdAt: new Date(record.createdAt),
319 displayName: record.displayName,
320 avatar: record.avatar?.ref.$link,
321 description: record.description,
322 })
323 .where(eq(usersTable.did, convertDidToString(event.did)));
324 Logger.verbose(`Updated profile: ${convertDidToString(event.did)}`, event);
325 return;
326 }
327
328 await db.insert(usersTable).values({
329 did: convertDidToString(event.did),
330 cid: event.commit.cid,
331 timestamp: convertMicroToDate(event.time_us),
332 createdAt: new Date(record.createdAt),
333 displayName: record.displayName,
334 avatar: record.avatar?.ref.$link,
335 description: record.description,
336 });
337
338 Logger.verbose(
339 `Indexed new profile: ${convertDidToString(event.did)}`,
340 event,
341 );
342}