OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / repos.js

461 lines · javascript ref:
/**
 * Repository Routes
 *
 * Handles:
 * - GET /api/repos - List all repos
 * - GET /api/p2p/info - P2P server info
 * - GET /api/scan - Scan for repos
 * - GET /api/fast-scan - Fast scan repos
 * - POST /api/repos/:repoName/import - Import repo
 * - GET /api/import-git-repos - Import all git repos
 * - POST /api/repos/:repoName/migrate - Migrate repo storage
 * - GET /api/repos/:repoName/punch-url - Get punch URL
 * - GET /api/repos/:repoName/storage - Get storage stats
 * - GET /api/repos/:repoName/collaborators - List collaborators
 * - GET /:username/:repoName - View repository
 * - GET /:username/:repoName/tree/*path - View directory
 * - GET /:username/:repoName/blob/*path - View file
 * - GET /:username/:repoName/commit/:hash - View commit with diff
 */

import fs from 'bare-fs'
import path from 'bare-path'
import z32 from 'z32'
import b4a from 'b4a'
import { html, json, notFound, ndjsonStream, readJson, escapeHtml } from '../response.js'
import { renderRepoViewer } from '../repo-viewer-ui.js'
import { renderIdentityScript } from '../identity-ui.js'
import { detectLanguage, getPrismAssets, isBinaryContent } from '../syntax.js'
import { parseDiff, getDiffStats, getStatusLabel } from '../diff-parser.js'
import { buildBreadcrumb, getBreadcrumbStyles } from '../breadcrumb.js'
import { renderTreePage, renderFilePage, renderCommitPage, renderSubmodulePage, renderSearchPage } from '../repo-pages-ui.js'

/**
 * Register repository routes
 */
export function registerRepoRoutes(router) {
  router.get('/api/repos', handleListRepos)
  router.get('/api/p2p/info', handleP2PInfo)
  router.get('/api/scan', handleScan)
  router.get('/api/fast-scan', handleFastScan)
  router.post('/api/repos/:repoName/import', handleImport)
  router.get('/api/import-git-repos', handleImportGitRepos)
  router.post('/api/repos/:repoName/migrate', handleMigrate)
  router.get('/api/repos/:repoName/punch-url', handlePunchUrl)
  router.get('/api/repos/:repoName/urls', handleRepoUrls)
  router.get('/api/repos/:repoName/storage', handleStorageStats)
  router.get('/api/repos/:repoName/collaborators', handleListCollaborators)
  router.post('/api/repos/migrate-path', handleMigrateReposPath)
  // raw file view is in repo-viewer.js
}

/**
 * List all repositories
 */
async function handleListRepos(req, res, server) {
  const { repos, linkedRepos } = server.repoData
  try {
    // Check if request is from an admin (has valid auth)
    const isAdmin = req.user && req.user.globalPermissions && req.user.globalPermissions.admin

    const repoList = repos
      .filter(repo => {
        // Private repos only visible to admins
        if ((repo.visibility === 'private') && !isAdmin) return false
        return true
      })
      .map(repo => ({
        name: repo.name,
        path: repo.path,
        commits: repo.commits || 0,
        branches: repo.branches || 0,
        lastCommit: repo.lastCommit || null,
        visibility: repo.visibility || 'public',
        owner: repo.owner || null,
        category: repo.category || null,
        namespace: repo.namespace || null,
        deploy: repo.deploy || null,
        linked: linkedRepos && linkedRepos.has(repo.name) ? true : undefined
      }))

    json(res, repoList)
  } catch (err) {
    console.error('Error listing repos:', err)
    json(res, { error: 'Failed to list repos' }, 500)
  }
}

/**
 * Get P2P server info
 */
async function handleP2PInfo(req, res, server) {
  const { p2pEnabled, serverIdentity, topicManager } = server.p2p
  const { backendManager } = server.repoData
  try {
    if (!p2pEnabled || !serverIdentity) {
      json(res, { error: 'P2P not enabled' }, 503)
      return
    }

    const serverKey = serverIdentity.getPublicKeyHex()
    const topics = topicManager ? topicManager.listTopics() : []
    const connections = topicManager ? topicManager.getConnectionCount() : 0

    // Resolve per-repo driveKeys for git+pear:// URLs
    const repos = await Promise.all(topics.map(async (t) => {
      let driveKeyHex = null
      try {
        driveKeyHex = await backendManager.getDriveKey(t.repoName)
      } catch {}
      const driveKeyZ32 = driveKeyHex ? z32.encode(b4a.from(driveKeyHex, 'hex')) : null

      return {
        name: t.repoName,
        punchUrl: `punch://${serverKey}/${t.repoName}`,
        cloneUrl: driveKeyZ32 ? `git+pear://${driveKeyZ32}/${t.repoName}` : null,
        shortUrl: `punch://${serverKey.slice(0, 8)}.../${t.repoName}`
      }
    }))

    json(res, {
      enabled: true,
      serverKey,
      shortKey: serverKey.slice(0, 16) + '...',
      connections,
      repos
    })
  } catch (err) {
    console.error('Error getting P2P info:', err)
    json(res, { error: 'Failed to get P2P info' }, 500)
  }
}

