Ctrl K
ComunidadGuías…

src/services/previewLinks.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
export interface ChannelPreviewLink {
  type: 'channel'
  appId: string
  channelId?: number
  channelName: string
  payloadUrl?: string
}

export interface BundlePreviewLink {
  type: 'bundle'
  appId?: string
  versionId?: number
  payloadUrl?: string
}

export type PreviewDeepLink = ChannelPreviewLink | BundlePreviewLink

const CHANNEL_PREVIEW_PATH = '/preview/channel'
const BUNDLE_PREVIEW_PATH = '/preview/bundle'
const CHANNEL_PREVIEW_SCHEME_URL = 'capgo://preview/channel'
const BUNDLE_PREVIEW_SCHEME_URL = 'capgo://preview/bundle'
const LOCAL_PREVIEW_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
export const NATIVE_CONFIRMED_PREVIEW_PARAM = 'nativeConfirmedPreview'

function parseUrl(value: string): URL | null {
  try {
    return new URL(value.trim())
  }
  catch {
    return null
  }
}

function isAllowedWebPreviewHost(hostname: string) {
  const normalizedHostname = hostname.toLowerCase()
  return LOCAL_PREVIEW_HOSTS.has(normalizedHostname)
    || normalizedHostname === 'web.capgo.app'
    || normalizedHostname === 'console.capgo.app'
    || /^console\.(?:dev|preprod|staging)\.capgo\.app$/.test(normalizedHostname)
}

function isPreviewLinkOriginAllowed(url: URL) {
  if (url.protocol === 'capgo:')
    return true

  if (url.protocol === 'https:')
    return isAllowedWebPreviewHost(url.hostname)

  if (url.protocol === 'http:')
    return LOCAL_PREVIEW_HOSTS.has(url.hostname.toLowerCase())

  return false
}

function getPreviewPath(url: URL) {
  if (url.protocol !== 'capgo:')
    return url.pathname

  const hostPath = url.hostname ? `/${url.hostname}${url.pathname}` : url.pathname
  return hostPath.replace(/\/+/g, '/')
}

function getTrimmedParam(url: URL, ...names: string[]) {
  for (const name of names) {
    const value = url.searchParams.get(name)?.trim()
    if (value)
      return value
  }
  return undefined
}

function parseSafeIntegerParam(url: URL, names: string[], options: { min: number }) {
  const value = getTrimmedParam(url, ...names)
  if (!value || !/^\d+$/.test(value))
    return undefined

  const parsed = Number.parseInt(value, 10)
  if (!Number.isSafeInteger(parsed) || parsed < options.min)
    return undefined

  return parsed
}

function getHttpUrlParam(url: URL, ...names: string[]) {
  for (const name of names) {
    const value = url.searchParams.get(name)?.trim()
    const parsed = value ? parseUrl(value) : null
    if (parsed && (parsed.protocol === 'https:' || parsed.protocol === 'http:'))
      return parsed.toString()
  }
  return undefined
}

export function buildChannelPreviewDeepLink(options: {
  appId: string
  channelId?: number
  channelName: string
  payloadUrl?: string
  origin?: string
}) {
  const url = options.origin
    ? new URL(CHANNEL_PREVIEW_PATH, options.origin)
    : new URL(CHANNEL_PREVIEW_SCHEME_URL)
  url.searchParams.set('appId', options.appId)
  url.searchParams.set('channel', options.channelName)
  if (typeof options.channelId === 'number')
    url.searchParams.set('channelId', String(options.channelId))
  if (options.payloadUrl)
    url.searchParams.set('url', options.payloadUrl)
  return url.toString()
}

export function buildBundlePreviewDeepLink(options: {
  appId?: string
  versionId?: number
  payloadUrl?: string
  origin?: string
}) {
  const url = options.origin
    ? new URL(BUNDLE_PREVIEW_PATH, options.origin)
    : new URL(BUNDLE_PREVIEW_SCHEME_URL)
  if (options.payloadUrl)
    url.searchParams.set('url', options.payloadUrl)
  if (options.appId)
    url.searchParams.set('appId', options.appId)
  if (typeof options.versionId === 'number')
    url.searchParams.set('versionId', String(options.versionId))
  return url.toString()
}

export function parsePreviewDeepLink(value: string): PreviewDeepLink | null {
  const url = parseUrl(value)
  if (!url)
    return null

  if (!isPreviewLinkOriginAllowed(url))
    return null

  const previewPath = getPreviewPath(url)
  if (previewPath !== CHANNEL_PREVIEW_PATH && previewPath !== BUNDLE_PREVIEW_PATH)
    return null

  const payloadUrl = getHttpUrlParam(url, 'url', 'payloadUrl')
  if (previewPath === BUNDLE_PREVIEW_PATH) {
    const parsedVersionId = parseSafeIntegerParam(url, ['versionId', 'bundleId'], { min: 0 })
    const appId = getTrimmedParam(url, 'appId', 'app')
    if (!payloadUrl && (!appId || typeof parsedVersionId !== 'number'))
      return null

    return {
      type: 'bundle',
      appId,
      payloadUrl,
      versionId: parsedVersionId,
    }
  }

  const appId = getTrimmedParam(url, 'appId', 'app')
  const channelName = getTrimmedParam(url, 'channel', 'channelName')
  const channelId = parseSafeIntegerParam(url, ['channelId'], { min: 1 })

  if (!appId || !channelName)
    return null

  return {
    type: 'channel',
    appId,
    channelId,
    channelName,
    payloadUrl,
  }
}

