about summary refs log tree commit diff
path: root/bskylink
diff options
context:
space:
mode:
authorhailey <hailey@blueskyweb.xyz>2025-09-02 13:36:20 -0700
committerGitHub <noreply@github.com>2025-09-02 13:36:20 -0700
commitacdc509630d5182f9f3d224b259e2a46000b1f27 (patch)
tree92d6b474bad9692e5b054ed8b693bca1cba816ac /bskylink
parentb2258fb6cbdb5de79a7c7d848347f3f157059aa5 (diff)
downloadvoidsky-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>
Diffstat (limited to 'bskylink')
-rw-r--r--bskylink/locales/en.json8
-rw-r--r--bskylink/locales/es.json8
-rw-r--r--bskylink/locales/fr.json8
-rw-r--r--bskylink/package.json5
-rw-r--r--bskylink/src/bin.ts7
-rw-r--r--bskylink/src/cache/rule.ts13
-rw-r--r--bskylink/src/cache/safelinkClient.ts352
-rw-r--r--bskylink/src/config.ts23
-rw-r--r--bskylink/src/context.ts8
-rw-r--r--bskylink/src/db/migrations/002-safelink.ts34
-rw-r--r--bskylink/src/db/migrations/index.ts2
-rw-r--r--bskylink/src/db/schema.ts22
-rw-r--r--bskylink/src/html/linkRedirectContents.ts21
-rw-r--r--bskylink/src/html/linkWarningContents.ts44
-rw-r--r--bskylink/src/html/linkWarningLayout.ts120
-rw-r--r--bskylink/src/i18n.ts15
-rw-r--r--bskylink/src/index.ts2
-rw-r--r--bskylink/src/logger.ts15
-rw-r--r--bskylink/src/routes/createShortLink.ts13
-rw-r--r--bskylink/src/routes/redirect.ts55
-rw-r--r--bskylink/tests/index.ts241
-rw-r--r--bskylink/tsconfig.json27
-rw-r--r--bskylink/yarn.lock124
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==