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