diff options
author | hailey <hailey@blueskyweb.xyz> | 2025-09-02 13:36:20 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-09-02 13:36:20 -0700 |
commit | acdc509630d5182f9f3d224b259e2a46000b1f27 (patch) | |
tree | 92d6b474bad9692e5b054ed8b693bca1cba816ac | |
parent | b2258fb6cbdb5de79a7c7d848347f3f157059aa5 (diff) | |
download | voidsky-acdc509630d5182f9f3d224b259e2a46000b1f27.tar.zst |
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>
-rw-r--r-- | bskylink/locales/en.json | 8 | ||||
-rw-r--r-- | bskylink/locales/es.json | 8 | ||||
-rw-r--r-- | bskylink/locales/fr.json | 8 | ||||
-rw-r--r-- | bskylink/package.json | 5 | ||||
-rw-r--r-- | bskylink/src/bin.ts | 7 | ||||
-rw-r--r-- | bskylink/src/cache/rule.ts | 13 | ||||
-rw-r--r-- | bskylink/src/cache/safelinkClient.ts | 352 | ||||
-rw-r--r-- | bskylink/src/config.ts | 23 | ||||
-rw-r--r-- | bskylink/src/context.ts | 8 | ||||
-rw-r--r-- | bskylink/src/db/migrations/002-safelink.ts | 34 | ||||
-rw-r--r-- | bskylink/src/db/migrations/index.ts | 2 | ||||
-rw-r--r-- | bskylink/src/db/schema.ts | 22 | ||||
-rw-r--r-- | bskylink/src/html/linkRedirectContents.ts | 21 | ||||
-rw-r--r-- | bskylink/src/html/linkWarningContents.ts | 44 | ||||
-rw-r--r-- | bskylink/src/html/linkWarningLayout.ts | 120 | ||||
-rw-r--r-- | bskylink/src/i18n.ts | 15 | ||||
-rw-r--r-- | bskylink/src/index.ts | 2 | ||||
-rw-r--r-- | bskylink/src/logger.ts | 15 | ||||
-rw-r--r-- | bskylink/src/routes/createShortLink.ts | 13 | ||||
-rw-r--r-- | bskylink/src/routes/redirect.ts | 55 | ||||
-rw-r--r-- | bskylink/tests/index.ts | 241 | ||||
-rw-r--r-- | bskylink/tsconfig.json | 27 | ||||
-rw-r--r-- | bskylink/yarn.lock | 124 |
23 files changed, 1120 insertions, 47 deletions
diff --git a/bskylink/locales/en.json b/bskylink/locales/en.json new file mode 100644 index 000000000..b204ad51c --- /dev/null +++ b/bskylink/locales/en.json @@ -0,0 +1,8 @@ +{ + "Potentially Dangerous Link": "Potentially Dangerous Link", + "Blocked Link": "Blocked Link", + "This link may be malicious. You should proceed at your own risk.": "This link may be malicious. You should proceed at your own risk.", + "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.", + "Continue Anyway": "Continue Anyway", + "Return to Bluesky": "Return to Bluesky" +} diff --git a/bskylink/locales/es.json b/bskylink/locales/es.json new file mode 100644 index 000000000..fdf3f1963 --- /dev/null +++ b/bskylink/locales/es.json @@ -0,0 +1,8 @@ +{ + "Potentially Dangerous Link": "Enlace Potencialmente Peligroso", + "Blocked Link": "Enlace Bloqueado", + "This link may be malicious. You should proceed at your own risk.": "Este enlace puede ser malicioso. Debes proceder bajo tu propio riesgo.", + "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.", + "Continue Anyway": "Continuar de Todos Modos", + "Return to Bluesky": "Regresar a Bluesky" +} diff --git a/bskylink/locales/fr.json b/bskylink/locales/fr.json new file mode 100644 index 000000000..111a984ff --- /dev/null +++ b/bskylink/locales/fr.json @@ -0,0 +1,8 @@ +{ + "Potentially Dangerous Link": "Lien potentiellement dangereux", + "Blocked Link": "Lien bloqué", + "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.", + "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é.", + "Continue Anyway": "Continuer quand même", + "Return to Bluesky": "Retourner sur Bluesky" +} diff --git a/bskylink/package.json b/bskylink/package.json index 21fe123ab..ab7dc0325 100644 --- a/bskylink/package.json +++ b/bskylink/package.json @@ -8,20 +8,23 @@ "build": "tsc" }, "dependencies": { - "@atproto/common": "^0.4.0", + "@atproto/common": "^0.4.11", "@types/escape-html": "^1.0.4", "body-parser": "^1.20.2", "cors": "^2.8.5", "escape-html": "^1.0.3", "express": "^4.19.2", "http-terminator": "^3.2.0", + "i18n": "^0.15.1", "kysely": "^0.27.3", + "lru-cache": "^11.1.0", "pg": "^8.12.0", "pino": "^9.2.0", "uint8arrays": "^5.1.0" }, "devDependencies": { "@types/cors": "^2.8.17", + "@types/i18n": "^0.13.12", "@types/pg": "^8.11.6", "typescript": "^5.4.5" } diff --git a/bskylink/src/bin.ts b/bskylink/src/bin.ts index 17f068841..3e0746a98 100644 --- a/bskylink/src/bin.ts +++ b/bskylink/src/bin.ts @@ -1,5 +1,4 @@ import {Database, envToCfg, httpLogger, LinkService, readEnv} from './index.js' - async function main() { const env = readEnv() const cfg = envToCfg(env) @@ -11,7 +10,13 @@ async function main() { await migrateDb.migrateToLatestOrThrow() await migrateDb.close() } + const link = await LinkService.create(cfg) + + if (link.ctx.cfg.service.safelinkEnabled) { + link.ctx.safelinkClient.runFetchEvents() + } + await link.start() httpLogger.info('link service is running') process.on('SIGTERM', async () => { diff --git a/bskylink/src/cache/rule.ts b/bskylink/src/cache/rule.ts new file mode 100644 index 000000000..09c4821f6 --- /dev/null +++ b/bskylink/src/cache/rule.ts @@ -0,0 +1,13 @@ +import {type SafelinkRule} from '../db/schema' + +export const exampleRule: SafelinkRule = { + id: 1, + eventType: 'addRule', + url: 'https://malicious.example.com/phishing', + pattern: 'domain', + action: 'block', + reason: 'phishing', + createdBy: 'did:plc:adminozonetools', + createdAt: '2024-06-01T12:00:00Z', + comment: 'Known phishing domain detected by automated scan.', +} diff --git a/bskylink/src/cache/safelinkClient.ts b/bskylink/src/cache/safelinkClient.ts new file mode 100644 index 000000000..547a9c843 --- /dev/null +++ b/bskylink/src/cache/safelinkClient.ts @@ -0,0 +1,352 @@ +import { + AtpAgent, + CredentialSession, + type ToolsOzoneSafelinkDefs, + type ToolsOzoneSafelinkQueryEvents, +} from '@atproto/api' +import {ExpiredTokenError} from '@atproto/api/dist/client/types/com/atproto/server/confirmEmail.js' +import {MINUTE} from '@atproto/common' +import {LRUCache} from 'lru-cache' + +import {type ServiceConfig} from '../config.js' +import type Database from '../db/index.js' +import {type SafelinkRule} from '../db/schema.js' +import {redirectLogger} from '../logger.js' + +const SAFELINK_MIN_FETCH_INTERVAL = 1_000 +const SAFELINK_MAX_FETCH_INTERVAL = 10_000 +const SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/ + +export class SafelinkClient { + private domainCache: LRUCache<string, SafelinkRule | 'ok'> + private urlCache: LRUCache<string, SafelinkRule | 'ok'> + + private db: Database + + private ozoneAgent: OzoneAgent + + private cursor?: string + + constructor({cfg, db}: {cfg: ServiceConfig; db: Database}) { + this.domainCache = new LRUCache<string, SafelinkRule | 'ok'>({ + max: 10000, + }) + + this.urlCache = new LRUCache<string, SafelinkRule | 'ok'>({ + max: 25000, + }) + + this.db = db + + this.ozoneAgent = new OzoneAgent( + cfg.safelinkPdsUrl!, + cfg.safelinkAgentIdentifier!, + cfg.safelinkAgentPass!, + ) + } + + public async tryFindRule(link: string): Promise<SafelinkRule | 'ok'> { + let url: string + let domain: string + try { + url = SafelinkClient.normalizeUrl(link) + domain = SafelinkClient.normalizeDomain(link) + } catch (e) { + redirectLogger.error( + {error: e, inputUrl: link}, + 'failed to normalize looked up link', + ) + // fail open + return 'ok' + } + + // First, check if there is an existing URL rule. Note that even if the rule is 'ok', we still + // want to check for a blocking domain rule, so we will only return here if the url rule exists + // _and_ it is not 'ok'. + const urlRule = this.urlCache.get(url) + if (urlRule && urlRule !== 'ok') { + return urlRule + } + + // If we find a domain rule of _any_ kind, including 'ok', we can now return that rule. + const domainRule = this.domainCache.get(domain) + if (domainRule) { + return domainRule + } + + try { + const maybeUrlRule = await this.getRule(this.db, url, 'url') + this.urlCache.set(url, maybeUrlRule) + return maybeUrlRule + } catch (e) { + this.urlCache.set(url, 'ok') + } + + try { + const maybeDomainRule = await this.getRule(this.db, domain, 'domain') + this.domainCache.set(domain, maybeDomainRule) + return maybeDomainRule + } catch (e) { + this.domainCache.set(domain, 'ok') + } + + return 'ok' + } + + private async getRule( + db: Database, + url: string, + pattern: ToolsOzoneSafelinkDefs.PatternType, + ): Promise<SafelinkRule> { + return db.db + .selectFrom('safelink_rule') + .selectAll() + .where('url', '=', url) + .where('pattern', '=', pattern) + .orderBy('createdAt', 'desc') + .executeTakeFirstOrThrow() + } + + private async addRule(db: Database, rule: SafelinkRule) { + try { + if (rule.pattern === 'url') { + rule.url = SafelinkClient.normalizeUrl(rule.url) + } else if (rule.pattern === 'domain') { + rule.url = SafelinkClient.normalizeDomain(rule.url) + } + } catch (e) { + redirectLogger.error( + {error: e, inputUrl: rule.url}, + 'failed to normalize rule input URL', + ) + return + } + + db.db + .insertInto('safelink_rule') + .values({ + id: rule.id, + eventType: rule.eventType, + url: rule.url, + pattern: rule.pattern, + action: rule.action, + createdAt: rule.createdAt, + }) + .execute() + .catch(err => { + redirectLogger.error( + {error: err, rule}, + 'failed to add rule to database', + ) + }) + + if (rule.pattern === 'domain') { + this.domainCache.delete(rule.url) + } else { + this.urlCache.delete(rule.url) + } + } + + private async removeRule(db: Database, rule: SafelinkRule) { + try { + if (rule.pattern === 'url') { + rule.url = SafelinkClient.normalizeUrl(rule.url) + } else if (rule.pattern === 'domain') { + rule.url = SafelinkClient.normalizeDomain(rule.url) + } + } catch (e) { + redirectLogger.error( + {error: e, inputUrl: rule.url}, + 'failed to normalize rule input URL', + ) + return + } + + await db.db + .deleteFrom('safelink_rule') + .where('pattern', '=', 'domain') + .where('url', '=', rule.url) + .execute() + .catch(err => { + redirectLogger.error( + {error: err, rule}, + 'failed to remove rule from database', + ) + }) + + if (rule.pattern === 'domain') { + this.domainCache.delete(rule.url) + } else { + this.urlCache.delete(rule.url) + } + } + + public async runFetchEvents() { + let agent: AtpAgent + try { + agent = await this.ozoneAgent.getAgent() + } catch (err) { + redirectLogger.error({error: err}, 'error getting Ozone agent') + setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) + return + } + + let res: ToolsOzoneSafelinkQueryEvents.Response + try { + const cursor = await this.getCursor() + res = await agent.tools.ozone.safelink.queryEvents({ + cursor, + limit: 100, + sortDirection: 'asc', + }) + } catch (err) { + if (err instanceof ExpiredTokenError) { + redirectLogger.info('ozone agent had expired session, refreshing...') + await this.ozoneAgent.refreshSession() + setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL) + return + } + + redirectLogger.error( + {error: err}, + 'error fetching safelink events from Ozone', + ) + setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) + return + } + + if (res.data.events.length === 0) { + redirectLogger.info('received no new safelink events from ozone') + setTimeout(() => this.runFetchEvents(), SAFELINK_MAX_FETCH_INTERVAL) + } else { + await this.db.transaction(async db => { + for (const rule of res.data.events) { + switch (rule.eventType) { + case 'removeRule': + await this.removeRule(db, rule) + break + case 'addRule': + case 'updateRule': + await this.addRule(db, rule) + break + default: + redirectLogger.warn({rule}, 'received unknown rule event type') + } + } + }) + if (res.data.cursor) { + redirectLogger.info( + {cursor: res.data.cursor}, + 'received new safelink events from Ozone', + ) + await this.setCursor(res.data.cursor) + } + setTimeout(() => this.runFetchEvents(), SAFELINK_MIN_FETCH_INTERVAL) + } + } + + private async getCursor() { + if (this.cursor === '') { + const res = await this.db.db + .selectFrom('safelink_cursor') + .selectAll() + .where('id', '=', 1) + .executeTakeFirst() + if (!res) { + return '' + } + this.cursor = res.cursor + } + return this.cursor + } + + private async setCursor(cursor: string) { + const updatedAt = new Date() + try { + await this.db.db + .insertInto('safelink_cursor') + .values({ + id: 1, + cursor, + updatedAt, + }) + .onConflict(oc => oc.column('id').doUpdateSet({cursor, updatedAt})) + .execute() + this.cursor = cursor + } catch (err) { + redirectLogger.error({error: err}, 'failed to update safelink cursor') + } + } + + private static normalizeUrl(input: string) { + if (!SCHEME_REGEX.test(input)) { + input = `https://${input}` + } + const u = new URL(input) + u.hash = '' + let normalized = u.href.replace(SCHEME_REGEX, '').toLowerCase() + if (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1) + } + return normalized + } + + private static normalizeDomain(input: string) { + if (!SCHEME_REGEX.test(input)) { + input = `https://${input}` + } + const u = new URL(input) + return u.host.toLowerCase() + } +} + +export class OzoneAgent { + private identifier: string + private password: string + + private session: CredentialSession + private agent: AtpAgent + + private refreshAt = 0 + + constructor(pdsHost: string, identifier: string, password: string) { + this.identifier = identifier + this.password = password + + this.session = new CredentialSession(new URL(pdsHost)) + this.agent = new AtpAgent(this.session) + } + + public async getAgent() { + if (!this.identifier && !this.password) { + throw new Error( + 'OZONE_AGENT_HANDLE and OZONE_AGENT_PASS environment variables must be set', + ) + } + + if (!this.session.hasSession) { + redirectLogger.info('creating Ozone session') + await this.session.login({ + identifier: this.identifier, + password: this.password, + }) + redirectLogger.info('ozone session created successfully') + this.refreshAt = Date.now() + 50 * MINUTE + } + + if (Date.now() <= this.refreshAt) { + await this.refreshSession() + } + + return this.agent + } + + public async refreshSession() { + try { + await this.session.refreshSession() + this.refreshAt = Date.now() + 50 * MINUTE + } catch (e) { + redirectLogger.error({error: e}, 'error refreshing session') + } + } +} diff --git a/bskylink/src/config.ts b/bskylink/src/config.ts index ce409cccc..795a7210f 100644 --- a/bskylink/src/config.ts +++ b/bskylink/src/config.ts @@ -1,4 +1,4 @@ -import {envInt, envList, envStr} from '@atproto/common' +import {envBool, envInt, envList, envStr} from '@atproto/common' export type Config = { service: ServiceConfig @@ -9,7 +9,12 @@ export type ServiceConfig = { port: number version?: string hostnames: string[] + hostnamesSet: Set<string> appHostname: string + safelinkEnabled: boolean + safelinkPdsUrl?: string + safelinkAgentIdentifier?: string + safelinkAgentPass?: string } export type DbConfig = { @@ -36,6 +41,10 @@ export type Environment = { dbPostgresPoolSize?: number dbPostgresPoolMaxUses?: number dbPostgresPoolIdleTimeoutMs?: number + safelinkEnabled?: boolean + safelinkPdsUrl?: string + safelinkAgentIdentifier?: string + safelinkAgentPass?: string } export const readEnv = (): Environment => { @@ -52,6 +61,10 @@ export const readEnv = (): Environment => { dbPostgresPoolIdleTimeoutMs: envInt( 'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS', ), + safelinkEnabled: envBool('LINK_SAFELINK_ENABLED'), + safelinkPdsUrl: envStr('LINK_SAFELINK_PDS_URL'), + safelinkAgentIdentifier: envStr('LINK_SAFELINK_AGENT_IDENTIFIER'), + safelinkAgentPass: envStr('LINK_SAFELINK_AGENT_PASS'), } } @@ -60,7 +73,12 @@ export const envToCfg = (env: Environment): Config => { port: env.port ?? 3000, version: env.version, hostnames: env.hostnames, - appHostname: env.appHostname || 'bsky.app', + hostnamesSet: new Set(env.hostnames), + appHostname: env.appHostname ?? 'bsky.app', + safelinkEnabled: env.safelinkEnabled ?? false, + safelinkPdsUrl: env.safelinkPdsUrl, + safelinkAgentIdentifier: env.safelinkAgentIdentifier, + safelinkAgentPass: env.safelinkAgentPass, } if (!env.dbPostgresUrl) { throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)') @@ -75,6 +93,7 @@ export const envToCfg = (env: Environment): Config => { size: env.dbPostgresPoolSize ?? 10, }, } + return { service: serviceCfg, db: dbCfg, diff --git a/bskylink/src/context.ts b/bskylink/src/context.ts index 7e6f2f34e..1520513ce 100644 --- a/bskylink/src/context.ts +++ b/bskylink/src/context.ts @@ -1,4 +1,5 @@ -import {Config} from './config.js' +import {SafelinkClient} from './cache/safelinkClient.js' +import {type Config} from './config.js' import Database from './db/index.js' export type AppContextOptions = { @@ -9,11 +10,16 @@ export type AppContextOptions = { export class AppContext { cfg: Config db: Database + safelinkClient: SafelinkClient abortController = new AbortController() constructor(private opts: AppContextOptions) { this.cfg = this.opts.cfg this.db = this.opts.db + this.safelinkClient = new SafelinkClient({ + cfg: this.opts.cfg.service, + db: this.opts.db, + }) } static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) { diff --git a/bskylink/src/db/migrations/002-safelink.ts b/bskylink/src/db/migrations/002-safelink.ts new file mode 100644 index 000000000..723d7b2e7 --- /dev/null +++ b/bskylink/src/db/migrations/002-safelink.ts @@ -0,0 +1,34 @@ +import {type Kysely, sql} from 'kysely' + +export async function up(db: Kysely<unknown>): Promise<void> { + await db.schema + .createTable('safelink_rule') + .addColumn('id', 'bigserial', col => col.primaryKey()) + .addColumn('eventType', 'varchar', col => col.notNull()) + .addColumn('url', 'varchar', col => col.notNull()) + .addColumn('pattern', 'varchar', col => col.notNull()) + .addColumn('action', 'varchar', col => col.notNull()) + .addColumn('createdAt', 'timestamptz', col => col.notNull()) + .execute() + + await db.schema + .createTable('safelink_cursor') + .addColumn('id', 'bigserial', col => col.notNull()) + .addColumn('cursor', 'varchar', col => col.notNull()) + .addColumn('updatedAt', 'timestamptz', col => col.notNull()) + .execute() + + await db.schema + .createIndex('safelink_rule_url_pattern_created_at_idx') + .on('safelink_rule') + .expression(sql`"url", "pattern", "createdAt" DESC`) + .execute() +} + +export async function down(db: Kysely<unknown>): Promise<void> { + await db.schema + .dropIndex('safelink_rule_url_pattern_created_at_idx') + .execute() + await db.schema.dropTable('safelink_rule').execute() + await db.schema.dropTable('safelink_cursor').execute() +} diff --git a/bskylink/src/db/migrations/index.ts b/bskylink/src/db/migrations/index.ts index 05e4de937..1f7385ab1 100644 --- a/bskylink/src/db/migrations/index.ts +++ b/bskylink/src/db/migrations/index.ts @@ -1,5 +1,7 @@ import * as init from './001-init.js' +import * as safelink from './002-safelink.js' export default { '001': init, + '002': safelink, } diff --git a/bskylink/src/db/schema.ts b/bskylink/src/db/schema.ts index 8d97f5800..d13a28038 100644 --- a/bskylink/src/db/schema.ts +++ b/bskylink/src/db/schema.ts @@ -1,7 +1,10 @@ -import {Selectable} from 'kysely' +import {type ToolsOzoneSafelinkDefs} from '@atproto/api' +import {type Selectable} from 'kysely' export type DbSchema = { link: Link + safelink_rule: SafelinkRule + safelink_cursor: SafelinkCursor } export interface Link { @@ -14,4 +17,21 @@ export enum LinkType { StarterPack = 1, } +export interface SafelinkRule { + id: number + eventType: ToolsOzoneSafelinkDefs.EventType + url: string + pattern: ToolsOzoneSafelinkDefs.PatternType + action: ToolsOzoneSafelinkDefs.ActionType + createdAt: string +} + +export interface SafelinkCursor { + id: number + cursor: string + updatedAt: Date +} + export type LinkEntry = Selectable<Link> +export type SafelinkRuleEntry = Selectable<SafelinkRule> +export type SafelinkCursorEntry = Selectable<SafelinkCursor> diff --git a/bskylink/src/html/linkRedirectContents.ts b/bskylink/src/html/linkRedirectContents.ts new file mode 100644 index 000000000..f1bcdbb91 --- /dev/null +++ b/bskylink/src/html/linkRedirectContents.ts @@ -0,0 +1,21 @@ +import escapeHTML from 'escape-html' + +export function linkRedirectContents(link: string): string { + return ` + <html> + <head> + <meta http-equiv="refresh" content="0; URL='${escapeHTML(link)}'" /> + <meta + http-equiv="Cache-Control" + content="no-store, no-cache, must-revalidate, max-age=0" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <style> + :root { + color-scheme: light dark; + } + </style> + </head> + </html> + ` +} diff --git a/bskylink/src/html/linkWarningContents.ts b/bskylink/src/html/linkWarningContents.ts new file mode 100644 index 000000000..31449c3c8 --- /dev/null +++ b/bskylink/src/html/linkWarningContents.ts @@ -0,0 +1,44 @@ +import escapeHTML from 'escape-html' +import {type Request} from 'express' + +export function linkWarningContents( + req: Request, + opts: { + type: 'warn' | 'block' + link: string + }, +): string { + const continueButton = + opts.type === 'warn' + ? `<a class="button secondary" href="${escapeHTML(opts.link)}">${req.__('Continue Anyway')}</a>` + : '' + + return ` + <div class="warning-icon">⚠️</div> + <h1> + ${ + opts.type === 'warn' + ? req.__('Potentially Dangerous Link') + : req.__('Blocked Link') + } + </h1> + <p class="warning-text"> + ${ + opts.type === 'warn' + ? req.__( + 'This link may be malicious. You should proceed at your own risk.', + ) + : req.__( + 'This link has been identified as malicious and has blocked for your safety.', + ) + } + </p> + <div class="blocked-site"> + <p class="site-url">${escapeHTML(opts.link)}</p> + </div> + <div class="button-group"> + ${continueButton} + <a class="button primary" href="https://bsky.app">${req.__('Return to Bluesky')}</a> + </div> + ` +} diff --git a/bskylink/src/html/linkWarningLayout.ts b/bskylink/src/html/linkWarningLayout.ts new file mode 100644 index 000000000..2d5361019 --- /dev/null +++ b/bskylink/src/html/linkWarningLayout.ts @@ -0,0 +1,120 @@ +import escapeHTML from 'escape-html' + +export function linkWarningLayout( + title: string, + containerContents: string, +): string { + return ` + <!DOCTYPE html> + <html> + <head> + <meta charset="UTF-8" /> + <meta + http-equiv="Cache-Control" + content="no-store, no-cache, must-revalidate, max-age=0" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>${escapeHTML(title)}</title> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, + sans-serif; + background-color: #ffffff; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + } + .container { + width: 100%; + max-width: 400px; + text-align: center; + } + .warning-icon { + font-size: 48px; + margin-bottom: 16px; + } + h1 { + font-size: 20px; + font-weight: 600; + margin-bottom: 12px; + color: #000000; + } + .warning-text { + font-size: 15px; + color: #536471; + line-height: 1.4; + margin-bottom: 24px; + padding: 0 20px; + } + .blocked-site { + background-color: #f7f9fa; + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; + text-align: left; + word-break: break-all; + } + .site-name { + font-size: 16px; + font-weight: 500; + color: #000000; + margin-bottom: 4px; + word-break: break-word; + display: block; + text-align: center; + } + .site-url { + font-size: 14px; + color: #536471; + word-break: break-all; + display: block; + text-align: center; + } + .button { + border: none; + border-radius: 24px; + padding: 12px 32px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + width: 100%; + max-width: 280px; + transition: background-color 0.2s; + } + .primary { + background-color: #1d9bf0; + color: white; + } + .secondary { + } + .back-button:hover { + background-color: #1a8cd8; + } + .back-button:active { + background-color: #1681c4; + } + @media (max-width: 480px) { + .warning-text { + padding: 0 10px; + } + .blocked-site { + padding: 8px; + } + } + </style> + </head> + <body> + <div class="container">${containerContents}</div> + </body> + </html> + ` +} diff --git a/bskylink/src/i18n.ts b/bskylink/src/i18n.ts new file mode 100644 index 000000000..8ecac3117 --- /dev/null +++ b/bskylink/src/i18n.ts @@ -0,0 +1,15 @@ +import path from 'node:path' +import {fileURLToPath} from 'node:url' + +import i18n from 'i18n' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +i18n.configure({ + locales: ['en', 'es', 'fr'], + defaultLocale: 'en', + directory: path.join(__dirname, '../locales'), +}) + +export default i18n diff --git a/bskylink/src/index.ts b/bskylink/src/index.ts index 5e7f5444a..c7d52681d 100644 --- a/bskylink/src/index.ts +++ b/bskylink/src/index.ts @@ -7,6 +7,7 @@ import {createHttpTerminator, type HttpTerminator} from 'http-terminator' import {type Config} from './config.js' import {AppContext} from './context.js' +import i18n from './i18n.js' import {default as routes, errorHandler} from './routes/index.js' export * from './config.js' @@ -25,6 +26,7 @@ export class LinkService { static async create(cfg: Config): Promise<LinkService> { let app = express() app.use(cors()) + app.use(i18n.init) const ctx = await AppContext.fromConfig(cfg) app = routes(ctx, app) diff --git a/bskylink/src/logger.ts b/bskylink/src/logger.ts index 25bb590a1..47ec00b22 100644 --- a/bskylink/src/logger.ts +++ b/bskylink/src/logger.ts @@ -1,4 +1,15 @@ import {subsystemLogger} from '@atproto/common' +import {type Logger} from 'pino' -export const httpLogger = subsystemLogger('bskylink') -export const dbLogger = subsystemLogger('bskylink:db') +export const httpLogger: Logger = subsystemLogger('bskylink') +export const dbLogger: Logger = subsystemLogger('bskylink:db') +export const redirectLogger: Logger = subsystemLogger('bskylink:redirect') + +redirectLogger.info = ( + orig => + (...args: any[]) => { + const [msg, ...rest] = args + orig.apply(redirectLogger, [String(msg), ...rest]) + console.log('[bskylink:redirect]', ...args) + } +)(redirectLogger.info) as typeof redirectLogger.info diff --git a/bskylink/src/routes/createShortLink.ts b/bskylink/src/routes/createShortLink.ts index db7c3f809..629119059 100644 --- a/bskylink/src/routes/createShortLink.ts +++ b/bskylink/src/routes/createShortLink.ts @@ -1,9 +1,9 @@ import assert from 'node:assert' import bodyParser from 'body-parser' -import {Express, Request} from 'express' +import {type Express, type Request} from 'express' -import {AppContext} from '../context.js' +import {type AppContext} from '../context.js' import {LinkType} from '../db/schema.js' import {randomId} from '../util.js' import {handler} from './util.js' @@ -83,18 +83,21 @@ const getUrl = (ctx: AppContext, req: Request, id: string) => { : `https://${req.headers.host}` return `${baseUrl}/${id}` } - const baseUrl = ctx.cfg.service.hostnames.includes(req.headers.host) - ? `https://${req.headers.host}` + const host = req.headers.host ?? '' + const baseUrl = ctx.cfg.service.hostnamesSet.has(host) + ? `https://${host}` : `https://${ctx.cfg.service.hostnames[0]}` return `${baseUrl}/${id}` } const normalizedPathFromParts = (parts: string[]): string => { + // When given ['path1', 'path2', 'te:fg'], output should be + // /path1/path2/te:fg return ( '/' + parts .map(encodeURIComponent) - .map(part => part.replaceAll('%3A', ':')) // preserve colons + .map(part => part.replace(/%3A/g, ':')) // preserve colons .join('/') ) } diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts index 7d68e4245..681dc0bb9 100644 --- a/bskylink/src/routes/redirect.ts +++ b/bskylink/src/routes/redirect.ts @@ -1,10 +1,13 @@ import assert from 'node:assert' import {DAY, SECOND} from '@atproto/common' -import escapeHTML from 'escape-html' import {type Express} from 'express' import {type AppContext} from '../context.js' +import {linkRedirectContents} from '../html/linkRedirectContents.js' +import {linkWarningContents} from '../html/linkWarningContents.js' +import {linkWarningLayout} from '../html/linkWarningLayout.js' +import {redirectLogger} from '../logger.js' import {handler} from './util.js' const INTERNAL_IP_REGEX = new RegExp( @@ -39,14 +42,54 @@ export default function (ctx: AppContext, app: Express) { return res.status(302).end() } + // Default to a max age header res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) - res.type('html') res.status(200) + res.type('html') - const escaped = escapeHTML(url.href) - return res.send( - `<html><head><meta http-equiv="refresh" content="0; URL='${escaped}'" /><style>:root { color-scheme: light dark; }</style></head></html>`, - ) + let html: string | undefined + + if (ctx.cfg.service.safelinkEnabled) { + const rule = await ctx.safelinkClient.tryFindRule(link) + if (rule !== 'ok') { + switch (rule.action) { + case 'whitelist': + redirectLogger.info({rule}, 'Whitelist rule matched') + break + case 'block': + html = linkWarningLayout( + 'Blocked Link Warning', + linkWarningContents(req, { + type: 'block', + link: url.href, + }), + ) + res.setHeader('Cache-Control', 'no-store') + redirectLogger.info({rule}, 'Block rule matched') + break + case 'warn': + html = linkWarningLayout( + 'Malicious Link Warning', + linkWarningContents(req, { + type: 'warn', + link: url.href, + }), + ) + res.setHeader('Cache-Control', 'no-store') + redirectLogger.info({rule}, 'Warn rule matched') + break + default: + redirectLogger.warn({rule}, 'Unknown rule matched') + } + } + } + + // If there is no html defined yet, we will create a redirect html + if (!html) { + html = linkRedirectContents(url.href) + } + + return res.end(html) }), ) } diff --git a/bskylink/tests/index.ts b/bskylink/tests/index.ts index c5604c7a1..1b3d06ad1 100644 --- a/bskylink/tests/index.ts +++ b/bskylink/tests/index.ts @@ -1,7 +1,9 @@ import assert from 'node:assert' -import {AddressInfo} from 'node:net' +import {type AddressInfo} from 'node:net' import {after, before, describe, it} from 'node:test' +import {ToolsOzoneSafelinkDefs} from '@atproto/api' + import {Database, envToCfg, LinkService, readEnv} from '../src/index.js' describe('link service', async () => { @@ -15,6 +17,10 @@ describe('link service', async () => { appHostname: 'test.bsky.app', dbPostgresSchema: 'link_test', dbPostgresUrl: process.env.DB_POSTGRES_URL, + safelinkEnabled: true, + ozoneUrl: 'http://localhost:2583', + ozoneAgentHandle: 'mod-authority.test', + ozoneAgentPass: 'hunter2', }) const migrateDb = Database.postgres({ url: cfg.db.url, @@ -26,8 +32,85 @@ describe('link service', async () => { await linkService.start() const {port} = linkService.server?.address() as AddressInfo baseUrl = `http://localhost:${port}` - }) + // Ensure blocklist, whitelist, and safelink rules are set up + const now = new Date().toISOString() + linkService.ctx.cfg.eventCache.smartUpdate({ + $type: 'tools.ozone.safelink.defs#event', + id: 1, + eventType: ToolsOzoneSafelinkDefs.ADDRULE, + url: 'https://en.wikipedia.org/wiki/Fight_Club', + pattern: ToolsOzoneSafelinkDefs.URL, + action: ToolsOzoneSafelinkDefs.WARN, + reason: ToolsOzoneSafelinkDefs.SPAM, + createdBy: 'did:example:admin', + createdAt: now, + comment: 'Do not talk about Fight Club', + }) + linkService.ctx.cfg.eventCache.smartUpdate({ + $type: 'tools.ozone.safelink.defs#event', + id: 2, + eventType: ToolsOzoneSafelinkDefs.ADDRULE, + url: 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a', + pattern: ToolsOzoneSafelinkDefs.URL, + action: ToolsOzoneSafelinkDefs.BLOCK, + reason: ToolsOzoneSafelinkDefs.SPAM, + createdBy: 'did:example:admin', + createdAt: now, + comment: 'All Bs', + }) + linkService.ctx.cfg.eventCache.smartUpdate({ + $type: 'tools.ozone.safelink.defs#event', + id: 3, + eventType: ToolsOzoneSafelinkDefs.ADDRULE, + url: 'https://en.wikipedia.org', + pattern: ToolsOzoneSafelinkDefs.DOMAIN, + action: ToolsOzoneSafelinkDefs.WHITELIST, + reason: ToolsOzoneSafelinkDefs.NONE, + createdBy: 'did:example:admin', + createdAt: now, + comment: 'Whitelisting the knowledge base of the internet', + }) + linkService.ctx.cfg.eventCache.smartUpdate({ + $type: 'tools.ozone.safelink.defs#event', + id: 4, + eventType: ToolsOzoneSafelinkDefs.ADDRULE, + url: 'https://www.instagram.com/teamseshbones/?hl=en', + pattern: ToolsOzoneSafelinkDefs.URL, + action: ToolsOzoneSafelinkDefs.BLOCK, + reason: ToolsOzoneSafelinkDefs.SPAM, + createdBy: 'did:example:admin', + createdAt: now, + comment: 'BONES has been erroneously blocked for the sake of this test', + }) + const later = new Date(Date.now() + 1000).toISOString() + linkService.ctx.cfg.eventCache.smartUpdate({ + $type: 'tools.ozone.safelink.defs#event', + id: 5, + eventType: ToolsOzoneSafelinkDefs.REMOVERULE, + url: 'https://www.instagram.com/teamseshbones/?hl=en', + pattern: ToolsOzoneSafelinkDefs.URL, + action: ToolsOzoneSafelinkDefs.REMOVERULE, + reason: ToolsOzoneSafelinkDefs.NONE, + createdBy: 'did:example:admin', + createdAt: later, + comment: + 'BONES has been resurrected to bring good music to the world once again', + }) + linkService.ctx.cfg.eventCache.smartUpdate({ + $type: 'tools.ozone.safelink.defs#event', + id: 6, + eventType: ToolsOzoneSafelinkDefs.ADDRULE, + url: 'https://www.leagueoflegends.com/en-us/', + pattern: ToolsOzoneSafelinkDefs.URL, + action: ToolsOzoneSafelinkDefs.WARN, + reason: ToolsOzoneSafelinkDefs.SPAM, + createdBy: 'did:example:admin', + createdAt: now, + comment: + 'Could be quite the mistake to get into this addicting game, but we will warn instead of block', + }) + }) after(async () => { await linkService?.destroy() }) @@ -76,6 +159,80 @@ describe('link service', async () => { assert.strictEqual(json.message, 'Link not found') }) + it('League of Legends warned', async () => { + const urlToRedirect = 'https://www.leagueoflegends.com/en-us/' + const url = new URL(`${baseUrl}/redirect`) + url.searchParams.set('u', urlToRedirect) + const res = await fetch(url, {redirect: 'manual'}) + assert.strictEqual(res.status, 200) + const html = await res.text() + assert.match( + html, + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) + // League of Legends is set to WARN, not BLOCK, so expect a warning (blocked-site div present) + assert.match( + html, + /Warning: Malicious Link/, + 'Expected warning not found in HTML', + ) + }) + + it('Wikipedia whitelisted, url restricted. Redirect safely since wikipedia is whitelisted', async () => { + const urlToRedirect = 'https://en.wikipedia.org/wiki/Fight_Club' + const url = new URL(`${baseUrl}/redirect`) + url.searchParams.set('u', urlToRedirect) + const res = await fetch(url, {redirect: 'manual'}) + assert.strictEqual(res.status, 200) + const html = await res.text() + assert.match(html, /meta http-equiv="refresh"/) + assert.match( + html, + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) + // Wikipedia domain is whitelisted, so no blocked-site div should be present + assert.doesNotMatch(html, /"blocked-site"/) + }) + + it('Unsafe redirect with block rule, due to the content of webpage.', async () => { + const urlToRedirect = + 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a' + const url = new URL(`${baseUrl}/redirect`) + url.searchParams.set('u', urlToRedirect) + const res = await fetch(url, {redirect: 'manual'}) + assert.strictEqual(res.status, 200) + const html = await res.text() + assert.match( + html, + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) + assert.match( + html, + /"blocked-site"/, + 'Expected blocked-site div not found in HTML', + ) + }) + + it('Rule adjustment, safe redirect, 200 response for Instagram Account of teamsesh Bones', async () => { + // Retrieve the latest event after all updates + const result = linkService.ctx.cfg.eventCache.smartGet( + 'https://www.instagram.com/teamseshbones/?hl=en', + ) + assert(result, 'Expected event not found in eventCache') + assert.strictEqual(result.eventType, ToolsOzoneSafelinkDefs.REMOVERULE) + const urlToRedirect = 'https://www.instagram.com/teamseshbones/?hl=en' + const url = new URL(`${baseUrl}/redirect`) + url.searchParams.set('u', urlToRedirect) + const res = await fetch(url, {redirect: 'manual'}) + assert.strictEqual(res.status, 200) + const html = await res.text() + assert.match(html, /meta http-equiv="refresh"/) + assert.match( + html, + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) + }) + async function getRedirect(link: string): Promise<[number, string]> { const url = new URL(link) const base = new URL(baseUrl) @@ -121,3 +278,83 @@ describe('link service', async () => { return payload.url } }) + +describe('link service no safelink', async () => { + let linkService: LinkService + let baseUrl: string + before(async () => { + const env = readEnv() + const cfg = envToCfg({ + ...env, + hostnames: ['test.bsky.link'], + appHostname: 'test.bsky.app', + dbPostgresSchema: 'link_test', + dbPostgresUrl: process.env.DB_POSTGRES_URL, + safelinkEnabled: false, + ozoneUrl: 'http://localhost:2583', + ozoneAgentHandle: 'mod-authority.test', + ozoneAgentPass: 'hunter2', + }) + const migrateDb = Database.postgres({ + url: cfg.db.url, + schema: cfg.db.schema, + }) + await migrateDb.migrateToLatestOrThrow() + await migrateDb.close() + linkService = await LinkService.create(cfg) + await linkService.start() + const {port} = linkService.server?.address() as AddressInfo + baseUrl = `http://localhost:${port}` + }) + after(async () => { + await linkService?.destroy() + }) + it('Wikipedia whitelisted, url restricted. Safelink is disabled, so redirect is always safe', async () => { + const urlToRedirect = 'https://en.wikipedia.org/wiki/Fight_Club' + const url = new URL(`${baseUrl}/redirect`) + url.searchParams.set('u', urlToRedirect) + const res = await fetch(url, {redirect: 'manual'}) + assert.strictEqual(res.status, 200) + const html = await res.text() + assert.match(html, /meta http-equiv="refresh"/) + assert.match( + html, + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) + // No blocked-site div, always safe + assert.doesNotMatch(html, /"blocked-site"/) + }) + + it('Unsafe redirect with block rule, but safelink is disabled so redirect is always safe', async () => { + const urlToRedirect = + 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a' + const url = new URL(`${baseUrl}/redirect`) + url.searchParams.set('u', urlToRedirect) + const res = await fetch(url, {redirect: 'manual'}) + assert.strictEqual(res.status, 200) + const html = await res.text() + assert.match(html, /meta http-equiv="refresh"/) + assert.match( + html, + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) + // No blocked-site div, always safe + assert.doesNotMatch(html, /"blocked-site"/) + }) + + it('Rule adjustment, safe redirect, safelink is disabled so always safe', async () => { + const urlToRedirect = 'https://www.instagram.com/teamseshbones/?hl=en' + const url = new URL(`${baseUrl}/redirect`) + url.searchParams.set('u', urlToRedirect) + const res = await fetch(url, {redirect: 'manual'}) + assert.strictEqual(res.status, 200) + const html = await res.text() + assert.match(html, /meta http-equiv="refresh"/) + assert.match( + html, + new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) + // No blocked-site div, always safe + assert.doesNotMatch(html, /"blocked-site"/) + }) +}) diff --git a/bskylink/tsconfig.json b/bskylink/tsconfig.json index 3c382acc4..a13b32033 100644 --- a/bskylink/tsconfig.json +++ b/bskylink/tsconfig.json @@ -1,10 +1,19 @@ { - "compilerOptions": { - "module": "NodeNext", - "esModuleInterop": true, - "moduleResolution": "NodeNext", - "outDir": "dist", - "lib": ["ES2021.String"] - }, - "include": ["./src/index.ts", "./src/bin.ts"] - } + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/bskylink/yarn.lock b/bskylink/yarn.lock index e72fea0b9..e360a5529 100644 --- a/bskylink/yarn.lock +++ b/bskylink/yarn.lock @@ -2,27 +2,27 @@ # yarn lockfile v1 -"@atproto/common-web@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.0.tgz#36da8c2c31d8cf8a140c3c8f03223319bf4430bb" - integrity sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA== +"@atproto/common-web@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.2.tgz#6e3add6939da93d3dfbc8f87e26dc4f57fad7259" + integrity sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw== dependencies: graphemer "^1.4.0" multiformats "^9.9.0" uint8arrays "3.0.0" - zod "^3.21.4" + zod "^3.23.8" -"@atproto/common@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.0.tgz#d77696c7eb545426df727837d9ee333b429fe7ef" - integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g== +"@atproto/common@^0.4.11": + version "0.4.11" + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.11.tgz#9291b7c26f8b3507e280f7ecbdf1695ab5ea62f6" + integrity sha512-Knv0viYXNMfCdIE7jLUiWJKnnMfEwg+vz2epJQi8WOjqtqCFb3W/3Jn72ZiuovIfpdm13MaOiny6w2NErUQC6g== dependencies: - "@atproto/common-web" "^0.3.0" + "@atproto/common-web" "^0.4.2" "@ipld/dag-cbor" "^7.0.3" cbor-x "^1.5.1" iso-datestring-validator "^2.2.2" multiformats "^9.9.0" - pino "^8.15.0" + pino "^8.21.0" "@cbor-extract/cbor-extract-darwin-arm64@2.2.0": version "2.2.0" @@ -62,6 +62,42 @@ cborg "^1.6.0" multiformats "^9.5.4" +"@messageformat/core@^3.0.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.4.0.tgz#2814c23383dec7bddf535d54f2a03e410165ca9f" + integrity sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw== + dependencies: + "@messageformat/date-skeleton" "^1.0.0" + "@messageformat/number-skeleton" "^1.0.0" + "@messageformat/parser" "^5.1.0" + "@messageformat/runtime" "^3.0.1" + make-plural "^7.0.0" + safe-identifier "^0.4.1" + +"@messageformat/date-skeleton@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz#3bad068cbf5873d14592cfc7a73dd4d8615e2739" + integrity sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A== + +"@messageformat/number-skeleton@^1.0.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz#e7c245c41a1b2722bc59dad68f4d454f761bc9b4" + integrity sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg== + +"@messageformat/parser@^5.1.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@messageformat/parser/-/parser-5.1.1.tgz#ca7d6c18e9f3f6b6bc984a465dac16da00106055" + integrity sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg== + dependencies: + moo "^0.5.1" + +"@messageformat/runtime@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@messageformat/runtime/-/runtime-3.0.1.tgz#94d1f6c43265c28ef7aed98ecfcc0968c6c849ac" + integrity sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg== + dependencies: + make-plural "^7.0.0" + "@types/cors@^2.8.17": version "2.8.17" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" @@ -74,6 +110,11 @@ resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-1.0.4.tgz#dc7c166b76c7b03b27e32f80edf01d91eb5d9af2" integrity sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg== +"@types/i18n@^0.13.12": + version "0.13.12" + resolved "https://registry.yarnpkg.com/@types/i18n/-/i18n-0.13.12.tgz#b457715766c63d8ffdfc51dd4fc1f72728e9d38f" + integrity sha512-iAd2QjKh+0ToBXocmCS3m38GskiaGzmSV1MTQz2GaOraqSqBiLf46J7u3EGINl+st+Uk4lO3OL7QyIjTJlrWIg== + "@types/node@*": version "20.14.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.2.tgz#a5f4d2bcb4b6a87bffcaa717718c5a0f208f4a18" @@ -230,6 +271,13 @@ debug@2.6.9: dependencies: ms "2.0.0" +debug@^4.3.3: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -446,6 +494,18 @@ http-terminator@^3.2.0: roarr "^7.0.4" type-fest "^2.3.3" +i18n@^0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/i18n/-/i18n-0.15.1.tgz#68fb8993c461cc440bc2485d82f72019f2b92de8" + integrity sha512-yue187t8MqUPMHdKjiZGrX+L+xcUsDClGO0Cz4loaKUOK9WrGw5pgan4bv130utOwX7fHE9w2iUeHFalVQWkXA== + dependencies: + "@messageformat/core" "^3.0.0" + debug "^4.3.3" + fast-printf "^1.6.9" + make-plural "^7.0.0" + math-interval-parser "^2.0.1" + mustache "^4.2.0" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -478,6 +538,21 @@ kysely@^0.27.3: resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.3.tgz#6cc6c757040500b43c4ac596cdbb12be400ee276" integrity sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA== +lru-cache@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.1.0.tgz#afafb060607108132dbc1cf8ae661afb69486117" + integrity sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A== + +make-plural@^7.0.0: + version "7.4.0" + resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-7.4.0.tgz#fa6990dd550dea4de6b20163f74e5ed83d8a8d6d" + integrity sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg== + +math-interval-parser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/math-interval-parser/-/math-interval-parser-2.0.1.tgz#e22cd6d15a0a7f4c03aec560db76513da615bed4" + integrity sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -510,12 +585,17 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +moo@^0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" + integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== -ms@2.1.3: +ms@2.1.3, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -530,6 +610,11 @@ multiformats@^9.4.2, multiformats@^9.5.4, multiformats@^9.9.0: resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" integrity sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg== +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" @@ -690,7 +775,7 @@ pino-std-serializers@^7.0.0: resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== -pino@^8.15.0: +pino@^8.21.0: version "8.21.0" resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d" integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q== @@ -848,6 +933,11 @@ safe-buffer@5.2.1, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-identifier@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb" + integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w== + safe-stable-stringify@^2.3.1, safe-stable-stringify@^2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886" @@ -1026,7 +1116,7 @@ xtend@^4.0.0: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== -zod@^3.21.4: - version "3.23.8" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" - integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== +zod@^3.23.8: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== |