Skip to content

Commit

Permalink
feat: finish implementing link verification.
Browse files Browse the repository at this point in the history
  • Loading branch information
zicklag committed Dec 21, 2024
1 parent c7517ec commit e947ad1
Show file tree
Hide file tree
Showing 12 changed files with 200 additions and 129 deletions.
28 changes: 18 additions & 10 deletions src/lib/components/social-media/featured-social-media-button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@
import Icon from '@iconify/svelte';
export let url;
export let verified = false;
const socialMedia = getSocialMediaDetails(url);
const featuredSocialMedia = getFeaturedSocialMediaDetails(url);
</script>

<a
href={url}
target="_blank"
title={featuredSocialMedia?.name || socialMedia.name}
class="variant-outline-primary btn btn-icon-sm"
>
<span>
<Icon icon={featuredSocialMedia?.icon || socialMedia.icon} class="h-6 w-6" />
</span>
</a>
<div class="relative">
{#if verified}
<span class="badge-icon absolute -right-1 -top-2 z-10">
<Icon icon="ph:seal-check-fill" font-size="40" class="text-blue-400" />
</span>
{/if}
<a
href={url}
target="_blank"
title={featuredSocialMedia?.name || socialMedia.name}
class="variant-outline-primary btn btn-icon-sm"
>
<span>
<Icon icon={featuredSocialMedia?.icon || socialMedia.icon} class="h-6 w-6" />
</span>
</a>
</div>
21 changes: 15 additions & 6 deletions src/lib/components/social-media/social-media-button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,24 @@
export let label = '';
export let url;
export let verified = false;
const socialMedia = getSocialMediaDetails(url);
</script>

<a href={url} target="_blank" class="variant-filled btn">
{#if socialMedia?.icon}
<span>
<Icon icon={socialMedia.icon} class="h-6 w-6" />
<div class="relative">
{#if verified}
<span class="badge-icon absolute -right-1 -top-2 z-10">
<Icon icon="ph:seal-check-fill" font-size="40" class="text-blue-400" />
</span>
{/if}

<span>{label || socialMedia?.name} </span>
</a>
<a href={url} target="_blank" class="variant-filled btn">
{#if socialMedia?.icon}
<span>
<Icon icon={socialMedia.icon} class="h-6 w-6" />
</span>
{/if}

<span>{label || socialMedia?.name} </span>
</a>
</div>
23 changes: 16 additions & 7 deletions src/lib/leaf/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -235,7 +235,10 @@ export async function getProfile(link: ExactLink): Promise<Profile | undefined>
);
}

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,
Expand Down Expand Up @@ -304,11 +307,17 @@ export async function getProfileByDomain(domain: string): Promise<Profile | unde
export async function setProfileById(rauthyId: string, profile: Profile): Promise<void> {
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);
Expand Down
24 changes: 0 additions & 24 deletions src/lib/link_verifier/LinkVerifier.test.ts

This file was deleted.

74 changes: 30 additions & 44 deletions src/lib/link_verifier/LinkVerifier.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,67 @@
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<string, LinkVerificationStrategyFactory> = {
'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<Window> {
const res = await fetch(webLink.url);
private static async fetchHtml(webLink: string): Promise<Window> {
const res = await fetch(webLink);

if (res.status === 200) {
const resText = await res.text();
return parseHTML(resText);
}

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<WebLink[]> {
const verifiedLinks: WebLink[] = [];
async verify(): Promise<string[]> {
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}`;
}
}
Original file line number Diff line number Diff line change
@@ -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) => `
<ul class="vcard-details">
<li class="vcard-detail pt-1 hide-sm hide-md" itemprop="worksFor" show_title="false" aria-label="Organization: @InfinyOn">
<svg class="octicon octicon-organization" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
Expand Down Expand Up @@ -61,22 +62,37 @@ const GITHUB_PROFILE_SNIPPET = `
<svg title="Social account" aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-link">
<path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path>
</svg>
<a rel="nofollow me" class="Link--primary" style="overflow-wrap: anywhere" href="https://a.weird.one/estebanborai">https://a.weird.one/estebanborai</a>
<a rel="nofollow me" class="Link--primary" style="overflow-wrap: anywhere" href="${weirdUrl}">${weirdUrl}</a>
</li>
</ul>`;

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);
});
30 changes: 30 additions & 0 deletions src/lib/link_verifier/strategy/DefaultLinkVerificationStrategy.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
25 changes: 0 additions & 25 deletions src/lib/link_verifier/strategy/GitHubLinkVerificationStrategy.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/lib/link_verifier/strategy/LinkVerificationStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export interface ILinkVerificationStrategy {
verify(userProfileLink: string): Promise<boolean>;
}

export type LinkVerificationStrategyFactory = (dom: Window) => ILinkVerificationStrategy;
export type LinkVerificationStrategyFactory = new (dom: Window) => ILinkVerificationStrategy;

export abstract class LinkVerificationStrategy implements ILinkVerificationStrategy {
protected dom: Window;
Expand Down
Loading

0 comments on commit e947ad1

Please sign in to comment.