OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / catalog-safety.cjs

358 lines · javascript ref:
function scrubPrototypeKeys (value, seen = new Set()) {
  if (!value || typeof value !== 'object' || seen.has(value)) return value
  seen.add(value)
  for (const key of ['__proto__', 'constructor', 'prototype']) {
    if (Object.prototype.hasOwnProperty.call(value, key)) delete value[key]
  }
  if (Array.isArray(value)) {
    for (const item of value) scrubPrototypeKeys(item, seen)
    return value
  }
  for (const key of Object.keys(value)) scrubPrototypeKeys(value[key], seen)
  return value
}

function safeJSONParse (str) {
  return scrubPrototypeKeys(JSON.parse(str))
}

function catalogAppsFromEnvelope (catalog) {
  if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) return []
  if (Array.isArray(catalog.apps)) return catalog.apps
  if (Array.isArray(catalog.items)) return catalog.items
  if (Array.isArray(catalog.entries)) return catalog.entries
  return []
}

const HEX64_RE = /^[0-9a-f]{64}$/i
const Z32_RE = /^[13-9a-km-uw-z]{52}$/i
const PEAR_V3_LINK_RE = /^pear:\/\/[13-9a-km-uw-z]{52}\/?$/i
// Catalog links are browsable content only. Native delivery must be represented
// separately as a verified package or a non-executable migration record.
const APP_LINK_RE = /^hyper:\/\/.+/i
const VERIFICATION_RANK = { 'author-signed': 3, 'relay-listed': 2, unverified: 1 }
const NATIVE_DELIVERY_STATUSES = new Set(['migration-required', 'available'])

function trimString (value) {
  return typeof value === 'string' ? value.trim() : ''
}

function hexFromZ32 (value) {
  try {
    const z32 = require('z32')
    const buf = z32.decode(String(value || '').toLowerCase())
    return buf && buf.length === 32 ? Buffer.from(buf).toString('hex') : ''
  } catch {
    return ''
  }
}