/**
 * Normalize a scanned QR payload into a single URL. Scanners and QR generators
 * can wrap the link in whitespace, quotes, angle brackets, markdown, or extra
 * text ("Open: capgo://..."), which made valid preview links fail to parse.
 */
export function normalizeScannedPreviewValue(value: string) {
  const trimmed = value.replace(/[\u200B-\u200D\uFEFF]/g, '').trim()
  // A capgo:// link wins over any earlier https:// URL in the surrounding text.
  const match = trimmed.match(/capgo:\/\/[^\s"'<>`]+/i) ?? trimmed.match(/https?:\/\/[^\s"'<>`]+/i)
  if (!match)
    return trimmed
  // A clean payload is kept verbatim: channel names may legitimately end in "."
  if (match[0] === trimmed)
    return trimmed

  // Wrapped in text: drop sentence punctuation and closers that are not part of the URL.
  let url = match[0]
  for (;;) {
    const last = url[url.length - 1]
    if (last && '.,;:!?'.includes(last)) {
      url = url.slice(0, -1)
      continue
    }
    if (last === ')' && url.split('(').length < url.split(')').length) {
      url = url.slice(0, -1)
      continue
    }
    if (last === ']' && url.split('[').length < url.split(']').length) {
      url = url.slice(0, -1)
      continue
    }
    return url
  }
}

export function hasNativeConfirmedPreview(value: string) {
  const url = parseUrl(value)
  return url?.searchParams.get(NATIVE_CONFIRMED_PREVIEW_PARAM) === '1'
}

export function buildDeferredPreviewInstallReferrerUrl(value: string) {
  const previewLink = parsePreviewDeepLink(value)
  const url = parseUrl(value)
  if (!previewLink || !url || (url.protocol !== 'https:' && url.protocol !== 'http:'))
    return undefined

  const referrerUrl = new URL(getPreviewPath(url), url.origin)
  if (previewLink.type === 'channel') {
    referrerUrl.searchParams.set('appId', previewLink.appId)
    referrerUrl.searchParams.set('channel', previewLink.channelName)
    if (typeof previewLink.channelId === 'number')
      referrerUrl.searchParams.set('channelId', String(previewLink.channelId))
    return referrerUrl.toString()
  }

  if (!previewLink.appId || typeof previewLink.versionId !== 'number')
    return undefined

  referrerUrl.searchParams.set('appId', previewLink.appId)
  referrerUrl.searchParams.set('versionId', String(previewLink.versionId))
  return referrerUrl.toString()
}

const DEFERRED_PREVIEW_REFERRER_KEYS = ['capgo_preview', 'preview']

function tryDecodeURIComponent(value: string) {
  try {
    return decodeURIComponent(value)
  }
  catch {
    return undefined
  }
}

function getReferrerCandidates(referrer?: string) {
  const trimmedReferrer = referrer?.trim()
  if (!trimmedReferrer)
    return []

  const referrerCandidates = new Set<string>([trimmedReferrer])
  const decodedReferrer = tryDecodeURIComponent(trimmedReferrer)
  if (decodedReferrer)
    referrerCandidates.add(decodedReferrer)
  return [...referrerCandidates]
}

function validatedPreviewLink(value: string | null) {
  const trimmedValue = value?.trim()
  if (!trimmedValue)
    return undefined

  return [trimmedValue, tryDecodeURIComponent(trimmedValue)]
    .find((candidate): candidate is string => !!candidate && !!parsePreviewDeepLink(candidate))
}

function previewLinkFromReferrerParams(candidate: string) {
  const params = new URLSearchParams(candidate)
  for (const key of DEFERRED_PREVIEW_REFERRER_KEYS) {
    const previewLink = validatedPreviewLink(params.get(key))
    if (previewLink)
      return previewLink
  }

  return undefined
}

export function previewLinkFromInstallReferrer(referrer?: string) {
  for (const candidate of getReferrerCandidates(referrer)) {
    const previewLink = validatedPreviewLink(candidate) ?? previewLinkFromReferrerParams(candidate)
    if (previewLink)
      return previewLink
  }

  return undefined
}

export function parseChannelPreviewDeepLink(value: string): ChannelPreviewLink | null {
  const previewLink = parsePreviewDeepLink(value)
  return previewLink?.type === 'channel' ? previewLink : null
}

export function buildChannelPreviewLatestOptions(previewLink: ChannelPreviewLink) {
  return {
    appId: previewLink.appId,
    channel: previewLink.channelName,
    preview: true,
  }
}