forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {AtUri} from '@atproto/api'
2import psl from 'psl'
3import TLDs from 'tlds'
4
5import {BSKY_SERVICE} from '#/lib/constants'
6import {isInvalidHandle} from '#/lib/strings/handles'
7import {startUriToStarterPackUri} from '#/lib/strings/starter-pack'
8import {logger} from '#/logger'
9
10export const BSKY_APP_HOST = 'https://bsky.app'
11const BSKY_TRUSTED_HOSTS = [
12 'bsky\\.app',
13 'bsky\\.social',
14 'blueskyweb\\.xyz',
15 'blueskyweb\\.zendesk\\.com',
16 ...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
17]
18
19/*
20 * This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain.
21 * It will also allow relative paths like /profile as well as #.
22 */
23const TRUSTED_REGEX = new RegExp(
24 `^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join(
25 '|([\\w-]+\\.)?',
26 )})|/|#)`,
27)
28
29export function isValidDomain(str: string): boolean {
30 return !!TLDs.find(tld => {
31 let i = str.lastIndexOf(tld)
32 if (i === -1) {
33 return false
34 }
35 return str.charAt(i - 1) === '.' && i === str.length - tld.length
36 })
37}
38
39export function makeRecordUri(
40 didOrName: string,
41 collection: string,
42 rkey: string,
43) {
44 const urip = new AtUri('at://placeholder.placeholder/')
45 // @ts-expect-error TODO new-sdk-migration
46 urip.host = didOrName
47 urip.collection = collection
48 urip.rkey = rkey
49 return urip.toString()
50}
51
52export function toNiceDomain(url: string): string {
53 try {
54 const urlp = new URL(url)
55 if (`https://${urlp.host}` === BSKY_SERVICE) {
56 return 'Bluesky Social'
57 }
58 return urlp.host ? urlp.host : url
59 } catch (e) {
60 return url
61 }
62}
63
64export function toShortUrl(url: string): string {
65 try {
66 const urlp = new URL(url)
67 if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
68 return url
69 }
70 const path =
71 (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
72 if (path.length > 15) {
73 return urlp.host + path.slice(0, 13) + '...'
74 }
75 return urlp.host + path
76 } catch (e) {
77 return url
78 }
79}
80
81export function toShareUrl(url: string): string {
82 if (!url.startsWith('https')) {
83 const urlp = new URL('https://bsky.app')
84 urlp.pathname = url
85 url = urlp.toString()
86 }
87 return url
88}
89
90export function toBskyAppUrl(url: string): string {
91 return new URL(url, BSKY_APP_HOST).toString()
92}
93
94export function isBskyAppUrl(url: string): boolean {
95 return url.startsWith('https://bsky.app/')
96}
97
98export function isRelativeUrl(url: string): boolean {
99 return /^\/[^/]/.test(url)
100}
101
102export function isBskyRSSUrl(url: string): boolean {
103 return (
104 (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
105 /\/rss\/?$/.test(url)
106 )
107}
108
109export function isExternalUrl(url: string): boolean {
110 const external = !isBskyAppUrl(url) && url.startsWith('http')
111 const rss = isBskyRSSUrl(url)
112 return external || rss
113}
114
115export function isTrustedUrl(url: string): boolean {
116 return TRUSTED_REGEX.test(url)
117}
118
119export function isBskyPostUrl(url: string): boolean {
120 if (isBskyAppUrl(url)) {
121 try {
122 const urlp = new URL(url)
123 return /profile\/(?<name>[^/]+)\/post\/(?<rkey>[^/]+)/i.test(
124 urlp.pathname,
125 )
126 } catch {}
127 }
128 return false
129}
130
131export function isBskyCustomFeedUrl(url: string): boolean {
132 if (isBskyAppUrl(url)) {
133 try {
134 const urlp = new URL(url)
135 return /profile\/(?<name>[^/]+)\/feed\/(?<rkey>[^/]+)/i.test(
136 urlp.pathname,
137 )
138 } catch {}
139 }
140 return false
141}
142
143export function isBskyListUrl(url: string): boolean {
144 if (isBskyAppUrl(url)) {
145 try {
146 const urlp = new URL(url)
147 return /profile\/(?<name>[^/]+)\/lists\/(?<rkey>[^/]+)/i.test(
148 urlp.pathname,
149 )
150 } catch {
151 console.error('Unexpected error in isBskyListUrl()', url)
152 }
153 }
154 return false
155}
156
157export function isBskyStartUrl(url: string): boolean {
158 if (isBskyAppUrl(url)) {
159 try {
160 const urlp = new URL(url)
161 return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
162 } catch {
163 console.error('Unexpected error in isBskyStartUrl()', url)
164 }
165 }
166 return false
167}
168
169export function isBskyStarterPackUrl(url: string): boolean {
170 if (isBskyAppUrl(url)) {
171 try {
172 const urlp = new URL(url)
173 return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
174 } catch {
175 console.error('Unexpected error in isBskyStartUrl()', url)
176 }
177 }
178 return false
179}
180
181export function isBskyDownloadUrl(url: string): boolean {
182 if (isExternalUrl(url)) {
183 return false
184 }
185 return url === '/download' || url.startsWith('/download?')
186}
187
188export function convertBskyAppUrlIfNeeded(url: string): string {
189 if (isBskyAppUrl(url)) {
190 try {
191 const urlp = new URL(url)
192
193 if (isBskyStartUrl(url)) {
194 return startUriToStarterPackUri(urlp.pathname)
195 }
196
197 // special-case search links
198 if (urlp.pathname === '/search') {
199 return `/search?q=${urlp.searchParams.get('q')}`
200 }
201
202 return urlp.pathname
203 } catch (e) {
204 console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
205 }
206 } else if (isShortLink(url)) {
207 // We only want to do this on native, web handles the 301 for us
208 return shortLinkToHref(url)
209 }
210 return url
211}
212
213export function listUriToHref(url: string): string {
214 try {
215 const {hostname, rkey} = new AtUri(url)
216 return `/profile/${hostname}/lists/${rkey}`
217 } catch {
218 return ''
219 }
220}
221
222export function feedUriToHref(url: string): string {
223 try {
224 const {hostname, rkey} = new AtUri(url)
225 return `/profile/${hostname}/feed/${rkey}`
226 } catch {
227 return ''
228 }
229}
230
231export function postUriToRelativePath(
232 uri: string,
233 options?: {handle?: string},
234): string | undefined {
235 try {
236 const {hostname, rkey} = new AtUri(uri)
237 const handleOrDid =
238 options?.handle && !isInvalidHandle(options.handle)
239 ? options.handle
240 : hostname
241 return `/profile/${handleOrDid}/post/${rkey}`
242 } catch {
243 return undefined
244 }
245}
246
247/**
248 * Checks if the label in the post text matches the host of the link facet.
249 *
250 * Hosts are case-insensitive, so should be lowercase for comparison.
251 * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
252 */
253export function linkRequiresWarning(uri: string, label: string) {
254 const labelDomain = labelToDomain(label)
255
256 // We should trust any relative URL or a # since we know it links to internal content
257 if (isRelativeUrl(uri) || uri === '#') {
258 return false
259 }
260
261 let urip
262 try {
263 urip = new URL(uri)
264 } catch {
265 return true
266 }
267
268 const host = urip.hostname.toLowerCase()
269 if (isTrustedUrl(uri)) {
270 // if this is a link to internal content, warn if it represents itself as a URL to another app
271 return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
272 } else {
273 // if this is a link to external content, warn if the label doesnt match the target
274 if (!labelDomain) {
275 return true
276 }
277 return labelDomain !== host
278 }
279}
280
281/**
282 * Returns a lowercase domain hostname if the label is a valid URL.
283 *
284 * Hosts are case-insensitive, so should be lowercase for comparison.
285 * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
286 */
287export function labelToDomain(label: string): string | undefined {
288 // any spaces just immediately consider the label a non-url
289 if (/\s/.test(label)) {
290 return undefined
291 }
292 try {
293 return new URL(label).hostname.toLowerCase()
294 } catch {}
295 try {
296 return new URL('https://' + label).hostname.toLowerCase()
297 } catch {}
298 return undefined
299}
300
301export function isPossiblyAUrl(str: string): boolean {
302 str = str.trim()
303 if (str.startsWith('http://')) {
304 return true
305 }
306 if (str.startsWith('https://')) {
307 return true
308 }
309 const [firstWord] = str.split(/[\s\/]/)
310 return isValidDomain(firstWord)
311}
312
313export function splitApexDomain(hostname: string): [string, string] {
314 const hostnamep = psl.parse(hostname)
315 if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
316 return ['', hostname]
317 }
318 return [
319 hostnamep.subdomain ? `${hostnamep.subdomain}.` : '',
320 hostnamep.domain,
321 ]
322}
323
324export function createBskyAppAbsoluteUrl(path: string): string {
325 const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
326 return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
327}
328
329export function createProxiedUrl(url: string): string {
330 let u
331 try {
332 u = new URL(url)
333 } catch {
334 return url
335 }
336
337 if (u?.protocol !== 'http:' && u?.protocol !== 'https:') {
338 return url
339 }
340
341 return `https://go.bsky.app/redirect?u=${encodeURIComponent(url)}`
342}
343
344export function isShortLink(url: string): boolean {
345 return url.startsWith('https://go.bsky.app/')
346}
347
348export function shortLinkToHref(url: string): string {
349 try {
350 const urlp = new URL(url)
351
352 // For now we only support starter packs, but in the future we should add additional paths to this check
353 const parts = urlp.pathname.split('/').filter(Boolean)
354 if (parts.length === 1) {
355 return `/starter-pack-short/${parts[0]}`
356 }
357 return url
358 } catch (e) {
359 logger.error('Failed to parse possible short link', {safeMessage: e})
360 return url
361 }
362}
363
364export function getHostnameFromUrl(url: string | URL): string | null {
365 let urlp
366 try {
367 urlp = new URL(url)
368 } catch (e) {
369 return null
370 }
371 return urlp.hostname
372}
373
374export function getServiceAuthAudFromUrl(url: string | URL): string | null {
375 const hostname = getHostnameFromUrl(url)
376 if (!hostname) {
377 return null
378 }
379 return `did:web:${hostname}`
380}
381
382// passes URL.parse, and has a TLD etc
383export function definitelyUrl(maybeUrl: string) {
384 try {
385 if (maybeUrl.endsWith('.')) return null
386
387 // Prepend 'https://' if the input doesn't start with a protocol
388 if (!maybeUrl.startsWith('https://') && !maybeUrl.startsWith('http://')) {
389 maybeUrl = 'https://' + maybeUrl
390 }
391
392 const url = new URL(maybeUrl)
393
394 // Extract the hostname and split it into labels
395 const hostname = url.hostname
396 const labels = hostname.split('.')
397
398 // Ensure there are at least two labels (e.g., 'example' and 'com')
399 if (labels.length < 2) return null
400
401 const tld = labels[labels.length - 1]
402
403 // Check that the TLD is at least two characters long and contains only letters
404 if (!/^[a-z]{2,}$/i.test(tld)) return null
405
406 return url.toString()
407 } catch {
408 return null
409 }
410}