diff --git a/web/src/app.d.ts b/web/src/app.d.ts --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -5,7 +5,7 @@ bobbinUrl: string; apiUrl: string; knotResolverUrl: string; - camoUrl: string; + camoEnabled: boolean; }; } } diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -59,13 +59,11 @@ export interface AuthProfile { did: Did; handle: string; - avatar?: string; } export interface CurrentUser { did: Did; handle: string; - avatar?: string; } export type { AuthAccount } from "./auth/accounts"; @@ -94,7 +92,6 @@ did: Did; handle: string; pds?: string; - avatar?: string; }; type OAuthSession = ConstructorParameters[0]; @@ -142,8 +139,7 @@ const profile = (await response.json()) as MiniDoc; return { did: profile.did, - handle: profile.handle, - avatar: profile.avatar + handle: profile.handle }; } } catch { @@ -204,7 +200,6 @@ const meta = upsertAccount(loadAccounts(), { did, handle: profile.handle, - avatar: profile.avatar, addedAt: Math.floor(Date.now() / 1000) }); saveAccounts(meta); @@ -396,8 +391,7 @@ if (!currentDid) return null; return { did: currentDid, - handle: profile?.handle ?? currentDid, - avatar: profile?.avatar + handle: profile?.handle ?? currentDid }; }, get accounts() { diff --git a/web/src/lib/avatar.ts b/web/src/lib/avatar.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/avatar.ts @@ -0,0 +1,4 @@ +// the service wants signed urls and the secret is server side, so this points +// at the route that signs +export const avatarUrl = (did: string, tiny = false): string => + `/avatar/${encodeURIComponent(did)}${tiny ? "?size=tiny" : ""}`; diff --git a/web/src/lib/api/identity.ts b/web/src/lib/api/identity.ts --- a/web/src/lib/api/identity.ts +++ b/web/src/lib/api/identity.ts @@ -5,7 +5,6 @@ did: string; handle: string; pds?: string; - avatar?: string; } export const resolveMiniDoc = ( diff --git a/web/src/lib/auth/accounts.ts b/web/src/lib/auth/accounts.ts --- a/web/src/lib/auth/accounts.ts +++ b/web/src/lib/auth/accounts.ts @@ -13,7 +13,6 @@ export interface AuthAccount { did: Did; handle: string; - avatar?: string; // unix seconds; appview parity addedAt: number; } @@ -37,7 +36,6 @@ return parsed.filter(isAccount).map((account) => ({ did: account.did, handle: account.handle, - avatar: account.avatar, addedAt: typeof account.addedAt === "number" ? account.addedAt : 0 })); } catch { @@ -50,7 +48,7 @@ localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); }; -// stored sessions are authoritative; metadata supplies order, handle, and avatar. +// stored sessions are authoritative, metadata only supplies order and handle export const reconcileAccounts = ( stored: readonly Did[], meta: readonly AuthAccount[] diff --git a/web/src/lib/markup/markdown.test.ts b/web/src/lib/markup/markdown.test.ts --- a/web/src/lib/markup/markdown.test.ts +++ b/web/src/lib/markup/markdown.test.ts @@ -81,6 +81,36 @@ ); }); + it("leaves off-site images alone when there is no camo", () => { + expect(render("![x](https://example.com/a.png)")).toContain('src="https://example.com/a.png"'); + }); + + describe("with camo", () => { + const camo = (source: string) => render(source, { camo: true }); + // hex of https://example.com/a.png, which is what camo signs + const hex = "68747470733a2f2f6578616d706c652e636f6d2f612e706e67"; + + it("sends an off-site image to the route that signs for camo", () => { + expect(camo("![x](https://example.com/a.png)")).toContain(`src="/camo/${hex}"`); + }); + + it("proxies raw html images and srcset candidates too", () => { + expect(camo('')).toContain(`src="/camo/${hex}"`); + expect(camo('')).toContain( + `srcset="/camo/${hex} 2x"` + ); + }); + + it("leaves our own images and repo files direct", () => { + expect(camo("![x](https://tangled.org/a.png)")).toContain('src="https://tangled.org/a.png"'); + expect(camo("![x](assets/a.png)")).toContain('src="/ada.test/infra/raw/main/assets/a.png"'); + }); + + it("does not proxy links, only what the page loads by itself", () => { + expect(camo("[x](https://example.com/a.png)")).toContain('href="https://example.com/a.png"'); + }); + }); + it("links a bare handle to its profile", () => { const html = render("thanks @ada.test for the fix"); expect(html).toContain('@ada.test'); diff --git a/web/src/lib/markup/paths.ts b/web/src/lib/markup/paths.ts --- a/web/src/lib/markup/paths.ts +++ b/web/src/lib/markup/paths.ts @@ -4,6 +4,7 @@ ref: string; dir?: string; host?: string; + camo?: boolean; } const ABSOLUTE = /^[a-z][a-z0-9+.-]*:|^\/\//i; @@ -45,12 +46,32 @@ export const rawUrl = (url: string, ctx: MarkupContext): string => repoUrl("raw", url, ctx); -export const rawSrcset = (srcset: string, ctx: MarkupContext): string => +const hostOf = (url: string): string | null => { + try { + // the base only matters for protocol relative urls, which have a host anyway + return new URL(url, "https://invalid.").host; + } catch { + return null; + } +}; + +// camo wants the target hex encoded +const toHex = (value: string): string => + Array.from(new TextEncoder().encode(value), (byte) => byte.toString(16).padStart(2, "0")).join( + "" + ); + +export const mediaUrl = (url: string, ctx: MarkupContext): string => { + if (isRepoRelative(url)) return rawUrl(url, ctx); + if (!ctx.camo || !isAbsoluteUrl(url) || hostOf(url) === ctx.host) return url; + return `/camo/${toHex(url)}`; +}; + +export const mediaSrcset = (srcset: string, ctx: MarkupContext): string => srcset .split(",") .map((candidate) => { const [url, ...descriptors] = candidate.trim().split(/\s+/); - if (!isRepoRelative(url)) return candidate.trim(); - return [rawUrl(url, ctx), ...descriptors].join(" "); + return [mediaUrl(url, ctx), ...descriptors].join(" "); }) .join(", "); diff --git a/web/src/lib/markup/sanitize.ts b/web/src/lib/markup/sanitize.ts --- a/web/src/lib/markup/sanitize.ts +++ b/web/src/lib/markup/sanitize.ts @@ -1,5 +1,5 @@ import sanitizeHtml from "sanitize-html"; -import { isRepoRelative, rawSrcset, rawUrl, treeUrl } from "./paths"; +import { isRepoRelative, mediaSrcset, mediaUrl, treeUrl } from "./paths"; import type { MarkupContext } from "./paths"; const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]; @@ -141,8 +141,6 @@ exclusiveFilter: (frame) => frame.tag === "input" && frame.attribs.type !== "checkbox" }); -// todo: external images should go through camo like the appview does, which needs -// the shared secret in web's config and moves rendering server side const resolveMedia = ( attribs: Record, ctx: MarkupContext @@ -150,9 +148,9 @@ const resolved = { ...attribs }; for (const key of ["src", "poster"]) { const value = resolved[key]; - if (value && isRepoRelative(value)) resolved[key] = rawUrl(value, ctx); + if (value) resolved[key] = mediaUrl(value, ctx); } - if (resolved.srcset) resolved.srcset = rawSrcset(resolved.srcset, ctx); + if (resolved.srcset) resolved.srcset = mediaSrcset(resolved.srcset, ctx); return resolved; }; diff --git a/web/src/lib/server/config.ts b/web/src/lib/server/config.ts --- a/web/src/lib/server/config.ts +++ b/web/src/lib/server/config.ts @@ -10,12 +10,16 @@ apiUrl: string; knotResolverUrl: string; camoUrl: string; + avatarUrl: string; + /** the secrets camo and avatar sign with, so neither leaves the server */ + camoSecret: string; + avatarSecret: string; }; -export type PublicWebConfig = Pick< - WebConfig, - "bobbinUrl" | "apiUrl" | "knotResolverUrl" | "camoUrl" ->; +export type PublicWebConfig = Pick & { + /** camo has a secret, so markup can route images through it */ + camoEnabled: boolean; +}; type WebConfigEnv = { BOBBIN_URL?: string; @@ -23,15 +27,31 @@ API_URL?: string; KNOT_RESOLVER_URL?: string; CAMO_URL?: string; + CAMO_SHARED_SECRET?: string; + AVATAR_URL?: string; + AVATAR_SHARED_SECRET?: string; }; export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, "https://knot1.tangled.sh"), - camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh") + camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), + avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"), + camoSecret: values.CAMO_SHARED_SECRET?.trim() ?? "", + avatarSecret: values.AVATAR_SHARED_SECRET?.trim() ?? "" }); export const getConfig = (): WebConfig => resolveConfig(env as WebConfigEnv); -export const getPublicConfig = (): PublicWebConfig => getConfig(); +// listed one by one, so anything new in WebConfig stays private until it is +// named here +export const getPublicConfig = (): PublicWebConfig => { + const config = getConfig(); + return { + bobbinUrl: config.bobbinUrl, + apiUrl: config.apiUrl, + knotResolverUrl: config.knotResolverUrl, + camoEnabled: config.camoSecret !== "" + }; +}; diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts --- a/web/src/routes/[handle]/+layout.ts +++ b/web/src/routes/[handle]/+layout.ts @@ -66,7 +66,7 @@ const notJoined = !profile && Object.values(counts).every((n) => n === 0); return { - identity: { did, handle: doc.handle, avatar: doc.avatar }, + identity: { did, handle: doc.handle }, profile, counts, viewerFollowRkey: raw.viewerFollowRkey, diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -83,7 +83,8 @@ }; }; -// resolves handles and avatars in input order, pulling follower counts and viewer status from the sidecar without extra requests +// the sidecar already carries follower counts and viewer status, so this costs +// no extra requests const resolvePeople = async ( ctx: BobbinContext, dids: string[], @@ -109,7 +110,6 @@ ? { did: doc.did, handle: doc.handle, - avatar: doc.avatar, followers, following, isSelf, @@ -136,7 +136,6 @@ uri: item.uri, did: otherDid, handle: doc?.handle ?? otherDid, - avatar: doc?.avatar, kind: value.kind === "denounce" ? "denounce" : "vouch", direction, reason: value.reason, diff --git a/web/src/lib/components/profile/FollowCard.svelte b/web/src/lib/components/profile/FollowCard.svelte --- a/web/src/lib/components/profile/FollowCard.svelte +++ b/web/src/lib/components/profile/FollowCard.svelte @@ -18,7 +18,7 @@
- +
diff --git a/web/src/lib/components/profile/ProfileCard.svelte b/web/src/lib/components/profile/ProfileCard.svelte --- a/web/src/lib/components/profile/ProfileCard.svelte +++ b/web/src/lib/components/profile/ProfileCard.svelte @@ -6,7 +6,7 @@ import Link from "$icon/link"; import Pencil from "$icon/pencil"; import Rss from "$icon/rss"; - import UserRound from "$icon/user-round"; + import Avatar from "$lib/components/ui/Avatar.svelte"; import Button from "$lib/components/ui/Button.svelte"; import { getAuth } from "$lib/auth.svelte"; import type { ProfileRecord } from "$lib/api/records"; @@ -14,7 +14,6 @@ interface Identity { did: string; handle: string; - avatar?: string; } interface Props { @@ -41,21 +40,12 @@
- {#if identity.avatar} - {identity.handle} - {:else} - - {/if} +
diff --git a/web/src/lib/components/profile/types.ts b/web/src/lib/components/profile/types.ts --- a/web/src/lib/components/profile/types.ts +++ b/web/src/lib/components/profile/types.ts @@ -45,7 +45,6 @@ export interface PersonData { did: string; handle: string; - avatar?: string; description?: string; followers?: number; following?: number; @@ -57,7 +56,6 @@ uri: string; did: string; handle: string; - avatar?: string; kind: "vouch" | "denounce"; direction: "incoming" | "outgoing"; reason?: string; diff --git a/web/src/lib/components/repo/RepoHeader.svelte b/web/src/lib/components/repo/RepoHeader.svelte --- a/web/src/lib/components/repo/RepoHeader.svelte +++ b/web/src/lib/components/repo/RepoHeader.svelte @@ -32,7 +32,7 @@ href={resolve(`/${repo.ownerHandle}` as "/")} class="flex items-center gap-2 text-foreground-default no-underline hover:underline" > - + {repo.ownerHandle} / diff --git a/web/src/lib/components/repo/types.ts b/web/src/lib/components/repo/types.ts --- a/web/src/lib/components/repo/types.ts +++ b/web/src/lib/components/repo/types.ts @@ -2,14 +2,12 @@ export type { BranchSummary, CommitSummary, TagSummary, TreeEntrySummary }; -/** the repo every page under [handle]/[repo] is scoped to */ export interface RepoInfo { uri: string; rkey: string; name: string; ownerDid: string; ownerHandle: string; - ownerAvatar?: string; repoDid?: string; knot: string; spindle?: string; diff --git a/web/src/lib/components/shell/Topbar.svelte b/web/src/lib/components/shell/Topbar.svelte --- a/web/src/lib/components/shell/Topbar.svelte +++ b/web/src/lib/components/shell/Topbar.svelte @@ -1,5 +1,6 @@ + diff --git a/web/src/lib/components/ui/Avatar.svelte b/web/src/lib/components/ui/Avatar.svelte --- a/web/src/lib/components/ui/Avatar.svelte +++ b/web/src/lib/components/ui/Avatar.svelte @@ -1,25 +1,31 @@ -{#if src && !failed} +{#if source && !failed} {handle (failed = true)} diff --git a/web/src/lib/components/ui/User.stories.svelte b/web/src/lib/components/ui/User.stories.svelte --- a/web/src/lib/components/ui/User.stories.svelte +++ b/web/src/lib/components/ui/User.stories.svelte @@ -8,7 +8,7 @@ tags: ["autodocs"], argTypes: { handle: { control: "text" }, - src: { control: "text" }, + did: { control: "text" }, size: { control: { type: "inline-radio" }, options: ["header", "large", "regular", "small", "mini"] @@ -26,7 +26,7 @@ - + diff --git a/web/src/lib/components/ui/User.svelte b/web/src/lib/components/ui/User.svelte --- a/web/src/lib/components/ui/User.svelte +++ b/web/src/lib/components/ui/User.svelte @@ -54,7 +54,7 @@ interface Props { handle?: string; - src?: string; + did?: string; size?: UserVariants["size"]; showImage?: boolean; showText?: boolean; @@ -63,7 +63,7 @@ let { handle, - src, + did, size = "regular", showImage = true, showText = true, @@ -81,7 +81,8 @@ {#if showImage} - + + {/if} {#if showText && handle} {handle} diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.ts --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.ts @@ -94,7 +94,6 @@ name: repoNameOf(view), ownerDid: doc.did, ownerHandle: doc.handle, - ownerAvatar: doc.avatar, repoDid, knot: record.knot, spindle: record.spindle, diff --git a/web/src/routes/[handle]/[repo]/+page.ts b/web/src/routes/[handle]/[repo]/+page.ts --- a/web/src/routes/[handle]/[repo]/+page.ts +++ b/web/src/routes/[handle]/[repo]/+page.ts @@ -72,7 +72,8 @@ ? await renderDocument(readme.filename, readme.contents, { repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, ref, - host: event.url.host + host: event.url.host, + camo: parent.publicConfig.camoEnabled }) : null; diff --git a/web/src/routes/avatar/[did]/+server.ts b/web/src/routes/avatar/[did]/+server.ts new file mode 100644 --- /dev/null +++ b/web/src/routes/avatar/[did]/+server.ts @@ -0,0 +1,33 @@ +import { createHmac } from "node:crypto"; +import { error } from "@sveltejs/kit"; +import { getConfig } from "$lib/server/config"; +import type { RequestHandler } from "./$types"; + +// half a day, the same as what the service puts on the image +const MAX_AGE = 43200; + +const DID = /^did:[a-z]+:[a-zA-Z0-9._:%-]{1,256}$/; + +// the service ignores anything else, so there is no point passing it on +const PASSED_THROUGH = ["size", "format"]; + +export const GET: RequestHandler = ({ params, url }) => { + const did = params.did; + if (!DID.test(did)) error(400, "Not a did"); + + const { avatarUrl, avatarSecret } = getConfig(); + if (!avatarSecret) error(404, "Avatars are not configured"); + + const query = new URLSearchParams(); + for (const key of PASSED_THROUGH) { + const value = url.searchParams.get(key); + if (value) query.set(key, value); + } + + const signature = createHmac("sha256", avatarSecret).update(did).digest("hex"); + const target = `${avatarUrl}/${signature}/${did}${query.size ? `?${query}` : ""}`; + return new Response(null, { + status: 302, + headers: { location: target, "cache-control": `public, max-age=${MAX_AGE}` } + }); +}; diff --git a/web/src/routes/camo/[hex]/+server.ts b/web/src/routes/camo/[hex]/+server.ts new file mode 100644 --- /dev/null +++ b/web/src/routes/camo/[hex]/+server.ts @@ -0,0 +1,39 @@ +import { createHmac } from "node:crypto"; +import { error } from "@sveltejs/kit"; +import { getConfig } from "$lib/server/config"; +import type { RequestHandler } from "./$types"; + +// the signed url only changes when the secret does, so it can cache for a day +const MAX_AGE = 86400; + +const HEX = /^(?:[0-9a-f]{2})+$/; + +export const GET: RequestHandler = ({ params }) => { + const hex = params.hex.toLowerCase(); + if (!HEX.test(hex)) error(400, "Not a camo url"); + + const target = Buffer.from(hex, "hex").toString("utf8"); + let parsed: URL; + try { + parsed = new URL(target); + } catch { + error(400, "Not a camo url"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + error(400, "Not a camo url"); + } + + // redirecting unsigned would make this an open redirect, so no secret means + // no images at all + const { camoUrl, camoSecret } = getConfig(); + if (!camoSecret) error(404, "Camo is not configured"); + + const signature = createHmac("sha256", camoSecret).update(target).digest("hex"); + return new Response(null, { + status: 302, + headers: { + location: `${camoUrl}/${signature}/${hex}`, + "cache-control": `public, max-age=${MAX_AGE}` + } + }); +}; diff --git a/web/src/routes/repo/new/+page.svelte b/web/src/routes/repo/new/+page.svelte --- a/web/src/routes/repo/new/+page.svelte +++ b/web/src/routes/repo/new/+page.svelte @@ -54,7 +54,7 @@