about summary refs log tree commit diff
path: root/bskylink/src
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/src
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/src')
-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
16 files changed, 728 insertions, 18 deletions
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)
     }),
   )
 }