/**
* 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 }