Bluesky app fork with some witchin' additions 馃挮
at main 428 lines 11 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://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}