this repo has no description
10
fork

Configure Feed

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

feat(registry): developer-facing SVG icon per profile

Adds an optional vector icon field on registry profiles, exposed only
via the public read API for developers building badges, app showcases,
and programmatic listings. Not rendered on the public Explore profile.

Backend
- lexicon: new optional `icon` blob (image/svg+xml, 200KB max)
- DB: additive icon_cid/icon_mime columns; existing deployments
pick them up via ALTER TABLE — no wipe required
- validator + registry queries + jetstream indexer all extended in
lockstep with the existing avatar plumbing

Sanitisation + serve path
- lib/svg-sanitize.ts strips <script>, <style>, <foreignObject>,
on*= handlers, comments/PIs/DOCTYPEs, and href/xlink:href values
pointing at javascript: or non-image data: URLs
- new GET /api/registry/icon/:did proxies the bytes with
Content-Type: image/svg+xml, X-Content-Type-Options: nosniff, and
a strict Content-Security-Policy so any script that survived the
sanitiser is neutralised at render time
- GET /api/registry/profile/:id now includes an `iconUrl` next to
`avatarUrl`

UI
- "Developer icon (SVG, optional)" section on the manage form with
a 64px preview slot, mime + size validation client-side, and the
same keep/upload/remove pattern as the main avatar
- explainer text makes it clear the icon is dev-only and not shown
on the public profile

Docs
- DeveloperResources endpoint reference lists the new
/api/registry/icon/:did endpoint
- i18n strings under forms.profile.icon and the API docs

