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 /bskylink/src | |
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>
Diffstat (limited to 'bskylink/src')
-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 |
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) }), ) } |