Sanitize external links in user generated content

main
Charlotte Som 2022-05-11 14:32:33 +01:00
parent a8f518f4b8
commit 55cf127b7e
7 changed files with 176 additions and 23 deletions

View File

@ -2,9 +2,11 @@
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
} }
html, body { html,
body {
background-color: var(--col-bg-0); background-color: var(--col-bg-0);
color: var(--col-fg-0); color: var(--col-fg-0);
margin: 0;
} }
a { a {
@ -17,7 +19,9 @@ a.link-muted {
border-bottom: 1px solid var(--col-fg-2); border-bottom: 1px solid var(--col-fg-2);
} }
h1, h2, h3 { h1,
h2,
h3 {
margin: 0; margin: 0;
margin-bottom: 0.25em; margin-bottom: 0.25em;
} }

View File

@ -4,5 +4,11 @@ export const getSession: GetSession = async () => {
const session: App.Session = { const session: App.Session = {
instance: { url: 'https://trans.enby.town', token: '' } instance: { url: 'https://trans.enby.town', token: '' }
}; };
try {
const token = window.localStorage.getItem('instance-token') || '';
session.instance.token = token;
} catch (_) {}
return session; return session;
}; };

View File

@ -1,7 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { MastodonAccount } from '$lib/mastoapi/account'; import { onMount } from 'svelte';
import type { MastodonAccount } from '$lib/mastoapi/account';
export let account: MastodonAccount; export let account: MastodonAccount;
let fieldsNode: HTMLElement;
onMount(() => {
for (const a of Array.from(fieldsNode.querySelectorAll('dd a'))) {
const href = a.getAttribute('href');
if (href == null) continue;
if (new URL(href, window.location.href).origin != window.location.origin) {
a.setAttribute('title', href);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'nofollow noopener external');
}
}
});
</script> </script>
<article class="account-profile"> <article class="account-profile">
@ -9,12 +25,25 @@
<img class="avatar" alt={account.acct} src={account.avatar} /> <img class="avatar" alt={account.acct} src={account.avatar} />
<div> <div>
<h1>{account.display_name}</h1> <h1>{account.display_name}</h1>
<a class="link-muted" href={account.url}>@{account.fqn}</a> <a class="link-muted" href={account.url} target="_blank" rel="nofollow noopener external">
@{account.fqn}
</a>
</div> </div>
</header> </header>
<div class="about"> <div class="about">
{@html account.note} <div class="fields" bind:this={fieldsNode}>
{#each account.fields as field}
<dl>
<dt>{field.name}</dt>
<dd>{@html field.value}</dd>
</dl>
{/each}
</div>
<div class="bio">
{@html account.note}
</div>
</div> </div>
</article> </article>
@ -41,4 +70,53 @@
.account-profile header > div { .account-profile header > div {
width: 100%; width: 100%;
} }
.about {
display: flex;
flex-direction: column;
}
.about > * {
border: 1px solid var(--col-bg-2);
border-collapse: collapse;
padding: 1em;
}
.bio {
border-block-start: none;
}
.fields dl {
display: flex;
margin: 0;
}
.fields dt {
background: var(--col-bg-2);
font-weight: 500;
max-width: 12ch;
min-width: 12ch;
}
.fields dd {
flex: 1 1 auto;
}
.fields dt,
.fields dd {
padding: 0.8em;
text-overflow: ellipsis;
overflow: hidden;
margin: 0;
max-height: 1em;
white-space: nowrap;
}
:global(.bio > p:first-child) {
margin-top: 0;
}
:global(.bio > p:last-child) {
margin-bottom: 0;
}
</style> </style>

View File

@ -1,36 +1,37 @@
<script lang="ts"> <script lang="ts">
import ago from '$lib/ago'; import ago from '$lib/ago';
import { now } from '$lib/global-now'; import { now } from '$lib/global-now';
import { linkAccount } from '$lib/mastoapi/account';
import { import { linkStatus, type MastodonStatus } from '$lib/mastoapi/status';
linkStatus,
type MastodonStatus, import StatusContent from './StatusContent.svelte';
type MastodonStatusContext
} from '$lib/mastoapi/status';
import StatusControls from './StatusControls.svelte'; import StatusControls from './StatusControls.svelte';
export let status: MastodonStatus; export let status: MastodonStatus;
export let focused: boolean = false; export let focused: boolean = false;
let createdDate = new Date(status.created_at);
</script> </script>
<article class="status" class:focused> <article class="status" class:focused>
<div class="status-content"> <div class="status-content">
<aside class="status-author"> <aside class="status-author">
<a href={linkAccount(status.account)}> <a href={`/acc/${status.account.id}`}>
<img class="avatar" alt={status.account.acct} src={status.account.avatar_static} /> <img class="avatar" alt={status.account.acct} src={status.account.avatar_static} />
</a> </a>
</aside> </aside>
<div class="status-main"> <div class="status-main">
<section class="top-line"> <section class="top-line">
<a href={linkAccount(status.account)} class="author-link"> <a href={`/acc/${status.account.id}`} class="author-link">
<strong class="author-display-name">{status.account.display_name}</strong> <strong class="author-display-name">{status.account.display_name}</strong>
<span class="author-handle">@{status.account.fqn}</span> <span class="author-handle">@{status.account.fqn}</span>
</a> </a>
<a class="link-muted" href={linkStatus(status)}> <a class="link-muted" href={linkStatus(status)}>
<time>{ago(new Date(status.created_at), $now)}</time> <time title={createdDate.toLocaleString()}>
{ago(createdDate, $now)}
</time>
</a> </a>
</section> </section>
@ -39,12 +40,12 @@
<p>[TODO: content warning <q>{status.spoiler_text}</q>]</p> <p>[TODO: content warning <q>{status.spoiler_text}</q>]</p>
{/if} {/if}
{@html status.content} <StatusContent {status} />
</section> </section>
<section class="status-attachments"> <section class="status-attachments">
{#each status.media_attachments as attachment} {#each status.media_attachments as attachment}
<p>[TODO: attachment <q>{attachment.description || '(no description)'}</q>]</p> <div>[TODO: attachment <q>{attachment.description || '(no description)'}</q>]</div>
{/each} {/each}
</section> </section>
</div> </div>
@ -70,13 +71,12 @@
} }
.status aside { .status aside {
width: 4rem; width: 3em;
margin-inline-end: 1em; margin-inline-end: 0.75em;
} }
.status .avatar { .status .avatar {
border-radius: 6px; border-radius: 4px;
height: 3em;
max-width: 3em; max-width: 3em;
} }
@ -99,9 +99,14 @@
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
margin-bottom: 0.5em;
} }
.status .status-body { .status .status-body {
margin-bottom: 0; margin-bottom: 0;
} }
:global(.status-body > *:first-child, .status-body > .foreign-content > *:first-child) {
margin-top: 0;
}
</style> </style>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { MastodonStatus } from '$lib/mastoapi/status';
export let status: MastodonStatus;
let node: HTMLElement;
onMount(() => {
for (let a of Array.from(node.getElementsByTagName('a'))) {
const href = a.getAttribute('href');
if (href == null) continue;
if (a.classList.contains('hashtag')) {
for (const tag of status.tags) {
if (href === tag.url) {
a.setAttribute('href', `/tags/${tag.name}`);
a.removeAttribute('rel');
a.removeAttribute('target');
}
}
} else if (a.classList.contains('mention')) {
for (const mention of status.mentions) {
if (href === mention.url) {
a.setAttribute('href', `/acc/${mention.id}`);
a.setAttribute('title', `@${mention.acct}`);
a.removeAttribute('rel');
a.removeAttribute('target');
}
}
} else if (new URL(href, window.location.href).origin != window.location.origin) {
a.setAttribute('title', href);
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'nofollow noopener external');
}
}
});
</script>
<div class="foreign-content" bind:this={node}>
{@html status.content}
</div>
<style>
.foreign-content {
display: contents;
}
</style>

View File

@ -13,10 +13,12 @@ export interface MastodonAccount extends MastodonObject {
note: string; note: string;
bot: boolean; bot: boolean;
}
export function linkAccount(account: MastodonAccount) { fields: {
return '/acc/' + account.id; name: string;
value: string;
verified_at: string | null;
}[];
} }
export async function fetchAccount(instance: InstanceInfo, id: string): Promise<MastodonAccount> { export async function fetchAccount(instance: InstanceInfo, id: string): Promise<MastodonAccount> {

View File

@ -18,6 +18,16 @@ export interface MastodonStatus extends MastodonObject {
url: string; url: string;
in_reply_to_id: string | null; in_reply_to_id: string | null;
media_attachments: MastodonMediaAttachment[]; media_attachments: MastodonMediaAttachment[];
tags: {
name: string;
url: string;
}[];
mentions: {
acct: string;
id: string;
username: string;
url: string;
}[];
} }
export interface MastodonStatusContext { export interface MastodonStatusContext {