my fork of the bluesky client
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://host/')
45 urip.host = didOrName
46 urip.collection = collection
47 urip.rkey = rkey
48 return urip.toString()
49}
50
51export function toNiceDomain(url: string): string {
52 try {
53 const urlp = new URL(url)
54 if (`https://${urlp.host}` === BSKY_SERVICE) {
55 return 'Bluesky Social'
56 }
57 return urlp.host ? urlp.host : url
58 } catch (e) {
59 return url
60 }
61}
62
63export function toShortUrl(url: string): string {
64 try {
65 const urlp = new URL(url)
66 if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
67 return url
68 }
69 const path =
70 (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
71 if (path.length > 15) {
72 return urlp.host + path.slice(0, 13) + '...'
73 }
74 return urlp.host + path
75 } catch (e) {
76 return url
77 }
78}
79
80export function toShareUrl(url: string): string {
81 if (!url.startsWith('https')) {
82 const urlp = new URL('https://bsky.app')
83 urlp.pathname = url
84 url = urlp.toString()
85 }
86 return url
87}
88
89export function toBskyAppUrl(url: string): string {
90 return new URL(url, BSKY_APP_HOST).toString()
91}
92
93export function isBskyAppUrl(url: string): boolean {
94 return url.startsWith('https://bsky.app/')
95}
96
97export function isRelativeUrl(url: string): boolean {
98 return /^\/[^/]/.test(url)
99}
100
101export function isBskyRSSUrl(url: string): boolean {
102 return (
103 (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
104 /\/rss\/?$/.test(url)
105 )
106}
107
108export function isExternalUrl(url: string): boolean {
109 const external = !isBskyAppUrl(url) && url.startsWith('http')
110 const rss = isBskyRSSUrl(url)
111 return external || rss
112}
113
114export function isTrustedUrl(url: string): boolean {
115 return TRUSTED_REGEX.test(url)
116}
117
118export function isBskyPostUrl(url: string): boolean {
119 if (isBskyAppUrl(url)) {
120 try {
121 const urlp = new URL(url)
122 return /profile\/(?<name>[^/]+)\/post\/(?<rkey>[^/]+)/i.test(
123 urlp.pathname,
124 )
125 } catch {}
126 }
127 return false
128}
129
130export function isBskyCustomFeedUrl(url: string): boolean {
131 if (isBskyAppUrl(url)) {
132 try {
133 const urlp = new URL(url)
134 return /profile\/(?<name>[^/]+)\/feed\/(?<rkey>[^/]+)/i.test(
135 urlp.pathname,
136 )
137 } catch {}
138 }
139 return false
140}
141
142export function isBskyListUrl(url: string): boolean {
143 if (isBskyAppUrl(url)) {
144 try {
145 const urlp = new URL(url)
146 return /profile\/(?<name>[^/]+)\/lists\/(?<rkey>[^/]+)/i.test(
147 urlp.pathname,
148 )
149 } catch {
150 console.error('Unexpected error in isBskyListUrl()', url)
151 }
152 }
153 return false
154}
155
156export function isBskyStartUrl(url: string): boolean {
157 if (isBskyAppUrl(url)) {
158 try {
159 const urlp = new URL(url)
160 return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
161 } catch {
162 console.error('Unexpected error in isBskyStartUrl()', url)
163 }
164 }
165 return false
166}
167
168export function isBskyStarterPackUrl(url: string): boolean {
169 if (isBskyAppUrl(url)) {
170 try {
171 const urlp = new URL(url)
172 return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
173 } catch {
174 console.error('Unexpected error in isBskyStartUrl()', url)
175 }
176 }
177 return false
178}
179
180export function isBskyDownloadUrl(url: string): boolean {
181 if (isExternalUrl(url)) {
182 return false
183 }
184 return url === '/download' || url.startsWith('/download?')
185}
186
187export function convertBskyAppUrlIfNeeded(url: string): string {
188 if (isBskyAppUrl(url)) {
189 try {
190 const urlp = new URL(url)
191
192 if (isBskyStartUrl(url)) {
193 return startUriToStarterPackUri(urlp.pathname)
194 }
195
196 return urlp.pathname
197 } catch (e) {
198 console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
199 }
200 } else if (isShortLink(url)) {
201 // We only want to do this on native, web handles the 301 for us
202 return shortLinkToHref(url)
203 }
204 return url
205}
206
207export function listUriToHref(url: string): string {
208 try {
209 const {hostname, rkey} = new AtUri(url)
210 return `/profile/${hostname}/lists/${rkey}`
211 } catch {
212 return ''
213 }
214}
215
216export function feedUriToHref(url: string): string {
217 try {
218 const {hostname, rkey} = new AtUri(url)
219 return `/profile/${hostname}/feed/${rkey}`
220 } catch {
221 return ''
222 }
223}
224
225export function postUriToRelativePath(
226 uri: string,
227 options?: {handle?: string},
228): string | undefined {
229 try {
230 const {hostname, rkey} = new AtUri(uri)
231 const handleOrDid =
232 options?.handle && !isInvalidHandle(options.handle)
233 ? options.handle
234 : hostname
235 return `/profile/${handleOrDid}/post/${rkey}`
236 } catch {
237 return undefined
238 }
239}
240
241/**
242 * Checks if the label in the post text matches the host of the link facet.
243 *
244 * Hosts are case-insensitive, so should be lowercase for comparison.
245 * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
246 */
247export function linkRequiresWarning(uri: string, label: string) {
248 const labelDomain = labelToDomain(label)
249
250 // We should trust any relative URL or a # since we know it links to internal content
251 if (isRelativeUrl(uri) || uri === '#') {
252 return false
253 }
254
255 let urip
256 try {
257 urip = new URL(uri)
258 } catch {
259 return true
260 }
261
262 const host = urip.hostname.toLowerCase()
263 if (isTrustedUrl(uri)) {
264 // if this is a link to internal content, warn if it represents itself as a URL to another app
265 return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
266 } else {
267 // if this is a link to external content, warn if the label doesnt match the target
268 if (!labelDomain) {
269 return true
270 }
271 return labelDomain !== host
272 }
273}
274
275/**
276 * Returns a lowercase domain hostname if the label is a valid URL.
277 *
278 * Hosts are case-insensitive, so should be lowercase for comparison.
279 * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2
280 */
281export function labelToDomain(label: string): string | undefined {
282 // any spaces just immediately consider the label a non-url
283 if (/\s/.test(label)) {
284 return undefined
285 }
286 try {
287 return new URL(label).hostname.toLowerCase()
288 } catch {}
289 try {
290 return new URL('https://' + label).hostname.toLowerCase()
291 } catch {}
292 return undefined
293}
294
295export function isPossiblyAUrl(str: string): boolean {
296 str = str.trim()
297 if (str.startsWith('http://')) {
298 return true
299 }
300 if (str.startsWith('https://')) {
301 return true
302 }
303 const [firstWord] = str.split(/[\s\/]/)
304 return isValidDomain(firstWord)
305}
306
307export function splitApexDomain(hostname: string): [string, string] {
308 const hostnamep = psl.parse(hostname)
309 if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) {
310 return ['', hostname]
311 }
312 return [
313 hostnamep.subdomain ? `${hostnamep.subdomain}.` : '',
314 hostnamep.domain,
315 ]
316}
317
318export function createBskyAppAbsoluteUrl(path: string): string {
319 const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
320 return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
321}
322
323export function isShortLink(url: string): boolean {
324 return url.startsWith('https://go.bsky.app/')
325}
326
327export function shortLinkToHref(url: string): string {
328 try {
329 const urlp = new URL(url)
330
331 // For now we only support starter packs, but in the future we should add additional paths to this check
332 const parts = urlp.pathname.split('/').filter(Boolean)
333 if (parts.length === 1) {
334 return `/starter-pack-short/${parts[0]}`
335 }
336 return url
337 } catch (e) {
338 logger.error('Failed to parse possible short link', {safeMessage: e})
339 return url
340 }
341}
342
343export function getHostnameFromUrl(url: string | URL): string | null {
344 let urlp
345 try {
346 urlp = new URL(url)
347 } catch (e) {
348 return null
349 }
350 return urlp.hostname
351}
352
353export function getServiceAuthAudFromUrl(url: string | URL): string | null {
354 const hostname = getHostnameFromUrl(url)
355 if (!hostname) {
356 return null
357 }
358 return `did:web:${hostname}`
359}