Bluesky app fork with some witchin' additions 馃挮
at 5ee667f307bc459ba53cdaabdad00a0ea1ee6846 410 lines 10 kB view raw
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}