OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / git.js

235 lines · javascript ref:
/**
 * Git Protocol Routes
 *
 * Handles Git Smart HTTP protocol:
 * - GET /:username/:repoName.git/info/refs - Git info/refs (clone/fetch)
 * - POST /:username/:repoName.git/git-upload-pack - Git upload-pack (clone/fetch)
 * - POST /:username/:repoName.git/git-receive-pack - Git receive-pack (push)
 */

import { text, unauthorized, forbidden, notFound } from '../response.js'
import { autoDeployAfterPush } from '../ci/deploy-hook.js'

/**
 * Register git protocol routes
 *
 * NOTE: These patterns use regex matching in the router.
 * The .git extension is part of the pattern.
 */
export function registerGitRoutes(router) {
  // Git info/refs (clone/fetch discovery)
  router.get('/:username/:repoName.git/info/refs', handleGitInfoRefs)

  // Git upload-pack (clone/fetch data transfer)
  router.post('/:username/:repoName.git/git-upload-pack', handleGitUploadPack)

  // Git receive-pack (push data transfer)
  router.post('/:username/:repoName.git/git-receive-pack', handleGitReceivePack)
}

/**
 * Handle git info/refs (clone/fetch discovery)
 * Requires READ permission for private repos
 */
async function handleGitInfoRefs(req, res, server) {
  const { repoRegistry, gitProtocol } = server.repoData
  const { authManager, authEnabled } = server.auth
  const { username, repoName } = req.params

  // Strip .git extension if present
  const cleanRepoName = repoName.endsWith('.git')
    ? repoName.slice(0, -4)
    : repoName

  const repo = await repoRegistry.get(cleanRepoName)
  if (!repo) {
    text(res, 'Repository not found', 404)
    return
  }

  // Check if auth is enabled
  if (!authEnabled) {
    return gitProtocol.handleInfoRefs(req, res, username, cleanRepoName)
  }

  // Authenticate request (allow null for anonymous)
  const auth = await authManager.authenticate(req).catch(err => { console.error('[Auth] Git auth error:', err.message); return null })

  // Check repo access based on visibility
  if (!authManager.hasRepoAccess(auth, repo, 'read')) {
    console.log(`[Git] Denied info-refs: ${auth ? auth.method : 'anonymous'} lacks access to ${repo.visibility} repo ${cleanRepoName}`)
    text(res, 'Repository not found or access denied', 403)
    return
  }

  console.log(`[Git] Authorized info-refs: ${auth ? auth.method : 'anonymous'} for ${repo.visibility} repo ${cleanRepoName}`)
  return gitProtocol.handleInfoRefs(req, res, username, cleanRepoName)
}

/**
 * Handle git upload-pack (clone/fetch data transfer)
 * Requires READ permission for private repos
 */
async function handleGitUploadPack(req, res, server) {
  const { repoRegistry, gitProtocol } = server.repoData
  const { authManager, authEnabled } = server.auth
  const { username, repoName } = req.params

  const cleanRepoName = repoName.endsWith('.git')
    ? repoName.slice(0, -4)
    : repoName

  const repo = await repoRegistry.get(cleanRepoName)
  if (!repo) {
    text(res, 'Repository not found', 404)
    return
  }

  if (!authEnabled) {
    return gitProtocol.handleUploadPack(req, res, username, cleanRepoName)
  }

  const auth = await authManager.authenticate(req).catch(err => { console.error('[Auth] Git auth error:', err.message); return null })

  if (!authManager.hasRepoAccess(auth, repo, 'read')) {
    console.log(`[Git] Denied upload-pack: ${auth ? auth.method : 'anonymous'} lacks access to ${repo.visibility} repo ${cleanRepoName}`)
    text(res, 'Repository not found or access denied', 403)
    return
  }

  console.log(`[Git] Authorized upload-pack: ${auth ? auth.method : 'anonymous'} for ${repo.visibility} repo ${cleanRepoName}`)
  return gitProtocol.handleUploadPack(req, res, username, cleanRepoName)
}

/**
 * Handle git receive-pack (push data transfer)
 * Requires WRITE permission
 */
