Compare commits

...

24 Commits

Author SHA1 Message Date
Hailey b06abba6b2 Merge branch 'main' into safelink 2025-08-20 09:24:31 -07:00
Hailey fe1fdce3f7 update api 2025-08-17 20:02:50 -07:00
Hailey 6f8c233397 tidy 2025-08-17 18:51:12 -07:00
Hailey 2ce5020814 rm swp 2025-08-17 18:49:03 -07:00
Hailey a36743ecc1 Merge remote-tracking branch 'origin/main' into safelink 2025-08-17 18:46:07 -07:00
Chenyu Huang 65fa002e67 fix linting 2025-08-01 13:43:44 -07:00
Chenyu Huang 96b5c6bf8d setup i18n 2025-07-31 16:10:32 -07:00
Hailey 3db994ac52 move whitelist/remove up 2025-06-24 15:13:07 -07:00
Hailey f796278329 use escapehtml for title 2025-06-24 14:58:08 -07:00
Hailey c81ad8efe4 refactor: use uhtml, cleanup express logic 2025-06-24 14:54:56 -07:00
Hailey a8d62c402d cleanup unnecessary falsy checks 2025-06-24 14:05:16 -07:00
BlueSkiesAndGreenPastures c0c844ed84 fixes 2025-06-23 13:42:45 -05:00
BlueSkiesAndGreenPastures 0b14d1110c Update bskylink/src/routes/redirect.ts
Co-authored-by: hailey <me@haileyok.com>
2025-06-23 13:40:39 -05:00
BlueSkiesAndGreenPastures fb1ee87596 updated placeholder content a bit/ added a warn test which was somehow missed 2025-06-23 12:55:37 -05:00
BlueSkiesAndGreenPastures 70e9ea9387 response code fix 2025-06-23 12:14:53 -05:00
BlueSkiesAndGreenPastures e8f9886d15 fix 2025-06-20 14:07:16 -05:00
BlueSkiesAndGreenPastures 468d32f184 adjusted things for feedback/clarity 2025-06-20 12:30:51 -05:00
BlueSkiesAndGreenPastures bc1573cf47 safelink updates regarding feedback 2025-06-20 12:24:59 -05:00
BlueSkiesAndGreenPastures 086256cd9a refactor of smartUpdateUrl 2025-06-20 11:12:46 -05:00
BlueSkiesAndGreenPastures 2daf20874d major refactor 2025-06-20 09:57:20 -05:00
BlueSkiesAndGreenPastures 0a5d9f0452 testing for safelink 2025-06-20 09:41:37 -05:00
BlueSkiesAndGreenPastures 014c78fa06 Merge branch 'main' into safelink 2025-06-18 17:37:02 -05:00
BlueSkiesAndGreenPastures 3957a6f68e initial self review, updating logging messaging to be more consistent. 2025-06-18 17:22:17 -05:00
will berry 5e8ee142b1 preliminary review ready impl of safelink 2025-06-18 17:16:43 -05:00
20 changed files with 1139 additions and 45 deletions
+8
View File
@@ -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"
}
+8
View File
@@ -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"
}
+8
View File
@@ -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 peut être malveillant. Vous devriez procéder à vos propres risques.",
"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 à Bluesky"
}
+4 -1
View File
@@ -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",
"pg": "^8.12.0",
"pino": "^9.2.0",
"uhtml": "^4.7.1",
"uint8arrays": "^5.1.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/i18n": "^0.13.12",
"@types/pg": "^8.11.6",
"typescript": "^5.4.5"
}
+6 -1
View File
@@ -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 (cfg.service.safelinkEnabled) {
cfg.eventCache.adaptiveFetchAndUpdate()
}
await link.start()
httpLogger.info('link service is running')
process.on('SIGTERM', async () => {
+242
View File
@@ -0,0 +1,242 @@
import {ToolsOzoneSafelinkDefs} from '@atproto/api'
import {type ServiceConfig} from '../config.js'
import {redirectLogger} from '../logger.js'
import {OzoneAgent} from './ozoneAgent.js'
let cacheCursor: string | undefined
export class EventCache {
private rules = new Map<string, ToolsOzoneSafelinkDefs.Event>()
private cfg: ServiceConfig
private pollInterval = 1 * 1000 // start at 1 second
constructor(cfg: ServiceConfig) {
this.cfg = cfg
}
async getConfig(): Promise<ServiceConfig | undefined> {
return this.cfg
}
insert(key: string, evt: ToolsOzoneSafelinkDefs.Event) {
const existing = this.rules.get(key)
if (!existing || new Date(evt.createdAt) > new Date(existing.createdAt)) {
this.rules.set(key, evt)
}
}
smartUpdateDomain(event: ToolsOzoneSafelinkDefs.Event) {
let domain: string
try {
domain = new URL(event.url).hostname
} catch (error) {
redirectLogger.error(
`[EventCache:smartUpdateDomain] Invalid URL: ${event.url}, error: ${error}`,
)
throw new Error(
`[EventCache:smartUpdateDomain] Error parsing domain from URL: ${error}`,
)
}
event.url = domain
try {
redirectLogger.info(
`[EventCache] smartUpdateDomain called for domain: ${domain}, action: ${event.action}`,
)
if (event.action) {
this.insert(domain, event)
redirectLogger.info(
`[EventCache] rule updated or inserted for: ${domain}`,
)
return
}
} catch (error) {
redirectLogger.error(
`[EventCache:smartUpdateDomain] Error updating rule for domain: ${domain}, error: ${error}`,
)
throw new Error(
`[EventCache:smartUpdateDomain] Error processing domain event: ${error}`,
)
}
}
smartUpdateUrl(event: ToolsOzoneSafelinkDefs.Event) {
let url: string
try {
url = new URL(event.url).toString()
} catch (error) {
redirectLogger.error(
`[EventCache:smartUpdateUrl] Invalid URL: ${event.url}, error: ${error}`,
)
throw new Error(`[EventCache:smartUpdateUrl] Error parsing URL: ${error}`)
}
event.url = url
try {
redirectLogger.info(
`[EventCache] smartUpdateUrl called for url: ${url}, action: ${event.action}`,
)
if (event.action) {
this.insert(url, event)
redirectLogger.info(
`[EventCache] rule updated or inserted for url: ${url}`,
)
return
}
} catch (error) {
redirectLogger.error(
`[EventCache:smartUpdateUrl] Error updating rule for url: ${url}, error: ${error}`,
)
throw new Error(
`[EventCache:smartUpdateUrl] Error processing url event: ${error}`,
)
}
}
smartUpdate(event: ToolsOzoneSafelinkDefs.Event) {
if (event.pattern === ToolsOzoneSafelinkDefs.DOMAIN) {
redirectLogger.info(
`[EventCache] smartUpdate called for domain event: ${event.url}, performing ${event.action}`,
)
return this.smartUpdateDomain(event)
}
if (event.pattern === ToolsOzoneSafelinkDefs.URL) {
redirectLogger.info(
`[EventCache] smartUpdate called for url event: ${event.url}`,
)
return this.smartUpdateUrl(event)
}
throw new Error('[EventCache] Unknown event pattern')
}
/**
* Attempts to retrieve an event for the given URL.
* Checks in order: domain, domain+path, then full URL.
*/
smartGet(url: string): ToolsOzoneSafelinkDefs.Event | undefined {
const parsedUrl = new URL(url)
const domain = parsedUrl.hostname
const domainAndPath = domain + parsedUrl.pathname
const byDomain = this.rules.get(domain)
if (byDomain) {
return byDomain
}
const byDomainAndPath = this.rules.get(domainAndPath)
if (byDomainAndPath) {
return byDomainAndPath
}
return this.rules.get(url)
}
delete(event: ToolsOzoneSafelinkDefs.Event) {
this.rules.delete(event.url)
}
get(url: string): ToolsOzoneSafelinkDefs.Event | undefined {
const event = this.rules.get(url)
return event
}
list(): ToolsOzoneSafelinkDefs.Event[] {
return Array.from(this.rules.values())
}
// Adaptive polling: slow down if no new events, speed up if updates found
async adaptiveFetchAndUpdate() {
const prevCursor = cacheCursor
const eventConfig = await this.getConfig()
if (eventConfig === undefined) {
redirectLogger.info(
`[adaptiveFetchAndUpdate] No Configuration found, skipping fetch.`,
)
} else {
await this.fetchAndUpdateEvents(eventConfig)
}
if (cacheCursor === prevCursor) {
this.pollInterval = Math.min(this.pollInterval * 2, 10 * 60 * 1000)
redirectLogger.info(
`[adaptiveFetchAndUpdate] No new events, backing off. Next poll in ${
this.pollInterval / 1000
}s`,
)
} else {
this.pollInterval = 5 * 1000
redirectLogger.info(
`[adaptiveFetchAndUpdate] New events found, resetting poll interval to ${
this.pollInterval / 1000
}s`,
)
}
setTimeout(() => this.adaptiveFetchAndUpdate(), this.pollInterval)
}
// Fetch and update events from the server
async fetchAndUpdateEvents(cfg: ServiceConfig) {
if (!cfg || !cfg.ozoneUrl || !cfg.ozoneAgentHandle || !cfg.ozoneAgentPass) {
console.error(
'[eventCache:fetchAndUpdateEvents] No active config, skipping actions',
)
return
}
redirectLogger.info(
`[eventCache] Fetching events with cursor: ${cacheCursor}`,
)
const ozoneAgent = new OzoneAgent(cfg)
const ozoneSession = await ozoneAgent.getSession?.()
if (!ozoneSession) {
console.error(
'[eventCache:fetchAndUpdateEvents] No active session found.',
)
return
}
const ozoneDid = ozoneSession.did
if (!ozoneDid || ozoneDid === 'did:plc:invalid') {
console.error(
'[eventCache:fetchAndUpdateEvents] Invalid or missing session DID.',
)
return
}
ozoneAgent.agent.setHeader?.('atproto-proxy', `${ozoneDid}#atproto_labeler`)
const res = await ozoneAgent.agent.tools?.ozone?.safelink?.queryEvents?.({
cursor: cacheCursor,
limit: 100,
})
if (res?.data.cursor === cacheCursor) {
redirectLogger.info(
'[eventCache:fetchAndUpdateEvents] No new events to update.',
)
return
}
redirectLogger.info(
`[eventCache:fetchAndUpdateEvents] Received response:`,
{
...res,
data: {
...res?.data,
rules: Array.isArray(res?.data?.events)
? res.data.events.map(event => JSON.stringify(event))
: res?.data?.events,
},
},
)
for (const event of res.data.events) {
this.smartUpdate(event)
}
cacheCursor = res.data?.cursor
redirectLogger.info(
'[eventCache:fetchAndUpdateEvents] Current cache contents:',
)
redirectLogger.info(this.list())
}
}
+49
View File
@@ -0,0 +1,49 @@
import {AtpAgent, CredentialSession} from '@atproto/api'
import {type ServiceConfig} from '../config'
export class OzoneAgent {
public session: CredentialSession
public agent: AtpAgent
private cfg: ServiceConfig
constructor(cfg: ServiceConfig) {
this.cfg = cfg
this.session = new CredentialSession(
new URL(cfg.ozoneUrl || 'http://localhost:2583'),
)
this.agent = new AtpAgent(this.session)
}
public async getSession(): Promise<CredentialSession> {
if (!this.session.hasSession) {
await this.getAgent()
}
return this.session
}
public async getAgent(): Promise<AtpAgent> {
if (!this.cfg.ozoneAgentHandle && !this.cfg.ozoneAgentPass) {
throw new Error(
'OZONE_AGENT_HANDLE and OZONE_AGENT_PASS environment variables must be set',
)
}
const identifier = this.cfg.ozoneAgentHandle || 'did:plc:invalid'
const password = this.cfg.ozoneAgentPass || 'invalid'
if (!this.session.hasSession) {
await this.session.login({identifier, password})
}
try {
await this.agent.com.atproto.server.getSession()
} catch (err) {
if ((err as any).status === 401) {
await this.session.login({identifier, password})
}
}
return this.agent
}
}
+29
View File
@@ -0,0 +1,29 @@
export type RuleEventType = '#addRule' | '#updateRule' | '#removeRule'
export type RulePatternType = '#domain' | '#url'
export type RuleActionType = '#block' | '#warn' | '#whitelist'
export type RuleReasonType = '#csam' | '#spam' | '#phishing' | '#none'
export interface Rule {
id: number
eventType: RuleEventType
url: string
pattern: RulePatternType
action: RuleActionType
reason: RuleReasonType
createdBy: string // DID format
createdAt: string // ISO datetime string
comment?: string
}
// Example Rule object
export const exampleRule: Rule = {
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.',
}
+26 -2
View File
@@ -1,7 +1,11 @@
import {envInt, envList, envStr} from '@atproto/common'
import {envBool, envInt, envList, envStr} from '@atproto/common'
// import { type EventCache, eventCache } from '../cache/cache.js'
import {EventCache} from './cache/cache.js'
export type Config = {
service: ServiceConfig
eventCache: EventCache
db: DbConfig
}
@@ -10,6 +14,10 @@ export type ServiceConfig = {
version?: string
hostnames: string[]
appHostname: string
safelinkEnabled: boolean
ozoneUrl?: string
ozoneAgentHandle?: string
ozoneAgentPass?: string
}
export type DbConfig = {
@@ -36,6 +44,10 @@ export type Environment = {
dbPostgresPoolSize?: number
dbPostgresPoolMaxUses?: number
dbPostgresPoolIdleTimeoutMs?: number
safelinkEnabled?: boolean
ozoneUrl?: string
ozoneAgentHandle?: string
ozoneAgentPass?: string
}
export const readEnv = (): Environment => {
@@ -52,6 +64,10 @@ export const readEnv = (): Environment => {
dbPostgresPoolIdleTimeoutMs: envInt(
'LINK_DB_POSTGRES_POOL_IDLE_TIMEOUT_MS',
),
safelinkEnabled: envBool('SAFELINK_ENABLED'),
ozoneUrl: envStr('OZONE_URL'),
ozoneAgentHandle: envStr('OZONE_AGENT_HANDLE'),
ozoneAgentPass: envStr('OZONE_AGENT_PASS'),
}
}
@@ -60,7 +76,11 @@ export const envToCfg = (env: Environment): Config => {
port: env.port ?? 3000,
version: env.version,
hostnames: env.hostnames,
appHostname: env.appHostname || 'bsky.app',
appHostname: env.appHostname ?? 'bsky.app',
safelinkEnabled: env.safelinkEnabled ?? false,
ozoneUrl: env.ozoneUrl,
ozoneAgentHandle: env.ozoneAgentHandle,
ozoneAgentPass: env.ozoneAgentPass,
}
if (!env.dbPostgresUrl) {
throw new Error('Must configure postgres url (LINK_DB_POSTGRES_URL)')
@@ -75,8 +95,12 @@ export const envToCfg = (env: Environment): Config => {
size: env.dbPostgresPoolSize ?? 10,
},
}
const eventCache = new EventCache(serviceCfg)
return {
service: serviceCfg,
eventCache: eventCache,
db: dbCfg,
}
}
+22
View File
@@ -0,0 +1,22 @@
import escapeHTML from 'escape-html'
import {type Hole, html} from 'uhtml'
export function linkRedirectContents(link: string): Hole {
return html`
<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>
`
}
+42
View File
@@ -0,0 +1,42 @@
import escapeHTML from 'escape-html'
import {type Request} from 'express'
import {type Hole, html} from 'uhtml'
export function linkWarningContents(
req: Request,
opts: {
type: 'warn' | 'block'
link: string
},
): Hole {
return html`
<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">
${opts.type === 'warn'
? html`<a class="button secondary" href="${escapeHTML(opts.link)}"
>${req.__('Continue Anyway')}</a
>`
: null}
<a class="button primary" href="https://bsky.app"
>${req.__('Return to Bluesky')}</a
>
</div>
`
}
+135
View File
@@ -0,0 +1,135 @@
import escapeHTML from 'escape-html'
import {type Hole, html} from 'uhtml'
export function linkWarningLayout(
title: string,
containerContents: Hole,
): Hole {
return html`
<!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>
`
}
+15
View File
@@ -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
+2
View File
@@ -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)
+13 -2
View File
@@ -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
+6 -5
View File
@@ -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,8 +83,9 @@ 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.hostnames.includes(host)
? `https://${host}`
: `https://${ctx.cfg.service.hostnames[0]}`
return `${baseUrl}/${id}`
}
@@ -94,7 +95,7 @@ const normalizedPathFromParts = (parts: string[]): string => {
'/' +
parts
.map(encodeURIComponent)
.map(part => part.replaceAll('%3A', ':')) // preserve colons
.map(part => part.replace(/%3A/g, ':')) // preserve colons
.join('/')
)
}
+62 -6
View File
@@ -1,10 +1,15 @@
import assert from 'node:assert'
import {ToolsOzoneSafelinkDefs} from '@atproto/api'
import {DAY, SECOND} from '@atproto/common'
import escapeHTML from 'escape-html'
import {type Express} from 'express'
import {type Hole} from 'uhtml'
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 +44,65 @@ 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 hole: Hole | undefined
if (ctx.cfg.service.safelinkEnabled) {
const rulePresent: ToolsOzoneSafelinkDefs.Event | undefined =
ctx.cfg.eventCache.smartGet(link)
if (rulePresent) {
switch (rulePresent.action) {
case ToolsOzoneSafelinkDefs.WHITELIST:
redirectLogger.info(
`Whitelist rule matched for ${rulePresent.url}`,
)
break
case ToolsOzoneSafelinkDefs.REMOVERULE:
redirectLogger.info(`Remove rule matched for ${rulePresent.url}`)
break
case ToolsOzoneSafelinkDefs.BLOCK:
hole = linkWarningLayout(
'Blocked Link Warning',
linkWarningContents(req, {
type: 'block',
link: url.href,
}),
)
res.setHeader('Cache-Control', 'no-store')
redirectLogger.info(`Block rule matched for ${rulePresent.url}`)
break
case ToolsOzoneSafelinkDefs.WARN:
hole = linkWarningLayout(
'Malicious Link Warning',
linkWarningContents(req, {
type: 'warn',
link: url.href,
}),
)
res.setHeader('Cache-Control', 'no-store')
redirectLogger.info(`Warn rule matched for ${rulePresent.url}`)
break
default:
redirectLogger.warn(
`${rulePresent.action} rule (an unknown rule) matched for ${rulePresent.url}`,
)
}
} else {
redirectLogger.info(`No rule present for ${rulePresent.url}`)
}
}
// If there is no hole defined yet, we will create a redirect hole
if (!hole) {
hole = linkRedirectContents(url.href)
}
return res.end(String(hole))
}),
)
}
+239 -2
View File
@@ -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"/)
})
})
+18 -9
View File
@@ -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"]
}
+205 -17
View File
@@ -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,47 @@
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"
"@preact/signals-core@^1.8.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@preact/signals-core/-/signals-core-1.10.0.tgz#765eb7045998b98c437e1ad1a660e5ff96a40136"
integrity sha512-qlKeXlfqtlC+sjxCPHt6Sk0/dXBrKZVcPlianqjNc/vW263YBFiP5mRrgKpHoO0q222Thm1TdYQWfCKpbbgvwA==
"@types/cors@^2.8.17":
version "2.8.17"
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b"
@@ -74,6 +115,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"
@@ -90,6 +136,18 @@
pg-protocol "*"
pg-types "^4.0.1"
"@webreflection/signal@^2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@webreflection/signal/-/signal-2.1.2.tgz#8adcf99b33f7e8ddfade4742b171c1743dc930d2"
integrity sha512-0dW0fstQQkIt588JwhDiPS4xgeeQcQnBHn6MVInrBzmFlnLtzoSJL9G7JqdAlZVVi19tfb8R1QisZIT31cgiug==
"@webreflection/uparser@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@webreflection/uparser/-/uparser-0.4.0.tgz#49c105455e9482f9a7398f96eb398e421e440f26"
integrity sha512-kAFWUEw5eool295y01VDr+DOsyog6lURX9l288JCJAD2gxc0tFk34dYaAi6O3BbJyfSoncVEV+nw87bsssdppQ==
dependencies:
domconstants "^1.1.6"
abort-controller@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
@@ -223,6 +281,11 @@ cors@^2.8.5:
object-assign "^4"
vary "^1"
custom-function@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/custom-function/-/custom-function-2.0.0.tgz#e421ce1712fa5f8e240a518080a4c82ae97e8606"
integrity sha512-2OPHkZzq3mK1nWpJqWWkGD6Z+0AajNeIxmXl+MRVL8Vysjjf5tf9B5mo713/X2khEwBn/3BKQ7NphpP1vpVKug==
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -230,6 +293,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"
@@ -259,6 +329,41 @@ detect-libc@^2.0.1:
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700"
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.2"
entities "^4.2.0"
domconstants@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/domconstants/-/domconstants-1.1.6.tgz#c6dd4e181a3ddf641a3d2f55ec79569a409d82d4"
integrity sha512-CuaDrThJ4VM+LyZ4ax8n52k0KbLJZtffyGkuj1WhpTRRcSfcy/9DfOBa68jenhX96oNUTunblSJEUNC4baFdmQ==
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^3.1.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -269,6 +374,11 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
entities@^4.2.0, entities@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
es-define-property@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845"
@@ -378,6 +488,11 @@ function-bind@^1.1.2:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
gc-hook@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/gc-hook/-/gc-hook-0.4.1.tgz#61e0ef4c5c2a13ae6f938cc2b0b9a71a94a12e68"
integrity sha512-uiF+uUftDVLr+VRdudsdsT3/LQYnv2ntwhRH964O7xXDI57Smrek5olv75Wb8Nnz6U+7iVTRXsBlxKcsaDTJTQ==
get-intrinsic@^1.1.3, get-intrinsic@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd"
@@ -425,6 +540,21 @@ hasown@^2.0.0:
dependencies:
function-bind "^1.1.2"
html-escaper@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-3.0.3.tgz#4d336674652beb1dcbc29ef6b6ba7f6be6fdfed6"
integrity sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==
htmlparser2@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23"
integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.1.0"
entities "^4.5.0"
http-errors@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
@@ -446,6 +576,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 +620,16 @@ kysely@^0.27.3:
resolved "https://registry.yarnpkg.com/kysely/-/kysely-0.27.3.tgz#6cc6c757040500b43c4ac596cdbb12be400ee276"
integrity sha512-lG03Ru+XyOJFsjH3OMY6R/9U38IjDPfnOfDgO3ynhbDr+Dz8fak+X6L62vqu3iybQnj+lG84OttBuU9KY3L9kA==
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 +662,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 +687,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 +852,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 +1010,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"
@@ -987,6 +1154,27 @@ typescript@^5.4.5:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611"
integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==
udomdiff@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/udomdiff/-/udomdiff-1.1.2.tgz#2979769943afddfb1e6f40e8bda41e431bdcd813"
integrity sha512-v+Z8Jal+GtmKGtJ34GIQlCJAxrDt9kbjpNsNvYoAXFyr4gNfWlD4uJJuoNNu/0UTVaKvQwHaSU095YDl71lKPw==
uhtml@^4.7.1:
version "4.7.1"
resolved "https://registry.yarnpkg.com/uhtml/-/uhtml-4.7.1.tgz#5a0e9c08aefb08c8e9d59e208d7d52f1646561e3"
integrity sha512-2Nv8m2WTVBAmep42aYDnMDTRf87yHRWFSif9uEqkCB1fgX85q7rxTXkP6PNINCNUs9/KmQJ+RBgSH1BNZmxEtg==
dependencies:
"@webreflection/uparser" "^0.4.0"
custom-function "^2.0.0"
domconstants "^1.1.6"
gc-hook "^0.4.1"
html-escaper "^3.0.3"
htmlparser2 "^9.1.0"
udomdiff "^1.1.2"
optionalDependencies:
"@preact/signals-core" "^1.8.0"
"@webreflection/signal" "^2.1.2"
uint8arrays@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-3.0.0.tgz#260869efb8422418b6f04e3fac73a3908175c63b"
@@ -1026,7 +1214,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==