OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / archive.js

146 lines · javascript ref:
/**
 * Archive Routes
 *
 * Serves pre-built release archives (tar.gz and zip) for repositories.
 *
 * Route: GET /:username/:repoName/archive/*filename
 *
 * Archives are lazily generated on first request and cached in the archives
 * directory. Subsequent requests stream the cached file.
 */

import fs from 'bare-fs'
import path from 'bare-path'
import { notFound, forbidden, error } from '../response.js'
import { generateArchive, getArchivePath } from '../archive.js'

const CONTENT_TYPES = {
  'tar.gz': 'application/gzip',
  'zip': 'application/zip'
}

/**
 * Parse archive format from a filename string.
 *
 * @param {string} filename - e.g. 'release.tar.gz' or 'release.zip'
 * @returns {'tar.gz'|'zip'|null} The format, or null if unrecognized
 */
export function parseArchiveFormat (filename) {
  if (!filename || typeof filename !== 'string') return null
  if (filename === 'release.tar.gz') return 'tar.gz'
  if (filename === 'release.zip') return 'zip'
  return null
}

/**
 * Register archive download routes
 */
export function registerArchiveRoutes (router) {
  router.get('/:username/:repoName/archive/*filename', handleArchiveDownload)
}

/**
 * Handle archive download request.
 *
 * 1. Parse format from filename param
 * 2. Look up repo
 * 3. Auth check (if enabled)
 * 4. Resolve archive path
 * 5. Check if pre-built archive exists (lazy generate if not)
 * 6. Stream file with proper headers
 */
async function handleArchiveDownload (req, res, server) {
  const { repoData, auth: authGroup, config } = server
  const { repoRegistry } = repoData
  const { authEnabled, authManager } = authGroup
  const { storage } = config
  const { repoName, filename } = req.params

  // 1. Parse format — only accept release.tar.gz or release.zip
  const format = parseArchiveFormat(filename)
  if (!format) {
    notFound(res, 'Archive not found. Use release.tar.gz or release.zip')
    return
  }

  // 2. Look up repo
  const repo = await repoRegistry.get(repoName)
  if (!repo) {
    notFound(res, 'Repository not found')
    return
  }

  // 3. Auth check if enabled
  if (authEnabled) {
    const auth = await authManager.authenticate(req).catch(() => null)

    if (!authManager.hasRepoAccess(auth, repo, 'read')) {
      console.log(`[Archive] Denied download: ${auth ? auth.method : 'anonymous'} lacks access to ${repo.visibility} repo ${repoName}`)
      forbidden(res, 'Repository not found or access denied')
      return
    }
  }

  // 4. Resolve archive path (arch-specific if ?arch= query param)
  const urlQuery = (req.url || '').split('?')[1] || ''
  const archMatch = urlQuery.match(/arch=([^&]+)/)
  const arch = archMatch ? archMatch[1] : null
  const storagePath = path.resolve(storage || './pear-git-storage')
  const archiveDir = path.join(storagePath, 'archives')
  const archSuffix = arch ? `-${arch}` : ''
  const archiveSubdir = path.join(archiveDir, repoName)
  const archivePath = path.join(archiveSubdir, `release${archSuffix}.${format}`)

  // 5. Check if pre-built archive exists
  let stat = null
  try {
    stat = fs.statSync(archivePath)
  } catch {
    // Not cached — lazy generation
  }

  if (!stat) {
    if (!repo.path) {
      error(res, 'Repository has no local path', 500)
      return
    }

    try {
      const result = await generateArchive(repo.path, repoName, archiveDir, format, { arch })
      stat = fs.statSync(result.path)
      console.log(`[Archive] Generated ${format}${arch ? ' (' + arch + ')' : ''} for ${repoName} (${(stat.size / 1024 / 1024).toFixed(1)} MB)`)
    } catch (err) {
      if (err.message.includes('has no release branch')) {
        notFound(res, 'No release branch found. Deploy first to create one.')
        return
      }
      console.error(`[Archive] Generation failed for ${repoName}:`, err.message)
      error(res, 'Archive generation failed', 500)
      return
    }
  }

  // 6. Stream file with proper headers
  const contentType = CONTENT_TYPES[format]
  res.statusCode = 200
  res.setHeader('Content-Type', contentType)
  res.setHeader('Content-Length', String(stat.size))
  res.setHeader('Content-Disposition', `attachment; filename="${repoName}-release.${format}"`)
  res.setHeader('Cache-Control', 'public, max-age=60')

  const stream = fs.createReadStream(archivePath)

  stream.on('error', (err) => {
    console.error(`[Archive] Stream error for ${repoName}:`, err.message)
    if (!res.headersSent) {
      error(res, 'Archive stream error', 500)
    } else {
      res.destroy()
    }
  })

  stream.pipe(res)
}

export { handleArchiveDownload }