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