function normalizeDriveKey (raw) {
  const s = trimString(raw)
  if (!s) return ''

  const target = /^hyper:\/\//i.test(s)
    ? s.replace(/^hyper:\/\//i, '').split(/[/?#]/)[0].trim()
    : s

  if (HEX64_RE.test(target)) return target.toLowerCase()
  if (Z32_RE.test(target)) return hexFromZ32(target)
  return ''
}

function driveKeyFromHyperLink (raw) {
  const s = trimString(raw)
  if (!/^hyper:\/\//i.test(s)) return ''
  const key = s.replace(/^hyper:\/\//i, '').split(/[/?#]/)[0].trim()
  if (HEX64_RE.test(key)) return key.toLowerCase()
  if (Z32_RE.test(key)) return hexFromZ32(key)
  return ''
}

function normalizeCatalogLink (raw) {
  const s = trimString(raw)
  if (!s) return ''
  const normalized = s.replace(/^([a-z][a-z0-9+.-]*):\/\//i, (_, scheme) => scheme.toLowerCase() + '://')
  return APP_LINK_RE.test(normalized) ? normalized : ''
}

function normalizeCategories (categories) {
  if (!Array.isArray(categories)) return []
  return categories
    .map((category) => String(category || '').trim())
    .filter(Boolean)
    .slice(0, 32)
}

function normalizeVerification (value) {
  const v = trimString(value)
  return v || 'unverified'
}

function normalizeAppType (value) {
  const type = trimString(value).toLowerCase()
  return type === 'standalone' || type === 'hypersite' ? type : ''
}

function normalizeLegacyMigrationId (value) {
  const id = trimString(value).toLowerCase()
  return Z32_RE.test(id) ? id : ''
}

function normalizeNativeDelivery (value) {
  const source = value && typeof value === 'object' ? value : { status: value }
  const status = trimString(source.status)
  if (!NATIVE_DELIVERY_STATUSES.has(status)) return null
  if (status === 'migration-required') return { status }

  const kind = trimString(source.kind).toLowerCase()
  const rawLink = trimString(source.installLink || source.link)
  const installLink = PEAR_V3_LINK_RE.test(rawLink)
    ? rawLink.replace(/\/$/, '').replace(/^PEAR:\/\//i, 'pear://').toLowerCase()
    : ''
  if (kind !== 'pear-v3' || !installLink) return null

  const productName = trimString(source.productName).slice(0, 120)
  const targets = Array.isArray(source.targets)
    ? source.targets.map((target) => trimString(target).toLowerCase()).filter((target) => /^[a-z0-9_-]{3,40}$/.test(target)).slice(0, 12)
    : []
  return {
    status,
    kind,
    installLink,
    ...(productName ? { productName } : {}),
    ...(targets.length ? { targets } : {})
  }
}

function normalizeCatalogApp (app, opts = {}) {
  if (!app || typeof app !== 'object' || Array.isArray(app)) return null
  const out = { ...app }
  const upstreamSource = trimString(out.sourceUrl) || (opts.source ? trimString(out.source) : '')

  const key = trimString(out.driveKey) ||
    trimString(out.appKey) ||
    trimString(out.key) ||
    ''
  const rawLink = trimString(out.link)
  const link = normalizeCatalogLink(out.link)
  const driveKey = normalizeDriveKey(key) || driveKeyFromHyperLink(link)
  const legacyMigrationId = normalizeLegacyMigrationId(out.legacyMigrationId)
  const nativeDelivery = normalizeNativeDelivery(out.nativeDelivery)
  const nativeInstallLink = nativeDelivery && nativeDelivery.status === 'available' ? nativeDelivery.installLink : ''
  const id = trimString(out.id) || driveKey || link || nativeInstallLink || (legacyMigrationId ? `legacy:${legacyMigrationId}` : '')
  if ((key && !driveKey && !link && !legacyMigrationId && !nativeInstallLink) || (rawLink && !link && !driveKey && !legacyMigrationId && !nativeInstallLink) || (!driveKey && !link && !legacyMigrationId && !nativeInstallLink) || !id) return null

  out.id = id || undefined
  out.name = trimString(out.name) || id || undefined
  if (driveKey) out.driveKey = driveKey
  else delete out.driveKey
  if (link) out.link = link
  else delete out.link
  if (legacyMigrationId) out.legacyMigrationId = legacyMigrationId
  else delete out.legacyMigrationId
  if (nativeDelivery) out.nativeDelivery = nativeDelivery
  else delete out.nativeDelivery
  const type = normalizeAppType(out.type)
  if (type) out.type = type
  else delete out.type
  out.version = out.version == null ? '' : String(out.version).trim()
  out.categories = normalizeCategories(out.categories)
  out.verification = normalizeVerification(out.verification)
  if (upstreamSource) out.sourceUrl = upstreamSource
  if (opts.source) out.source = opts.source
  if (opts.catalogKey && !out.catalogKey) out.catalogKey = opts.catalogKey
  if (opts.catalogName && !out.catalogName) out.catalogName = opts.catalogName
  return out
}

function normalizeCatalogData (catalog, opts = {}) {
  const data = scrubPrototypeKeys(catalog && typeof catalog === 'object' && !Array.isArray(catalog)
    ? catalog
    : {})
  const appOpts = { ...opts, catalogName: opts.catalogName || data.name }
  const apps = catalogAppsFromEnvelope(data)
    .map((app) => normalizeCatalogApp(app, appOpts))
    .filter(Boolean)
  return { ...data, apps }
}

function catalogAppStableKey (app) {
  if (!app || typeof app !== 'object' || Array.isArray(app)) return ''
  const key = trimString(app.driveKey) ||
    trimString(app.appKey) ||
    trimString(app.key) ||
    ''
  const link = normalizeCatalogLink(app.link)
  const driveKey = normalizeDriveKey(key) || driveKeyFromHyperLink(link)
  if (driveKey) return `drive:${driveKey}`
  if (link) return `link:${link}`
  const nativeDelivery = normalizeNativeDelivery(app.nativeDelivery)
  if (nativeDelivery && nativeDelivery.status === 'available') return `native:${nativeDelivery.installLink}`
  const legacyMigrationId = normalizeLegacyMigrationId(app.legacyMigrationId)
  if (legacyMigrationId) return `legacy:${legacyMigrationId}`
  const id = trimString(app.id)
  if (id) return `id:${id}`
  return ''
}

function versionGreater (a, b) {
  const pa = String(a == null ? '0' : a).split('.').map((n) => parseInt(n, 10) || 0)
  const pb = String(b == null ? '0' : b).split('.').map((n) => parseInt(n, 10) || 0)
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const da = pa[i] || 0
    const db = pb[i] || 0
    if (da !== db) return da > db
  }
  return false
}

function betterCatalogApp (a, b) {
  const va = VERIFICATION_RANK[a && a.verification] || 1
  const vb = VERIFICATION_RANK[b && b.verification] || 1
  if (va !== vb) return va > vb ? a : b
  if (versionGreater(a && a.version, b && b.version)) return a
  if (versionGreater(b && b.version, a && a.version)) return b
  return b
}

function mergeCatalogAppEntries (incoming, existing) {
  if (!existing) return incoming
  const winner = betterCatalogApp(incoming, existing)
  const other = winner === incoming ? existing : incoming
  const merged = { ...winner }
  if (!merged.iconData && other.iconData) merged.iconData = other.iconData
  if (!merged.icon && other.icon) merged.icon = other.icon
  const sources = [
    ...(Array.isArray(existing._sources) ? existing._sources : []),
    ...(Array.isArray(incoming._sources) ? incoming._sources : []),
    existing.catalogName,
    incoming.catalogName
  ].filter(Boolean)
  if (sources.length) merged._sources = [...new Set(sources)]
  return merged
}

function defaultCatalogEntrySource (entry) {
  if (entry && entry.type) return entry.type
  return entry && entry.drive ? 'hyperdrive' : 'catalog'
}

function aggregateCatalogApps (catalogs, sourceForEntry = defaultCatalogEntrySource) {
  const byTarget = new Map()
  const anon = []
  const entries = catalogs && typeof catalogs[Symbol.iterator] === 'function' ? catalogs : []
  for (const [catalogKey, entry] of entries) {
    if (!entry || !entry.data || !Array.isArray(entry.data.apps)) continue
    const catalogName = entry.data.name || 'Catalog'
    const source = sourceForEntry(entry)
    for (const app of entry.data.apps) {
      const normalized = normalizeCatalogApp(app, { source, catalogKey, catalogName })
      if (!normalized) continue
      const tagged = { ...normalized, source, catalogKey, catalogName }
      const stableKey = catalogAppStableKey(tagged)
      if (!stableKey) { anon.push(tagged); continue }
      byTarget.set(stableKey, mergeCatalogAppEntries(tagged, byTarget.get(stableKey)))
    }
  }
  return [...byTarget.values(), ...anon]
}

function searchAppsList (apps, query) {
  const list = Array.isArray(apps) ? apps : []
  const q = String(query || '').normalize('NFKC').trim().toLowerCase()
  if (!q) return list
  return list.filter(app => catalogAppSearchText(app).includes(q))
}

function catalogAppSearchText (app) {
  if (!app || typeof app !== 'object') return ''
  const fields = [
    app.name,
    app.description,
    app.author,
    app.homepage,
    app.sourceUrl,
    app.license,
    app.id,
    app.version,
    app.source,
    app.catalogName,
    app.verification,
    app.legacyMigrationId,
    app.nativeDelivery && app.nativeDelivery.status,
    app.nativeDelivery && app.nativeDelivery.kind,
    app.nativeDelivery && app.nativeDelivery.installLink,
    app.nativeDelivery && app.nativeDelivery.productName,
    ...(Array.isArray(app.nativeDelivery && app.nativeDelivery.targets) ? app.nativeDelivery.targets : []),
    app.link,
    app.driveKey,
    ...(Array.isArray(app.categories) ? app.categories : []),
    ...(Array.isArray(app._sources) ? app._sources : [])
  ]
  return fields
    .filter((value) => value != null && value !== '')
    .map((value) => String(value).normalize('NFKC').toLowerCase())
    .join(' ')
}

function sanitizePersonalCatalogEntry (app) {
  if (!app || typeof app !== 'object') throw new Error('Invalid app')
  const str = (v, n) => (typeof v === 'string' ? v.trim().slice(0, n) : undefined)
  const draft = {
    id: str(app.id, 128),
    name: str(app.name, 200),
    description: str(app.description, 1000),
    driveKey: str(app.driveKey, 128),
    link: str(app.link, 300),
    version: str(app.version, 40),
    author: str(app.author, 200),
    icon: str(app.icon, 300),
    nativeDelivery: normalizeNativeDelivery(app.nativeDelivery),
    // Launch gating (PBACS §9): explicit standalone (own window) vs hypersite
    // (inline tab). Only the two valid enum values survive; anything else drops.
    type: (['standalone', 'hypersite'].includes(String(app.type || '').trim()) ? String(app.type).trim() : undefined)
  }
  if (Array.isArray(app.categories)) {
    draft.categories = app.categories.map((c) => String(c).trim().slice(0, 60)).filter(Boolean).slice(0, 12)
  }
  for (const k of Object.keys(draft)) if (draft[k] === undefined || draft[k] === '') delete draft[k]
  const out = normalizeCatalogApp(draft)
  if (!out) throw new Error('App needs a valid 64-hex drive key, browsable hyper:// content, a legacy migration id, or a configured Pear v3 native delivery link.')
  return {
    id: out.id,
    name: out.name,
    ...(draft.type ? { type: draft.type } : {}),
    description: out.description || '',
    ...(out.driveKey ? { driveKey: out.driveKey } : {}),
    ...(out.link ? { link: out.link } : {}),
    ...(out.nativeDelivery ? { nativeDelivery: out.nativeDelivery } : {}),
    version: out.version || '',
    author: out.author || '',
    categories: Array.isArray(out.categories) ? out.categories : [],
    ...(out.icon ? { icon: out.icon } : {})
  }
}

module.exports = {
  VERIFICATION_RANK,
  aggregateCatalogApps,
  betterCatalogApp,
  catalogAppStableKey,
  catalogAppSearchText,
  catalogAppsFromEnvelope,
  mergeCatalogAppEntries,
  normalizeCatalogApp,
  normalizeCatalogLink,
  normalizeDriveKey,
  driveKeyFromHyperLink,
  normalizeCatalogData,
  normalizeLegacyMigrationId,
  normalizeNativeDelivery,
  normalizeAppType,
  scrubPrototypeKeys,
  safeJSONParse,
  sanitizePersonalCatalogEntry,
  searchAppsList
}