Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: show lexicon favicons

Hugo 55e57513 209e8ff2

+653 -17
+19
app/components/NsidCode/index.tsx
··· 1 + import { nsidToDomain } from "../../../lib/lexicons/resolver.ts"; 2 + import { InlineCode } from "../CodeBlock/index.tsx"; 3 + import * as s from "./styles.css.ts"; 4 + 5 + export function NsidCode({ children }: { children: string }) { 6 + const domain = nsidToDomain(children); 7 + return ( 8 + <span class={s.wrapper}> 9 + <img 10 + src={`/api/favicon/${domain}`} 11 + alt="" 12 + class={s.favicon} 13 + loading="lazy" 14 + onerror="this.style.display='none'" 15 + /> 16 + <InlineCode>{children}</InlineCode> 17 + </span> 18 + ); 19 + }
+14
app/components/NsidCode/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { space } from "../../styles/tokens/spacing.ts"; 3 + 4 + export const wrapper = style({ 5 + display: "inline-flex", 6 + alignItems: "center", 7 + gap: space[1], 8 + }); 9 + 10 + export const favicon = style({ 11 + inlineSize: "14px", 12 + blockSize: "14px", 13 + flexShrink: 0, 14 + });
+22
app/routes/api/favicon/[domain].ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { getFavicon } from "@/favicon.js"; 3 + 4 + const DOMAIN_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; 5 + const BLOCKED = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]); 6 + 7 + export const GET = createRoute(async (c) => { 8 + const domain = c.req.param("domain")!; 9 + if (!DOMAIN_RE.test(domain)) return c.body(null, 400); 10 + if (BLOCKED.has(domain) || domain.endsWith(".local") || domain.endsWith(".internal")) 11 + return c.body(null, 400); 12 + 13 + const result = await getFavicon(domain); 14 + if (!result) return c.body(null, 404); 15 + 16 + c.header("Content-Type", result.contentType); 17 + c.header("Cache-Control", "public, max-age=604800, immutable"); 18 + c.header("Content-Security-Policy", "default-src 'none'"); 19 + c.header("Content-Disposition", "inline"); 20 + c.header("X-Content-Type-Options", "nosniff"); 21 + return c.body(new Uint8Array(result.data)); 22 + });
+4 -3
app/routes/dashboard/automations/[rkey].tsx
··· 12 12 import { Button } from "../../../components/Button/index.js"; 13 13 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 14 14 import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 15 + import { NsidCode } from "../../../components/NsidCode/index.js"; 15 16 import { Stack } from "../../../components/Layout/Stack/index.js"; 16 17 import ThemeToggle from "../../../islands/ThemeToggle.js"; 17 18 import DeliveryLog from "../../../islands/DeliveryLog.js"; ··· 86 87 <DescriptionList> 87 88 <dt>Lexicon</dt> 88 89 <dd> 89 - <InlineCode>{auto.lexicon}</InlineCode> 90 + <NsidCode>{auto.lexicon}</NsidCode> 90 91 </dd> 91 92 <dt>Operations</dt> 92 93 <dd> ··· 222 223 <> 223 224 <dt>Target Collection</dt> 224 225 <dd> 225 - <InlineCode>{action.targetCollection}</InlineCode> 226 + <NsidCode>{action.targetCollection}</NsidCode> 226 227 </dd> 227 228 <dt>Base Record URI</dt> 228 229 <dd> ··· 237 238 <> 238 239 <dt>Target Collection</dt> 239 240 <dd> 240 - <InlineCode>{action.targetCollection}</InlineCode> 241 + <NsidCode>{action.targetCollection}</NsidCode> 241 242 </dd> 242 243 <dt>Record Template</dt> 243 244 <dd>
+2 -1
app/routes/dashboard/index.tsx
··· 12 12 import { Badge } from "../../components/Badge/index.js"; 13 13 import { Card } from "../../components/Card/index.js"; 14 14 import { InlineCode } from "../../components/CodeBlock/index.js"; 15 + import { NsidCode } from "../../components/NsidCode/index.js"; 15 16 import ThemeToggle from "../../islands/ThemeToggle.js"; 16 17 import { centerTextSm } from "../../styles/utilities.css.js"; 17 18 ··· 64 65 <a href={`/dashboard/automations/${auto.rkey}`}>{auto.name}</a> 65 66 </td> 66 67 <td> 67 - <InlineCode>{auto.lexicon}</InlineCode> 68 + <NsidCode>{auto.lexicon}</NsidCode> 68 69 </td> 69 70 <td> 70 71 {auto.operations.map((op, i) => (
+2 -2
app/routes/index.tsx
··· 10 10 import { Container } from "../components/Layout/Container/index.js"; 11 11 import { Button } from "../components/Button/index.js"; 12 12 import { Table } from "../components/Table/index.js"; 13 - import { InlineCode } from "../components/CodeBlock/index.js"; 13 + import { NsidCode } from "../components/NsidCode/index.js"; 14 14 import ThemeToggle from "../islands/ThemeToggle.js"; 15 15 import * as s from "../styles/pages/landing.css.js"; 16 16 ··· 115 115 <tr key={row.lexicon}> 116 116 <td> 117 117 <a href={`/lexicons/${row.lexicon}`}> 118 - <InlineCode>{row.lexicon}</InlineCode> 118 + <NsidCode>{row.lexicon}</NsidCode> 119 119 </a> 120 120 </td> 121 121 <td>{row.count}</td>
+2 -2
app/routes/lexicons/index.tsx
··· 8 8 import { Container } from "../../components/Layout/Container/index.js"; 9 9 import { PageHeader } from "../../components/Layout/PageHeader/index.js"; 10 10 import { Table } from "../../components/Table/index.js"; 11 - import { InlineCode } from "../../components/CodeBlock/index.js"; 11 + import { NsidCode } from "../../components/NsidCode/index.js"; 12 12 import ThemeToggle from "../../islands/ThemeToggle.js"; 13 13 14 14 export default createRoute(async (c) => { ··· 41 41 <tr key={row.lexicon}> 42 42 <td> 43 43 <a href={`/lexicons/${row.lexicon}`}> 44 - <InlineCode>{row.lexicon}</InlineCode> 44 + <NsidCode>{row.lexicon}</NsidCode> 45 45 </a> 46 46 </td> 47 47 <td>{row.count}</td>
+4 -3
app/routes/u/[handle]/[rkey].tsx
··· 15 15 import { Button } from "../../../components/Button/index.js"; 16 16 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 17 17 import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 18 + import { NsidCode } from "../../../components/NsidCode/index.js"; 18 19 import { Stack } from "../../../components/Layout/Stack/index.js"; 19 20 import ThemeToggle from "../../../islands/ThemeToggle.js"; 20 21 import { ··· 130 131 <dt>Lexicon</dt> 131 132 <dd> 132 133 <a href={`/lexicons/${auto.lexicon}`}> 133 - <InlineCode>{auto.lexicon}</InlineCode> 134 + <NsidCode>{auto.lexicon}</NsidCode> 134 135 </a> 135 136 </dd> 136 137 <dt>Operations</dt> ··· 249 250 <> 250 251 <dt>Target Collection</dt> 251 252 <dd> 252 - <InlineCode>{action.targetCollection}</InlineCode> 253 + <NsidCode>{action.targetCollection}</NsidCode> 253 254 </dd> 254 255 <dt>Base Record URI</dt> 255 256 <dd> ··· 264 265 <> 265 266 <dt>Target Collection</dt> 266 267 <dd> 267 - <InlineCode>{action.targetCollection}</InlineCode> 268 + <NsidCode>{action.targetCollection}</NsidCode> 268 269 </dd> 269 270 <dt>Record Template</dt> 270 271 <dd>
+3 -2
app/routes/u/[handle]/index.tsx
··· 19 19 import { Button } from "../../../components/Button/index.js"; 20 20 import { Table } from "../../../components/Table/index.js"; 21 21 import { InlineCode } from "../../../components/CodeBlock/index.js"; 22 + import { NsidCode } from "../../../components/NsidCode/index.js"; 22 23 import { Stack } from "../../../components/Layout/Stack/index.js"; 23 24 import ThemeToggle from "../../../islands/ThemeToggle.js"; 24 25 import * as s from "../../../styles/pages/profile.css.js"; ··· 152 153 </td> 153 154 <td> 154 155 <a href={`/lexicons/${auto.lexicon}`}> 155 - <InlineCode>{auto.lexicon}</InlineCode> 156 + <NsidCode>{auto.lexicon}</NsidCode> 156 157 </a> 157 158 </td> 158 159 <td> ··· 202 203 <tr key={nsid}> 203 204 <td> 204 205 <a href={`/lexicons/${nsid}`}> 205 - <InlineCode>{nsid}</InlineCode> 206 + <NsidCode>{nsid}</NsidCode> 206 207 </a> 207 208 </td> 208 209 <td>{subCounts.get(nsid) ?? 0}</td>
+1
app/styles/components.css.ts
··· 18 18 import "../components/Table/styles.css.ts"; 19 19 import "../components/DescriptionList/styles.css.ts"; 20 20 import "../components/CodeBlock/styles.css.ts"; 21 + import "../components/NsidCode/styles.css.ts";
+6
lib/db/migrations/0002_minor_skin.sql
··· 1 + CREATE TABLE `favicon_cache` ( 2 + `domain` text PRIMARY KEY NOT NULL, 3 + `data` text NOT NULL, 4 + `content_type` text NOT NULL, 5 + `fetched_at` integer NOT NULL 6 + );
+406
lib/db/migrations/meta/0002_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "96ed2bef-4b39-4079-99e1-e2c0a52b50bb", 5 + "prevId": "360540df-91e2-43c5-87c8-d58fd0e41ec3", 6 + "tables": { 7 + "automations": { 8 + "name": "automations", 9 + "columns": { 10 + "uri": { 11 + "name": "uri", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "rkey": { 25 + "name": "rkey", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "name": { 32 + "name": "name", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "description": { 39 + "name": "description", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "lexicon": { 46 + "name": "lexicon", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + }, 52 + "operation": { 53 + "name": "operation", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": true, 57 + "autoincrement": false, 58 + "default": "'[\"create\"]'" 59 + }, 60 + "actions": { 61 + "name": "actions", 62 + "type": "text", 63 + "primaryKey": false, 64 + "notNull": true, 65 + "autoincrement": false, 66 + "default": "'[]'" 67 + }, 68 + "fetches": { 69 + "name": "fetches", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true, 73 + "autoincrement": false, 74 + "default": "'[]'" 75 + }, 76 + "conditions": { 77 + "name": "conditions", 78 + "type": "text", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false, 82 + "default": "'[]'" 83 + }, 84 + "active": { 85 + "name": "active", 86 + "type": "integer", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false, 90 + "default": false 91 + }, 92 + "dry_run": { 93 + "name": "dry_run", 94 + "type": "integer", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false, 98 + "default": false 99 + }, 100 + "indexed_at": { 101 + "name": "indexed_at", 102 + "type": "integer", 103 + "primaryKey": false, 104 + "notNull": true, 105 + "autoincrement": false 106 + } 107 + }, 108 + "indexes": {}, 109 + "foreignKeys": {}, 110 + "compositePrimaryKeys": {}, 111 + "uniqueConstraints": {}, 112 + "checkConstraints": {} 113 + }, 114 + "delivery_logs": { 115 + "name": "delivery_logs", 116 + "columns": { 117 + "id": { 118 + "name": "id", 119 + "type": "integer", 120 + "primaryKey": true, 121 + "notNull": true, 122 + "autoincrement": true 123 + }, 124 + "automation_uri": { 125 + "name": "automation_uri", 126 + "type": "text", 127 + "primaryKey": false, 128 + "notNull": true, 129 + "autoincrement": false 130 + }, 131 + "action_index": { 132 + "name": "action_index", 133 + "type": "integer", 134 + "primaryKey": false, 135 + "notNull": true, 136 + "autoincrement": false, 137 + "default": 0 138 + }, 139 + "event_time_us": { 140 + "name": "event_time_us", 141 + "type": "integer", 142 + "primaryKey": false, 143 + "notNull": true, 144 + "autoincrement": false 145 + }, 146 + "payload": { 147 + "name": "payload", 148 + "type": "text", 149 + "primaryKey": false, 150 + "notNull": false, 151 + "autoincrement": false 152 + }, 153 + "status_code": { 154 + "name": "status_code", 155 + "type": "integer", 156 + "primaryKey": false, 157 + "notNull": false, 158 + "autoincrement": false 159 + }, 160 + "message": { 161 + "name": "message", 162 + "type": "text", 163 + "primaryKey": false, 164 + "notNull": false, 165 + "autoincrement": false 166 + }, 167 + "error": { 168 + "name": "error", 169 + "type": "text", 170 + "primaryKey": false, 171 + "notNull": false, 172 + "autoincrement": false 173 + }, 174 + "dry_run": { 175 + "name": "dry_run", 176 + "type": "integer", 177 + "primaryKey": false, 178 + "notNull": true, 179 + "autoincrement": false, 180 + "default": false 181 + }, 182 + "attempt": { 183 + "name": "attempt", 184 + "type": "integer", 185 + "primaryKey": false, 186 + "notNull": true, 187 + "autoincrement": false, 188 + "default": 1 189 + }, 190 + "created_at": { 191 + "name": "created_at", 192 + "type": "integer", 193 + "primaryKey": false, 194 + "notNull": true, 195 + "autoincrement": false 196 + } 197 + }, 198 + "indexes": {}, 199 + "foreignKeys": { 200 + "delivery_logs_automation_uri_automations_uri_fk": { 201 + "name": "delivery_logs_automation_uri_automations_uri_fk", 202 + "tableFrom": "delivery_logs", 203 + "tableTo": "automations", 204 + "columnsFrom": [ 205 + "automation_uri" 206 + ], 207 + "columnsTo": [ 208 + "uri" 209 + ], 210 + "onDelete": "cascade", 211 + "onUpdate": "no action" 212 + } 213 + }, 214 + "compositePrimaryKeys": {}, 215 + "uniqueConstraints": {}, 216 + "checkConstraints": {} 217 + }, 218 + "favicon_cache": { 219 + "name": "favicon_cache", 220 + "columns": { 221 + "domain": { 222 + "name": "domain", 223 + "type": "text", 224 + "primaryKey": true, 225 + "notNull": true, 226 + "autoincrement": false 227 + }, 228 + "data": { 229 + "name": "data", 230 + "type": "text", 231 + "primaryKey": false, 232 + "notNull": true, 233 + "autoincrement": false 234 + }, 235 + "content_type": { 236 + "name": "content_type", 237 + "type": "text", 238 + "primaryKey": false, 239 + "notNull": true, 240 + "autoincrement": false 241 + }, 242 + "fetched_at": { 243 + "name": "fetched_at", 244 + "type": "integer", 245 + "primaryKey": false, 246 + "notNull": true, 247 + "autoincrement": false 248 + } 249 + }, 250 + "indexes": {}, 251 + "foreignKeys": {}, 252 + "compositePrimaryKeys": {}, 253 + "uniqueConstraints": {}, 254 + "checkConstraints": {} 255 + }, 256 + "lexicon_cache": { 257 + "name": "lexicon_cache", 258 + "columns": { 259 + "nsid": { 260 + "name": "nsid", 261 + "type": "text", 262 + "primaryKey": true, 263 + "notNull": true, 264 + "autoincrement": false 265 + }, 266 + "schema": { 267 + "name": "schema", 268 + "type": "text", 269 + "primaryKey": false, 270 + "notNull": true, 271 + "autoincrement": false 272 + }, 273 + "fetched_at": { 274 + "name": "fetched_at", 275 + "type": "integer", 276 + "primaryKey": false, 277 + "notNull": true, 278 + "autoincrement": false 279 + } 280 + }, 281 + "indexes": {}, 282 + "foreignKeys": {}, 283 + "compositePrimaryKeys": {}, 284 + "uniqueConstraints": {}, 285 + "checkConstraints": {} 286 + }, 287 + "oauth_sessions": { 288 + "name": "oauth_sessions", 289 + "columns": { 290 + "key": { 291 + "name": "key", 292 + "type": "text", 293 + "primaryKey": true, 294 + "notNull": true, 295 + "autoincrement": false 296 + }, 297 + "value": { 298 + "name": "value", 299 + "type": "text", 300 + "primaryKey": false, 301 + "notNull": true, 302 + "autoincrement": false 303 + }, 304 + "expires_at": { 305 + "name": "expires_at", 306 + "type": "integer", 307 + "primaryKey": false, 308 + "notNull": false, 309 + "autoincrement": false 310 + } 311 + }, 312 + "indexes": {}, 313 + "foreignKeys": {}, 314 + "compositePrimaryKeys": {}, 315 + "uniqueConstraints": {}, 316 + "checkConstraints": {} 317 + }, 318 + "oauth_states": { 319 + "name": "oauth_states", 320 + "columns": { 321 + "key": { 322 + "name": "key", 323 + "type": "text", 324 + "primaryKey": true, 325 + "notNull": true, 326 + "autoincrement": false 327 + }, 328 + "value": { 329 + "name": "value", 330 + "type": "text", 331 + "primaryKey": false, 332 + "notNull": true, 333 + "autoincrement": false 334 + }, 335 + "expires_at": { 336 + "name": "expires_at", 337 + "type": "integer", 338 + "primaryKey": false, 339 + "notNull": false, 340 + "autoincrement": false 341 + } 342 + }, 343 + "indexes": {}, 344 + "foreignKeys": {}, 345 + "compositePrimaryKeys": {}, 346 + "uniqueConstraints": {}, 347 + "checkConstraints": {} 348 + }, 349 + "users": { 350 + "name": "users", 351 + "columns": { 352 + "id": { 353 + "name": "id", 354 + "type": "integer", 355 + "primaryKey": true, 356 + "notNull": true, 357 + "autoincrement": true 358 + }, 359 + "did": { 360 + "name": "did", 361 + "type": "text", 362 + "primaryKey": false, 363 + "notNull": true, 364 + "autoincrement": false 365 + }, 366 + "handle": { 367 + "name": "handle", 368 + "type": "text", 369 + "primaryKey": false, 370 + "notNull": true, 371 + "autoincrement": false 372 + }, 373 + "created_at": { 374 + "name": "created_at", 375 + "type": "integer", 376 + "primaryKey": false, 377 + "notNull": true, 378 + "autoincrement": false 379 + } 380 + }, 381 + "indexes": { 382 + "users_did_unique": { 383 + "name": "users_did_unique", 384 + "columns": [ 385 + "did" 386 + ], 387 + "isUnique": true 388 + } 389 + }, 390 + "foreignKeys": {}, 391 + "compositePrimaryKeys": {}, 392 + "uniqueConstraints": {}, 393 + "checkConstraints": {} 394 + } 395 + }, 396 + "views": {}, 397 + "enums": {}, 398 + "_meta": { 399 + "schemas": {}, 400 + "tables": {}, 401 + "columns": {} 402 + }, 403 + "internal": { 404 + "indexes": {} 405 + } 406 + }
+7
lib/db/migrations/meta/_journal.json
··· 15 15 "when": 1775740206840, 16 16 "tag": "0001_tranquil_timeslip", 17 17 "breakpoints": true 18 + }, 19 + { 20 + "idx": 2, 21 + "version": "6", 22 + "when": 1776156786266, 23 + "tag": "0002_minor_skin", 24 + "breakpoints": true 18 25 } 19 26 ] 20 27 }
+7
lib/db/schema.ts
··· 107 107 schema: text("schema").notNull(), // JSON lexicon definition 108 108 fetchedAt: integer("fetched_at", { mode: "timestamp_ms" }).notNull(), 109 109 }); 110 + 111 + export const faviconCache = sqliteTable("favicon_cache", { 112 + domain: text("domain").primaryKey(), 113 + data: text("data").notNull(), // base64-encoded image 114 + contentType: text("content_type").notNull(), 115 + fetchedAt: integer("fetched_at", { mode: "timestamp_ms" }).notNull(), 116 + });
+139
lib/favicon.ts
··· 1 + import { eq } from "drizzle-orm"; 2 + import { db } from "./db/index.js"; 3 + import { faviconCache } from "./db/schema.js"; 4 + 5 + const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days 6 + const NEGATIVE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day for "no favicon" entries 7 + const FETCH_TIMEOUT = 3000; 8 + const MAX_SIZE = 100 * 1024; // 100 KB 9 + 10 + /** Check whether an IPv4 or IPv6 address is in a private/reserved range. */ 11 + function isPrivateIP(ip: string): boolean { 12 + // IPv4-mapped IPv6 — extract the v4 part 13 + if (ip.startsWith("::ffff:")) return isPrivateIP(ip.slice(7)); 14 + 15 + // IPv4 16 + const v4 = ip.split("."); 17 + if (v4.length === 4) { 18 + const [a, b] = [Number(v4[0]), Number(v4[1])]; 19 + if (a === 10) return true; // 10.0.0.0/8 20 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 21 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 22 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata) 23 + if (a === 127) return true; // 127.0.0.0/8 24 + if (a === 0) return true; // 0.0.0.0/8 25 + if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 (CGN / Tailscale) 26 + if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 (benchmarking) 27 + if (a >= 240) return true; // 240.0.0.0/4 (reserved) 28 + return false; 29 + } 30 + 31 + // IPv6 32 + const normalized = ip.toLowerCase(); 33 + if (normalized === "::1") return true; // loopback 34 + if (normalized === "::") return true; 35 + if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // ULA 36 + if (normalized.startsWith("fe80")) return true; // link-local 37 + return false; 38 + } 39 + 40 + /** 41 + * Resolve a domain (A + AAAA), reject private IPs, return a safe address. 42 + * Resolving once and fetching by IP eliminates DNS TOCTOU / rebinding. 43 + */ 44 + async function resolveSafeIP(domain: string): Promise<string | null> { 45 + try { 46 + const { resolve } = await import("node:dns/promises"); 47 + const [v4, v6] = await Promise.allSettled([resolve(domain, "A"), resolve(domain, "AAAA")]); 48 + const addresses = [ 49 + ...(v4.status === "fulfilled" ? v4.value : []), 50 + ...(v6.status === "fulfilled" ? v6.value : []), 51 + ]; 52 + if (addresses.length === 0) return null; 53 + if (!addresses.every((addr) => !isPrivateIP(addr))) return null; 54 + return addresses[0]!; 55 + } catch { 56 + return null; 57 + } 58 + } 59 + 60 + /** Format an IP for use in a URL (brackets for IPv6). */ 61 + function formatIPForURL(ip: string): string { 62 + return ip.includes(":") ? `[${ip}]` : ip; 63 + } 64 + 65 + async function tryFetch( 66 + ip: string, 67 + domain: string, 68 + path: string, 69 + ): Promise<{ data: Buffer; contentType: string } | null> { 70 + try { 71 + const res = await fetch(`https://${formatIPForURL(ip)}${path}`, { 72 + signal: AbortSignal.timeout(FETCH_TIMEOUT), 73 + redirect: "error", 74 + headers: { Host: domain }, 75 + tls: { serverName: domain }, 76 + }); 77 + if (!res.ok) return null; 78 + const ct = res.headers.get("content-type") ?? ""; 79 + if (!ct.startsWith("image/")) return null; 80 + const buf = Buffer.from(await res.arrayBuffer()); 81 + if (buf.length === 0 || buf.length > MAX_SIZE) return null; 82 + return { data: buf, contentType: ct.split(";")[0]! }; 83 + } catch { 84 + return null; 85 + } 86 + } 87 + 88 + async function fetchFavicon(domain: string): Promise<{ data: Buffer; contentType: string } | null> { 89 + const ip = await resolveSafeIP(domain); 90 + if (!ip) return null; 91 + 92 + return ( 93 + (await tryFetch(ip, domain, "/favicon.ico")) ?? 94 + (await tryFetch(ip, domain, "/favicon.svg")) ?? 95 + (await tryFetch(ip, domain, "/favicon.png")) 96 + ); 97 + } 98 + 99 + /** Empty marker stored for domains with no favicon, to avoid re-fetching. */ 100 + const EMPTY_MARKER = ""; 101 + 102 + export async function getFavicon( 103 + domain: string, 104 + ): Promise<{ data: Buffer; contentType: string } | null> { 105 + const row = await db.query.faviconCache.findFirst({ 106 + where: eq(faviconCache.domain, domain), 107 + }); 108 + 109 + if (row) { 110 + const age = Date.now() - row.fetchedAt.getTime(); 111 + if (row.data === EMPTY_MARKER) { 112 + if (age < NEGATIVE_TTL_MS) return null; 113 + } else if (age < TTL_MS) { 114 + return { data: Buffer.from(row.data, "base64"), contentType: row.contentType }; 115 + } 116 + } 117 + 118 + const result = await fetchFavicon(domain); 119 + const now = new Date(); 120 + 121 + await db 122 + .insert(faviconCache) 123 + .values({ 124 + domain, 125 + data: result?.data.toString("base64") ?? EMPTY_MARKER, 126 + contentType: result?.contentType ?? "", 127 + fetchedAt: now, 128 + }) 129 + .onConflictDoUpdate({ 130 + target: faviconCache.domain, 131 + set: { 132 + data: result?.data.toString("base64") ?? EMPTY_MARKER, 133 + contentType: result?.contentType ?? "", 134 + fetchedAt: now, 135 + }, 136 + }); 137 + 138 + return result; 139 + }
+9
lib/lexicons/resolver.ts
··· 33 33 } 34 34 35 35 /** 36 + * Derive the base domain from an NSID (first two segments, reversed). 37 + * e.g. "sh.tangled.feed.star" -> "tangled.sh" 38 + */ 39 + export function nsidToDomain(nsid: string): string { 40 + const parts = nsid.split("."); 41 + return parts.slice(0, 2).reverse().join("."); 42 + } 43 + 44 + /** 36 45 * Check whether an NSID is allowed by the instance's allowlist/blocklist. 37 46 * Blocklist takes precedence. Supports glob patterns like "app.bsky.*". 38 47 */
+6 -4
lib/security-headers.ts
··· 10 10 c.header("X-Frame-Options", "DENY"); 11 11 c.header("Referrer-Policy", "strict-origin-when-cross-origin"); 12 12 c.header("Strict-Transport-Security", "max-age=63072000; includeSubDomains"); 13 - c.header( 14 - "Content-Security-Policy", 15 - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https:; connect-src 'self'; frame-ancestors 'none'", 16 - ); 13 + if (!c.res.headers.has("Content-Security-Policy")) { 14 + c.header( 15 + "Content-Security-Policy", 16 + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https:; connect-src 'self'; frame-ancestors 'none'", 17 + ); 18 + } 17 19 c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); 18 20 }); 19 21 }