JavaScript-optional public web frontend for Bluesky anartia.kelinci.net
sveltekit atcute bluesky typescript svelte

refactor: redo the redirection

mary.my.id 186375db 446f1653

verified
Changed files
+109 -57
src
+6 -4
src/lib/components/embeds/external-embed.svelte
··· 15 15 16 16 const external = $derived(embed.external); 17 17 18 - const domain = $derived(safeUrlParse(external.uri)?.host.replace(/^www\./, '')); 19 - const redirectUrl = $derived(redirectBskyUrl(external.uri)); 18 + const parsed = $derived(safeUrlParse(external.uri)); 19 + 20 + const domain = $derived(parsed?.host.replace(/^www\./, '')); 21 + const redir = $derived(parsed && redirectBskyUrl(parsed)); 20 22 </script> 21 23 22 24 <a 23 - target={!redirectUrl ? '_blank' : undefined} 24 - href={redirectUrl || (domain ? external.uri : '')} 25 + target={!redir || redir.type === 'external' ? '_blank' : undefined} 26 + href={redir ? redir.url : domain ? external.uri : ''} 25 27 rel="noopener noreferrer nofollow" 26 28 class="external-embed" 27 29 >
+17 -9
src/lib/components/richtext-raw-renderer.svelte
··· 3 3 </script> 4 4 5 5 <script lang="ts"> 6 + import { tokenize } from '@atcute/bluesky-richtext-parser'; 7 + 6 8 import { base } from '$app/paths'; 7 - import { redirectBskyUrl } from '$lib/redirector'; 8 9 9 - import { tokenize } from '@atcute/bluesky-richtext-parser'; 10 + import { redirectBskyUrl } from '$lib/redirector'; 11 + import { safeUrlParse } from '$lib/utils/url'; 10 12 11 13 interface Props { 12 14 text: string; ··· 19 21 <p class={`rich-text` + (large ? ` is-large` : ` is-small`)}> 20 22 {#each tokenize(text) as token} 21 23 {#if token.type === 'autolink'} 22 - {@const redirectUrl = redirectBskyUrl(token.url)} 23 - {@const label = token.raw.replace(HTTP_RE, '')} 24 + {@const parsed = safeUrlParse(token.url)} 24 25 25 - {#if redirectUrl} 26 - <a href={redirectUrl} class="link">{label}</a> 26 + {#if parsed === null} 27 + {token.raw} 27 28 {:else} 28 - <a target="_blank" href={token.url} rel="noopener nofollow" class="link"> 29 - {label} 30 - </a> 29 + {@const redir = redirectBskyUrl(parsed)} 30 + {@const label = token.raw.replace(HTTP_RE, '')} 31 + 32 + {#if redir && redir.type === 'internal'} 33 + <a href={redir.url} class="link">{label}</a> 34 + {:else} 35 + <a target="_blank" href={redir ? redir.url : parsed.href} rel="noopener nofollow" class="link" 36 + >{label}</a 37 + > 38 + {/if} 31 39 {/if} 32 40 {:else if token.type === 'mention'} 33 41 <a href="{base}/{token.handle.toLowerCase()}" class="mention">{token.raw}</a>
+13 -4
src/lib/components/richtext-renderer.svelte
··· 15 15 import { base } from '$app/paths'; 16 16 17 17 import { redirectBskyUrl } from '$lib/redirector'; 18 + import { safeUrlParse } from '$lib/utils/url'; 18 19 19 20 interface Props { 20 21 text: string; ··· 32 33 {#if !feature} 33 34 {segment.text} 34 35 {:else if feature.$type === 'app.bsky.richtext.facet#link'} 35 - {@const redirectUrl = redirectBskyUrl(feature.uri)} 36 + {@const parsed = safeUrlParse(feature.uri)} 36 37 37 - {#if redirectUrl} 38 - <a href={redirectUrl} class="link">{segment.text}</a> 38 + {#if parsed === null} 39 + {segment.text} 39 40 {:else} 40 - <a target="_blank" href={feature.uri} rel="noopener nofollow" class="link">{segment.text}</a> 41 + {@const redir = redirectBskyUrl(parsed)} 42 + 43 + {#if redir && redir.type === 'internal'} 44 + <a href={redir.url} class="link">{segment.text}</a> 45 + {:else} 46 + <a target="_blank" href={redir ? redir.url : parsed.href} rel="noopener nofollow" class="link" 47 + >{segment.text}</a 48 + > 49 + {/if} 41 50 {/if} 42 51 {:else if feature.$type === 'app.bsky.richtext.facet#mention'} 43 52 <a href="{base}/{feature.did}" class="mention">{segment.text}</a>
+49 -31
src/lib/redirector.ts
··· 1 1 import { base } from '$app/paths'; 2 - import { parsePartialAtUri, type PartialAtUri } from './types/at-uri'; 2 + import { type PartialAtUri, parsePartialAtUri } from './types/at-uri'; 3 3 4 4 import { isDid, isHandle } from './types/identity'; 5 5 import { isRecordKey, isTid } from './types/rkey'; ··· 15 15 } from './utils/bluesky/urls'; 16 16 import { safeUrlParse } from './utils/url'; 17 17 18 - export const redirectBskyUrl = (rawUrl: string): string | null | undefined => { 19 - const url = safeUrlParse(rawUrl); 20 - if (!url) { 21 - return; 22 - } 18 + export type RedirectResult = 19 + // Internal link pointing to ourselves 20 + | { type: 'internal'; url: string } 21 + // External link pointing to another website 22 + | { type: 'external'; url: string } 23 + // Matched but not considered valid 24 + | null 25 + // Not matched 26 + | undefined; 23 27 28 + export const redirectBskyUrl = (url: URL): RedirectResult => { 24 29 const host = url.host; 25 30 const pathname = url.pathname; 26 31 let match: RegExpExecArray | null | undefined; ··· 33 38 return null; 34 39 } 35 40 36 - return `${base}/${match[1]}`; 41 + return { type: 'internal', url: `${base}/${match[1]}` }; 37 42 } 38 43 39 44 if ((match = BSKY_POST_LINK_RE.exec(pathname))) { ··· 46 51 return null; 47 52 } 48 53 49 - return `${base}/${actor}/${rkey}#main`; 54 + return { type: 'internal', url: `${base}/${actor}/${rkey}#main` }; 50 55 } 51 56 52 57 if ((match = BSKY_FEED_LINK_RE.exec(pathname))) { ··· 59 64 return null; 60 65 } 61 66 62 - return `${base}/${actor}/feeds/${rkey}`; 67 + return { type: 'internal', url: `${base}/${actor}/feeds/${rkey}` }; 63 68 } 64 69 65 70 if ((match = BSKY_LIST_LINK_RE.exec(pathname))) { ··· 72 77 return null; 73 78 } 74 79 75 - return `${base}/${actor}/lists/${rkey}`; 80 + return { type: 'internal', url: `${base}/${actor}/lists/${rkey}` }; 76 81 } 77 82 78 83 if ((match = BSKY_STARTERPACK_LINK_RE.exec(pathname))) { ··· 85 90 return null; 86 91 } 87 92 88 - return `${base}/${actor}/packs/${rkey}`; 93 + return { type: 'internal', url: `${base}/${actor}/packs/${rkey}` }; 89 94 } 90 95 91 96 if ((match = BSKY_SEARCH_LINK_RE.exec(pathname))) { ··· 94 99 return null; 95 100 } 96 101 97 - return `${base}/search/posts?q=${encodeURIComponent(query)}`; 102 + return { type: 'internal', url: `${base}/search/posts?q=${encodeURIComponent(query)}` }; 98 103 } 99 104 100 105 if ((match = BSKY_HASHTAG_LINK_RE.exec(pathname))) { 101 106 const [, tag] = match; 102 107 103 - return `${base}/search/posts?q=${encodeURIComponent('#' + tag)}`; 108 + return { type: 'internal', url: `${base}/search/posts?q=${encodeURIComponent('#' + tag)}` }; 104 109 } 105 110 106 111 return null; 107 112 } 108 113 109 114 if (host === 'go.bsky.app') { 115 + if (pathname === '/redirect') { 116 + const raw = url.searchParams.get('u'); 117 + if (raw === null) { 118 + return null; 119 + } 120 + 121 + const parsed = safeUrlParse(raw); 122 + if (parsed === null) { 123 + return null; 124 + } 125 + 126 + return redirectBskyUrl(parsed) || { type: 'external', url: parsed.href }; 127 + } 128 + 110 129 if ((match = BSKY_GO_SHORTLINK_RE.exec(pathname))) { 111 130 const [, id] = match; 112 131 113 - return `${base}/go/${id}`; 132 + return { type: 'internal', url: `${base}/go/${id}` }; 114 133 } 115 134 } 116 135 ··· 120 139 // https://skywriter.blue/pages/georgemonbiot.bsky.social/post/3livzzfqc4c2c 121 140 const SKYWRITER_UNROLL_RE = /^\/pages\/([^/]+)\/post\/([^/]+)\/?$/; 122 141 123 - export const redirectOtherUrl = (rawUrl: string): string | null | undefined => { 124 - const url = safeUrlParse(rawUrl); 125 - if (!url) { 126 - return; 127 - } 128 - 142 + export const redirectOtherUrl = (url: URL): RedirectResult => { 129 143 const host = url.host; 130 144 const pathname = url.pathname; 131 145 let match: RegExpExecArray | null | undefined; ··· 145 159 return null; 146 160 } 147 161 148 - return `${base}/${author}/${post}#main`; 162 + return { type: 'internal', url: `${base}/${author}/${post}#main` }; 149 163 } 150 164 151 165 if (host === 'skywriter.blue') { ··· 159 173 return null; 160 174 } 161 175 162 - return `${base}/${actor}/${rkey}/unroll`; 176 + return { type: 'internal', url: `${base}/${actor}/${rkey}/unroll` }; 163 177 } 164 178 } 165 179 166 180 // https://skyview.social/?url=https://bsky.app/profile/did:plc:tyt7lpgpfbn3c37tylht7ksy/post/3lithx22epk2l&viewtype=unroll 167 181 if (host === 'skyview.social') { 168 182 const uri = url.searchParams.get('url'); 169 - 170 183 if (uri === null) { 171 184 return null; 172 185 } 173 186 174 - const redirect = redirectBskyUrl(uri); 187 + const parsed = safeUrlParse(uri); 188 + if (parsed === null) { 189 + return null; 190 + } 191 + 192 + const redirect = redirectBskyUrl(parsed); 175 193 if (redirect == null) { 176 194 return null; 177 195 } ··· 182 200 return; 183 201 }; 184 202 185 - export const redirectAtUri = (raw: string): string | null | undefined => { 203 + export const redirectAtUri = (raw: string): RedirectResult => { 186 204 let uri: PartialAtUri; 187 205 try { 188 206 uri = parsePartialAtUri(raw); ··· 193 211 if (uri.rkey) { 194 212 switch (uri.collection) { 195 213 case 'app.bsky.actor.profile': { 196 - return `${base}/${uri.repo}`; 214 + return { type: 'internal', url: `${base}/${uri.repo}` }; 197 215 } 198 216 case 'app.bsky.feed.post': { 199 217 if (!isTid(uri.rkey)) { 200 218 return null; 201 219 } 202 220 203 - return `${base}/${uri.repo}/${uri.rkey}#main`; 221 + return { type: 'internal', url: `${base}/${uri.repo}/${uri.rkey}#main` }; 204 222 } 205 223 case 'app.bsky.feed.generator': { 206 - return `${base}/${uri.repo}/feeds/${uri.rkey}`; 224 + return { type: 'internal', url: `${base}/${uri.repo}/feeds/${uri.rkey}` }; 207 225 } 208 226 case 'app.bsky.graph.list': { 209 - return `${base}/${uri.repo}/lists/${uri.rkey}`; 227 + return { type: 'internal', url: `${base}/${uri.repo}/lists/${uri.rkey}` }; 210 228 } 211 229 case 'app.bsky.graph.starterpack': { 212 - return `${base}/${uri.repo}/packs/${uri.rkey}`; 230 + return { type: 'internal', url: `${base}/${uri.repo}/packs/${uri.rkey}` }; 213 231 } 214 232 } 215 233 } 216 234 217 235 if (uri.collection === undefined) { 218 - return `${base}/${uri.repo}`; 236 + return { type: 'internal', url: `${base}/${uri.repo}` }; 219 237 } 220 238 221 239 return null;
+14 -4
src/routes/(app)/+page.server.ts
··· 2 2 3 3 import { base } from '$app/paths'; 4 4 5 - import { redirectAtUri, redirectBskyUrl, redirectOtherUrl } from '$lib/redirector'; 5 + import { redirectAtUri, redirectBskyUrl, redirectOtherUrl, type RedirectResult } from '$lib/redirector'; 6 + import { safeUrlParse } from '$lib/utils/url'; 6 7 7 8 const MAYBE_HANDLE_RE = /^@[a-zA-Z0-9-. ]+$/; 8 9 ··· 33 34 34 35 query = query.trim(); 35 36 36 - const redirectUrl = redirectBskyUrl(query) || redirectOtherUrl(query) || redirectAtUri(query); 37 - if (redirectUrl) { 38 - redirect(302, redirectUrl); 37 + let redir: RedirectResult | undefined; 38 + if (query.startsWith('at://')) { 39 + redir = redirectAtUri(query); 40 + } else { 41 + const url = safeUrlParse(query); 42 + if (url) { 43 + redir = redirectBskyUrl(url) || redirectOtherUrl(url); 44 + } 45 + } 46 + 47 + if (redir && redir.type === 'internal') { 48 + redirect(302, redir.url); 39 49 } 40 50 41 51 return fail(400, { place: 'redirect', error: `Invalid link provided` });
+10 -5
src/routes/go/[shortid]/+page.ts
··· 6 6 import type { PageLoad } from './$types'; 7 7 8 8 import { redirectBskyUrl } from '$lib/redirector'; 9 + import { safeUrlParse } from '$lib/utils/url'; 9 10 10 11 const jsonSchema = v.object({ 11 12 url: v.string(), ··· 26 27 } 27 28 28 29 const raw = await response.json(); 29 - const result = jsonSchema.try(raw); 30 30 31 + const result = jsonSchema.try(raw); 31 32 if (!result.ok) { 32 33 error(500, `Invalid response from upstream server`); 33 34 } 34 35 35 - const url = result.value.url; 36 - const redirectUrl = redirectBskyUrl(url); 36 + const url = safeUrlParse(result.value.url); 37 + if (!url) { 38 + error(500, `Invalid URL from upstream server; got ${result.value.url}`); 39 + } 37 40 38 - if (!redirectUrl) { 41 + const redir = redirectBskyUrl(url); 42 + 43 + if (!redir || redir.type !== 'internal') { 39 44 error(500, `Invalid URL from upstream server; got ${url}`); 40 45 } 41 46 42 - redirect(301, redirectUrl); 47 + redirect(301, redir.url); 43 48 };