+6
-4
src/lib/components/embeds/external-embed.svelte
+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
+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
+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
+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
+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
+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
};