Monorepo for Tangled tangled.org
1

Configure Feed

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

web: serve images through camo and avatars through the avatar service

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 25, 2026, 9:13 PM +0300) commit e9987c69 parent 1a94c966 change-id yuxuputs
+199 -84
+1 -1
web/src/app.d.ts
··· 5 5 bobbinUrl: string; 6 6 apiUrl: string; 7 7 knotResolverUrl: string; 8 - camoUrl: string; 8 + camoEnabled: boolean; 9 9 }; 10 10 } 11 11 }
-1
web/src/lib/api/identity.ts
··· 5 5 did: string; 6 6 handle: string; 7 7 pds?: string; 8 - avatar?: string; 9 8 } 10 9 11 10 export const resolveMiniDoc = (
+2 -8
web/src/lib/auth.svelte.ts
··· 59 59 export interface AuthProfile { 60 60 did: Did; 61 61 handle: string; 62 - avatar?: string; 63 62 } 64 63 65 64 export interface CurrentUser { 66 65 did: Did; 67 66 handle: string; 68 - avatar?: string; 69 67 } 70 68 71 69 export type { AuthAccount } from "./auth/accounts"; ··· 94 92 did: Did; 95 93 handle: string; 96 94 pds?: string; 97 - avatar?: string; 98 95 }; 99 96 100 97 type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0]; ··· 142 139 const profile = (await response.json()) as MiniDoc; 143 140 return { 144 141 did: profile.did, 145 - handle: profile.handle, 146 - avatar: profile.avatar 142 + handle: profile.handle 147 143 }; 148 144 } 149 145 } catch { ··· 204 200 const meta = upsertAccount(loadAccounts(), { 205 201 did, 206 202 handle: profile.handle, 207 - avatar: profile.avatar, 208 203 addedAt: Math.floor(Date.now() / 1000) 209 204 }); 210 205 saveAccounts(meta); ··· 396 391 if (!currentDid) return null; 397 392 return { 398 393 did: currentDid, 399 - handle: profile?.handle ?? currentDid, 400 - avatar: profile?.avatar 394 + handle: profile?.handle ?? currentDid 401 395 }; 402 396 }, 403 397 get accounts() {
+1 -3
web/src/lib/auth/accounts.ts
··· 13 13 export interface AuthAccount { 14 14 did: Did; 15 15 handle: string; 16 - avatar?: string; 17 16 // unix seconds; appview parity 18 17 addedAt: number; 19 18 } ··· 37 36 return parsed.filter(isAccount).map((account) => ({ 38 37 did: account.did, 39 38 handle: account.handle, 40 - avatar: account.avatar, 41 39 addedAt: typeof account.addedAt === "number" ? account.addedAt : 0 42 40 })); 43 41 } catch { ··· 50 48 localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); 51 49 }; 52 50 53 - // stored sessions are authoritative; metadata supplies order, handle, and avatar. 51 + // stored sessions are authoritative, metadata only supplies order and handle 54 52 export const reconcileAccounts = ( 55 53 stored: readonly Did[], 56 54 meta: readonly AuthAccount[]
+4
web/src/lib/avatar.ts
··· 1 + // the service wants signed urls and the secret is server side, so this points 2 + // at the route that signs 3 + export const avatarUrl = (did: string, tiny = false): string => 4 + `/avatar/${encodeURIComponent(did)}${tiny ? "?size=tiny" : ""}`;
+1 -1
web/src/lib/components/profile/FollowCard.svelte
··· 18 18 19 19 <div class="flex flex-col gap-4 md:flex-row md:items-center"> 20 20 <div class="flex h-24 w-24 shrink-0 items-center justify-center"> 21 - <Avatar src={person.avatar} handle={person.handle} size="size-24" class="p-2" /> 21 + <Avatar did={person.did} handle={person.handle} size="size-24" class="p-2" /> 22 22 </div> 23 23 24 24 <div class="flex min-w-0 flex-1 flex-col gap-2 md:flex-row md:items-center md:justify-between">
+7 -17
web/src/lib/components/profile/ProfileCard.svelte
··· 6 6 import Link from "$icon/link"; 7 7 import Pencil from "$icon/pencil"; 8 8 import Rss from "$icon/rss"; 9 - import UserRound from "$icon/user-round"; 9 + import Avatar from "$lib/components/ui/Avatar.svelte"; 10 10 import Button from "$lib/components/ui/Button.svelte"; 11 11 import { getAuth } from "$lib/auth.svelte"; 12 12 import type { ProfileRecord } from "$lib/api/records"; ··· 14 14 interface Identity { 15 15 did: string; 16 16 handle: string; 17 - avatar?: string; 18 17 } 19 18 20 19 interface Props { ··· 41 40 <div class="grid grid-cols-3 items-center gap-1 md:grid-cols-1"> 42 41 <div class="col-span-1 flex items-center justify-center"> 43 42 <div class="relative aspect-square w-3/4"> 44 - {#if identity.avatar} 45 - <img 46 - class="absolute inset-0 h-full w-full rounded-full object-cover p-2" 47 - src={identity.avatar} 48 - alt={identity.handle} 49 - /> 50 - {:else} 51 - <span class="absolute inset-0 h-full w-full p-2" aria-hidden="true"> 52 - <span 53 - class="flex h-full w-full items-center justify-center rounded-full bg-background-inset text-foreground-placeholder" 54 - > 55 - <UserRound class="size-1/2" /> 56 - </span> 57 - </span> 58 - {/if} 43 + <Avatar 44 + did={identity.did} 45 + handle={identity.handle} 46 + size="h-full w-full" 47 + class="absolute inset-0 p-2" 48 + /> 59 49 </div> 60 50 </div> 61 51
-2
web/src/lib/components/profile/types.ts
··· 45 45 export interface PersonData { 46 46 did: string; 47 47 handle: string; 48 - avatar?: string; 49 48 description?: string; 50 49 followers?: number; 51 50 following?: number; ··· 57 56 uri: string; 58 57 did: string; 59 58 handle: string; 60 - avatar?: string; 61 59 kind: "vouch" | "denounce"; 62 60 direction: "incoming" | "outgoing"; 63 61 reason?: string;
+1 -1
web/src/lib/components/repo/RepoHeader.svelte
··· 32 32 href={resolve(`/${repo.ownerHandle}` as "/")} 33 33 class="flex items-center gap-2 text-foreground-default no-underline hover:underline" 34 34 > 35 - <Avatar src={repo.ownerAvatar} handle={repo.ownerHandle} size="size-6" /> 35 + <Avatar did={repo.ownerDid} handle={repo.ownerHandle} size="size-6" tiny /> 36 36 {repo.ownerHandle} 37 37 </a> 38 38 <span class="text-foreground-subtle select-none">/</span>
-2
web/src/lib/components/repo/types.ts
··· 2 2 3 3 export type { BranchSummary, CommitSummary, TagSummary, TreeEntrySummary }; 4 4 5 - /** the repo every page under [handle]/[repo] is scoped to */ 6 5 export interface RepoInfo { 7 6 uri: string; 8 7 rkey: string; 9 8 name: string; 10 9 ownerDid: string; 11 10 ownerHandle: string; 12 - ownerAvatar?: string; 13 11 repoDid?: string; 14 12 knot: string; 15 13 spindle?: string;
+2 -18
web/src/lib/components/shell/Topbar.svelte
··· 1 1 <script lang="ts"> 2 2 import { resolve } from "$app/paths"; 3 + import Avatar from "../ui/Avatar.svelte"; 3 4 import Button from "../ui/Button.svelte"; 4 5 import Dropdown from "../ui/Dropdown.svelte"; 5 6 import DropdownItem from "../ui/DropdownItem.svelte"; ··· 13 14 import Plus from "$icon/plus"; 14 15 import Search from "$icon/search"; 15 16 import User from "$icon/user"; 16 - import UserRound from "$icon/user-round"; 17 17 import Spinner from "../ui/Spinner.svelte"; 18 18 import Logo from "../ui/Logo.svelte"; 19 19 import TopbarSearch from "./TopbarSearch.svelte"; ··· 21 21 interface User { 22 22 handle: string; 23 23 did: string; 24 - avatar?: string; 25 24 } 26 25 27 26 interface Props { ··· 127 126 {:else if user} 128 127 <Dropdown id="profile-menu" group="topbar" align="right"> 129 128 {#snippet trigger()} 130 - {#if user.avatar} 131 - <img 132 - src={user.avatar} 133 - alt={user.handle} 134 - class="size-8 rounded-full border border-border-default object-cover md:size-5" 135 - /> 136 - {:else} 137 - <span 138 - class="flex size-8 items-center justify-center rounded-full border border-border-default bg-background-inset md:size-5" 139 - > 140 - <UserRound 141 - class="size-5 text-foreground-placeholder md:size-4" 142 - aria-hidden="true" 143 - /> 144 - </span> 145 - {/if} 129 + <Avatar did={user.did} handle={user.handle} size="size-8 md:size-5" tiny /> 146 130 <span class="hidden max-w-48 truncate md:inline">{user.handle}</span> 147 131 {/snippet} 148 132 <DropdownItem href={`/${user.handle}`} icon={User}>Profile</DropdownItem>
+3
web/src/lib/components/ui/Avatar.stories.svelte
··· 7 7 component: Avatar, 8 8 tags: ["autodocs"], 9 9 argTypes: { 10 + did: { control: "text" }, 10 11 src: { control: "text" }, 11 12 handle: { control: "text" }, 13 + tiny: { control: "boolean" }, 12 14 size: { control: "text" } 13 15 }, 14 16 args: { ··· 18 20 </script> 19 21 20 22 <Story name="Fallback" /> 23 + <Story name="From a did" args={{ did: "did:plc:3p7ejjxnufohsygt5vbyxb2i" }} /> 21 24 <Story name="With image" args={{ src: "https://avatars.githubusercontent.com/u/1" }} /> 22 25 <Story name="Broken image" args={{ src: "https://example.invalid/broken.png" }} /> 23 26
+10 -4
web/src/lib/components/ui/Avatar.svelte
··· 1 1 <script lang="ts"> 2 2 import UserRound from "$icon/user-round"; 3 + import { avatarUrl } from "$lib/avatar"; 3 4 4 5 interface Props { 6 + did?: string; 5 7 src?: string; 6 8 handle?: string; 9 + tiny?: boolean; 7 10 size?: string; 8 11 class?: string; 9 12 } 10 13 11 - let { src, handle, size = "size-10", class: className = "" }: Props = $props(); 14 + let { did, src, handle, tiny = false, size = "size-10", class: className = "" }: Props = $props(); 15 + 16 + const source = $derived(src ?? (did ? avatarUrl(did, tiny) : undefined)); 12 17 18 + // an unconfigured avatar service 404s, same as a broken image 13 19 let failed = $state(false); 14 20 15 21 $effect(() => { 16 - if (src) failed = false; 22 + if (source) failed = false; 17 23 }); 18 24 </script> 19 25 20 - {#if src && !failed} 26 + {#if source && !failed} 21 27 <img 22 - {src} 28 + src={source} 23 29 alt={handle ? `${handle}'s avatar` : "avatar"} 24 30 class={`${size} shrink-0 rounded-full border border-border-default object-cover ${className}`} 25 31 onerror={() => (failed = true)}
+2 -2
web/src/lib/components/ui/User.stories.svelte
··· 8 8 tags: ["autodocs"], 9 9 argTypes: { 10 10 handle: { control: "text" }, 11 - src: { control: "text" }, 11 + did: { control: "text" }, 12 12 size: { 13 13 control: { type: "inline-radio" }, 14 14 options: ["sm", "md", "lg"] ··· 26 26 </script> 27 27 28 28 <Story name="Default" /> 29 - <Story name="With avatar" args={{ src: "https://avatars.githubusercontent.com/u/1" }} /> 29 + <Story name="With avatar" args={{ did: "did:plc:3p7ejjxnufohsygt5vbyxb2i" }} /> 30 30 <Story name="Image only" args={{ showText: false }} /> 31 31 <Story name="Text only" args={{ showImage: false }} /> 32 32
+4 -3
web/src/lib/components/ui/User.svelte
··· 35 35 36 36 interface Props { 37 37 handle?: string; 38 - src?: string; 38 + did?: string; 39 39 size?: UserVariants["size"]; 40 40 showImage?: boolean; 41 41 showText?: boolean; ··· 44 44 45 45 let { 46 46 handle, 47 - src, 47 + did, 48 48 size = "md", 49 49 showImage = true, 50 50 showText = true, ··· 56 56 57 57 <span class={`inline-flex items-center gap-2 ${className ?? ""}`}> 58 58 {#if showImage} 59 - <Avatar {src} {handle} size={slots.avatar()} /> 59 + <!-- every variant here is size-8 or under, so the small image always fits --> 60 + <Avatar {did} {handle} size={slots.avatar()} tiny /> 60 61 {/if} 61 62 {#if showText && handle} 62 63 <span class={slots.handle()}>{handle}</span>
+30
web/src/lib/markup/markdown.test.ts
··· 81 81 ); 82 82 }); 83 83 84 + it("leaves off-site images alone when there is no camo", () => { 85 + expect(render("![x](https://example.com/a.png)")).toContain('src="https://example.com/a.png"'); 86 + }); 87 + 88 + describe("with camo", () => { 89 + const camo = (source: string) => render(source, { camo: true }); 90 + // hex of https://example.com/a.png, which is what camo signs 91 + const hex = "68747470733a2f2f6578616d706c652e636f6d2f612e706e67"; 92 + 93 + it("sends an off-site image to the route that signs for camo", () => { 94 + expect(camo("![x](https://example.com/a.png)")).toContain(`src="/camo/${hex}"`); 95 + }); 96 + 97 + it("proxies raw html images and srcset candidates too", () => { 98 + expect(camo('<img src="https://example.com/a.png">')).toContain(`src="/camo/${hex}"`); 99 + expect(camo('<source srcset="https://example.com/a.png 2x">')).toContain( 100 + `srcset="/camo/${hex} 2x"` 101 + ); 102 + }); 103 + 104 + it("leaves our own images and repo files direct", () => { 105 + expect(camo("![x](https://tangled.org/a.png)")).toContain('src="https://tangled.org/a.png"'); 106 + expect(camo("![x](assets/a.png)")).toContain('src="/ada.test/infra/raw/main/assets/a.png"'); 107 + }); 108 + 109 + it("does not proxy links, only what the page loads by itself", () => { 110 + expect(camo("[x](https://example.com/a.png)")).toContain('href="https://example.com/a.png"'); 111 + }); 112 + }); 113 + 84 114 it("links a bare handle to its profile", () => { 85 115 const html = render("thanks @ada.test for the fix"); 86 116 expect(html).toContain('<a href="/ada.test" class="mention">@ada.test</a>');
+24 -3
web/src/lib/markup/paths.ts
··· 4 4 ref: string; 5 5 dir?: string; 6 6 host?: string; 7 + camo?: boolean; 7 8 } 8 9 9 10 const ABSOLUTE = /^[a-z][a-z0-9+.-]*:|^\/\//i; ··· 45 46 46 47 export const rawUrl = (url: string, ctx: MarkupContext): string => repoUrl("raw", url, ctx); 47 48 48 - export const rawSrcset = (srcset: string, ctx: MarkupContext): string => 49 + const hostOf = (url: string): string | null => { 50 + try { 51 + // the base only matters for protocol relative urls, which have a host anyway 52 + return new URL(url, "https://invalid.").host; 53 + } catch { 54 + return null; 55 + } 56 + }; 57 + 58 + // camo wants the target hex encoded 59 + const toHex = (value: string): string => 60 + Array.from(new TextEncoder().encode(value), (byte) => byte.toString(16).padStart(2, "0")).join( 61 + "" 62 + ); 63 + 64 + export const mediaUrl = (url: string, ctx: MarkupContext): string => { 65 + if (isRepoRelative(url)) return rawUrl(url, ctx); 66 + if (!ctx.camo || !isAbsoluteUrl(url) || hostOf(url) === ctx.host) return url; 67 + return `/camo/${toHex(url)}`; 68 + }; 69 + 70 + export const mediaSrcset = (srcset: string, ctx: MarkupContext): string => 49 71 srcset 50 72 .split(",") 51 73 .map((candidate) => { 52 74 const [url, ...descriptors] = candidate.trim().split(/\s+/); 53 - if (!isRepoRelative(url)) return candidate.trim(); 54 - return [rawUrl(url, ctx), ...descriptors].join(" "); 75 + return [mediaUrl(url, ctx), ...descriptors].join(" "); 55 76 }) 56 77 .join(", ");
+3 -5
web/src/lib/markup/sanitize.ts
··· 1 1 import sanitizeHtml from "sanitize-html"; 2 - import { isRepoRelative, rawSrcset, rawUrl, treeUrl } from "./paths"; 2 + import { isRepoRelative, mediaSrcset, mediaUrl, treeUrl } from "./paths"; 3 3 import type { MarkupContext } from "./paths"; 4 4 5 5 const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]; ··· 141 141 exclusiveFilter: (frame) => frame.tag === "input" && frame.attribs.type !== "checkbox" 142 142 }); 143 143 144 - // todo: external images should go through camo like the appview does, which needs 145 - // the shared secret in web's config and moves rendering server side 146 144 const resolveMedia = ( 147 145 attribs: Record<string, string>, 148 146 ctx: MarkupContext ··· 150 148 const resolved = { ...attribs }; 151 149 for (const key of ["src", "poster"]) { 152 150 const value = resolved[key]; 153 - if (value && isRepoRelative(value)) resolved[key] = rawUrl(value, ctx); 151 + if (value) resolved[key] = mediaUrl(value, ctx); 154 152 } 155 - if (resolved.srcset) resolved.srcset = rawSrcset(resolved.srcset, ctx); 153 + if (resolved.srcset) resolved.srcset = mediaSrcset(resolved.srcset, ctx); 156 154 return resolved; 157 155 }; 158 156
+26 -6
web/src/lib/server/config.ts
··· 10 10 apiUrl: string; 11 11 knotResolverUrl: string; 12 12 camoUrl: string; 13 + avatarUrl: string; 14 + /** the secrets camo and avatar sign with, so neither leaves the server */ 15 + camoSecret: string; 16 + avatarSecret: string; 13 17 }; 14 18 15 - export type PublicWebConfig = Pick< 16 - WebConfig, 17 - "bobbinUrl" | "apiUrl" | "knotResolverUrl" | "camoUrl" 18 - >; 19 + export type PublicWebConfig = Pick<WebConfig, "bobbinUrl" | "apiUrl" | "knotResolverUrl"> & { 20 + /** camo has a secret, so markup can route images through it */ 21 + camoEnabled: boolean; 22 + }; 19 23 20 24 type WebConfigEnv = { 21 25 BOBBIN_URL?: string; ··· 23 27 API_URL?: string; 24 28 KNOT_RESOLVER_URL?: string; 25 29 CAMO_URL?: string; 30 + CAMO_SHARED_SECRET?: string; 31 + AVATAR_URL?: string; 32 + AVATAR_SHARED_SECRET?: string; 26 33 }; 27 34 28 35 export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ 29 36 bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), 30 37 apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), 31 38 knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, "https://knot1.tangled.sh"), 32 - camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh") 39 + camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), 40 + avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"), 41 + camoSecret: values.CAMO_SHARED_SECRET?.trim() ?? "", 42 + avatarSecret: values.AVATAR_SHARED_SECRET?.trim() ?? "" 33 43 }); 34 44 35 45 export const getConfig = (): WebConfig => resolveConfig(env as WebConfigEnv); 36 46 37 - export const getPublicConfig = (): PublicWebConfig => getConfig(); 47 + // listed one by one, so anything new in WebConfig stays private until it is 48 + // named here 49 + export const getPublicConfig = (): PublicWebConfig => { 50 + const config = getConfig(); 51 + return { 52 + bobbinUrl: config.bobbinUrl, 53 + apiUrl: config.apiUrl, 54 + knotResolverUrl: config.knotResolverUrl, 55 + camoEnabled: config.camoSecret !== "" 56 + }; 57 + };
+1 -1
web/src/routes/[handle]/+layout.ts
··· 66 66 const notJoined = !profile && Object.values(counts).every((n) => n === 0); 67 67 68 68 return { 69 - identity: { did, handle: doc.handle, avatar: doc.avatar }, 69 + identity: { did, handle: doc.handle }, 70 70 profile, 71 71 counts, 72 72 viewerFollowRkey: raw.viewerFollowRkey,
+2 -3
web/src/routes/[handle]/+page.ts
··· 83 83 }; 84 84 }; 85 85 86 - // resolves handles and avatars in input order, pulling follower counts and viewer status from the sidecar without extra requests 86 + // the sidecar already carries follower counts and viewer status, so this costs 87 + // no extra requests 87 88 const resolvePeople = async ( 88 89 ctx: BobbinContext, 89 90 dids: string[], ··· 109 110 ? { 110 111 did: doc.did, 111 112 handle: doc.handle, 112 - avatar: doc.avatar, 113 113 followers, 114 114 following, 115 115 isSelf, ··· 136 136 uri: item.uri, 137 137 did: otherDid, 138 138 handle: doc?.handle ?? otherDid, 139 - avatar: doc?.avatar, 140 139 kind: value.kind === "denounce" ? "denounce" : "vouch", 141 140 direction, 142 141 reason: value.reason,
-1
web/src/routes/[handle]/[repo]/+layout.ts
··· 94 94 name: repoNameOf(view), 95 95 ownerDid: doc.did, 96 96 ownerHandle: doc.handle, 97 - ownerAvatar: doc.avatar, 98 97 repoDid, 99 98 knot: record.knot, 100 99 spindle: record.spindle,
+2 -1
web/src/routes/[handle]/[repo]/+page.ts
··· 72 72 ? await renderDocument(readme.filename, readme.contents, { 73 73 repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 74 74 ref, 75 - host: event.url.host 75 + host: event.url.host, 76 + camo: parent.publicConfig.camoEnabled 76 77 }) 77 78 : null; 78 79
+33
web/src/routes/avatar/[did]/+server.ts
··· 1 + import { createHmac } from "node:crypto"; 2 + import { error } from "@sveltejs/kit"; 3 + import { getConfig } from "$lib/server/config"; 4 + import type { RequestHandler } from "./$types"; 5 + 6 + // half a day, the same as what the service puts on the image 7 + const MAX_AGE = 43200; 8 + 9 + const DID = /^did:[a-z]+:[a-zA-Z0-9._:%-]{1,256}$/; 10 + 11 + // the service ignores anything else, so there is no point passing it on 12 + const PASSED_THROUGH = ["size", "format"]; 13 + 14 + export const GET: RequestHandler = ({ params, url }) => { 15 + const did = params.did; 16 + if (!DID.test(did)) error(400, "Not a did"); 17 + 18 + const { avatarUrl, avatarSecret } = getConfig(); 19 + if (!avatarSecret) error(404, "Avatars are not configured"); 20 + 21 + const query = new URLSearchParams(); 22 + for (const key of PASSED_THROUGH) { 23 + const value = url.searchParams.get(key); 24 + if (value) query.set(key, value); 25 + } 26 + 27 + const signature = createHmac("sha256", avatarSecret).update(did).digest("hex"); 28 + const target = `${avatarUrl}/${signature}/${did}${query.size ? `?${query}` : ""}`; 29 + return new Response(null, { 30 + status: 302, 31 + headers: { location: target, "cache-control": `public, max-age=${MAX_AGE}` } 32 + }); 33 + };
+39
web/src/routes/camo/[hex]/+server.ts
··· 1 + import { createHmac } from "node:crypto"; 2 + import { error } from "@sveltejs/kit"; 3 + import { getConfig } from "$lib/server/config"; 4 + import type { RequestHandler } from "./$types"; 5 + 6 + // the signed url only changes when the secret does, so it can cache for a day 7 + const MAX_AGE = 86400; 8 + 9 + const HEX = /^(?:[0-9a-f]{2})+$/; 10 + 11 + export const GET: RequestHandler = ({ params }) => { 12 + const hex = params.hex.toLowerCase(); 13 + if (!HEX.test(hex)) error(400, "Not a camo url"); 14 + 15 + const target = Buffer.from(hex, "hex").toString("utf8"); 16 + let parsed: URL; 17 + try { 18 + parsed = new URL(target); 19 + } catch { 20 + error(400, "Not a camo url"); 21 + } 22 + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { 23 + error(400, "Not a camo url"); 24 + } 25 + 26 + // redirecting unsigned would make this an open redirect, so no secret means 27 + // no images at all 28 + const { camoUrl, camoSecret } = getConfig(); 29 + if (!camoSecret) error(404, "Camo is not configured"); 30 + 31 + const signature = createHmac("sha256", camoSecret).update(target).digest("hex"); 32 + return new Response(null, { 33 + status: 302, 34 + headers: { 35 + location: `${camoUrl}/${signature}/${hex}`, 36 + "cache-control": `public, max-age=${MAX_AGE}` 37 + } 38 + }); 39 + };
+1 -1
web/src/routes/repo/new/+page.svelte
··· 54 54 <div 55 55 class="hidden shrink-0 items-center gap-1 px-2 py-2 text-sm text-foreground-muted md:flex md:rounded-l md:border md:border-r-0 md:border-border-default md:bg-background-inset" 56 56 > 57 - <Avatar src={user?.avatar} handle={user?.handle} size="size-5" /> 57 + <Avatar did={user?.did} handle={user?.handle} size="size-5" tiny /> 58 58 <span>{user?.handle ?? "…"}</span> 59 59 </div> 60 60 <input