diff --git a/web/src/lib/api/profile.test.ts b/web/src/lib/api/profile.test.ts index 1f20db8c4..4470a68b6 100644 --- a/web/src/lib/api/profile.test.ts +++ b/web/src/lib/api/profile.test.ts @@ -1,8 +1,8 @@ -import { expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "$lib/api/client"; import { FOLLOW_VIEWER, STAR_VIEWER } from "$lib/api/descriptors"; import { TYPE_VIEWER } from "$lib/api/enrich"; -import { getTrendingWithViewerState } from "$lib/api/profile"; +import { getTrendingWithViewerState, normalizeProfileLink } from "$lib/api/profile"; it("gets viewer-neutral trending data with relationship state in the enrichment sidecar", async () => { const fetchMock = vi.fn().mockResolvedValue( @@ -34,3 +34,28 @@ it("gets viewer-neutral trending data with relationship state in the enrichment ] }); }); + +describe("normalizeProfileLink", () => { + it("treats blank input as no link", () => { + expect(normalizeProfileLink("")).toBeUndefined(); + expect(normalizeProfileLink(" ")).toBeUndefined(); + }); + + it("assumes https for a bare domain", () => { + expect(normalizeProfileLink("example.com")).toBe("https://example.com/"); + }); + + it("keeps an explicit scheme and normalizes the url", () => { + expect(normalizeProfileLink(" http://example.com/a ")).toBe("http://example.com/a"); + expect(normalizeProfileLink("https://example.com")).toBe("https://example.com/"); + }); + + it("rejects non-http(s) schemes", () => { + expect(() => normalizeProfileLink("mailto:someone@example.com")).toThrow(/http or https/); + expect(() => normalizeProfileLink("javascript:alert(1)")).toThrow(/http or https/); + }); + + it("rejects input that is not a url", () => { + expect(() => normalizeProfileLink("http://")).toThrow(/valid URL/); + }); +}); diff --git a/web/src/lib/api/profile.ts b/web/src/lib/api/profile.ts index 87abd1966..950c56184 100644 --- a/web/src/lib/api/profile.ts +++ b/web/src/lib/api/profile.ts @@ -34,6 +34,24 @@ export const getTrendingWithViewerState = ( init ); +export type ProfileLink = NonNullable[number]; + +export const normalizeProfileLink = (raw: string): ProfileLink | undefined => { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + let url: URL; + try { + url = new URL(/^[a-z][a-z\d+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`); + } catch { + throw new Error("Website must be a valid URL."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Website must use http or https."); + } + return url.toString() as ProfileLink; +}; + export const uploadProfileAvatar = async ( agent: OAuthUserAgent, image: Blob