my fork of the bluesky client
at main 359 lines 9.0 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://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}