/**
 * Handle filesystem scan (streaming)
 */
async function handleScan(req, res, server) {
  const { scanner } = server.repoData
  const stream = ndjsonStream(res)

  const url = new URL(req.url, `http://${req.headers.host}`)
  const scanPath = url.searchParams.get('path')
  const scanOptions = scanPath ? { paths: [scanPath], maxDepth: 1 } : {}

  let count = 0

  for await (const repo of scanner.scan(scanOptions)) {
    count++
    stream.write({ type: 'progress', message: `Found ${count} repositories...`, percent: 0 })
    stream.write({ type: 'repo', repo })
  }

  stream.end()
}

/**
 * Fast scan - register repos without importing
 */
async function handleFastScan(req, res, server) {
  const { repoRegistry } = server.repoData
  try {
    const previousNames = new Set(server.repos.map(r => r.name))

    const repos = await repoRegistry.scanAndRegister({
      paths: [repoRegistry.gitReposPath],
      maxDepth: 1
    })

    server.repos = repos

    // Announce any newly discovered repos on DHT
    const topicManager = server.p2p?.topicManager
    if (topicManager) {
      const newRepos = repos.filter(r => !previousNames.has(r.name))
      for (const r of newRepos) {
        await topicManager.announceRepo(r.name)
      }
    }

    json(res, {
      success: true,
      count: repos.length,
      repos: repos.map(r => ({ name: r.name, commits: r.commits, branches: r.branches }))
    })
  } catch (err) {
    console.error('Fast scan error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Import specific repo
 */
async function handleImport(req, res, server) {
  const { repos, scanner } = server.repoData
  const { repoName } = req.params
  const repo = repos.find(r => r.name === repoName)

  if (!repo) {
    notFound(res, 'Repo not found')
    return
  }

  const imported = await scanner.importRepo(repo)

  if (imported) {
    server.repos = []
    for await (const entry of scanner.listImported()) {
      server.repos.push(entry)
    }

    // Announce on DHT so federation peers can fetch via punch://
    const topicManager = server.p2p?.topicManager
    if (topicManager) {
      await topicManager.announceRepo(repoName)
    }

    json(res, {
      success: true,
      driveKey: imported.driveKey,
      url: imported.url
    })
  } else {
    json(res, { error: 'Import failed' }, 500)
  }
}

/**
 * Import all repos from git-repos directory
 */
async function handleImportGitRepos(req, res, server) {
  const { scanner, repoRegistry } = server.repoData
  const stream = ndjsonStream(res)

  let imported = 0
  let errors = 0

  try {
    for await (const repo of scanner.scan({ paths: [repoRegistry.gitReposPath], maxDepth: 1 })) {
      stream.write({ type: 'progress', message: `Importing ${repo.name}...` })

      const result = await scanner.importRepo(repo)

      if (result) {
        imported++
        stream.write({ type: 'success', repo: repo.name, url: result.url })
      } else {
        errors++
        stream.write({ type: 'error', repo: repo.name, message: 'Import failed' })
      }
    }

    // Reload repos list
    server.repos = []
    for await (const entry of scanner.listImported()) {
      server.repos.push(entry)
    }

    stream.write({ type: 'complete', imported, errors, total: server.repos.length })
    stream.end()
  } catch (err) {
    json(res, { error: err.message }, 500)
  }
}

/**
 * Migrate repo to new storage strategy
 */
async function handleMigrate(req, res, server) {
  const { repoRegistry, backendManager } = server.repoData
  try {
    const { repoName } = req.params
    const { strategy, options } = await readJson(req)

    if (!strategy) {
      json(res, { error: 'strategy required' }, 400)
      return
    }

    const hasRepo = await repoRegistry.hasRepo(repoName)
    if (!hasRepo) {
      notFound(res, 'Repository not found')
      return
    }

    const result = await backendManager.migrateRepo(repoName, strategy, options || {})

    json(res, { success: true, repoName, strategy, ...result })
  } catch (err) {
    console.error('Migration error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Get punch URL for repo
 */
async function handlePunchUrl(req, res, server) {
  const { p2pEnabled, serverIdentity } = server.p2p
  try {
    const { repoName } = req.params

    const serverKey = p2pEnabled && serverIdentity
      ? serverIdentity.getPublicKeyHex()
      : null

    if (!serverKey) {
      json(res, {
        error: 'No punch:// URL available',
        message: 'P2P is not enabled on this server'
      }, 404)
      return
    }

    json(res, { repoName, punchUrl: `punch://${serverKey}/${repoName}` })
  } catch (err) {
    console.error('Punch URL error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Get all URL schemes for repo (punch://, git+pear://, pear://)
 */
async function handleRepoUrls(req, res, server) {
  const { p2pEnabled, serverIdentity } = server.p2p
  const { backendManager } = server.repoData
  try {
    const { repoName } = req.params

    const serverKey = p2pEnabled && serverIdentity
      ? serverIdentity.getPublicKeyHex()
      : null

    // Resolve Hyperdrive key for git+pear:// and pear:// URLs
    let driveKeyZ32 = null
    try {
      const driveKeyHex = await backendManager.getDriveKey(repoName)
      if (driveKeyHex) {
        driveKeyZ32 = z32.encode(b4a.from(driveKeyHex, 'hex'))
      }
    } catch (err) { console.error('[Repo] getDriveKey error:', err.message) }

    json(res, {
      repoName,
      punchUrl: serverKey ? `punch://${serverKey}/${repoName}` : null,
      cloneUrl: driveKeyZ32 ? `git+pear://${driveKeyZ32}/${repoName}` : null,
      pearUrl: driveKeyZ32 ? `pear://${driveKeyZ32}` : null,
      viewerUrl: serverKey ? `punch://${serverKey}/${repoName}` : null
    })
  } catch (err) {
    console.error('Repo URLs error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Get storage stats for repo
 */
async function handleStorageStats(req, res, server) {
  const { backendManager } = server.repoData
  try {
    const { repoName } = req.params
    const stats = await backendManager.getStorageStats(repoName)

    if (!stats) {
      notFound(res, 'Repository not found')
      return
    }

    json(res, stats)
  } catch (err) {
    console.error('Storage stats error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * List repo collaborators
 */
async function handleListCollaborators(req, res, server) {
  const { repoRegistry } = server.repoData
  try {
    const { repoName } = req.params
    const collaborators = await repoRegistry.getCollaborators(repoName)
    json(res, { collaborators })
  } catch (err) {
    console.error('[API] List collaborators error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Migrate all repos to a new directory path
 * POST /api/repos/migrate-path { newPath: "/mnt/bigdisk/git-repos" }
 */
async function handleMigrateReposPath(req, res, server) {
  const { repoRegistry, gitProtocol, backendManager } = server.repoData
  try {
    const { newPath } = await readJson(req)

    if (!newPath) {
      json(res, { error: 'newPath required' }, 400)
      return
    }

    // Verify newPath is writable
    try {
      await fs.promises.mkdir(newPath, { recursive: true })
      // Test write access
      const testFile = newPath + '/.write-test'
      await fs.promises.writeFile(testFile, 'test')
      await fs.promises.unlink(testFile)
    } catch (err) {
      json(res, { error: `Path not writable: ${err.message}` }, 400)
      return
    }

    const result = await repoRegistry.migrateReposPath(newPath)

    // Update dependent components
    gitProtocol.gitReposPath = newPath
    backendManager.gitReposPath = newPath

    // Refresh in-memory repos
    server.repos = await repoRegistry.listAll()

    json(res, {
      success: true,
      newPath,
      ...result
    })
  } catch (err) {
    console.error('Migrate repos path error:', err)
    json(res, { error: err.message }, 500)
  }
}

/**
 * Serve repository page
 */
// Helper function to format bytes

/**
 * Render search results page
 */

export {
  handleListRepos,
  handleP2PInfo,
  handleScan,
  handleFastScan,
  handleImport,
  handleImportGitRepos,
  handleMigrate,
  handleMigrateReposPath,
  handlePunchUrl,
  handleRepoUrls,
  handleStorageStats,
  handleListCollaborators
}