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