async function handleGitReceivePack(req, res, server) {
  const { repoRegistry, gitProtocol } = server.repoData
  const { authManager, authEnabled } = server.auth
  const { username, repoName } = req.params

  const cleanRepoName = repoName.endsWith('.git')
    ? repoName.slice(0, -4)
    : repoName

  const repo = await repoRegistry.get(cleanRepoName)
  if (!repo) {
    text(res, 'Repository not found', 404)
    return
  }

  if (!authEnabled) {
    await gitProtocol.handleReceivePack(req, res, username, cleanRepoName)
    refreshRepoAfterPush(server, cleanRepoName)
    broadcastRefUpdateAfterPush(server, cleanRepoName)
    return
  }

  // Write requires authentication
  const auth = await authManager.authenticate(req).catch(err => { console.error('[Auth] Git auth error:', err.message); return null })

  if (!auth) {
    console.log(`[Git] Denied receive-pack: Not authenticated for ${cleanRepoName}`)
    res.statusCode = 401
    res.setHeader('WWW-Authenticate', 'Basic realm="Git"')
    res.setHeader('Content-Type', 'text/plain')
    res.end('Authentication required: Use ?token=<token> or X-Wirecore-PublicKey header')
    return
  }

  if (!authManager.hasRepoAccess(auth, repo, 'write')) {
    console.log(`[Git] Denied receive-pack: ${auth.method} lacks write access to ${repo.visibility} repo ${cleanRepoName}`)
    text(res, 'Repository not found or write access denied', 403)
    return
  }

  console.log(`[Git] Authorized receive-pack: ${auth.method} for ${repo.visibility} repo ${cleanRepoName}`)
  await gitProtocol.handleReceivePack(req, res, username, cleanRepoName)

  // Sync Hyperdrive after push (if repo uses hyperdrive backend)
  syncHyperdriveAfterPush(server, cleanRepoName)

  // Auto-deploy after push (if configured, non-blocking)
  autoDeployAfterPush(server, cleanRepoName)

  // Refresh repo metadata (commits, branches, lastCommit) non-blocking
  refreshRepoAfterPush(server, cleanRepoName)

  // Broadcast ref update to federation peers via Protomux
  broadcastRefUpdateAfterPush(server, cleanRepoName)
}

/**
 * Sync Hyperdrive content after a git push (non-blocking)
 */
function syncHyperdriveAfterPush (server, repoName) {
  const { backendManager } = server.repoData
  backendManager.getBackend(repoName).then(backend => {
    if (backend.getStrategy() === 'hyperdrive' && backend.importRepo) {
      console.log(`[Hyperdrive] Syncing ${repoName} after push...`)
      backend.importRepo(repoName).then(result => {
        console.log(`[Hyperdrive] Synced ${repoName}: ${result.filesImported} files`)
      }).catch(err => {
        console.error(`[Hyperdrive] Sync error for ${repoName}:`, err.message)
      })
    }
  }).catch(err => { console.error('[Hyperdrive] Sync lookup error:', err.message) })
}

/**
 * Refresh repo metadata after push (non-blocking, fire-and-forget)
 * Re-computes commits, branches, lastCommit and updates in-memory cache.
 */
function refreshRepoAfterPush (server, repoName) {
  const { repoRegistry, repos } = server.repoData
  repoRegistry.refreshRepoMetadata(repoName).then(updated => {
    if (!updated) return
    const idx = repos.findIndex(r => r.name === repoName)
    if (idx >= 0) repos[idx] = updated
    else repos.push(updated)
  }).catch(() => {})
}

/**
 * Broadcast ref update to federation peers via Protomux (non-blocking, fire-and-forget)
 */
function broadcastRefUpdateAfterPush (server, repoName) {
  const { gitSyncManager } = server.p2p
  const { repoRegistry, linkedRepos } = server.repoData
  const { serverIdentity } = server.p2p
  import('../git/ref-reader.js').then(({ readRefs }) => {
    const repoPath = repoRegistry.gitReposPath + '/' + repoName + '.git'
    return readRefs(repoPath)
  }).then(async refs => {
    // Write refs to LinkedRepo Autobase — replicates via wakeup protocol
    const linked = linkedRepos.get(repoName)
    if (linked) {
      const userId = serverIdentity ? serverIdentity.getPublicKeyHex().slice(0, 16) : 'system'
      for (const [ref, hash] of Object.entries(refs)) {
        if (ref === 'HEAD') continue
        try {
          await linked.updateRef(ref, hash, userId)
        } catch {}
      }
      console.log(`[GitSync] Wrote ${Object.keys(refs).length} refs to LinkedRepo for ${repoName}`)
    }

    // Also broadcast via Protomux for immediate notification
    if (gitSyncManager) {
      const serverKey = serverIdentity ? serverIdentity.getPublicKeyHex() : null
      gitSyncManager.broadcastRefUpdate(repoName, refs, serverKey)
    }
  }).catch(err => {
    console.error('[GitSync] Ref update failed:', err.message)
  })
}

export {
  handleGitInfoRefs,
  handleGitUploadPack,
  handleGitReceivePack,
  refreshRepoAfterPush
}