Bluesky app fork with some witchin' additions 💫

safelink (#8917)

Co-authored-by: hailey <me@haileyok.com>
Co-authored-by: Stanislas Signoud <signez@stanisoft.net>
Co-authored-by: will berry <wsb@wills-MBP.attlocal.net>
Co-authored-by: BlueSkiesAndGreenPastures <will@blueskyweb.xyz>
Co-authored-by: Chenyu Huang <itschenyu@gmail.com>

+1120 -47
+8
bskylink/locales/en.json
··· 1 + { 2 + "Potentially Dangerous Link": "Potentially Dangerous Link", 3 + "Blocked Link": "Blocked Link", 4 + "This link may be malicious. You should proceed at your own risk.": "This link may be malicious. You should proceed at your own risk.", 5 + "This link has been identified as malicious and has blocked for your safety.": "This link has been identified as malicious and has blocked for your safety.", 6 + "Continue Anyway": "Continue Anyway", 7 + "Return to Bluesky": "Return to Bluesky" 8 + }
+8
bskylink/locales/es.json
··· 1 + { 2 + "Potentially Dangerous Link": "Enlace Potencialmente Peligroso", 3 + "Blocked Link": "Enlace Bloqueado", 4 + "This link may be malicious. You should proceed at your own risk.": "Este enlace puede ser malicioso. Debes proceder bajo tu propio riesgo.", 5 + "This link has been identified as malicious and has blocked for your safety.": "Este enlace ha sido identificado como malicioso y ha sido bloqueado por tu seguridad.", 6 + "Continue Anyway": "Continuar de Todos Modos", 7 + "Return to Bluesky": "Regresar a Bluesky" 8 + }
+8
bskylink/locales/fr.json
··· 1 + { 2 + "Potentially Dangerous Link": "Lien potentiellement dangereux", 3 + "Blocked Link": "Lien bloqué", 4 + "This link may be malicious. You should proceed at your own risk.": "Ce lien est peut-être malveillant. Ne continuez qu’à vos risques et périls.", 5 + "This link has been identified as malicious and has blocked for your safety.": "Ce lien a été identifié comme malveillant et a été bloqué pour votre sécurité.", 6 + "Continue Anyway": "Continuer quand même", 7 + "Return to Bluesky": "Retourner sur Bluesky" 8 + }
+4 -1
bskylink/package.json
··· 8 8 "build": "tsc" 9 9 }, 10 10 "dependencies": { 11 - "@atproto/common": "^0.4.0", 11 + "@atproto/common": "^0.4.11", 12 12 "@types/escape-html": "^1.0.4", 13 13 "body-parser": "^1.20.2", 14 14 "cors": "^2.8.5", 15 15 "escape-html": "^1.0.3", 16 16 "express": "^4.19.2", 17 17 "http-terminator": "^3.2.0", 18 + "i18n": "^0.15.1", 18 19 "kysely": "^0.27.3", 20 + "lru-cache": "^11.1.0", 19 21 "pg": "^8.12.0", 20 22 "pino": "^9.2.0", 21 23 "uint8arrays": "^5.1.0" 22 24 }, 23 25 "devDependencies": { 24 26 "@types/cors": "^2.8.17", 27 + "@types/i18n": "^0.13.12", 25 28 "@types/pg": "^8.11.6", 26 29 "typescript": "^5.4.5" 27 30 }
+6 -1
bskylink/src/bin.ts
··· 1 1 import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js' 2 - 3 2 async function main() { 4 3 const env = readEnv() 5 4 const cfg = envToCfg(env) ··· 11 10 await migrateDb.migrateToLatestOrThrow() 12 11 await migrateDb.close() 13 12 } 13 + 14 14 const link = await LinkService.create(cfg) 15 + 16 + if (link.ctx.cfg.service.safelinkEnabled) { 17 + link.ctx.safelinkClient.runFetchEvents() 18 + } 19 + 15 20 await link.start() 16 21 httpLogger.info('link service is running') 17 22 process.on('SIGTERM', async () => {
+13
bskylink/src/cache/rule.ts
··· 1 + import {type SafelinkRule} from '../db/schema' 2 + 3 + export const exampleRule: SafelinkRule = { 4 + id: 1, 5 + eventType: 'addRule', 6 + url: 'https://malicious.example.com/phishing', 7 + pattern: 'domain', 8 + action: 'block', 9 + reason: 'phishing', 10 + createdBy: 'did:plc:adminozonetools', 11 + createdAt: '2024-06-01T12:00:00Z', 12 + comment: 'Known phishing domain detected by automated scan.', 13 + }
+352
bskylink/src/cache/safelinkClient.ts
··· 1 + import { 2 + AtpAgent, 3 + CredentialSession, 4 + type ToolsOzoneSafelinkDefs, 5 + type ToolsOzoneSafelinkQueryEvents, 6 + } from '@atproto/api' 7 + import {ExpiredTokenError} from '@atproto/api/dist/client/types/com/atproto/server/confirmEmail.js' 8 + import {MINUTE} from '@atproto/common' 9 + import {LRUCache} from 'lru-cache' 10 + 11 + import {type ServiceConfig} from '../config.js' 12 + import type Database from '../db/index.js' 13 + import {type SafelinkRule} from '../db/schema.js' 14 + import {redirectLogger} from '../logger.js' 15 + 16 + const SAFELINK_MIN_FETCH_INTERVAL = 1_000 17 + const SAFELINK_MAX_FETCH_INTERVAL = 10_000 18 + const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ 19 + 20 + export class SafelinkClient { 21 + private domainCache: LRUCache<string, SafelinkRule | 'ok'> 22 + private urlCache: LRUCache<string, SafelinkRule | 'ok'> 23 + 24 + private db: Database 25 + 26 + private ozoneAgent: OzoneAgent 27 + 28 + private cursor?: string 29 + 30 + constructor({cfg, db}: {cfg: ServiceConfig; db: Database}) { 31 + this.domainCache = new LRUCache<string, SafelinkRule | 'ok'>({ 32 + max: 10000, 33 + }) 34 + 35 + this.urlCache = new LRUCache<string, SafelinkRule | 'ok'>({ 36 + max: 25000, 37 + }) 38 + 39 + this.db = db 40 + 41 + this.ozoneAgent = new OzoneAgent( 42 + cfg.safelinkPdsUrl!, 43 + cfg.safelinkAgentIdentifier!, 44 + cfg.safelinkAgentPass!, 45 + ) 46 + } 47 + 48 + public async tryFindRule(link: string): Promise<SafelinkRule | 'ok'> { 49 + let url: string 50 + let domain: string 51 + try { 52 + url = SafelinkClient.normalizeUrl(link) 53 + domain = SafelinkClient.normalizeDomain(link) 54 + } catch (e) { 55 + redirectLogger.error( 56 + {error: e, inputUrl: link}, 57 + 'failed to normalize looked up link', 58 + ) 59 + // fail open 60 + return 'ok' 61 + } 62 + 63 + // First, check if there is an existing URL rule. Note that even if the rule is 'ok', we still 64 + // want to check for a blocking domain rule, so we will only return here if the url rule exists 65 + // _and_ it is not 'ok'. 66 + const urlRule = this.urlCache.get(url) 67 + if (urlRule && urlRule !== 'ok') { 68 + return urlRule 69 + } 70 + 71 + // If we find a domain rule of _any_ kind, including 'ok', we can now return that rule. 72 + const domainRule = this.domainCache.get(domain) 73 + if (domainRule) { 74 + return domainRule 75 + } 76 + 77 + try { 78 + const maybeUrlRule = await this.getRule(this.db, url, 'url') 79 + this.urlCache.set(url, maybeUrlRule) 80 + return maybeUrlRule 81 + } catch (e) { 82 + this.urlCache.set(url, 'ok') 83 + } 84 + 85 + try { 86 + const maybeDomainRule = await this.getRule(this.db, domain, 'domain') 87 + this.domainCache.set(domain, maybeDomainRule) 88 + return maybeDomainRule 89 + } catch (e) { 90 + this.domainCache.set(domain, 'ok') 91 + } 92 + 93 + return 'ok' 94 + } 95 + 96 + private async getRule( 97 + db: Database, 98 + url: string, 99 + pattern: ToolsOzoneSafelinkDefs.PatternType, 100 + ): Promise<SafelinkRule> { 101 + return db.db 102 + .selectFrom('safelink_rule') 103 + .selectAll() 104 + .where('url', '=', url) 105 + .where('pattern', '=', pattern) 106 + .orderBy('createdAt', 'desc') 107 + .executeTakeFirstOrThrow() 108 + } 109 + 110 + private async addRule(db: Database, rule: SafelinkRule) { 111 + try { 112 + if (rule.pattern === 'url') { 113 + rule.url = SafelinkClient.normalizeUrl(rule.url) 114 + } else if (rule.pattern === 'domain') { 115 + rule.url = SafelinkClient.normalizeDomain(rule.url) 116 + } 117 + } catch (e) { 118 + redirectLogger.error( 119 + {error: e, inputUrl: rule.url}, 120 + 'failed to normalize rule input URL', 121 + ) 122 + return 123 + } 124 + 125 + db.db 126 + .insertInto('safelink_rule') 127 + .values({ 128 + id: rule.id, 129 + eventType: rule.eventType, 130 + url: rule.url, 131 + pattern: rule.pattern, 132 + action: rule.action, 133 + createdAt: rule.createdAt, 134 + }) 135 + .execute() 136 + .catch(err => { 137 + redirectLogger.error( 138 + {error: err, rule}, 139 + 'failed to add rule to database', 140 + ) 141 + }) 142 + 143 + if (rule.pattern === 'domain') { 144 + this.domainCache.delete(rule.url) 145 + } else { 146 + this.urlCache.delete(rule.url) 147 + } 148 + } 149 + 150 + private async removeRule(db: Database, rule: SafelinkRule) { 151 + try { 152 + if (rule.pattern === 'url') { 153 + rule.url = SafelinkClient.normalizeUrl(rule.url) 154 + } else if (rule.pattern === 'domain') { 155 + rule.url = SafelinkClient.normalizeDomain(rule.url) 156 + } 157 + } catch (e) { 158 + redirectLogger.error( 159 + {error: e, inputUrl: rule.url}, 160 + 'failed to normalize rule input URL', 161 + ) 162 + return 163 + } 164 + 165 + await db.db 166 + .deleteFrom('safelink_rule') 167 + .where('pattern', '=', 'domain') 168 + .where('url', '=', rule.url) 169 + .execute() 170 + .catch(err => { 171 + redirectLogger.error( 172 + {error: err, rule}, 173 + 'failed to remove rule from database', 174 + ) 175 + }) 176 + 177 + if (rule.pattern === 'domain') { 178 + this.domainCache.delete(rule.url) 179 + } else { 180 + this.urlCache.delete(rule.url) 181 + } 182 + } 183 + 184 + public async runFetchEvents() { 185 + let agent: AtpAgent 186 + try { 187 + agent = await this.ozoneAgent.getAgent() 188 + } catch (err) { 189 + redirectLogger.error({error: err}, 'error getting Ozone agent') 190 + setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) 191 + return 192 + } 193 + 194 + let res: ToolsOzoneSafelinkQueryEvents.Response 195 + try { 196 + const cursor = await this.getCursor() 197 + res = await agent.tools.ozone.safelink.queryEvents({ 198 + cursor, 199 + limit: 100, 200 + sortDirection: 'asc', 201 + }) 202 + } catch (err) { 203 + if (err instanceof ExpiredTokenError) { 204 + redirectLogger.info('ozone agent had expired session, refreshing...') 205 + await this.ozoneAgent.refreshSession() 206 + setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL) 207 + return 208 + } 209 + 210 + redirectLogger.error( 211 + {error: err}, 212 + 'error fetching safelink events from Ozone', 213 + ) 214 + setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) 215 + return 216 + } 217 + 218 + if (res.data.events.length === 0) { 219 + redirectLogger.info('received no new safelink events from ozone') 220 + setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) 221 + } else { 222 + await this.db.transaction(async db => { 223 + for (const rule of res.data.events) { 224 + switch (rule.eventType) { 225 + case 'removeRule': 226 + await this.removeRule(db, rule) 227 + break 228 + case 'addRule': 229 + case 'updateRule': 230 + await this.addRule(db, rule) 231 + break 232 + default: 233 + redirectLogger.warn({rule}, 'received unknown rule event type') 234 + } 235 + } 236 + }) 237 + if (res.data.cursor) { 238 + redirectLogger.info( 239 + {cursor: res.data.cursor}, 240 + 'received new safelink events from Ozone', 241 + ) 242 + await this.setCursor(res.data.cursor) 243 + } 244 + setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL) 245 + } 246 + } 247 + 248 + private async getCursor() { 249 + if (this.cursor === '') { 250 + const res = await this.db.db 251 + .selectFrom('safelink_cursor') 252 + .selectAll() 253 + .where('id', '=', 1) 254 + .executeTakeFirst() 255 + if (!res) { 256 + return '' 257 + } 258 + this.cursor = res.cursor 259 + } 260 + return this.cursor 261 + } 262 + 263 + private async setCursor(cursor: string) { 264 + const updatedAt = new Date() 265 + try { 266 + await this.db.db 267 + .insertInto('safelink_cursor') 268 + .values({ 269 + id: 1, 270 + cursor, 271 + updatedAt, 272 + }) 273 + .onConflict(oc => oc.column('id').doUpdateSet({cursor, updatedAt})) 274 + .execute() 275 + this.cursor = cursor 276 + } catch (err) { 277 + redirectLogger.error({error: err}, 'failed to update safelink cursor') 278 + } 279 + } 280 + 281 + private static normalizeUrl(input: string) { 282 + if (!SCHEME_REGEX.test(input)) { 283 + input = `https://${input}` 284 + } 285 + const u = new URL(input) 286 + u.hash = '' 287 + let normalized = u.href.replace(SCHEME_REGEX, '').toLowerCase() 288 + if (normalized.endsWith('/')) { 289 + normalized = normalized.substring(0, normalized.length - 1) 290 + } 291 + return normalized 292 + } 293 + 294 + private static normalizeDomain(input: string) { 295 + if (!SCHEME_REGEX.test(input)) { 296 + input = `https://${input}` 297 + } 298 + const u = new URL(input) 299 + return u.host.toLowerCase() 300 + } 301 + } 302 + 303 + export class OzoneAgent { 304 + private identifier: string 305 + private password: string 306 + 307 + private session: CredentialSession 308 + private agent: AtpAgent 309 + 310 + private refreshAt = 0 311 + 312 + constructor(pdsHost: string, identifier: string, password: string) { 313 + this.identifier = identifier 314 + this.password = password 315 + 316 + this.session = new CredentialSession(new URL(pdsHost)) 317 + this.agent = new AtpAgent(this.session) 318 + } 319 + 320 + public async getAgent() { 321 + if (!this.identifier && !this.password) { 322 + throw new Error( 323 + 'OZONE_AGENT_HANDLE and OZONE_AGENT_PASS environment variables must be set', 324 + ) 325 + } 326 + 327 + if (!this.session.hasSession) { 328 + redirectLogger.info('creating Ozone session') 329 + await this.session.login({ 330 + identifier: this.identifier, 331 + password: this.password, 332 + }) 333 + redirectLogger.info('ozone session created successfully') 334 + this.refreshAt = Date.now() + 50 * MINUTE 335 + } 336 + 337 + if (Date.now() <= this.refreshAt) { 338 + await this.refreshSession() 339 + } 340 + 341 + return this.agent 342 + } 343 + 344 + public async refreshSession() { 345 + try { 346 + await this.session.refreshSession() 347 + this.refreshAt = Date.now() + 50 * MINUTE 348 + } catch (e) { 349 + redirectLogger.error({error: e}, 'error refreshing session') 350 + } 351 + } 352 + }
+21 -2
bskylink/src/config.ts
··· 1 - import {envInt, envList, envStr} from '@atproto/common' 1 + import {envBool, envInt, envList, envStr} from '@atproto/common' 2 2 3 3 export type Config = { 4 4 service: ServiceConfig ··· 9 9 port: number 10 10 version?: string 11 11 hostnames: string[] 12 + hostnamesSet: Set<string> 12 13 appHostname: string 14 + safelinkEnabled: boolean 15 + safelinkPdsUrl?: string 16 + safelinkAgentIdentifier?: string 17 + safelinkAgentPass?: string 13 18 } 14 19 15 20 export type DbConfig = { ··· 36 41 dbPostgresPoolSize?: number 37 42 dbPostgresPoolMaxUses?: number 38 43 dbPostgresPoolIdleTimeoutMs?: number 44 + safelinkEnabled?: boolean 45 + safelinkPdsUrl?: string 46 + safelinkAgentIdentifier?: string 47 + safelinkAgentPass?: string 39 48 } 40 49 41 50 export const readEnv = (): Environment => { ··· 52 61 dbPostgresPoolIdleTimeoutMs: envInt( 53 62 'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS', 54 63 ), 64 + safelinkEnabled: envBool('LINK_SAFELINK_ENABLED'), 65 + safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'), 66 + safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'), 67 + safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'), 55 68 } 56 69 } 57 70 ··· 60 73 port: env.port ?? 3000, 61 74 version: env.version, 62 75 hostnames: env.hostnames, 63 - appHostname: env.appHostname || 'bsky.app', 76 + hostnamesSet: new Set(env.hostnames), 77 + appHostname: env.appHostname ?? 'bsky.app', 78 + safelinkEnabled: env.safelinkEnabled ?? false, 79 + safelinkPdsUrl: env.safelinkPdsUrl, 80 + safelinkAgentIdentifier: env.safelinkAgentIdentifier, 81 + safelinkAgentPass: env.safelinkAgentPass, 64 82 } 65 83 if (!env.dbPostgresUrl) { 66 84 throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)') ··· 75 93 size: env.dbPostgresPoolSize ?? 10, 76 94 }, 77 95 } 96 + 78 97 return { 79 98 service: serviceCfg, 80 99 db: dbCfg,
+7 -1
bskylink/src/context.ts
··· 1 - import {Config} from './config.js' 1 + import {SafelinkClient} from './cache/safelinkClient.js' 2 + import {type Config} from './config.js' 2 3 import Database from './db/index.js' 3 4 4 5 export type AppContextOptions = { ··· 9 10 export class AppContext { 10 11 cfg: Config 11 12 db: Database 13 + safelinkClient: SafelinkClient 12 14 abortController = new AbortController() 13 15 14 16 constructor(private opts: AppContextOptions) { 15 17 this.cfg = this.opts.cfg 16 18 this.db = this.opts.db 19 + this.safelinkClient = new SafelinkClient({ 20 + cfg: this.opts.cfg.service, 21 + db: this.opts.db, 22 + }) 17 23 } 18 24 19 25 static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
+34
bskylink/src/db/migrations/002-safelink.ts
··· 1 + import {type Kysely, sql} from 'kysely' 2 + 3 + export async function up(db: Kysely<unknown>): Promise<void> { 4 + await db.schema 5 + .createTable('safelink_rule') 6 + .addColumn('id', 'bigserial', col => col.primaryKey()) 7 + .addColumn('eventType', 'varchar', col => col.notNull()) 8 + .addColumn('url', 'varchar', col => col.notNull()) 9 + .addColumn('pattern', 'varchar', col => col.notNull()) 10 + .addColumn('action', 'varchar', col => col.notNull()) 11 + .addColumn('createdAt', 'timestamptz', col => col.notNull()) 12 + .execute() 13 + 14 + await db.schema 15 + .createTable('safelink_cursor') 16 + .addColumn('id', 'bigserial', col => col.notNull()) 17 + .addColumn('cursor', 'varchar', col => col.notNull()) 18 + .addColumn('updatedAt', 'timestamptz', col => col.notNull()) 19 + .execute() 20 + 21 + await db.schema 22 + .createIndex('safelink_rule_url_pattern_created_at_idx') 23 + .on('safelink_rule') 24 + .expression(sql`"url", "pattern", "createdAt" DESC`) 25 + .execute() 26 + } 27 + 28 + export async function down(db: Kysely<unknown>): Promise<void> { 29 + await db.schema 30 + .dropIndex('safelink_rule_url_pattern_created_at_idx') 31 + .execute() 32 + await db.schema.dropTable('safelink_rule').execute() 33 + await db.schema.dropTable('safelink_cursor').execute() 34 + }
+2
bskylink/src/db/migrations/index.ts
··· 1 1 import * as init from './001-init.js' 2 + import * as safelink from './002-safelink.js' 2 3 3 4 export default { 4 5 '001': init, 6 + '002': safelink, 5 7 }
+21 -1
bskylink/src/db/schema.ts
··· 1 - import {Selectable} from 'kysely' 1 + import {type ToolsOzoneSafelinkDefs} from '@atproto/api' 2 + import {type Selectable} from 'kysely' 2 3 3 4 export type DbSchema = { 4 5 link: Link 6 + safelink_rule: SafelinkRule 7 + safelink_cursor: SafelinkCursor 5 8 } 6 9 7 10 export interface Link { ··· 14 17 StarterPack = 1, 15 18 } 16 19 20 + export interface SafelinkRule { 21 + id: number 22 + eventType: ToolsOzoneSafelinkDefs.EventType 23 + url: string 24 + pattern: ToolsOzoneSafelinkDefs.PatternType 25 + action: ToolsOzoneSafelinkDefs.ActionType 26 + createdAt: string 27 + } 28 + 29 + export interface SafelinkCursor { 30 + id: number 31 + cursor: string 32 + updatedAt: Date 33 + } 34 + 17 35 export type LinkEntry = Selectable<Link> 36 + export type SafelinkRuleEntry = Selectable<SafelinkRule> 37 + export type SafelinkCursorEntry = Selectable<SafelinkCursor>
+21
bskylink/src/html/linkRedirectContents.ts
··· 1 + import escapeHTML from 'escape-html' 2 + 3 + export function linkRedirectContents(link: string): string { 4 + return ` 5 + <html> 6 + <head> 7 + <meta http-equiv="refresh" content="0; URL='${escapeHTML(link)}'" /> 8 + <meta 9 + http-equiv="Cache-Control" 10 + content="no-store, no-cache, must-revalidate, max-age=0" /> 11 + <meta http-equiv="Pragma" content="no-cache" /> 12 + <meta http-equiv="Expires" content="0" /> 13 + <style> 14 + :root { 15 + color-scheme: light dark; 16 + } 17 + </style> 18 + </head> 19 + </html> 20 + ` 21 + }
+44
bskylink/src/html/linkWarningContents.ts
··· 1 + import escapeHTML from 'escape-html' 2 + import {type Request} from 'express' 3 + 4 + export function linkWarningContents( 5 + req: Request, 6 + opts: { 7 + type: 'warn' | 'block' 8 + link: string 9 + }, 10 + ): string { 11 + const continueButton = 12 + opts.type === 'warn' 13 + ? `<a class="button secondary" href="${escapeHTML(opts.link)}">${req.__('Continue Anyway')}</a>` 14 + : '' 15 + 16 + return ` 17 + <div class="warning-icon">⚠️</div> 18 + <h1> 19 + ${ 20 + opts.type === 'warn' 21 + ? req.__('Potentially Dangerous Link') 22 + : req.__('Blocked Link') 23 + } 24 + </h1> 25 + <p class="warning-text"> 26 + ${ 27 + opts.type === 'warn' 28 + ? req.__( 29 + 'This link may be malicious. You should proceed at your own risk.', 30 + ) 31 + : req.__( 32 + 'This link has been identified as malicious and has blocked for your safety.', 33 + ) 34 + } 35 + </p> 36 + <div class="blocked-site"> 37 + <p class="site-url">${escapeHTML(opts.link)}</p> 38 + </div> 39 + <div class="button-group"> 40 + ${continueButton} 41 + <a class="button primary" href="https://bsky.app">${req.__('Return to Bluesky')}</a> 42 + </div> 43 + ` 44 + }
+120
bskylink/src/html/linkWarningLayout.ts
··· 1 + import escapeHTML from 'escape-html' 2 + 3 + export function linkWarningLayout( 4 + title: string, 5 + containerContents: string, 6 + ): string { 7 + return ` 8 + <!DOCTYPE html> 9 + <html> 10 + <head> 11 + <meta charset="UTF-8" /> 12 + <meta 13 + http-equiv="Cache-Control" 14 + content="no-store, no-cache, must-revalidate, max-age=0" /> 15 + <meta http-equiv="Pragma" content="no-cache" /> 16 + <meta http-equiv="Expires" content="0" /> 17 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 18 + <title>${escapeHTML(title)}</title> 19 + <style> 20 + * { 21 + margin: 0; 22 + padding: 0; 23 + box-sizing: border-box; 24 + } 25 + body { 26 + font-family: 27 + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, 28 + sans-serif; 29 + background-color: #ffffff; 30 + min-height: 100vh; 31 + display: flex; 32 + align-items: center; 33 + justify-content: center; 34 + padding: 20px; 35 + } 36 + .container { 37 + width: 100%; 38 + max-width: 400px; 39 + text-align: center; 40 + } 41 + .warning-icon { 42 + font-size: 48px; 43 + margin-bottom: 16px; 44 + } 45 + h1 { 46 + font-size: 20px; 47 + font-weight: 600; 48 + margin-bottom: 12px; 49 + color: #000000; 50 + } 51 + .warning-text { 52 + font-size: 15px; 53 + color: #536471; 54 + line-height: 1.4; 55 + margin-bottom: 24px; 56 + padding: 0 20px; 57 + } 58 + .blocked-site { 59 + background-color: #f7f9fa; 60 + border-radius: 12px; 61 + padding: 16px; 62 + margin-bottom: 24px; 63 + text-align: left; 64 + word-break: break-all; 65 + } 66 + .site-name { 67 + font-size: 16px; 68 + font-weight: 500; 69 + color: #000000; 70 + margin-bottom: 4px; 71 + word-break: break-word; 72 + display: block; 73 + text-align: center; 74 + } 75 + .site-url { 76 + font-size: 14px; 77 + color: #536471; 78 + word-break: break-all; 79 + display: block; 80 + text-align: center; 81 + } 82 + .button { 83 + border: none; 84 + border-radius: 24px; 85 + padding: 12px 32px; 86 + font-size: 16px; 87 + font-weight: 600; 88 + cursor: pointer; 89 + width: 100%; 90 + max-width: 280px; 91 + transition: background-color 0.2s; 92 + } 93 + .primary { 94 + background-color: #1d9bf0; 95 + color: white; 96 + } 97 + .secondary { 98 + } 99 + .back-button:hover { 100 + background-color: #1a8cd8; 101 + } 102 + .back-button:active { 103 + background-color: #1681c4; 104 + } 105 + @media (max-width: 480px) { 106 + .warning-text { 107 + padding: 0 10px; 108 + } 109 + .blocked-site { 110 + padding: 8px; 111 + } 112 + } 113 + </style> 114 + </head> 115 + <body> 116 + <div class="container">${containerContents}</div> 117 + </body> 118 + </html> 119 + ` 120 + }
+15
bskylink/src/i18n.ts
··· 1 + import path from 'node:path' 2 + import {fileURLToPath} from 'node:url' 3 + 4 + import i18n from 'i18n' 5 + 6 + const __filename = fileURLToPath(import.meta.url) 7 + const __dirname = path.dirname(__filename) 8 + 9 + i18n.configure({ 10 + locales: ['en', 'es', 'fr'], 11 + defaultLocale: 'en', 12 + directory: path.join(__dirname, '../locales'), 13 + }) 14 + 15 + export default i18n
+2
bskylink/src/index.ts
··· 7 7 8 8 import {type Config} from './config.js' 9 9 import {AppContext} from './context.js' 10 + import i18n from './i18n.js' 10 11 import {default as routes, errorHandler} from './routes/index.js' 11 12 12 13 export * from './config.js' ··· 25 26 static async create(cfg: Config): Promise<LinkService> { 26 27 let app = express() 27 28 app.use(cors()) 29 + app.use(i18n.init) 28 30 29 31 const ctx = await AppContext.fromConfig(cfg) 30 32 app = routes(ctx, app)
+13 -2
bskylink/src/logger.ts
··· 1 1 import {subsystemLogger} from '@atproto/common' 2 + import {type Logger} from 'pino' 2 3 3 - export const httpLogger = subsystemLogger('bskylink') 4 - export const dbLogger = subsystemLogger('bskylink:db') 4 + export const httpLogger: Logger = subsystemLogger('bskylink') 5 + export const dbLogger: Logger = subsystemLogger('bskylink:db') 6 + export const redirectLogger: Logger = subsystemLogger('bskylink:redirect') 7 + 8 + redirectLogger.info = ( 9 + orig => 10 + (...args: any[]) => { 11 + const [msg, ...rest] = args 12 + orig.apply(redirectLogger, [String(msg), ...rest]) 13 + console.log('[bskylink:redirect]', ...args) 14 + } 15 + )(redirectLogger.info) as typeof redirectLogger.info
+8 -5
bskylink/src/routes/createShortLink.ts
··· 1 1 import assert from 'node:assert' 2 2 3 3 import bodyParser from 'body-parser' 4 - import {Express, Request} from 'express' 4 + import {type Express, type Request} from 'express' 5 5 6 - import {AppContext} from '../context.js' 6 + import {type AppContext} from '../context.js' 7 7 import {LinkType} from '../db/schema.js' 8 8 import {randomId} from '../util.js' 9 9 import {handler} from './util.js' ··· 83 83 : `https://${req.headers.host}` 84 84 return `${baseUrl}/${id}` 85 85 } 86 - const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host) 87 - ? `https://${req.headers.host}` 86 + const host = req.headers.host ?? '' 87 + const baseUrl = ctx.cfg.service.hostnamesSet.has(host) 88 + ? `https://${host}` 88 89 : `https://${ctx.cfg.service.hostnames[0]}` 89 90 return `${baseUrl}/${id}` 90 91 } 91 92 92 93 const normalizedPathFromParts = (parts: string[]): string => { 94 + // When given ['path1', 'path2', 'te:fg'], output should be 95 + // /path1/path2/te:fg 93 96 return ( 94 97 '/' + 95 98 parts 96 99 .map(encodeURIComponent) 97 - .map(part => part.replaceAll('%3A', ':')) // preserve colons 100 + .map(part => part.replace(/%3A/g, ':')) // preserve colons 98 101 .join('/') 99 102 ) 100 103 }
+49 -6
bskylink/src/routes/redirect.ts
··· 1 1 import assert from 'node:assert' 2 2 3 3 import {DAY, SECOND} from '@atproto/common' 4 - import escapeHTML from 'escape-html' 5 4 import {type Express} from 'express' 6 5 7 6 import {type AppContext} from '../context.js' 7 + import {linkRedirectContents} from '../html/linkRedirectContents.js' 8 + import {linkWarningContents} from '../html/linkWarningContents.js' 9 + import {linkWarningLayout} from '../html/linkWarningLayout.js' 10 + import {redirectLogger} from '../logger.js' 8 11 import {handler} from './util.js' 9 12 10 13 const INTERNAL_IP_REGEX = new RegExp( ··· 39 42 return res.status(302).end() 40 43 } 41 44 45 + // Default to a max age header 42 46 res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) 47 + res.status(200) 43 48 res.type('html') 44 - res.status(200) 49 + 50 + let html: string | undefined 51 + 52 + if (ctx.cfg.service.safelinkEnabled) { 53 + const rule = await ctx.safelinkClient.tryFindRule(link) 54 + if (rule !== 'ok') { 55 + switch (rule.action) { 56 + case 'whitelist': 57 + redirectLogger.info({rule}, 'Whitelist rule matched') 58 + break 59 + case 'block': 60 + html = linkWarningLayout( 61 + 'Blocked Link Warning', 62 + linkWarningContents(req, { 63 + type: 'block', 64 + link: url.href, 65 + }), 66 + ) 67 + res.setHeader('Cache-Control', 'no-store') 68 + redirectLogger.info({rule}, 'Block rule matched') 69 + break 70 + case 'warn': 71 + html = linkWarningLayout( 72 + 'Malicious Link Warning', 73 + linkWarningContents(req, { 74 + type: 'warn', 75 + link: url.href, 76 + }), 77 + ) 78 + res.setHeader('Cache-Control', 'no-store') 79 + redirectLogger.info({rule}, 'Warn rule matched') 80 + break 81 + default: 82 + redirectLogger.warn({rule}, 'Unknown rule matched') 83 + } 84 + } 85 + } 86 + 87 + // If there is no html defined yet, we will create a redirect html 88 + if (!html) { 89 + html = linkRedirectContents(url.href) 90 + } 45 91 46 - const escaped = escapeHTML(url.href) 47 - return res.send( 48 - `<html><head><meta http-equiv="refresh" content="0; URL='${escaped}'" /><style>:root { color-scheme: light dark; }</style></head></html>`, 49 - ) 92 + return res.end(html) 50 93 }), 51 94 ) 52 95 }
+239 -2
bskylink/tests/index.ts
··· 1 1 import assert from 'node:assert' 2 - import {AddressInfo} from 'node:net' 2 + import {type AddressInfo} from 'node:net' 3 3 import {after, before, describe, it} from 'node:test' 4 + 5 + import {ToolsOzoneSafelinkDefs} from '@atproto/api' 4 6 5 7 import {Database, envToCfg, LinkService, readEnv} from '../src/index.js' 6 8 ··· 15 17 appHostname: 'test.bsky.app', 16 18 dbPostgresSchema: 'link_test', 17 19 dbPostgresUrl: process.env.DB_POSTGRES_URL, 20 + safelinkEnabled: true, 21 + ozoneUrl: 'http://localhost:2583', 22 + ozoneAgentHandle: 'mod-authority.test', 23 + ozoneAgentPass: 'hunter2', 18 24 }) 19 25 const migrateDb = Database.postgres({ 20 26 url: cfg.db.url, ··· 26 32 await linkService.start() 27 33 const {port} = linkService.server?.address() as AddressInfo 28 34 baseUrl = `http://localhost:${port}` 35 + 36 + // Ensure blocklist, whitelist, and safelink rules are set up 37 + const now = new Date().toISOString() 38 + linkService.ctx.cfg.eventCache.smartUpdate({ 39 + $type: 'tools.ozone.safelink.defs#event', 40 + id: 1, 41 + eventType: ToolsOzoneSafelinkDefs.ADDRULE, 42 + url: 'https://en.wikipedia.org/wiki/Fight_Club', 43 + pattern: ToolsOzoneSafelinkDefs.URL, 44 + action: ToolsOzoneSafelinkDefs.WARN, 45 + reason: ToolsOzoneSafelinkDefs.SPAM, 46 + createdBy: 'did:example:admin', 47 + createdAt: now, 48 + comment: 'Do not talk about Fight Club', 49 + }) 50 + linkService.ctx.cfg.eventCache.smartUpdate({ 51 + $type: 'tools.ozone.safelink.defs#event', 52 + id: 2, 53 + eventType: ToolsOzoneSafelinkDefs.ADDRULE, 54 + url: 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a', 55 + pattern: ToolsOzoneSafelinkDefs.URL, 56 + action: ToolsOzoneSafelinkDefs.BLOCK, 57 + reason: ToolsOzoneSafelinkDefs.SPAM, 58 + createdBy: 'did:example:admin', 59 + createdAt: now, 60 + comment: 'All Bs', 61 + }) 62 + linkService.ctx.cfg.eventCache.smartUpdate({ 63 + $type: 'tools.ozone.safelink.defs#event', 64 + id: 3, 65 + eventType: ToolsOzoneSafelinkDefs.ADDRULE, 66 + url: 'https://en.wikipedia.org', 67 + pattern: ToolsOzoneSafelinkDefs.DOMAIN, 68 + action: ToolsOzoneSafelinkDefs.WHITELIST, 69 + reason: ToolsOzoneSafelinkDefs.NONE, 70 + createdBy: 'did:example:admin', 71 + createdAt: now, 72 + comment: 'Whitelisting the knowledge base of the internet', 73 + }) 74 + linkService.ctx.cfg.eventCache.smartUpdate({ 75 + $type: 'tools.ozone.safelink.defs#event', 76 + id: 4, 77 + eventType: ToolsOzoneSafelinkDefs.ADDRULE, 78 + url: 'https://www.instagram.com/teamseshbones/?hl=en', 79 + pattern: ToolsOzoneSafelinkDefs.URL, 80 + action: ToolsOzoneSafelinkDefs.BLOCK, 81 + reason: ToolsOzoneSafelinkDefs.SPAM, 82 + createdBy: 'did:example:admin', 83 + createdAt: now, 84 + comment: 'BONES has been erroneously blocked for the sake of this test', 85 + }) 86 + const later = new Date(Date.now() + 1000).toISOString() 87 + linkService.ctx.cfg.eventCache.smartUpdate({ 88 + $type: 'tools.ozone.safelink.defs#event', 89 + id: 5, 90 + eventType: ToolsOzoneSafelinkDefs.REMOVERULE, 91 + url: 'https://www.instagram.com/teamseshbones/?hl=en', 92 + pattern: ToolsOzoneSafelinkDefs.URL, 93 + action: ToolsOzoneSafelinkDefs.REMOVERULE, 94 + reason: ToolsOzoneSafelinkDefs.NONE, 95 + createdBy: 'did:example:admin', 96 + createdAt: later, 97 + comment: 98 + 'BONES has been resurrected to bring good music to the world once again', 99 + }) 100 + linkService.ctx.cfg.eventCache.smartUpdate({ 101 + $type: 'tools.ozone.safelink.defs#event', 102 + id: 6, 103 + eventType: ToolsOzoneSafelinkDefs.ADDRULE, 104 + url: 'https://www.leagueoflegends.com/en-us/', 105 + pattern: ToolsOzoneSafelinkDefs.URL, 106 + action: ToolsOzoneSafelinkDefs.WARN, 107 + reason: ToolsOzoneSafelinkDefs.SPAM, 108 + createdBy: 'did:example:admin', 109 + createdAt: now, 110 + comment: 111 + 'Could be quite the mistake to get into this addicting game, but we will warn instead of block', 112 + }) 29 113 }) 30 - 31 114 after(async () => { 32 115 await linkService?.destroy() 33 116 }) ··· 76 159 assert.strictEqual(json.message, 'Link not found') 77 160 }) 78 161 162 + it('League of Legends warned', async () => { 163 + const urlToRedirect = 'https://www.leagueoflegends.com/en-us/' 164 + const url = new URL(`${baseUrl}/redirect`) 165 + url.searchParams.set('u', urlToRedirect) 166 + const res = await fetch(url, {redirect: 'manual'}) 167 + assert.strictEqual(res.status, 200) 168 + const html = await res.text() 169 + assert.match( 170 + html, 171 + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 172 + ) 173 + // League of Legends is set to WARN, not BLOCK, so expect a warning (blocked-site div present) 174 + assert.match( 175 + html, 176 + /Warning: Malicious Link/, 177 + 'Expected warning not found in HTML', 178 + ) 179 + }) 180 + 181 + it('Wikipedia whitelisted, url restricted. Redirect safely since wikipedia is whitelisted', async () => { 182 + const urlToRedirect = 'https://en.wikipedia.org/wiki/Fight_Club' 183 + const url = new URL(`${baseUrl}/redirect`) 184 + url.searchParams.set('u', urlToRedirect) 185 + const res = await fetch(url, {redirect: 'manual'}) 186 + assert.strictEqual(res.status, 200) 187 + const html = await res.text() 188 + assert.match(html, /meta http-equiv="refresh"/) 189 + assert.match( 190 + html, 191 + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 192 + ) 193 + // Wikipedia domain is whitelisted, so no blocked-site div should be present 194 + assert.doesNotMatch(html, /"blocked-site"/) 195 + }) 196 + 197 + it('Unsafe redirect with block rule, due to the content of webpage.', async () => { 198 + const urlToRedirect = 199 + 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a' 200 + const url = new URL(`${baseUrl}/redirect`) 201 + url.searchParams.set('u', urlToRedirect) 202 + const res = await fetch(url, {redirect: 'manual'}) 203 + assert.strictEqual(res.status, 200) 204 + const html = await res.text() 205 + assert.match( 206 + html, 207 + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 208 + ) 209 + assert.match( 210 + html, 211 + /"blocked-site"/, 212 + 'Expected blocked-site div not found in HTML', 213 + ) 214 + }) 215 + 216 + it('Rule adjustment, safe redirect, 200 response for Instagram Account of teamsesh Bones', async () => { 217 + // Retrieve the latest event after all updates 218 + const result = linkService.ctx.cfg.eventCache.smartGet( 219 + 'https://www.instagram.com/teamseshbones/?hl=en', 220 + ) 221 + assert(result, 'Expected event not found in eventCache') 222 + assert.strictEqual(result.eventType, ToolsOzoneSafelinkDefs.REMOVERULE) 223 + const urlToRedirect = 'https://www.instagram.com/teamseshbones/?hl=en' 224 + const url = new URL(`${baseUrl}/redirect`) 225 + url.searchParams.set('u', urlToRedirect) 226 + const res = await fetch(url, {redirect: 'manual'}) 227 + assert.strictEqual(res.status, 200) 228 + const html = await res.text() 229 + assert.match(html, /meta http-equiv="refresh"/) 230 + assert.match( 231 + html, 232 + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 233 + ) 234 + }) 235 + 79 236 async function getRedirect(link: string): Promise<[number, string]> { 80 237 const url = new URL(link) 81 238 const base = new URL(baseUrl) ··· 121 278 return payload.url 122 279 } 123 280 }) 281 + 282 + describe('link service no safelink', async () => { 283 + let linkService: LinkService 284 + let baseUrl: string 285 + before(async () => { 286 + const env = readEnv() 287 + const cfg = envToCfg({ 288 + ...env, 289 + hostnames: ['test.bsky.link'], 290 + appHostname: 'test.bsky.app', 291 + dbPostgresSchema: 'link_test', 292 + dbPostgresUrl: process.env.DB_POSTGRES_URL, 293 + safelinkEnabled: false, 294 + ozoneUrl: 'http://localhost:2583', 295 + ozoneAgentHandle: 'mod-authority.test', 296 + ozoneAgentPass: 'hunter2', 297 + }) 298 + const migrateDb = Database.postgres({ 299 + url: cfg.db.url, 300 + schema: cfg.db.schema, 301 + }) 302 + await migrateDb.migrateToLatestOrThrow() 303 + await migrateDb.close() 304 + linkService = await LinkService.create(cfg) 305 + await linkService.start() 306 + const {port} = linkService.server?.address() as AddressInfo 307 + baseUrl = `http://localhost:${port}` 308 + }) 309 + after(async () => { 310 + await linkService?.destroy() 311 + }) 312 + it('Wikipedia whitelisted, url restricted. Safelink is disabled, so redirect is always safe', async () => { 313 + const urlToRedirect = 'https://en.wikipedia.org/wiki/Fight_Club' 314 + const url = new URL(`${baseUrl}/redirect`) 315 + url.searchParams.set('u', urlToRedirect) 316 + const res = await fetch(url, {redirect: 'manual'}) 317 + assert.strictEqual(res.status, 200) 318 + const html = await res.text() 319 + assert.match(html, /meta http-equiv="refresh"/) 320 + assert.match( 321 + html, 322 + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 323 + ) 324 + // No blocked-site div, always safe 325 + assert.doesNotMatch(html, /"blocked-site"/) 326 + }) 327 + 328 + it('Unsafe redirect with block rule, but safelink is disabled so redirect is always safe', async () => { 329 + const urlToRedirect = 330 + 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a' 331 + const url = new URL(`${baseUrl}/redirect`) 332 + url.searchParams.set('u', urlToRedirect) 333 + const res = await fetch(url, {redirect: 'manual'}) 334 + assert.strictEqual(res.status, 200) 335 + const html = await res.text() 336 + assert.match(html, /meta http-equiv="refresh"/) 337 + assert.match( 338 + html, 339 + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 340 + ) 341 + // No blocked-site div, always safe 342 + assert.doesNotMatch(html, /"blocked-site"/) 343 + }) 344 + 345 + it('Rule adjustment, safe redirect, safelink is disabled so always safe', async () => { 346 + const urlToRedirect = 'https://www.instagram.com/teamseshbones/?hl=en' 347 + const url = new URL(`${baseUrl}/redirect`) 348 + url.searchParams.set('u', urlToRedirect) 349 + const res = await fetch(url, {redirect: 'manual'}) 350 + assert.strictEqual(res.status, 200) 351 + const html = await res.text() 352 + assert.match(html, /meta http-equiv="refresh"/) 353 + assert.match( 354 + html, 355 + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 356 + ) 357 + // No blocked-site div, always safe 358 + assert.doesNotMatch(html, /"blocked-site"/) 359 + }) 360 + })
+18 -9
bskylink/tsconfig.json
··· 1 1 { 2 - "compilerOptions": { 3 - "module": "NodeNext", 4 - "esModuleInterop": true, 5 - "moduleResolution": "NodeNext", 6 - "outDir": "dist", 7 - "lib": ["ES2021.String"] 8 - }, 9 - "include": ["./src/index.ts", "./src/bin.ts"] 10 - } 2 + "compilerOptions": { 3 + "target": "ES2020", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "allowSyntheticDefaultImports": true, 7 + "esModuleInterop": true, 8 + "skipLibCheck": true, 9 + "strict": true, 10 + "outDir": "./dist", 11 + "rootDir": "./src", 12 + "declaration": true, 13 + "declarationMap": true, 14 + "sourceMap": true 15 + }, 16 + "include": ["src/**/*"], 17 + "exclude": ["node_modules", "dist"] 18 + } 19 +
+107 -17
bskylink/yarn.lock
··· 2 2 # yarn lockfile v1 3 3 4 4 5 - "@atproto/common-web@^0.3.0": 6 - version "0.3.0" 7 - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.0.tgz#36da8c2c31d8cf8a140c3c8f03223319bf4430bb" 8 - integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA== 5 + "@atproto/common-web@^0.4.2": 6 + version "0.4.2" 7 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.2.tgz#6e3add6939da93d3dfbc8f87e26dc4f57fad7259" 8 + integrity sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw== 9 9 dependencies: 10 10 graphemer "^1.4.0" 11 11 multiformats "^9.9.0" 12 12 uint8arrays "3.0.0" 13 - zod "^3.21.4" 13 + zod "^3.23.8" 14 14 15 - "@atproto/common@^0.4.0": 16 - version "0.4.0" 17 - resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.0.tgz#d77696c7eb545426df727837d9ee333b429fe7ef" 18 - integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g== 15 + "@atproto/common@^0.4.11": 16 + version "0.4.11" 17 + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.11.tgz#9291b7c26f8b3507e280f7ecbdf1695ab5ea62f6" 18 + integrity sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g== 19 19 dependencies: 20 - "@atproto/common-web" "^0.3.0" 20 + "@atproto/common-web" "^0.4.2" 21 21 "@ipld/dag-cbor" "^7.0.3" 22 22 cbor-x "^1.5.1" 23 23 iso-datestring-validator "^2.2.2" 24 24 multiformats "^9.9.0" 25 - pino "^8.15.0" 25 + pino "^8.21.0" 26 26 27 27 "@cbor-extract/cbor-extract-darwin-arm64@2.2.0": 28 28 version "2.2.0" ··· 62 62 cborg "^1.6.0" 63 63 multiformats "^9.5.4" 64 64 65 + "@messageformat/core@^3.0.0": 66 + version "3.4.0" 67 + resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.4.0.tgz#2814c23383dec7bddf535d54f2a03e410165ca9f" 68 + integrity sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw== 69 + dependencies: 70 + "@messageformat/date-skeleton" "^1.0.0" 71 + "@messageformat/number-skeleton" "^1.0.0" 72 + "@messageformat/parser" "^5.1.0" 73 + "@messageformat/runtime" "^3.0.1" 74 + make-plural "^7.0.0" 75 + safe-identifier "^0.4.1" 76 + 77 + "@messageformat/date-skeleton@^1.0.0": 78 + version "1.1.0" 79 + resolved "https://registry.yarnpkg.com/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz#3bad068cbf5873d14592cfc7a73dd4d8615e2739" 80 + integrity sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A== 81 + 82 + "@messageformat/number-skeleton@^1.0.0": 83 + version "1.2.0" 84 + resolved "https://registry.yarnpkg.com/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz#e7c245c41a1b2722bc59dad68f4d454f761bc9b4" 85 + integrity sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg== 86 + 87 + "@messageformat/parser@^5.1.0": 88 + version "5.1.1" 89 + resolved "https://registry.yarnpkg.com/@messageformat/parser/-/parser-5.1.1.tgz#ca7d6c18e9f3f6b6bc984a465dac16da00106055" 90 + integrity sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg== 91 + dependencies: 92 + moo "^0.5.1" 93 + 94 + "@messageformat/runtime@^3.0.1": 95 + version "3.0.1" 96 + resolved "https://registry.yarnpkg.com/@messageformat/runtime/-/runtime-3.0.1.tgz#94d1f6c43265c28ef7aed98ecfcc0968c6c849ac" 97 + integrity sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg== 98 + dependencies: 99 + make-plural "^7.0.0" 100 + 65 101 "@types/cors@^2.8.17": 66 102 version "2.8.17" 67 103 resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" ··· 74 110 resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.4.tgz#dc7c166b76c7b03b27e32f80edf01d91eb5d9af2" 75 111 integrity sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg== 76 112 113 + "@types/i18n@^0.13.12": 114 + version "0.13.12" 115 + resolved "https://registry.yarnpkg.com/@types/i18n/-/i18n-0.13.12.tgz#b457715766c63d8ffdfc51dd4fc1f72728e9d38f" 116 + integrity sha512-iAd2QjKh+0ToBXocmCS3m38GskiaGzmSV1MTQz2GaOraqSqBiLf46J7u3EGINl+st+Uk4lO3OL7QyIjTJlrWIg== 117 + 77 118 "@types/node@*": 78 119 version "20.14.2" 79 120 resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18" ··· 229 270 integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 230 271 dependencies: 231 272 ms "2.0.0" 273 + 274 + debug@^4.3.3: 275 + version "4.4.1" 276 + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" 277 + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== 278 + dependencies: 279 + ms "^2.1.3" 232 280 233 281 define-data-property@^1.1.4: 234 282 version "1.1.4" ··· 446 494 roarr "^7.0.4" 447 495 type-fest "^2.3.3" 448 496 497 + i18n@^0.15.1: 498 + version "0.15.1" 499 + resolved "https://registry.yarnpkg.com/i18n/-/i18n-0.15.1.tgz#68fb8993c461cc440bc2485d82f72019f2b92de8" 500 + integrity sha512-yue187t8MqUPMHdKjiZGrX+L+xcUsDClGO0Cz4loaKUOK9WrGw5pgan4bv130utOwX7fHE9w2iUeHFalVQWkXA== 501 + dependencies: 502 + "@messageformat/core" "^3.0.0" 503 + debug "^4.3.3" 504 + fast-printf "^1.6.9" 505 + make-plural "^7.0.0" 506 + math-interval-parser "^2.0.1" 507 + mustache "^4.2.0" 508 + 449 509 iconv-lite@0.4.24: 450 510 version "0.4.24" 451 511 resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" ··· 478 538 resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.3.tgz#6cc6c757040500b43c4ac596cdbb12be400ee276" 479 539 integrity sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA== 480 540 541 + lru-cache@^11.1.0: 542 + version "11.1.0" 543 + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.1.0.tgz#afafb060607108132dbc1cf8ae661afb69486117" 544 + integrity sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A== 545 + 546 + make-plural@^7.0.0: 547 + version "7.4.0" 548 + resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-7.4.0.tgz#fa6990dd550dea4de6b20163f74e5ed83d8a8d6d" 549 + integrity sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg== 550 + 551 + math-interval-parser@^2.0.1: 552 + version "2.0.1" 553 + resolved "https://registry.yarnpkg.com/math-interval-parser/-/math-interval-parser-2.0.1.tgz#e22cd6d15a0a7f4c03aec560db76513da615bed4" 554 + integrity sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA== 555 + 481 556 media-typer@0.3.0: 482 557 version "0.3.0" 483 558 resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" ··· 510 585 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 511 586 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 512 587 588 + moo@^0.5.1: 589 + version "0.5.2" 590 + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" 591 + integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q== 592 + 513 593 ms@2.0.0: 514 594 version "2.0.0" 515 595 resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 516 596 integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== 517 597 518 - ms@2.1.3: 598 + ms@2.1.3, ms@^2.1.3: 519 599 version "2.1.3" 520 600 resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 521 601 integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== ··· 529 609 version "9.9.0" 530 610 resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" 531 611 integrity sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg== 612 + 613 + mustache@^4.2.0: 614 + version "4.2.0" 615 + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" 616 + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== 532 617 533 618 negotiator@0.6.3: 534 619 version "0.6.3" ··· 690 775 resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" 691 776 integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== 692 777 693 - pino@^8.15.0: 778 + pino@^8.21.0: 694 779 version "8.21.0" 695 780 resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d" 696 781 integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q== ··· 847 932 version "5.2.1" 848 933 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 849 934 integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 935 + 936 + safe-identifier@^0.4.1: 937 + version "0.4.2" 938 + resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb" 939 + integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w== 850 940 851 941 safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3: 852 942 version "2.4.3" ··· 1026 1116 resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" 1027 1117 integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== 1028 1118 1029 - zod@^3.21.4: 1030 - version "3.23.8" 1031 - resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" 1032 - integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== 1119 + zod@^3.23.8: 1120 + version "3.25.76" 1121 + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" 1122 + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==