Sanitize external links in user generated content
This commit is contained in:
		
							parent
							
								
									a8f518f4b8
								
							
						
					
					
						commit
						55cf127b7e
					
				
					 7 changed files with 176 additions and 23 deletions
				
			
		|  | @ -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,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> | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |  | ||||||
							
								
								
									
										48
									
								
								src/lib/components/StatusContent.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/lib/components/StatusContent.svelte
									
									
									
									
									
										Normal 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> | ||||||
|  | @ -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 a new issue