OAuth scope is unchanged — image/svg+xml is already covered by the
existing blob:image/* permission set.

Made-with: Cursor

+534 -75
+49 -7
assets/styles.css
··· 2866 2866 } 2867 2867 } 2868 2868 2869 + /* ---- Developer SVG icon slot (separate from main avatar) -------------- */ 2870 + /* Smaller and squarer than the main avatar — this is a developer asset, 2871 + not the public face of the project, so it shouldn't compete visually 2872 + with the avatar block above. */ 2873 + .profile-form-icon-row { 2874 + display: flex; 2875 + align-items: center; 2876 + gap: 0.85rem; 2877 + flex-wrap: wrap; 2878 + } 2879 + .profile-form-icon-preview { 2880 + width: 64px; 2881 + height: 64px; 2882 + border-radius: 12px; 2883 + background: rgba(255, 255, 255, 0.5); 2884 + border: 1px solid rgba(18, 26, 47, 0.08); 2885 + display: flex; 2886 + align-items: center; 2887 + justify-content: center; 2888 + flex: 0 0 auto; 2889 + } 2890 + .profile-form-icon-preview-img { 2891 + width: 100%; 2892 + height: 100%; 2893 + object-fit: contain; 2894 + padding: 6px; 2895 + } 2896 + .profile-form-icon-placeholder { 2897 + font-size: 0.7rem; 2898 + font-weight: 600; 2899 + letter-spacing: 0.05em; 2900 + color: rgba(18, 26, 47, 0.4); 2901 + } 2902 + .profile-form-icon-actions { 2903 + display: flex; 2904 + align-items: center; 2905 + gap: 0.5rem; 2906 + flex-wrap: wrap; 2907 + } 2908 + .dark-phase .profile-form-icon-preview { 2909 + background: rgba(255, 255, 255, 0.06); 2910 + border-color: rgba(255, 255, 255, 0.1); 2911 + } 2912 + .dark-phase .profile-form-icon-placeholder { 2913 + color: rgba(255, 255, 255, 0.5); 2914 + } 2915 + 2869 2916 /* ------------------------------------------------------------------ * 2870 2917 * Modal popup (used by BskyClientPickerModal) 2871 2918 * ------------------------------------------------------------------ */ ··· 3293 3340 color: #0e1428; 3294 3341 font-family: "IBM Plex Mono", monospace; 3295 3342 font-size: 0.8rem; 3296 - transition: 3297 - background 0.2s ease, 3298 - box-shadow 0.2s ease, 3299 - transform 0.15s ease; 3343 + transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease; 3300 3344 } 3301 3345 3302 3346 .account-menu-trigger:hover { ··· 3481 3525 .dark-phase .account-menu-popup.glass { 3482 3526 background: rgba(22, 28, 48, 0.94); 3483 3527 border-color: rgba(255, 255, 255, 0.22); 3484 - box-shadow: 3485 - 0 20px 50px rgba(0, 0, 0, 0.55), 3486 - 0 6px 16px rgba(0, 0, 0, 0.4); 3528 + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55), 0 6px 16px rgba(0, 0, 0, 0.4); 3487 3529 } 3488 3530 3489 3531 .dark-phase .account-menu-header-label {
+52 -46
components/DeveloperResources.tsx
··· 61 61 </div> 62 62 </section> 63 63 64 - {/* Profile API: interactive playground + endpoint reference. The 64 + { 65 + /* Profile API: interactive playground + endpoint reference. The 65 66 endpoint reference is server-rendered so it's discoverable 66 67 without JS; the playground itself is a small island that 67 - handles fetch + copy interactions client-side. */} 68 + handles fetch + copy interactions client-side. */ 69 + } 68 70 <section class="section-sm reveal"> 69 71 <div class="container-narrow"> 70 72 <div class="text-center"> ··· 75 77 76 78 <RegistryApiPlayground /> 77 79 78 - {/* Visual gap between the playground card and the next 80 + { 81 + /* Visual gap between the playground card and the next 79 82 subheading. We don't use the global mt-* utilities here 80 83 because they top out at mt-4 (2rem); the Endpoints/Schema 81 - dividers want a touch more breathing room. */} 84 + dividers want a touch more breathing room. */ 85 + } 82 86 <div class="api-section-heading"> 83 87 <h3 class="text-subsection">{tApi.endpointsHeading}</h3> 84 88 <div class="divider" /> 85 89 </div> 86 90 <dl class="api-endpoints"> 87 - {(["profile", "search", "featured", "avatar"] as const).map( 88 - (key) => { 89 - const e = tApi.endpoints[key]; 90 - const params = "params" in e ? e.params : undefined; 91 - return ( 92 - <div class="api-endpoint" key={key}> 93 - <dt class="api-endpoint-path"> 94 - <span class="api-endpoint-method">{e.method}</span> 95 - <code>{e.path}</code> 96 - </dt> 97 - <dd class="api-endpoint-summary">{e.summary}</dd> 98 - {params && params.length > 0 && ( 99 - <dd class="api-endpoint-params"> 100 - <span class="api-endpoint-params-label"> 101 - {tApi.paramsLabel} 102 - </span> 103 - <ul class="api-endpoint-params-list"> 104 - {params.map((p) => ( 105 - <li key={p.name}> 106 - <code class="api-endpoint-param-name"> 107 - {p.name} 108 - </code> 109 - <span class="api-endpoint-param-desc"> 110 - {" — "} 111 - {p.desc} 112 - </span> 113 - {"default" in p && p.default && ( 114 - <span class="api-endpoint-param-default"> 115 - {" "} 116 - ({tApi.paramDefault}: <code>{p.default}</code>) 91 + {(["profile", "search", "featured", "avatar", "icon"] as const) 92 + .map( 93 + (key) => { 94 + const e = tApi.endpoints[key]; 95 + const params = "params" in e ? e.params : undefined; 96 + return ( 97 + <div class="api-endpoint" key={key}> 98 + <dt class="api-endpoint-path"> 99 + <span class="api-endpoint-method">{e.method}</span> 100 + <code>{e.path}</code> 101 + </dt> 102 + <dd class="api-endpoint-summary">{e.summary}</dd> 103 + {params && params.length > 0 && ( 104 + <dd class="api-endpoint-params"> 105 + <span class="api-endpoint-params-label"> 106 + {tApi.paramsLabel} 107 + </span> 108 + <ul class="api-endpoint-params-list"> 109 + {params.map((p) => ( 110 + <li key={p.name}> 111 + <code class="api-endpoint-param-name"> 112 + {p.name} 113 + </code> 114 + <span class="api-endpoint-param-desc"> 115 + {" — "} 116 + {p.desc} 117 117 </span> 118 - )} 119 - </li> 120 - ))} 121 - </ul> 118 + {"default" in p && p.default && ( 119 + <span class="api-endpoint-param-default"> 120 + {" "} 121 + ({tApi.paramDefault}:{" "} 122 + <code>{p.default}</code>) 123 + </span> 124 + )} 125 + </li> 126 + ))} 127 + </ul> 128 + </dd> 129 + )} 130 + <dd class="api-endpoint-cache"> 131 + <code>cache-control: {e.cache}</code> 122 132 </dd> 123 - )} 124 - <dd class="api-endpoint-cache"> 125 - <code>cache-control: {e.cache}</code> 126 - </dd> 127 - </div> 128 - ); 129 - }, 130 - )} 133 + </div> 134 + ); 135 + }, 136 + )} 131 137 </dl> 132 138 133 139 <div class="api-section-heading">
+20 -4
i18n/messages/en.tsx
··· 375 375 "Avatar bytes for the given DID — proxied + cached from the user's PDS. Long cache headers; safe to use directly in <img src>.", 376 376 cache: "public, max-age=3600, s-maxage=86400", 377 377 }, 378 + icon: { 379 + method: "GET", 380 + path: "/api/registry/icon/:did", 381 + summary: 382 + "Optional vector icon (SVG) provided by the project for developer use — sign-in badges, app showcases, programmatic listings. Sanitised on upload and served with strict CSP + nosniff so it's safe to embed via <img src>. Returns 404 when the project hasn't supplied an icon.", 383 + cache: "public, max-age=3600, s-maxage=86400", 384 + }, 378 385 }, 379 386 schemaHeading: "Schema", 380 387 schemaBody: ··· 541 548 sectionLabel: "Atmosphere links", 542 549 sectionHint: (handle: string): VNode => ( 543 550 <> 544 - Toggle which services to show on your page. Links are generated 545 - from your handle <strong>@{handle}</strong>. 551 + Toggle which services to show on your page. Links are generated from 552 + your handle <strong>@{handle}</strong>. 546 553 </> 547 554 ), 548 555 bskyDescription: "Decentralised social network", ··· 568 575 title: "Bluesky clients", 569 576 body: 570 577 "Pick the client(s) that open when visitors click the Bluesky button on your profile. Your handle works on all of them — you can show more than one.", 571 - empty: 572 - "Pick at least one client to keep the Bluesky toggle enabled.", 578 + empty: "Pick at least one client to keep the Bluesky toggle enabled.", 573 579 done: "Done", 574 580 cancel: "Cancel", 575 581 }, ··· 583 589 labelPlaceholder: "Label", 584 590 urlPlaceholder: "https://…", 585 591 removeAriaLabel: "Remove link", 592 + }, 593 + icon: { 594 + sectionLabel: "Developer icon (SVG, optional)", 595 + hint: 596 + "A vector mark for developers — sign-in badges, app showcases, programmatic listings. Not shown on your public profile. SVG only, 200KB max.", 597 + upload: "Upload SVG", 598 + replace: "Replace SVG", 599 + remove: "Remove SVG", 600 + invalidType: "Icon must be an SVG (image/svg+xml).", 601 + tooLarge: "Icon must be 200KB or smaller.", 586 602 }, 587 603 }, 588 604 },
+131 -11
islands/CreateProfileForm.tsx
··· 25 25 subcategories: string[]; 26 26 links: LinkEntry[]; 27 27 avatar: { ref: string; mime: string } | null; 28 + /** Optional developer-facing SVG icon. */ 29 + icon: { ref: string; mime: string } | null; 28 30 } 29 31 30 32 interface Props { ··· 169 171 const website = useSignal<string>(initialSplit.website); 170 172 const customLinks = useSignal<CustomLinkRow[]>(initialSplit.custom); 171 173 174 + const tIcon = tForm.icon; 175 + 172 176 const avatarKeep = useSignal<BlobRefShape | null>(null); 173 177 /** 174 178 * Preview URL precedence: ··· 190 194 const avatarFile = useSignal<File | null>(null); 191 195 const avatarRemoved = useSignal(false); 192 196 197 + /* ---------------- Developer icon (SVG) signals ----------------------- */ 198 + /** 199 + * SVG icons get a separate slot from the main avatar — the avatar is 200 + * for the public profile, the icon is a vector mark exposed only via 201 + * the developer API. We store the keep/upload/remove state in the 202 + * same shape as avatar for consistency on Save. 203 + */ 204 + const iconKeep = useSignal<BlobRefShape | null>(null); 205 + const iconPreviewUrl = useSignal<string | null>( 206 + initial?.icon ? `/api/registry/icon/${encodeURIComponent(did)}` : null, 207 + ); 208 + const iconFile = useSignal<File | null>(null); 209 + const iconRemoved = useSignal(false); 210 + 193 211 const submitting = useSignal(false); 194 212 const deleting = useSignal(false); 195 213 const message = useSignal<{ kind: "ok" | "error"; text: string } | null>( ··· 202 220 $type: "blob", 203 221 ref: { $link: initial.avatar.ref }, 204 222 mimeType: initial.avatar.mime, 223 + size: 0, 224 + }; 225 + }, []); 226 + 227 + useEffect(() => { 228 + if (!initial?.icon) return; 229 + iconKeep.value = { 230 + $type: "blob", 231 + ref: { $link: initial.icon.ref }, 232 + mimeType: initial.icon.mime, 205 233 size: 0, 206 234 }; 207 235 }, []); ··· 270 298 avatarPreview.value = null; 271 299 }; 272 300 301 + const onIconChange = (event: Event) => { 302 + const input = event.currentTarget as HTMLInputElement; 303 + const file = input.files?.[0]; 304 + if (!file) return; 305 + if (file.type !== "image/svg+xml") { 306 + message.value = { kind: "error", text: tIcon.invalidType }; 307 + input.value = ""; 308 + return; 309 + } 310 + if (file.size > 200_000) { 311 + message.value = { kind: "error", text: tIcon.tooLarge }; 312 + input.value = ""; 313 + return; 314 + } 315 + iconFile.value = file; 316 + iconRemoved.value = false; 317 + iconPreviewUrl.value = URL.createObjectURL(file); 318 + }; 319 + 320 + const removeIcon = () => { 321 + iconFile.value = null; 322 + iconKeep.value = null; 323 + iconRemoved.value = true; 324 + iconPreviewUrl.value = null; 325 + }; 326 + 273 327 /** 274 328 * Reduce the form's working state into the lexicon-shaped LinkEntry[] 275 329 * we send to the API. Order matters — we put atmosphere links first ··· 336 390 payload.avatar = null; 337 391 } 338 392 393 + if (iconFile.value) { 394 + payload.iconUpload = { 395 + dataBase64: await readFileAsBase64(iconFile.value), 396 + mimeType: iconFile.value.type, 397 + }; 398 + } else if (!iconRemoved.value && iconKeep.value) { 399 + payload.icon = iconKeep.value; 400 + } else { 401 + payload.icon = null; 402 + } 403 + 339 404 const res = await fetch("/api/registry/profile", { 340 405 method: "PUT", 341 406 headers: { "content-type": "application/json" }, ··· 489 554 return ( 490 555 <label 491 556 key={c} 492 - class={`profile-form-chip ${ 493 - selected ? "is-selected" : "" 494 - }`} 557 + class={`profile-form-chip ${selected ? "is-selected" : ""}`} 495 558 > 496 559 <input 497 560 type="checkbox" ··· 613 676 + {tCustom.addButton} 614 677 </button> 615 678 </fieldset> 679 + 680 + {/* ---------------- Developer SVG icon -------------------- */} 681 + { 682 + /* 683 + Vector mark exposed only via /api/registry/icon/:did, for 684 + developers building badges and app showcases. Not shown on 685 + the public Explore profile. Kept visually lightweight so it 686 + doesn't compete with the main avatar slot above. 687 + */ 688 + } 689 + <fieldset class="profile-form-field"> 690 + <legend class="profile-form-label">{tIcon.sectionLabel}</legend> 691 + <div class="profile-form-icon-row"> 692 + <div class="profile-form-icon-preview" aria-hidden="true"> 693 + {iconPreviewUrl.value 694 + ? ( 695 + <img 696 + src={iconPreviewUrl.value} 697 + alt="" 698 + class="profile-form-icon-preview-img" 699 + onError={() => { 700 + iconPreviewUrl.value = null; 701 + }} 702 + /> 703 + ) 704 + : <span class="profile-form-icon-placeholder">SVG</span>} 705 + </div> 706 + <div class="profile-form-icon-actions"> 707 + <label class="profile-form-button-secondary"> 708 + {iconPreviewUrl.value ? tIcon.replace : tIcon.upload} 709 + <input 710 + type="file" 711 + accept="image/svg+xml" 712 + hidden 713 + onChange={onIconChange} 714 + /> 715 + </label> 716 + {iconPreviewUrl.value && ( 717 + <button 718 + type="button" 719 + class="profile-form-button-link" 720 + onClick={removeIcon} 721 + > 722 + {tIcon.remove} 723 + </button> 724 + )} 725 + </div> 726 + </div> 727 + <p class="profile-form-hint">{tIcon.hint}</p> 728 + </fieldset> 616 729 </div> 617 730 </div> 618 731 ··· 628 741 ? tManage.updateButton 629 742 : tManage.publishButton} 630 743 </button> 631 - {/* 744 + { 745 + /* 632 746 "View public profile" sits between Update and Remove so it 633 747 reads as the natural read-only complement to the destructive 634 748 actions. We only render it when the user has actually ··· 636 750 otherwise the link would 404. We use `published.value` so the 637 751 link appears immediately after a first-time publish without a 638 752 page reload. 639 - */} 753 + */ 754 + } 640 755 {published.value && publicProfileHandle && ( 641 756 <a 642 757 href={`/explore/${encodeURIComponent(publicProfileHandle)}`} ··· 672 787 onClose={() => (bskyPickerOpen.value = false)} 673 788 /> 674 789 675 - {/* URL-override modal, shared by Tangled and Supper. Only one is 790 + { 791 + /* URL-override modal, shared by Tangled and Supper. Only one is 676 792 open at a time so we render a single instance and switch its 677 - props on `urlOverrideOpen`. */} 793 + props on `urlOverrideOpen`. */ 794 + } 678 795 {(() => { 679 796 const which = urlOverrideOpen.value; 680 797 const svc = which ? getAtmosphereService(which) : null; ··· 810 927 <span class="atmosphere-row-name"> 811 928 {primaryClient?.name ?? svc.name} 812 929 </span> 813 - {/* Only render the secondary line when there's something 930 + { 931 + /* Only render the secondary line when there's something 814 932 meaningful to show (i.e. extra clients selected). The 815 933 service description ("Decentralised social network") is 816 - redundant next to the brand name and was just noise. */} 934 + redundant next to the brand name and was just noise. */ 935 + } 817 936 {ids.length > 1 && ( 818 937 <span class="atmosphere-row-desc"> 819 938 {`${ ··· 861 980 <input 862 981 type="checkbox" 863 982 checked={on.value} 864 - onChange={(e) => 865 - (on.value = (e.currentTarget as HTMLInputElement).checked)} 983 + onChange={( 984 + e, 985 + ) => (on.value = (e.currentTarget as HTMLInputElement).checked)} 866 986 /> 867 987 <span class="atmosphere-toggle-track" aria-hidden="true"> 868 988 <span class="atmosphere-toggle-thumb" />
+6
lexicons/com/atmosphereaccount/registry/profile.json
··· 30 30 "maxSize": 1000000, 31 31 "description": "Project icon. Recommended 512x512 square." 32 32 }, 33 + "icon": { 34 + "type": "blob", 35 + "accept": ["image/svg+xml"], 36 + "maxSize": 200000, 37 + "description": "Optional vector icon (SVG) intended for developers building badges, app showcases, sign-in flows, etc. Not displayed on the public Explore profile. Sanitised on upload (script tags, event handlers, foreignObject and javascript:/data: hrefs are stripped)." 38 + }, 33 39 "categories": { 34 40 "type": "array", 35 41 "minLength": 1,
+12
lib/db.ts
··· 75 75 links TEXT NOT NULL DEFAULT '[]', 76 76 avatar_cid TEXT, 77 77 avatar_mime TEXT, 78 + icon_cid TEXT, 79 + icon_mime TEXT, 78 80 pds_url TEXT NOT NULL, 79 81 record_cid TEXT NOT NULL, 80 82 record_rev TEXT NOT NULL, ··· 158 160 table: "profile", 159 161 column: "links", 160 162 ddl: "ALTER TABLE profile ADD COLUMN links TEXT NOT NULL DEFAULT '[]'", 163 + }, 164 + { 165 + table: "profile", 166 + column: "icon_cid", 167 + ddl: "ALTER TABLE profile ADD COLUMN icon_cid TEXT", 168 + }, 169 + { 170 + table: "profile", 171 + column: "icon_mime", 172 + ddl: "ALTER TABLE profile ADD COLUMN icon_mime TEXT", 161 173 }, 162 174 ]; 163 175 for (const m of additiveColumns) {
+15
lib/lexicons.ts
··· 115 115 name: string; 116 116 description: string; 117 117 avatar?: BlobRef; 118 + /** 119 + * Optional vector icon (SVG) intended for developer use — sign-in 120 + * badges, app showcases, programmatic listings. Not displayed on the 121 + * public profile. Must be `image/svg+xml`; we sanitise on upload. 122 + */ 123 + icon?: BlobRef; 118 124 /** All categories that apply to the project (1-4). The first item is the 119 125 * primary category used for sort/grouping in lists. */ 120 126 categories: string[]; ··· 328 334 if (v.avatar !== undefined && !isBlob(v.avatar)) { 329 335 return { ok: false, error: "avatar: invalid blob ref" }; 330 336 } 337 + if (v.icon !== undefined) { 338 + if (!isBlob(v.icon)) { 339 + return { ok: false, error: "icon: invalid blob ref" }; 340 + } 341 + if ((v.icon as BlobRef).mimeType !== "image/svg+xml") { 342 + return { ok: false, error: "icon: must be image/svg+xml" }; 343 + } 344 + } 331 345 const linksRes = normalizeLinks(v.links); 332 346 if (!linksRes.ok) return { ok: false, error: linksRes.error }; 333 347 if (v.subcategories !== undefined) { ··· 351 365 name: v.name as string, 352 366 description: v.description as string, 353 367 avatar: v.avatar as BlobRef | undefined, 368 + icon: v.icon as BlobRef | undefined, 354 369 categories: normalizedCategories, 355 370 subcategories: v.subcategories as string[] | undefined, 356 371 links: linksRes.value.length > 0 ? linksRes.value : undefined,
+16 -5
lib/registry.ts
··· 19 19 links: LinkEntry[]; 20 20 avatarCid: string | null; 21 21 avatarMime: string | null; 22 + /** Optional developer-facing SVG icon. Not rendered on public profile. */ 23 + iconCid: string | null; 24 + iconMime: string | null; 22 25 pdsUrl: string; 23 26 recordCid: string; 24 27 recordRev: string; ··· 41 44 links: string | null; 42 45 avatar_cid: string | null; 43 46 avatar_mime: string | null; 47 + icon_cid: string | null; 48 + icon_mime: string | null; 44 49 pds_url: string; 45 50 record_cid: string; 46 51 record_rev: string; ··· 66 71 const v = JSON.parse(text); 67 72 if (!Array.isArray(v)) return []; 68 73 return v 69 - .filter((x): x is Record<string, unknown> => 70 - !!x && typeof x === "object" 71 - ) 74 + .filter((x): x is Record<string, unknown> => !!x && typeof x === "object") 72 75 .filter((x) => typeof x.kind === "string") 73 76 .map((x) => { 74 77 const e: LinkEntry = { kind: x.kind as string }; ··· 95 98 links: safeJsonLinks(r.links), 96 99 avatarCid: r.avatar_cid, 97 100 avatarMime: r.avatar_mime, 101 + iconCid: r.icon_cid, 102 + iconMime: r.icon_mime, 98 103 pdsUrl: r.pds_url, 99 104 recordCid: r.record_cid, 100 105 recordRev: r.record_rev, ··· 121 126 links?: LinkEntry[] | null; 122 127 avatarCid?: string | null; 123 128 avatarMime?: string | null; 129 + iconCid?: string | null; 130 + iconMime?: string | null; 124 131 pdsUrl: string; 125 132 recordCid: string; 126 133 recordRev: string; ··· 152 159 sql: ` 153 160 INSERT INTO profile ( 154 161 did, handle, name, description, categories, subcategories, links, 155 - avatar_cid, avatar_mime, pds_url, record_cid, 162 + avatar_cid, avatar_mime, icon_cid, icon_mime, pds_url, record_cid, 156 163 record_rev, created_at, indexed_at 157 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 164 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 158 165 ON CONFLICT(did) DO UPDATE SET 159 166 handle=excluded.handle, 160 167 name=excluded.name, ··· 164 171 links=excluded.links, 165 172 avatar_cid=excluded.avatar_cid, 166 173 avatar_mime=excluded.avatar_mime, 174 + icon_cid=excluded.icon_cid, 175 + icon_mime=excluded.icon_mime, 167 176 pds_url=excluded.pds_url, 168 177 record_cid=excluded.record_cid, 169 178 record_rev=excluded.record_rev, ··· 180 189 JSON.stringify(input.links ?? []), 181 190 input.avatarCid ?? null, 182 191 input.avatarMime ?? null, 192 + input.iconCid ?? null, 193 + input.iconMime ?? null, 183 194 input.pdsUrl, 184 195 input.recordCid, 185 196 input.recordRev,
+93
lib/svg-sanitize.ts
··· 1 + /** 2 + * Defensive SVG sanitiser for the developer-icon upload path. SVGs are 3 + * XML and can carry inline `<script>`, event-handler attributes 4 + * (`onclick=…`), `<foreignObject>` HTML payloads, and `javascript:` / 5 + * non-image `data:` URLs in href/xlink:href. We strip all of those and 6 + * normalise the file before persisting to the PDS, which lets us serve 7 + * the bytes back as `image/svg+xml` without worrying that an embedder 8 + * pulls in active content. 9 + * 10 + * This is paired with `Content-Security-Policy: default-src 'none'` 11 + * + `X-Content-Type-Options: nosniff` on the serve path, so it's 12 + * defence-in-depth rather than the sole guard. The sanitiser uses a 13 + * regex/string-substitution approach (no DOM) because Deno doesn't ship 14 + * a built-in HTML/XML parser; the SVGs we accept are dev-supplied logo 15 + * marks, so a small rewrite pass is plenty. 16 + */ 17 + 18 + const SCRIPT_TAG_RE = /<script\b[\s\S]*?<\/script\s*>/gi; 19 + const SCRIPT_SELFCLOSE_RE = /<script\b[^>]*\/>/gi; 20 + const FOREIGN_OBJECT_RE = /<foreignObject\b[\s\S]*?<\/foreignObject\s*>/gi; 21 + const FOREIGN_OBJECT_SELFCLOSE_RE = /<foreignObject\b[^>]*\/>/gi; 22 + const STYLE_TAG_RE = /<style\b[\s\S]*?<\/style\s*>/gi; 23 + const COMMENT_RE = /<!--[\s\S]*?-->/g; 24 + const PI_RE = /<\?[\s\S]*?\?>/g; 25 + const DOCTYPE_RE = /<!DOCTYPE[\s\S]*?>/gi; 26 + const ON_HANDLER_ATTR_RE = / on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi; 27 + 28 + /** 29 + * Match `href`/`xlink:href` attributes. We replace the attribute value 30 + * with `#` if it points at anything other than a fragment, http(s):, 31 + * mailto:, or an inline `data:image/...` URL. This blocks `javascript:`, 32 + * `vbscript:`, plain `data:text/html`, etc. 33 + */ 34 + const HREF_ATTR_RE = 35 + /\s(?:xlink:)?href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; 36 + 37 + function safeHref(value: string): boolean { 38 + const v = value.trim().toLowerCase(); 39 + if (v.startsWith("#")) return true; 40 + if (v.startsWith("http://") || v.startsWith("https://")) return true; 41 + if (v.startsWith("mailto:")) return true; 42 + // Allow inline image data URLs only — these are common in SVGs that 43 + // embed raster fallbacks or pattern fills. 44 + if (v.startsWith("data:image/")) return true; 45 + return false; 46 + } 47 + 48 + /** 49 + * Strip script-y bits from an SVG string. Returns the cleaned SVG. 50 + * Throws if the input doesn't contain an `<svg>` root element. 51 + */ 52 + export function sanitizeSvg(input: string): string { 53 + let s = input; 54 + 55 + s = s.replace(COMMENT_RE, ""); 56 + s = s.replace(PI_RE, ""); 57 + s = s.replace(DOCTYPE_RE, ""); 58 + 59 + s = s.replace(SCRIPT_TAG_RE, ""); 60 + s = s.replace(SCRIPT_SELFCLOSE_RE, ""); 61 + s = s.replace(FOREIGN_OBJECT_RE, ""); 62 + s = s.replace(FOREIGN_OBJECT_SELFCLOSE_RE, ""); 63 + // Inline <style> blocks can fetch external resources via @import or 64 + // url(javascript:...). Stripping them is the simplest safe option; 65 + // logos we care about use presentation attributes / <defs> instead. 66 + s = s.replace(STYLE_TAG_RE, ""); 67 + 68 + s = s.replace(ON_HANDLER_ATTR_RE, ""); 69 + 70 + s = s.replace(HREF_ATTR_RE, (match, dq, sq, bare) => { 71 + const value = (dq ?? sq ?? bare ?? "") as string; 72 + if (safeHref(value)) return match; 73 + // Preserve the attribute name + quote style but neutralise the value. 74 + if (dq !== undefined) return match.replace(dq, "#"); 75 + if (sq !== undefined) return match.replace(sq, "#"); 76 + return match.replace(bare as string, "#"); 77 + }); 78 + 79 + if (!/<svg[\s>]/i.test(s)) { 80 + throw new Error("not an SVG: missing <svg> root element"); 81 + } 82 + return s.trim(); 83 + } 84 + 85 + /** 86 + * Decode a UTF-8 byte buffer (typed-array friendly) into a string for 87 + * sanitisation; re-encode the cleaned SVG back to bytes for upload. 88 + */ 89 + export function sanitizeSvgBytes(bytes: Uint8Array): Uint8Array { 90 + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes); 91 + const cleaned = sanitizeSvg(text); 92 + return new TextEncoder().encode(cleaned); 93 + }
+66
routes/api/registry/icon/[did].ts
··· 1 + /** 2 + * Proxy + cache the developer-facing SVG icon for a registry profile. 3 + * 4 + * GET /api/registry/icon/did:plc:abc123… 5 + * 6 + * Looks up `(pdsUrl, icon_cid)` in our DB and streams the bytes back 7 + * with conservative caching. 8 + * 9 + * SVGs can carry inline `<script>` and event handlers, so even though 10 + * we sanitise on upload (see `lib/svg-sanitize.ts`) we also harden the 11 + * serve path: 12 + * 13 + * - `Content-Type: image/svg+xml` 14 + * - `X-Content-Type-Options: nosniff` 15 + * - `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; 16 + * img-src data:` — neutralises any script that survived the 17 + * sanitiser when the SVG is loaded directly. CSP is ignored when 18 + * the SVG is embedded via `<img>` in another document, but `<img>` 19 + * embedding is intrinsically script-free. 20 + * - `Content-Disposition: inline; filename="atmosphere-icon.svg"` so 21 + * browsers render it instead of downloading. 22 + */ 23 + import { define } from "../../../../utils.ts"; 24 + import { getProfileByDid } from "../../../../lib/registry.ts"; 25 + import { fetchBlobPublic } from "../../../../lib/pds.ts"; 26 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 27 + 28 + export const handler = define.handlers({ 29 + GET: withRateLimit(async (ctx) => { 30 + const did = decodeURIComponent(ctx.params.did); 31 + const profile = await getProfileByDid(did).catch(() => null); 32 + if (!profile || !profile.iconCid) { 33 + return new Response("not found", { status: 404 }); 34 + } 35 + try { 36 + const upstream = await fetchBlobPublic( 37 + profile.pdsUrl, 38 + did, 39 + profile.iconCid, 40 + ); 41 + if (!upstream.ok) { 42 + return new Response("not found", { status: 404 }); 43 + } 44 + const headers = new Headers(); 45 + headers.set("content-type", "image/svg+xml; charset=utf-8"); 46 + headers.set("x-content-type-options", "nosniff"); 47 + headers.set( 48 + "content-security-policy", 49 + "default-src 'none'; style-src 'unsafe-inline'; img-src data:", 50 + ); 51 + headers.set( 52 + "content-disposition", 53 + 'inline; filename="atmosphere-icon.svg"', 54 + ); 55 + headers.set( 56 + "cache-control", 57 + "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 58 + ); 59 + headers.set("etag", profile.iconCid); 60 + return new Response(upstream.body, { status: 200, headers }); 61 + } catch (err) { 62 + console.warn("icon proxy error:", err); 63 + return new Response("upstream error", { status: 502 }); 64 + } 65 + }), 66 + });
+59 -2
routes/api/registry/profile.ts
··· 22 22 validateProfile, 23 23 } from "../../../lib/lexicons.ts"; 24 24 import { deleteProfile, upsertProfile } from "../../../lib/registry.ts"; 25 + import { sanitizeSvgBytes } from "../../../lib/svg-sanitize.ts"; 26 + 27 + const ICON_MAX_BYTES = 200_000; 25 28 26 29 interface LinkPayload { 27 30 kind?: string; ··· 45 48 size: number; 46 49 } | null; 47 50 avatarUpload?: { dataBase64: string; mimeType: string }; 51 + /** 52 + * Developer-facing SVG icon. Same shape as `avatar`: pass the 53 + * existing BlobRef to keep, `null` to clear, or `iconUpload` to 54 + * replace. 55 + */ 56 + icon?: { 57 + $type: "blob"; 58 + ref: { $link: string }; 59 + mimeType: string; 60 + size: number; 61 + } | null; 62 + iconUpload?: { dataBase64: string; mimeType: string }; 48 63 } 49 64 50 65 function trimOrNull(s: unknown): string | undefined { ··· 88 103 // will surface cleaner errors for malformed entries that slip 89 104 // through, but dropping the no-op ones here keeps "Save" idempotent 90 105 // when the form hasn't filled a row in yet. 91 - const isAtmosphere = 92 - (ATMOSPHERE_LINK_KINDS as readonly string[]).includes(kind); 106 + const isAtmosphere = (ATMOSPHERE_LINK_KINDS as readonly string[]).includes( 107 + kind, 108 + ); 93 109 if (!isAtmosphere && !entry.url) continue; 94 110 if (kind === "bsky" && !entry.clientId) continue; 95 111 ··· 141 157 } 142 158 } 143 159 160 + /** 161 + * Developer-facing SVG icon. We sanitise the bytes before upload 162 + * (strips <script>, on*, foreignObject, javascript: hrefs) so the 163 + * blob persisted on the user's PDS is already clean — even if some 164 + * other consumer fetches it directly without our serve-time CSP. 165 + */ 166 + let icon = body.icon ?? undefined; 167 + if (body.iconUpload?.dataBase64) { 168 + const mime = body.iconUpload.mimeType; 169 + if (mime !== "image/svg+xml") { 170 + return new Response("icon must be image/svg+xml", { status: 400 }); 171 + } 172 + const raw = decodeBase64(body.iconUpload.dataBase64); 173 + if (raw.byteLength > ICON_MAX_BYTES) { 174 + return new Response(`icon exceeds ${ICON_MAX_BYTES} bytes`, { 175 + status: 400, 176 + }); 177 + } 178 + let cleaned: Uint8Array; 179 + try { 180 + cleaned = sanitizeSvgBytes(raw); 181 + } catch (err) { 182 + const m = err instanceof Error ? err.message : String(err); 183 + return new Response(`invalid svg: ${m}`, { status: 400 }); 184 + } 185 + try { 186 + icon = await uploadBlob( 187 + user.did, 188 + session.pdsUrl, 189 + cleaned, 190 + "image/svg+xml", 191 + ); 192 + } catch (err) { 193 + const m = err instanceof Error ? err.message : String(err); 194 + return new Response(`icon upload failed: ${m}`, { status: 502 }); 195 + } 196 + } 197 + 144 198 // Dedupe categories defensively. The lexicon validator below also 145 199 // does this, but normalising here means we surface a clean 400 ("at 146 200 // least one category") instead of a validator error string. ··· 169 223 subcategories: asArray(body.subcategories), 170 224 links: links.length > 0 ? links : undefined, 171 225 avatar: avatar ?? undefined, 226 + icon: icon ?? undefined, 172 227 createdAt: new Date().toISOString(), 173 228 }; 174 229 ··· 208 263 links: validation.value.links ?? [], 209 264 avatarCid: validation.value.avatar?.ref.$link ?? null, 210 265 avatarMime: validation.value.avatar?.mimeType ?? null, 266 + iconCid: validation.value.icon?.ref.$link ?? null, 267 + iconMime: validation.value.icon?.mimeType ?? null, 211 268 pdsUrl: session.pdsUrl, 212 269 recordCid: result.cid, 213 270 recordRev: result.commit?.rev ?? result.cid,
+9
routes/api/registry/profile/[id].ts
··· 24 24 interface PublicProfileResponse extends ProfileRow { 25 25 /** Fully-qualified URL for the profile's avatar, or null if unset. */ 26 26 avatarUrl: string | null; 27 + /** 28 + * Fully-qualified URL for the profile's developer-facing SVG icon, 29 + * or null if unset. Served as `image/svg+xml` with strict CSP + 30 + * `nosniff`; safe for `<img src>` embedding. 31 + */ 32 + iconUrl: string | null; 27 33 } 28 34 29 35 export const handler = define.handlers({ ··· 51 57 ...profile, 52 58 avatarUrl: profile.avatarCid 53 59 ? `${origin}/api/registry/avatar/${encodeURIComponent(profile.did)}` 60 + : null, 61 + iconUrl: profile.iconCid 62 + ? `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}` 54 63 : null, 55 64 }; 56 65
+4
routes/explore/manage.tsx
··· 54 54 avatar: existing.avatarCid && existing.avatarMime 55 55 ? { ref: existing.avatarCid, mime: existing.avatarMime } 56 56 : null, 57 + icon: existing.iconCid && existing.iconMime 58 + ? { ref: existing.iconCid, mime: existing.iconMime } 59 + : null, 57 60 }; 58 61 } else { 59 62 const session = await loadSession(user.did); ··· 74 77 mime: bsky.avatar.mimeType, 75 78 } 76 79 : null, 80 + icon: null, 77 81 }; 78 82 if (bsky.avatar) { 79 83 initialAvatarUrl = bskyCdnAvatarUrl(
+2
worker/indexer.ts
··· 115 115 links: r.links ?? [], 116 116 avatarCid: r.avatar?.ref.$link ?? null, 117 117 avatarMime: r.avatar?.mimeType ?? null, 118 + iconCid: r.icon?.ref.$link ?? null, 119 + iconMime: r.icon?.mimeType ?? null, 118 120 pdsUrl, 119 121 recordCid: fetched.cid, 120 122 recordRev: commit.rev,