social bookmarking for atproto
at main 342 lines 9.3 kB view raw
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}