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; | ||||
| } | ||||
| 
 | ||||
| html, body { | ||||
| html, | ||||
| body { | ||||
|   background-color: var(--col-bg-0); | ||||
|   color: var(--col-fg-0); | ||||
|   margin: 0; | ||||
| } | ||||
| 
 | ||||
| a { | ||||
|  | @ -17,7 +19,9 @@ a.link-muted { | |||
|   border-bottom: 1px solid var(--col-fg-2); | ||||
| } | ||||
| 
 | ||||
| h1, h2, h3 { | ||||
| h1, | ||||
| h2, | ||||
| h3 { | ||||
|   margin: 0; | ||||
|   margin-bottom: 0.25em; | ||||
| } | ||||
|  |  | |||
|  | @ -4,5 +4,11 @@ export const getSession: GetSession = async () => { | |||
|   const session: App.Session = { | ||||
|     instance: { url: 'https://trans.enby.town', token: '' } | ||||
|   }; | ||||
| 
 | ||||
|   try { | ||||
|     const token = window.localStorage.getItem('instance-token') || ''; | ||||
|     session.instance.token = token; | ||||
|   } catch (_) {} | ||||
| 
 | ||||
|   return session; | ||||
| }; | ||||
|  |  | |||
|  | @ -1,7 +1,23 @@ | |||
| <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; | ||||
| 
 | ||||
|   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> | ||||
| 
 | ||||
| <article class="account-profile"> | ||||
|  | @ -9,13 +25,26 @@ | |||
|     <img class="avatar" alt={account.acct} src={account.avatar} /> | ||||
|     <div> | ||||
|       <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> | ||||
|   </header> | ||||
| 
 | ||||
|   <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} | ||||
|     </div> | ||||
|   </div> | ||||
| </article> | ||||
| 
 | ||||
| <style> | ||||
|  | @ -41,4 +70,53 @@ | |||
|   .account-profile header > div { | ||||
|     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> | ||||
|  |  | |||
|  | @ -1,36 +1,37 @@ | |||
| <script lang="ts"> | ||||
|   import ago from '$lib/ago'; | ||||
|   import { now } from '$lib/global-now'; | ||||
|   import { linkAccount } from '$lib/mastoapi/account'; | ||||
| 
 | ||||
|   import { | ||||
|     linkStatus, | ||||
|     type MastodonStatus, | ||||
|     type MastodonStatusContext | ||||
|   } from '$lib/mastoapi/status'; | ||||
|   import { linkStatus, type MastodonStatus } from '$lib/mastoapi/status'; | ||||
| 
 | ||||
|   import StatusContent from './StatusContent.svelte'; | ||||
|   import StatusControls from './StatusControls.svelte'; | ||||
| 
 | ||||
|   export let status: MastodonStatus; | ||||
|   export let focused: boolean = false; | ||||
| 
 | ||||
|   let createdDate = new Date(status.created_at); | ||||
| </script> | ||||
| 
 | ||||
| <article class="status" class:focused> | ||||
|   <div class="status-content"> | ||||
|     <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} /> | ||||
|       </a> | ||||
|     </aside> | ||||
| 
 | ||||
|     <div class="status-main"> | ||||
|       <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> | ||||
|           <span class="author-handle">@{status.account.fqn}</span> | ||||
|         </a> | ||||
| 
 | ||||
|         <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> | ||||
|       </section> | ||||
| 
 | ||||
|  | @ -39,12 +40,12 @@ | |||
|           <p>[TODO: content warning <q>{status.spoiler_text}</q>]</p> | ||||
|         {/if} | ||||
| 
 | ||||
|         {@html status.content} | ||||
|         <StatusContent {status} /> | ||||
|       </section> | ||||
| 
 | ||||
|       <section class="status-attachments"> | ||||
|         {#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} | ||||
|       </section> | ||||
|     </div> | ||||
|  | @ -70,13 +71,12 @@ | |||
|   } | ||||
| 
 | ||||
|   .status aside { | ||||
|     width: 4rem; | ||||
|     margin-inline-end: 1em; | ||||
|     width: 3em; | ||||
|     margin-inline-end: 0.75em; | ||||
|   } | ||||
| 
 | ||||
|   .status .avatar { | ||||
|     border-radius: 6px; | ||||
|     height: 3em; | ||||
|     border-radius: 4px; | ||||
|     max-width: 3em; | ||||
|   } | ||||
| 
 | ||||
|  | @ -99,9 +99,14 @@ | |||
|     flex-direction: row; | ||||
|     justify-content: space-between; | ||||
|     width: 100%; | ||||
|     margin-bottom: 0.5em; | ||||
|   } | ||||
| 
 | ||||
|   .status .status-body { | ||||
|     margin-bottom: 0; | ||||
|   } | ||||
| 
 | ||||
|   :global(.status-body > *:first-child, .status-body > .foreign-content > *:first-child) { | ||||
|     margin-top: 0; | ||||
|   } | ||||
| </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; | ||||
| 
 | ||||
|   bot: boolean; | ||||
| } | ||||
| 
 | ||||
| export function linkAccount(account: MastodonAccount) { | ||||
|   return '/acc/' + account.id; | ||||
|   fields: { | ||||
|     name: string; | ||||
|     value: string; | ||||
|     verified_at: string | null; | ||||
|   }[]; | ||||
| } | ||||
| 
 | ||||
| export async function fetchAccount(instance: InstanceInfo, id: string): Promise<MastodonAccount> { | ||||
|  |  | |||
|  | @ -18,6 +18,16 @@ export interface MastodonStatus extends MastodonObject { | |||
|   url: string; | ||||
|   in_reply_to_id: string | null; | ||||
|   media_attachments: MastodonMediaAttachment[]; | ||||
|   tags: { | ||||
|     name: string; | ||||
|     url: string; | ||||
|   }[]; | ||||
|   mentions: { | ||||
|     acct: string; | ||||
|     id: string; | ||||
|     username: string; | ||||
|     url: string; | ||||
|   }[]; | ||||
| } | ||||
| 
 | ||||
| export interface MastodonStatusContext { | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue