Sanitize external links in user generated content
parent
a8f518f4b8
commit
55cf127b7e
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,13 +25,26 @@
|
||||||
<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">
|
||||||
|
<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}
|
{@html account.note}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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> {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue