From e947ad1c029947ef451ea058bb28f03d01726c21 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Sat, 21 Dec 2024 15:47:04 -0600 Subject: [PATCH] feat: finish implementing link verification. --- .../featured-social-media-button.svelte | 28 ++++--- .../social-media/social-media-button.svelte | 21 ++++-- src/lib/leaf/profile.ts | 23 ++++-- src/lib/link_verifier/LinkVerifier.test.ts | 24 ------ src/lib/link_verifier/LinkVerifier.ts | 74 ++++++++----------- ...> DefaultLinkVerificationStrategy.test.ts} | 36 ++++++--- .../DefaultLinkVerificationStrategy.ts | 30 ++++++++ .../GitHubLinkVerificationStrategy.ts | 25 ------- .../strategy/LinkVerificationStrategy.ts | 2 +- src/lib/verifiedLinks.ts | 53 +++++++++++++ src/routes/(app)/[username]/+layout.server.ts | 2 + src/routes/(app)/[username]/+page.svelte | 11 ++- 12 files changed, 200 insertions(+), 129 deletions(-) delete mode 100644 src/lib/link_verifier/LinkVerifier.test.ts rename src/lib/link_verifier/strategy/{GitHubLinkVerificationStrategy.test.ts => DefaultLinkVerificationStrategy.test.ts} (86%) create mode 100644 src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.ts delete mode 100644 src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.ts create mode 100644 src/lib/verifiedLinks.ts diff --git a/src/lib/components/social-media/featured-social-media-button.svelte b/src/lib/components/social-media/featured-social-media-button.svelte index b222249..c1c8058 100644 --- a/src/lib/components/social-media/featured-social-media-button.svelte +++ b/src/lib/components/social-media/featured-social-media-button.svelte @@ -3,18 +3,26 @@ import Icon from '@iconify/svelte'; export let url; + export let verified = false; const socialMedia = getSocialMediaDetails(url); const featuredSocialMedia = getFeaturedSocialMediaDetails(url); - - - - - +
+ {#if verified} + + + + {/if} + + + + + +
diff --git a/src/lib/components/social-media/social-media-button.svelte b/src/lib/components/social-media/social-media-button.svelte index 065d55f..9a82900 100644 --- a/src/lib/components/social-media/social-media-button.svelte +++ b/src/lib/components/social-media/social-media-button.svelte @@ -4,15 +4,24 @@ export let label = ''; export let url; + export let verified = false; const socialMedia = getSocialMediaDetails(url); - - {#if socialMedia?.icon} - - +
+ {#if verified} + + {/if} - {label || socialMedia?.name} - + + {#if socialMedia?.icon} + + + + {/if} + + {label || socialMedia?.name} + +
diff --git a/src/lib/leaf/profile.ts b/src/lib/leaf/profile.ts index 621a4fd..5307ad0 100644 --- a/src/lib/leaf/profile.ts +++ b/src/lib/leaf/profile.ts @@ -3,12 +3,12 @@ import { CommonMark, Description, RawImage, Name } from 'leaf-proto/components'; import _ from 'underscore'; import { usernames } from '$lib/usernames/index'; -import { LinkVerifier } from '$lib/link_verifier/LinkVerifier'; import { resolveUserSubspaceFromDNS } from '$lib/dns/resolve'; import { leafClient, subspace_link } from '.'; import type { ExactLink, IntoPathSegment, Unit } from 'leaf-proto'; import type { Benefit } from '$lib/billing'; +import { verifiedLinks } from '$lib/verifiedLinks'; /** A "complete" profile loaded from multiple components. */ export interface Profile { @@ -235,7 +235,10 @@ export async function getProfile(link: ExactLink): Promise ); } -export async function setProfile(link: ExactLink, profile: Profile) { +/** + * Sets the profile data. Usually you want to call `setProfileById` instead, since it will also + * update the profile's verified links. */ +export async function setRawProfile(link: ExactLink, profile: Profile) { await leafClient.update_components(link, [ profile.display_name ? new Name(profile.display_name) : Name, profile.bio ? new Description(profile.bio) : Description, @@ -304,11 +307,17 @@ export async function getProfileByDomain(domain: string): Promise { let link = await profileLinkById(rauthyId); if (!link) throw `user has not yet claimed a username.`; - const userName = await usernames.getByRauthyId(rauthyId); - if (!userName) throw `user has no username`; - const linkVerifier = new LinkVerifier(profile.links, userName); - await linkVerifier.verify(); - await setProfile(link, profile); + + // Update the user's verified links + const username = await usernames.getByRauthyId(rauthyId); + if (username) { + await verifiedLinks.verify( + username, + profile.links.map((x) => x.url) + ); + } + + await setRawProfile(link, profile); } export async function deleteAllProfileDataById(rauthyId: string) { const subspace = await usernames.subspaceByRauthyId(rauthyId); diff --git a/src/lib/link_verifier/LinkVerifier.test.ts b/src/lib/link_verifier/LinkVerifier.test.ts deleted file mode 100644 index ec39b1c..0000000 --- a/src/lib/link_verifier/LinkVerifier.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from 'vitest'; - -import { LinkVerifier } from './LinkVerifier'; - -test('filters verifiable origins', () => { - const webLinks = [ - { - url: 'https://github.com/EstebanBorai', - label: 'GitHub' - }, - { - url: 'https://www.nytimes.com/', - label: 'The New York Times' - } - ]; - const linkVerifier = new LinkVerifier(webLinks, 'username'); - const filteredLinks = linkVerifier.links.map((webLink) => ({ - url: webLink.url, - label: webLink.label - })); - - expect(filteredLinks.find(({ url }) => webLinks[0].url === url)).toBeTruthy(); - expect(filteredLinks.find(({ url }) => webLinks[1].url === url)).toBeFalsy(); -}); diff --git a/src/lib/link_verifier/LinkVerifier.ts b/src/lib/link_verifier/LinkVerifier.ts index 272195d..a3b51a8 100644 --- a/src/lib/link_verifier/LinkVerifier.ts +++ b/src/lib/link_verifier/LinkVerifier.ts @@ -1,36 +1,26 @@ import { parseHTML } from 'linkedom'; -import { GitHubLinkVerificationStrategy } from './strategy/GitHubLinkVerificationStrategy'; -import type { WebLink } from '$lib/leaf/profile'; import type { LinkVerificationStrategyFactory } from './strategy/LinkVerificationStrategy'; -import { env } from '$env/dynamic/public'; +import { DefaultLinkVerificationStrategy } from './strategy/DefaultLinkVerificationStrategy'; export const VERIFIABLE_ORIGIN_STRATEGY: Record = { - 'https://github.com': (dom) => new GitHubLinkVerificationStrategy(dom) + // Put custom link verifiers for specific domains here once we have them. + // 'https://github.com': GitHubLinkVerificationStrategy }; export const VERIFIABLE_ORIGINS: string[] = Object.keys(VERIFIABLE_ORIGIN_STRATEGY); -export function verifiableOriginFilter(webLink: WebLink): boolean { - return ( - VERIFIABLE_ORIGINS.findIndex((url) => new URL(webLink.url).origin === new URL(url).origin) !== - -1 - ); -} - -export type LinkArray = { label?: string; url: string }[]; - export class LinkVerifier { - private webLinks: LinkArray; + private webLinks: string[]; private userName: string; - constructor(links: LinkArray, userName: string) { - this.webLinks = links.filter(verifiableOriginFilter); - this.userName = userName; + constructor(links: string[], username: string) { + this.webLinks = links; + this.userName = username; } - private static async fetchHtml(webLink: WebLink): Promise { - const res = await fetch(webLink.url); + private static async fetchHtml(webLink: string): Promise { + const res = await fetch(webLink); if (res.status === 200) { const resText = await res.text(); @@ -38,44 +28,40 @@ export class LinkVerifier { } throw new Error( - `Failed to fetch "${webLink.url}", expected a 200 HTTP Response, got "${res.status}" instead.` + `Failed to fetch "${webLink}", expected a 200 HTTP Response, got "${res.status}" instead.` ); } - get links(): WebLink[] { + get links(): string[] { return [...this.webLinks]; } - async verify(): Promise { - const verifiedLinks: WebLink[] = []; + async verify(): Promise { + const verifiedLinks: string[] = []; for (const webLink of this.webLinks) { - const origin = new URL(webLink.url).origin; - const linkVerificationStrategyFactory = VERIFIABLE_ORIGIN_STRATEGY[ - origin - ] as LinkVerificationStrategyFactory | null; - - if (typeof linkVerificationStrategyFactory === 'function') { - const dom = await LinkVerifier.fetchHtml(webLink); - const strategy = linkVerificationStrategyFactory(dom); - const isVerified = await strategy.verify(this.userProfileLink()); - - if (isVerified) { - verifiedLinks.push(webLink); - } - + const origin = new URL(webLink).origin; + const linkVerificationStrategyFactory = + (VERIFIABLE_ORIGIN_STRATEGY[origin] as LinkVerificationStrategyFactory) || + DefaultLinkVerificationStrategy; + + let dom; + try { + dom = await LinkVerifier.fetchHtml(webLink); + } catch (_) { + // If it can't be fetched, it just isn't verified. continue; } + const strategy = new linkVerificationStrategyFactory(dom); + const isVerified = await strategy.verify(this.userName); + + if (isVerified) { + verifiedLinks.push(webLink); + } - // This should not happen, but if we got here somehow its likely we got a false positive - // in the origins map. - throw new Error(`The WebLink with URL "${webLink.url}" is not supported by any strategy.`); + continue; } return verifiedLinks; } - - private userProfileLink(): string { - return `${env.PUBLIC_URL}/${this.userName}`; - } } diff --git a/src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.test.ts b/src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.test.ts similarity index 86% rename from src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.test.ts rename to src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.test.ts index 4ebdf22..f0dd8a2 100644 --- a/src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.test.ts +++ b/src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.test.ts @@ -1,9 +1,10 @@ import { parseHTML } from 'linkedom'; import { expect, test } from 'vitest'; -import { GitHubLinkVerificationStrategy } from './GitHubLinkVerificationStrategy'; +import { DefaultLinkVerificationStrategy } from './DefaultLinkVerificationStrategy'; +import { env } from '$env/dynamic/public'; -const GITHUB_PROFILE_SNIPPET = ` +const makeGithubProfileSnippet = (weirdUrl: string) => ` `; -test('verifies an owned githubs link', async () => { - const dom = parseHTML(GITHUB_PROFILE_SNIPPET); - const gitHubLinkVerificationStrategy = new GitHubLinkVerificationStrategy(dom); - const isOwner = await gitHubLinkVerificationStrategy.verify('https://a.weird.one/estebanborai'); +test('verifies an owned githubs link to pubpage', async () => { + const dom = parseHTML(makeGithubProfileSnippet('http://estebanborai.user.localhost:9523')); + const gitHubLinkVerificationStrategy = new DefaultLinkVerificationStrategy(dom); + const isOwner = await gitHubLinkVerificationStrategy.verify('estebanborai.user.localhost:9523'); + + expect(isOwner).toStrictEqual(true); +}); + +test('verifies an owned githubs link to weird app page ( short )', async () => { + const dom = parseHTML(makeGithubProfileSnippet(env.PUBLIC_URL + '/estebanborai')); + const gitHubLinkVerificationStrategy = new DefaultLinkVerificationStrategy(dom); + const isOwner = await gitHubLinkVerificationStrategy.verify('estebanborai.user.localhost:9523'); + + expect(isOwner).toStrictEqual(true); +}); +test('verifies an owned githubs link to weird app page ( long )', async () => { + const dom = parseHTML(makeGithubProfileSnippet(env.PUBLIC_URL + '/estebanborai.user.localhost:9523')); + const gitHubLinkVerificationStrategy = new DefaultLinkVerificationStrategy(dom); + const isOwner = await gitHubLinkVerificationStrategy.verify('estebanborai.user.localhost:9523'); expect(isOwner).toStrictEqual(true); }); test('invalidates a non owners github link', async () => { - const dom = parseHTML(GITHUB_PROFILE_SNIPPET); - const gitHubLinkVerificationStrategy = new GitHubLinkVerificationStrategy(dom); - const isOwner = await gitHubLinkVerificationStrategy.verify('https://a.weird.one/zicklag'); + const dom = parseHTML(makeGithubProfileSnippet('http://estebanborai.user.localhost:9523')); + const gitHubLinkVerificationStrategy = new DefaultLinkVerificationStrategy(dom); + const isOwner = await gitHubLinkVerificationStrategy.verify('zicklag.weird.one'); expect(isOwner).toStrictEqual(false); }); diff --git a/src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.ts b/src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.ts new file mode 100644 index 0000000..494cf62 --- /dev/null +++ b/src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.ts @@ -0,0 +1,30 @@ +import { env } from '$env/dynamic/public'; +import { usernames } from '$lib/usernames/client'; +import { LinkVerificationStrategy } from './LinkVerificationStrategy'; + +export class DefaultLinkVerificationStrategy extends LinkVerificationStrategy { + constructor(dom: Window) { + super('DefaultLinkVerificationStrategy', dom); + } + + async verify(userDomain: string): Promise { + const document = this.dom.document; + const nodes = Array.from(document.querySelectorAll('a[rel~="me"]')); + for (const node of nodes) { + const href = node.getAttribute('href'); + try { + if (href) { + const url = new URL(href); + if ( + url.host == userDomain || + url.href == env.PUBLIC_URL + '/' + userDomain || + url.href == env.PUBLIC_URL + '/' + usernames.shortNameOrDomain(userDomain) + ) + return true; + } + } catch (_) {} // Just in case URL is invalid + } + + return false; + } +} diff --git a/src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.ts b/src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.ts deleted file mode 100644 index fcdc210..0000000 --- a/src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { LinkVerificationStrategy } from './LinkVerificationStrategy'; - -export class GitHubLinkVerificationStrategy extends LinkVerificationStrategy { - constructor(dom: Window) { - super('GitHubLinkVerificationStrategy', dom); - } - - async verify(userProfileLink: string): Promise { - const document = this.dom.document; - const nodes = Array.from(document.querySelectorAll('a[rel="nofollow me"]')); - const element = nodes.find((node) => node.getAttribute('href')?.startsWith(userProfileLink)); - - if (!element) { - return false; - } - - const href = element.getAttribute('href'); - - if (!href) { - return false; - } - - return href.startsWith(userProfileLink); - } -} diff --git a/src/lib/link_verifier/strategy/LinkVerificationStrategy.ts b/src/lib/link_verifier/strategy/LinkVerificationStrategy.ts index 5fd649b..3dd40ba 100644 --- a/src/lib/link_verifier/strategy/LinkVerificationStrategy.ts +++ b/src/lib/link_verifier/strategy/LinkVerificationStrategy.ts @@ -3,7 +3,7 @@ export interface ILinkVerificationStrategy { verify(userProfileLink: string): Promise; } -export type LinkVerificationStrategyFactory = (dom: Window) => ILinkVerificationStrategy; +export type LinkVerificationStrategyFactory = new (dom: Window) => ILinkVerificationStrategy; export abstract class LinkVerificationStrategy implements ILinkVerificationStrategy { protected dom: Window; diff --git a/src/lib/verifiedLinks.ts b/src/lib/verifiedLinks.ts new file mode 100644 index 0000000..d56014d --- /dev/null +++ b/src/lib/verifiedLinks.ts @@ -0,0 +1,53 @@ +import { BorshSchema, Component, type ExactLink } from 'leaf-proto'; +import { instance_link, leafClient } from './leaf'; +import { CommonMark } from 'leaf-proto/components'; +import { LinkVerifier } from './link_verifier/LinkVerifier'; + +const verifiedLinksPrefix = 'verified_links'; + +export class VerifiedLinks extends Component { + value: string[]; + constructor(links: (string | URL)[] = []) { + super(); + this.value = links.map((x) => new URL(x).href); + } + static componentName(): string { + return 'VerifiedLinks'; + } + static borshSchema(): BorshSchema { + return BorshSchema.Vec(BorshSchema.String); + } + static specification(): Component[] { + return [ + new CommonMark(`A list of links that have been verified by the Weird server\ +as pointing at a user's profile.`) + ]; + } +} + +function entityLinkForUserVerifiedLinks(username: string): ExactLink { + return instance_link(verifiedLinksPrefix, username); +} + +export const verifiedLinks = { + /** Gets the list of URLs that have been verified for the given user ID. */ + async get(username: string): Promise { + const entityLink = entityLinkForUserVerifiedLinks(username); + const verifiedLinksEnt = await leafClient.get_components(entityLink, VerifiedLinks); + return verifiedLinksEnt?.get(VerifiedLinks)?.value || []; + }, + + /** Sets the list of URLs that have been verified for the given user ID. */ + async set(username: string, links: (string | URL)[]) { + const linksComponent = new VerifiedLinks(links); + const entityLink = entityLinkForUserVerifiedLinks(username); + await leafClient.update_components(entityLink, [linksComponent]); + }, + + /** Verifies and updates the verified links for a user, given the links from their profile. */ + async verify(username: string, links: string[]) { + const linkVerifier = new LinkVerifier(links, username); + const verifiedLinks = await linkVerifier.verify(); + this.set(username, verifiedLinks); + } +}; diff --git a/src/routes/(app)/[username]/+layout.server.ts b/src/routes/(app)/[username]/+layout.server.ts index daecd52..25fd1ea 100644 --- a/src/routes/(app)/[username]/+layout.server.ts +++ b/src/routes/(app)/[username]/+layout.server.ts @@ -7,6 +7,7 @@ import { Name } from 'leaf-proto/components'; import { usernames } from '$lib/usernames/index'; import { base32Encode } from 'leaf-proto'; import { billing, type UserSubscriptionInfo } from '$lib/billing'; +import { verifiedLinks } from '$lib/verifiedLinks'; export const load: LayoutServerLoad = async ({ fetch, params, request }) => { const username = usernames.shortNameOrDomain(params.username!); @@ -54,6 +55,7 @@ export const load: LayoutServerLoad = async ({ fetch, params, request }) => { return { profile, + verifiedLinks: await verifiedLinks.get(fullUsername), profileMatchesUserSession, pages, username: fullUsername, diff --git a/src/routes/(app)/[username]/+page.svelte b/src/routes/(app)/[username]/+page.svelte index 80ebed2..ccd126a 100644 --- a/src/routes/(app)/[username]/+page.svelte +++ b/src/routes/(app)/[username]/+page.svelte @@ -199,7 +199,10 @@ {#if !editingState.editing}
{#each featuredProfileLinks as link} - + {/each}
{/if} @@ -279,7 +282,11 @@ {#if !editingState.editing}
    {#each normalProfileLinks as link (link.url)} - + {/each}
{:else}