| | /** |
| | * SimpleGit - A secure multi-repository Git viewer |
| | - * No external dependencies, no shell execution, no uploads |
| | - */ |
| | - |
| | -function getHomeDirectory() { |
| | - if (!empty($_SERVER['HOME'])) { |
| | - return $_SERVER['HOME']; |
| | - } |
| | - |
| | - if (!empty(getenv('HOME'))) { |
| | - return getenv('HOME'); |
| | - } |
| | - |
| | - if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { |
| | - $userInfo = posix_getpwuid(posix_getuid()); |
| | - |
| | - if (!empty($userInfo['dir'])) { |
| | - return $userInfo['dir']; |
| | - } |
| | - } |
| | - |
| | - return ''; |
| | -} |
| | - |
| | -define('REPOS_PATH', getHomeDirectory() . '/repos'); |
| | -define('SITE_TITLE', "Dave Jarvis' Repositories"); |
| | - |
| | -ini_set('display_errors', 0); |
| | -ini_set('log_errors', 1); |
| | -ini_set('error_log', __DIR__ . '/error.log'); |
| | - |
| | -function getRepositories() { |
| | - $repos = []; |
| | - if (!is_dir(REPOS_PATH)) { |
| | - return $repos; |
| | - } |
| | - |
| | - foreach (glob(REPOS_PATH . '/*.git') as $path) { |
| | - if (is_dir($path)) { |
| | - $name = basename($path, '.git'); |
| | - $displayName = urldecode($name); |
| | - $repos[$name] = [ |
| | - 'path' => $path, |
| | - 'name' => $displayName, |
| | - 'safe_name' => $name |
| | - ]; |
| | - } |
| | - } |
| | - |
| | - uasort($repos, function($a, $b) { |
| | - return strcasecmp($a['name'], $b['name']); |
| | - }); |
| | - |
| | - return $repos; |
| | -} |
| | - |
| | -function getCurrentRepo() { |
| | - $repos = getRepositories(); |
| | - $requested = $_GET['repo'] ?? ''; |
| | - $decodedRequested = urldecode($requested); |
| | - |
| | - foreach ($repos as $key => $repo) { |
| | - if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) { |
| | - return $repo; |
| | - } |
| | - } |
| | - |
| | - return null; |
| | -} |
| | - |
| | -function sanitizePath($path) { |
| | - $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); |
| | - return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); |
| | -} |
| | - |
| | -function getObjectPath($repoPath, $hash) { |
| | - if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { |
| | - return false; |
| | - } |
| | - $dir = substr($hash, 0, 2); |
| | - $file = substr($hash, 2); |
| | - return $repoPath . '/objects/' . $dir . '/' . $file; |
| | -} |
| | - |
| | -function readPackedObject($repoPath, $hash) { |
| | - $packDir = $repoPath . '/objects/pack'; |
| | - if (!is_dir($packDir)) { |
| | - return false; |
| | - } |
| | - |
| | - foreach (glob($packDir . '/*.idx') as $idxFile) { |
| | - $result = readFromPackIndex($idxFile, $hash); |
| | - if ($result !== false) { |
| | - return $result; |
| | - } |
| | - } |
| | - |
| | - return false; |
| | -} |
| | - |
| | -function readFromPackIndex($idxFile, $hash) { |
| | - $packFile = str_replace('.idx', '.pack', $idxFile); |
| | - if (!file_exists($packFile)) { |
| | - return false; |
| | - } |
| | - |
| | - $idx = fopen($idxFile, 'rb'); |
| | - if (!$idx) return false; |
| | - |
| | - $magic = fread($idx, 4); |
| | - $version = 0; |
| | - |
| | - if ($magic === "\377tOc") { |
| | - $versionData = fread($idx, 4); |
| | - $version = unpack('N', $versionData)[1]; |
| | - if ($version !== 2) { |
| | - fclose($idx); |
| | - return false; |
| | - } |
| | - } else { |
| | - fseek($idx, 0); |
| | - } |
| | - |
| | - fseek($idx, 256 * 4 - 4); |
| | - $numObjects = unpack('N', fread($idx, 4))[1]; |
| | - |
| | - fseek($idx, 256 * 4); |
| | - $targetHash = hex2bin($hash); |
| | - $left = 0; |
| | - $right = $numObjects - 1; |
| | - $foundOffset = -1; |
| | - |
| | - while ($left <= $right) { |
| | - $mid = (int)(($left + $right) / 2); |
| | - fseek($idx, 256 * 4 + $mid * 20); |
| | - $midHash = fread($idx, 20); |
| | - |
| | - $cmp = strcmp($midHash, $targetHash); |
| | - if ($cmp === 0) { |
| | - fseek($idx, 256 * 4 + $numObjects * 20 + $mid * 4); |
| | - $offset = unpack('N', fread($idx, 4))[1]; |
| | - |
| | - if ($offset & 0x80000000) { |
| | - fseek($idx, 256 * 4 + $numObjects * 24 + ($offset & 0x7fffffff) * 8); |
| | - $offset = unpack('J', fread($idx, 8))[1]; |
| | - } |
| | - |
| | - $foundOffset = $offset; |
| | - break; |
| | - } elseif ($cmp < 0) { |
| | - $left = $mid + 1; |
| | - } else { |
| | - $right = $mid - 1; |
| | - } |
| | - } |
| | - |
| | - fclose($idx); |
| | - |
| | - if ($foundOffset < 0) { |
| | - return false; |
| | - } |
| | - |
| | - return readPackObject($packFile, $foundOffset); |
| | -} |
| | - |
| | -function readPackObject($packFile, $offset) { |
| | - $pack = fopen($packFile, 'rb'); |
| | - if (!$pack) return false; |
| | - |
| | - fseek($pack, $offset); |
| | - $byte = ord(fread($pack, 1)); |
| | - $type = ($byte >> 4) & 0x07; |
| | - $size = $byte & 0x0f; |
| | - $shift = 4; |
| | - |
| | - while ($byte & 0x80) { |
| | - $byte = ord(fread($pack, 1)); |
| | - $size |= ($byte & 0x7f) << $shift; |
| | - $shift += 7; |
| | - } |
| | - |
| | - // Handle Offset Deltas (Type 6) |
| | - if ($type === 6) { |
| | - $byte = ord(fread($pack, 1)); |
| | - $baseOffset = $byte & 0x7f; |
| | - while ($byte & 0x80) { |
| | - $byte = ord(fread($pack, 1)); |
| | - $baseOffset = (($baseOffset + 1) << 7) | ($byte & 0x7f); |
| | - } |
| | - |
| | - $compressed = stream_get_contents($pack); |
| | - $deltaData = @uncompressGitData($compressed); |
| | - $baseObj = readPackObject($packFile, $offset - $baseOffset); |
| | - fclose($pack); |
| | - |
| | - return [ |
| | - 'type' => $baseObj['type'], |
| | - 'content' => applyGitDelta($baseObj['content'], $deltaData) |
| | - ]; |
| | - } |
| | - |
| | - // Standard Objects (Commit, Tree, Blob) |
| | - $compressed = stream_get_contents($pack); |
| | - fclose($pack); |
| | - |
| | - // Use a wrapper to handle the raw stream decompression |
| | - $uncompressed = @uncompressGitData($compressed); |
| | - |
| | - if ($uncompressed === false) return false; |
| | - |
| | - $types = ['', 'commit', 'tree', 'blob', 'tag']; |
| | - return [ |
| | - 'type' => $types[$type] ?? 'unknown', |
| | - 'content' => $uncompressed |
| | - ]; |
| | -} |
| | - |
| | -/** |
| | - * Helper to handle decompression when trailing data exists in the buffer |
| | - */ |
| | -function uncompressGitData($data) { |
| | - return @zlib_decode($data); |
| | -} |
| | - |
| | -function applyGitDelta($base, $delta) { |
| | - $pos = 0; |
| | - $readVarInt = function() use (&$delta, &$pos) { |
| | - $res = 0; $shift = 0; |
| | - do { |
| | - $b = ord($delta[$pos++]); |
| | - $res |= ($b & 0x7f) << $shift; |
| | - $shift += 7; |
| | - } while ($b & 0x80); |
| | - return $res; |
| | - }; |
| | - |
| | - $baseSize = $readVarInt(); |
| | - $targetSize = $readVarInt(); |
| | - $res = ''; |
| | - |
| | - while ($pos < strlen($delta)) { |
| | - $opcode = ord($delta[$pos++]); |
| | - if ($opcode & 0x80) { // Copy from base |
| | - $off = 0; $sz = 0; |
| | - if ($opcode & 0x01) $off |= ord($delta[$pos++]); |
| | - if ($opcode & 0x02) $off |= ord($delta[$pos++] ) << 8; |
| | - if ($opcode & 0x04) $off |= ord($delta[$pos++] ) << 16; |
| | - if ($opcode & 0x08) $off |= ord($delta[$pos++] ) << 24; |
| | - if ($opcode & 0x10) $sz |= ord($delta[$pos++]); |
| | - if ($opcode & 0x20) $sz |= ord($delta[$pos++] ) << 8; |
| | - if ($opcode & 0x40) $sz |= ord($delta[$pos++] ) << 16; |
| | - if ($sz === 0) $sz = 0x10000; |
| | - $res .= substr($base, $off, $sz); |
| | - } else { // Insert new data |
| | - $res .= substr($delta, $pos, $opcode); |
| | - $pos += $opcode; |
| | - } |
| | - } |
| | - return $res; |
| | -} |
| | - |
| | -function readGitObject($repoPath, $hash) { |
| | - $path = getObjectPath($repoPath, $hash); |
| | - if ($path && file_exists($path)) { |
| | - $content = @file_get_contents($path); |
| | - if ($content !== false) { |
| | - $decompressed = @gzuncompress($content); |
| | - if ($decompressed !== false) { |
| | - $nullPos = strpos($decompressed, "\0"); |
| | - if ($nullPos !== false) { |
| | - $header = substr($decompressed, 0, $nullPos); |
| | - $parts = explode(' ', $header, 2); |
| | - return [ |
| | - 'type' => $parts[0] ?? 'unknown', |
| | - 'size' => $parts[1] ?? 0, |
| | - 'content' => substr($decompressed, $nullPos + 1) |
| | - ]; |
| | - } |
| | - } |
| | - } |
| | - } |
| | - |
| | - return readPackedObject($repoPath, $hash); |
| | -} |
| | - |
| | -function parseTree($content) { |
| | - $entries = []; |
| | - $offset = 0; |
| | - $len = strlen($content); |
| | - |
| | - while ($offset < $len) { |
| | - $spacePos = strpos($content, ' ', $offset); |
| | - if ($spacePos === false) break; |
| | - |
| | - $mode = substr($content, $offset, $spacePos - $offset); |
| | - $offset = $spacePos + 1; |
| | - |
| | - $nullPos = strpos($content, "\0", $offset); |
| | - if ($nullPos === false) break; |
| | - |
| | - $name = substr($content, $offset, $nullPos - $offset); |
| | - $offset = $nullPos + 1; |
| | - |
| | - if ($offset + 20 > $len) break; |
| | - $hash = bin2hex(substr($content, $offset, 20)); |
| | - $offset += 20; |
| | - |
| | - $isTree = in_array($mode, ['040000', '40000', '160000']); |
| | - |
| | - $entries[] = [ |
| | - 'mode' => $mode, |
| | - 'name' => $name, |
| | - 'hash' => $hash, |
| | - 'type' => $isTree ? 'tree' : 'blob' |
| | - ]; |
| | - } |
| | - |
| | - usort($entries, function($a, $b) { |
| | - if ($a['type'] !== $b['type']) { |
| | - return $a['type'] === 'tree' ? -1 : 1; |
| | - } |
| | - return strcasecmp($a['name'], $b['name']); |
| | - }); |
| | - |
| | - return $entries; |
| | -} |
| | - |
| | -function parseCommit($content) { |
| | - $lines = explode("\n", $content); |
| | - $commit = [ |
| | - 'tree' => '', |
| | - 'parents' => [], |
| | - 'author' => '', |
| | - 'committer' => '', |
| | - 'message' => '' |
| | - ]; |
| | - |
| | - $inMessage = false; |
| | - $messageLines = []; |
| | - |
| | - foreach ($lines as $line) { |
| | - if ($inMessage) { |
| | - $messageLines[] = $line; |
| | - } elseif ($line === '') { |
| | - $inMessage = true; |
| | - } elseif (strpos($line, 'tree ') === 0) { |
| | - $commit['tree'] = substr($line, 5); |
| | - } elseif (strpos($line, 'parent ') === 0) { |
| | - $commit['parents'][] = substr($line, 7); |
| | - } elseif (strpos($line, 'author ') === 0) { |
| | - $commit['author'] = substr($line, 7); |
| | - } elseif (strpos($line, 'committer ') === 0) { |
| | - $commit['committer'] = substr($line, 10); |
| | - } |
| | - } |
| | - |
| | - $commit['message'] = implode("\n", $messageLines); |
| | - return $commit; |
| | -} |
| | - |
| | -function getHead($repoPath) { |
| | - $headFile = $repoPath . '/HEAD'; |
| | - if (!file_exists($headFile)) { |
| | - return false; |
| | - } |
| | - |
| | - $content = trim(file_get_contents($headFile)); |
| | - |
| | - if (preg_match('/^[a-f0-9]{40}$/', $content)) { |
| | - return ['type' => 'detached', 'hash' => $content, 'ref' => null]; |
| | - } |
| | - |
| | - if (strpos($content, 'ref: ') === 0) { |
| | - $ref = substr($content, 5); |
| | - $refFile = $repoPath . '/' . $ref; |
| | - if (file_exists($refFile)) { |
| | - $hash = trim(file_get_contents($refFile)); |
| | - return ['type' => 'ref', 'hash' => $hash, 'ref' => $ref]; |
| | - } |
| | - } |
| | - |
| | - return false; |
| | -} |
| | - |
| | -function listRefs($repoPath) { |
| | - $refs = []; |
| | - |
| | - $branchesDir = $repoPath . '/refs/heads'; |
| | - if (is_dir($branchesDir)) { |
| | - foreach (glob($branchesDir . '/*') as $file) { |
| | - if (is_file($file)) { |
| | - $name = basename($file); |
| | - $hash = trim(file_get_contents($file)); |
| | - $refs['branches'][$name] = $hash; |
| | - } |
| | - } |
| | - } |
| | - |
| | - $tagsDir = $repoPath . '/refs/tags'; |
| | - if (is_dir($tagsDir)) { |
| | - foreach (glob($tagsDir . '/*') as $file) { |
| | - if (is_file($file)) { |
| | - $name = basename($file); |
| | - $content = file_get_contents($file); |
| | - $refs['tags'][$name] = trim($content); |
| | - } |
| | - } |
| | - } |
| | - |
| | - return $refs; |
| | -} |
| | - |
| | -function formatDate($line) { |
| | - if (preg_match('/(\d+)\s+([\+\-]\d{4})$/', $line, $matches)) { |
| | - $timestamp = $matches[1]; |
| | - return date('Y-m-d H:i:s', $timestamp); |
| | - } |
| | - return 'Unknown'; |
| | -} |
| | - |
| | -function getAuthor($line) { |
| | - if (preg_match('/^([^<]+)/', $line, $matches)) { |
| | - return trim($matches[1]); |
| | - } |
| | - return $line; |
| | -} |
| | - |
| | -function getLog($repoPath, $commitHash, $max = 20) { |
| | - $log = []; |
| | - $seen = []; |
| | - $queue = [$commitHash]; |
| | - |
| | - while (!empty($queue) && count($log) < $max) { |
| | - $hash = array_shift($queue); |
| | - if (isset($seen[$hash])) continue; |
| | - $seen[$hash] = true; |
| | - |
| | - $obj = readGitObject($repoPath, $hash); |
| | - if (!$obj || $obj['type'] !== 'commit') continue; |
| | - |
| | - $commit = parseCommit($obj['content']); |
| | - $commit['hash'] = $hash; |
| | - $log[] = $commit; |
| | - |
| | - foreach ($commit['parents'] as $parent) { |
| | - $queue[] = $parent; |
| | - } |
| | - } |
| | - |
| | - return $log; |
| | -} |
| | - |
| | -function getMainBranch($repoPath) { |
| | - $refs = listRefs($repoPath); |
| | - |
| | - $priority = ['main', 'master', 'trunk', 'develop']; |
| | - foreach ($priority as $branch) { |
| | - if (isset($refs['branches'][$branch])) { |
| | - return ['name' => $branch, 'hash' => $refs['branches'][$branch]]; |
| | - } |
| | - } |
| | - |
| | - if (!empty($refs['branches'])) { |
| | - $first = array_key_first($refs['branches']); |
| | - return ['name' => $first, 'hash' => $refs['branches'][$first]]; |
| | - } |
| | - |
| | - return null; |
| | -} |
| | - |
| | -$action = $_GET['action'] ?? 'home'; |
| | -$hash = sanitizePath($_GET['hash'] ?? ''); |
| | - |
| | -$currentRepo = getCurrentRepo(); |
| | -$repoParam = $currentRepo ? '&repo=' . urlencode($currentRepo['safe_name']) : ''; |
| | - |
| | -$repositories = getRepositories(); |
| | - |
| | -?> |
| | -<!DOCTYPE html> |
| | -<html lang="en"> |
| | -<head> |
| | - <meta charset="UTF-8"> |
| | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | - <title><?php echo SITE_TITLE; ?><?php echo $currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''; ?></title> |
| | - <link rel="stylesheet" href="kimi-style.css"> |
| | -</head> |
| | -<body> |
| | - <div class="container"> |
| | - <header> |
| | - <h1><?php echo SITE_TITLE; ?></h1> |
| | - <nav class="nav"> |
| | - <a href="?">Home</a> |
| | - <?php if ($currentRepo): ?> |
| | - <a href="?repo=<?php echo urlencode($currentRepo['safe_name']); ?>">Files</a> |
| | - <a href="?action=commits<?php echo $repoParam; ?>">Commits</a> |
| | - <a href="?action=refs<?php echo $repoParam; ?>">Branches</a> |
| | - <?php endif; ?> |
| | - <?php if ($currentRepo): ?> |
| | - <div class="repo-selector"> |
| | - <label>Repository:</label> |
| | - <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> |
| | - <option value="">Select repository...</option> |
| | - <?php foreach ($repositories as $repo): ?> |
| | - <option value="<?php echo htmlspecialchars($repo['safe_name']); ?>" <?php echo $repo['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> |
| | - <?php echo htmlspecialchars($repo['name']); ?> |
| | - </option> |
| | - <?php endforeach; ?> |
| | - </select> |
| | - </div> |
| | - <?php endif; ?> |
| | - </nav> |
| | - |
| | - <?php if ($currentRepo): ?> |
| | - <div style="margin-top: 15px;"> |
| | - <span class="current-repo"> |
| | - Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong> |
| | - </span> |
| | - </div> |
| | - <?php endif; ?> |
| | - </header> |
| | - |
| | - <?php |
| | - if (!$currentRepo) { |
| | - echo '<h2>Repositories</h2>'; |
| | - |
| | - if (empty($repositories)) { |
| | - echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(REPOS_PATH) . '</div>'; |
| | - } else { |
| | - echo '<div class="repo-grid">'; |
| | - foreach ($repositories as $repo) { |
| | - $mainBranch = getMainBranch($repo['path']); |
| | - $head = getHead($repo['path']); |
| | - |
| | - echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; |
| | - echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; |
| | - |
| | - if ($mainBranch) { |
| | - echo '<p>Branch: ' . htmlspecialchars($mainBranch['name']) . '</p>'; |
| | - } |
| | - |
| | - $refs = listRefs($repo['path']); |
| | - $branchCount = count($refs['branches'] ?? []); |
| | - $tagCount = count($refs['tags'] ?? []); |
| | - |
| | - echo '<p>' . $branchCount . ' branches, ' . $tagCount . ' tags</p>'; |
| | - |
| | - if ($head) { |
| | - $obj = readGitObject($repo['path'], $head['hash']); |
| | - if ($obj && $obj['type'] === 'commit') { |
| | - $commit = parseCommit($obj['content']); |
| | - echo '<p style="margin-top: 8px; color: #58a6ff;">' . substr($head['hash'], 0, 7) . ' - ' . htmlspecialchars(substr(trim(explode("\n", $commit['message'])[0]), 0, 50)) . '</p>'; |
| | - } |
| | - } |
| | - echo '</a>'; |
| | - } |
| | - echo '</div>'; |
| | - } |
| | - } else { |
| | - $mainBranch = getMainBranch($currentRepo['path']); |
| | - |
| | - if (!$mainBranch) { |
| | - echo '<div class="empty-state">'; |
| | - echo '<h3>No branches found</h3>'; |
| | - echo '<p>This repository appears to be empty.</p>'; |
| | - echo '</div>'; |
| | - } else { |
| | - $obj = readGitObject($currentRepo['path'], $mainBranch['hash']); |
| | - |
| | - if (!$obj || $obj['type'] !== 'commit') { |
| | - echo '<div class="empty-state">Error reading commit</div>'; |
| | - } else { |
| | - $commit = parseCommit($obj['content']); |
| | - $treeHash = $commit['tree']; |
| | - |
| | - $viewHash = $hash ?: $treeHash; |
| | - $viewObj = readGitObject($currentRepo['path'], $viewHash); |
| | - |
| | - if ($viewObj && $viewObj['type'] === 'blob') { |
| | - echo '<div class="breadcrumb">'; |
| | - echo '<a href="?">Repositories</a>'; |
| | - echo '<span>/</span>'; |
| | - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; |
| | - echo '<span>/</span>'; |
| | - echo '<span>File</span>'; |
| | - echo '</div>'; |
| | - |
| | - echo '<h2>' . substr($viewHash, 0, 7) . '</h2>'; |
| | - |
| | - echo '<div class="blob-content">'; |
| | - echo '<div class="blob-header">' . strlen($viewObj['content']) . ' bytes</div>'; |
| | - echo '<div class="blob-code">' . htmlspecialchars($viewObj['content']) . '</div>'; |
| | - echo '</div>'; |
| | - } else { |
| | - $treeObj = readGitObject($currentRepo['path'], $viewHash); |
| | - |
| | - if (!$treeObj || $treeObj['type'] !== 'tree') { |
| | - echo '<div class="empty-state">Directory not found</div>'; |
| | - } else { |
| | - $entries = parseTree($treeObj['content']); |
| | - |
| | - echo '<div class="breadcrumb">'; |
| | - echo '<a href="?">Repositories</a>'; |
| | - echo '<span>/</span>'; |
| | - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; |
| | - if ($viewHash !== $treeHash) { |
| | - echo '<span>/</span>'; |
| | - echo '<span>Tree ' . substr($viewHash, 0, 7) . '</span>'; |
| | - } |
| | - echo '</div>'; |
| | - |
| | - echo '<h2>' . htmlspecialchars($currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($mainBranch['name']) . '</span></h2>'; |
| | - |
| | - echo '<div class="commit-item" style="margin-bottom: 20px;">'; |
| | - echo '<div><a href="?action=commit&hash=' . $mainBranch['hash'] . $repoParam . '" class="commit-hash">' . substr($mainBranch['hash'], 0, 7) . '</a></div>'; |
| | - echo '<div class="commit-message">' . htmlspecialchars(trim($commit['message'])) . '</div>'; |
| | - echo '<div class="commit-meta">'; |
| | - echo '<span class="commit-author">' . htmlspecialchars(getAuthor($commit['author'])) . '</span>'; |
| | - echo ' committed on '; |
| | - echo '<span class="commit-date">' . formatDate($commit['committer']) . '</span>'; |
| | - echo '</div>'; |
| | - echo '</div>'; |
| | - |
| | - echo '<h3>Files</h3>'; |
| | - |
| | - if (empty($entries)) { |
| | - echo '<div class="empty-state">Empty directory</div>'; |
| | - } else { |
| | - echo '<div class="file-list">'; |
| | - foreach ($entries as $entry) { |
| | - $icon = $entry['type'] === 'tree' ? '[dir]' : '[file]'; |
| | - // CRITICAL FIX: Always include hash so directories work |
| | - $url = '?repo=' . urlencode($currentRepo['safe_name']) . '&hash=' . $entry['hash']; |
| | - |
| | - echo '<a href="' . $url . '" class="file-item">'; |
| | - echo '<span class="file-mode">' . $entry['mode'] . '</span>'; |
| | - echo '<span class="file-name"><span class="' . ($entry['type'] === 'tree' ? 'dir-icon' : 'file-icon') . '">' . $icon . '</span> ' . htmlspecialchars($entry['name']) . '</span>'; |
| | - echo '</a>'; |
| | - } |
| | - echo '</div>'; |
| | - } |
| | - } |
| | - } |
| | - } |
| | - } |
| | - } |
| | - ?> |
| | -</div> |
| | -</body> |
| | -</html> |
| | + * Fixed: Stream-based decompression and robust HEAD resolution |
| | + */ |
| | + |
| | +function getHomeDirectory() { |
| | + if (!empty($_SERVER['HOME'])) return $_SERVER['HOME']; |
| | + if (!empty(getenv('HOME'))) return getenv('HOME'); |
| | + if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { |
| | + $userInfo = posix_getpwuid(posix_getuid()); |
| | + if (!empty($userInfo['dir'])) return $userInfo['dir']; |
| | + } |
| | + return ''; |
| | +} |
| | + |
| | +define('REPOS_PATH', getHomeDirectory() . '/repos'); |
| | +define('SITE_TITLE', "Dave Jarvis' Repositories"); |
| | + |
| | +ini_set('display_errors', 0); |
| | +ini_set('log_errors', 1); |
| | +ini_set('error_log', __DIR__ . '/error.log'); |
| | + |
| | +function getRepositories() { |
| | + $repos = []; |
| | + if (!is_dir(REPOS_PATH)) return $repos; |
| | + foreach (glob(REPOS_PATH . '/*.git') as $path) { |
| | + if (is_dir($path)) { |
| | + $name = basename($path, '.git'); |
| | + $repos[$name] = [ |
| | + 'path' => $path, |
| | + 'name' => urldecode($name), |
| | + 'safe_name' => $name |
| | + ]; |
| | + } |
| | + } |
| | + uasort($repos, function($a, $b) { return strcasecmp($a['name'], $b['name']); }); |
| | + return $repos; |
| | +} |
| | + |
| | +function getCurrentRepo() { |
| | + $repos = getRepositories(); |
| | + $requested = $_GET['repo'] ?? ''; |
| | + $decodedRequested = urldecode($requested); |
| | + foreach ($repos as $repo) { |
| | + if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) return $repo; |
| | + } |
| | + return null; |
| | +} |
| | + |
| | +function sanitizePath($path) { |
| | + $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); |
| | + return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); |
| | +} |
| | + |
| | +function getObjectPath($repoPath, $hash) { |
| | + if (!preg_match('/^[a-f0-9]{40}$/', $hash)) return false; |
| | + return $repoPath . '/objects/' . substr($hash, 0, 2) . '/' . substr($hash, 2); |
| | +} |
| | + |
| | +function uncompressGitData($handle) { |
| | + $inflator = inflate_init(ZLIB_ENCODING_ANY); |
| | + $output = ''; |
| | + while (!feof($handle)) { |
| | + $chunk = fread($handle, 8192); |
| | + if ($chunk === false) break; |
| | + $output .= inflate_add($inflator, $chunk, PHP_ZLIB_FINISH_FLUSH); |
| | + if (inflate_get_status($inflator) === ZLIB_STREAM_END) break; |
| | + } |
| | + return $output; |
| | +} |
| | + |
| | +function readPackObject($packFile, $offset) { |
| | + $pack = fopen($packFile, 'rb'); |
| | + if (!$pack) return false; |
| | + |
| | + fseek($pack, $offset); |
| | + $byte = ord(fread($pack, 1)); |
| | + $type = ($byte >> 4) & 0x07; |
| | + $size = $byte & 0x0f; |
| | + $shift = 4; |
| | + |
| | + while ($byte & 0x80) { |
| | + $byte = ord(fread($pack, 1)); |
| | + $size |= ($byte & 0x7f) << $shift; |
| | + $shift += 7; |
| | + } |
| | + |
| | + if ($type === 6) { // Offset Delta |
| | + $byte = ord(fread($pack, 1)); |
| | + $baseOffset = $byte & 0x7f; |
| | + while ($byte & 0x80) { |
| | + $byte = ord(fread($pack, 1)); |
| | + $baseOffset = (($baseOffset + 1) << 7) | ($byte & 0x7f); |
| | + } |
| | + $deltaData = uncompressGitData($pack); |
| | + $baseObj = readPackObject($packFile, $offset - $baseOffset); |
| | + fclose($pack); |
| | + return [ |
| | + 'type' => $baseObj['type'], |
| | + 'content' => applyGitDelta($baseObj['content'], $deltaData) |
| | + ]; |
| | + } |
| | + |
| | + $uncompressed = uncompressGitData($pack); |
| | + fclose($pack); |
| | + |
| | + $types = ['', 'commit', 'tree', 'blob', 'tag']; |
| | + return [ |
| | + 'type' => $types[$type] ?? 'unknown', |
| | + 'content' => $uncompressed |
| | + ]; |
| | +} |
| | + |
| | +function readFromPackIndex($idxFile, $hash) { |
| | + $packFile = str_replace('.idx', '.pack', $idxFile); |
| | + if (!file_exists($packFile)) return false; |
| | + $idx = fopen($idxFile, 'rb'); |
| | + if (!$idx) return false; |
| | + |
| | + $magic = fread($idx, 4); |
| | + if ($magic === "\377tOc") { |
| | + $version = unpack('N', fread($idx, 4))[1]; |
| | + if ($version !== 2) { fclose($idx); return false; } |
| | + } else { fseek($idx, 0); } |
| | + |
| | + fseek($idx, 256 * 4 - 4); |
| | + $numObjects = unpack('N', fread($idx, 4))[1]; |
| | + $targetHash = hex2bin($hash); |
| | + $left = 0; $right = $numObjects - 1; $foundOffset = -1; |
| | + |
| | + while ($left <= $right) { |
| | + $mid = (int)(($left + $right) / 2); |
| | + fseek($idx, 256 * 4 + $mid * 20); |
| | + $midHash = fread($idx, 20); |
| | + $cmp = strcmp($midHash, $targetHash); |
| | + if ($cmp === 0) { |
| | + fseek($idx, 256 * 4 + $numObjects * 20 + $mid * 4); |
| | + $offset = unpack('N', fread($idx, 4))[1]; |
| | + if ($offset & 0x80000000) { |
| | + fseek($idx, 256 * 4 + $numObjects * 24 + ($offset & 0x7fffffff) * 8); |
| | + $offset = unpack('J', fread($idx, 8))[1]; |
| | + } |
| | + $foundOffset = $offset; |
| | + break; |
| | + } elseif ($cmp < 0) { $left = $mid + 1; } else { $right = $mid - 1; } |
| | + } |
| | + fclose($idx); |
| | + return $foundOffset >= 0 ? readPackObject($packFile, $foundOffset) : false; |
| | +} |
| | + |
| | +function readPackedObject($repoPath, $hash) { |
| | + $packDir = $repoPath . '/objects/pack'; |
| | + if (!is_dir($packDir)) return false; |
| | + foreach (glob($packDir . '/*.idx') as $idxFile) { |
| | + $result = readFromPackIndex($idxFile, $hash); |
| | + if ($result !== false) return $result; |
| | + } |
| | + return false; |
| | +} |
| | + |
| | +function applyGitDelta($base, $delta) { |
| | + $pos = 0; |
| | + $readVarInt = function() use (&$delta, &$pos) { |
| | + $res = 0; $shift = 0; |
| | + do { $b = ord($delta[$pos++]); $res |= ($b & 0x7f) << $shift; $shift += 7; } while ($b & 0x80); |
| | + return $res; |
| | + }; |
| | + $readVarInt(); // baseSize |
| | + $readVarInt(); // targetSize |
| | + $res = ''; |
| | + while ($pos < strlen($delta)) { |
| | + $opcode = ord($delta[$pos++]); |
| | + if ($opcode & 0x80) { |
| | + $off = 0; $sz = 0; |
| | + if ($opcode & 0x01) $off |= ord($delta[$pos++]); |
| | + if ($opcode & 0x02) $off |= ord($delta[$pos++]) << 8; |
| | + if ($opcode & 0x04) $off |= ord($delta[$pos++]) << 16; |
| | + if ($opcode & 0x08) $off |= ord($delta[$pos++]) << 24; |
| | + if ($opcode & 0x10) $sz |= ord($delta[$pos++]); |
| | + if ($opcode & 0x20) $sz |= ord($delta[$pos++]) << 8; |
| | + if ($opcode & 0x40) $sz |= ord($delta[$pos++]) << 16; |
| | + if ($sz === 0) $sz = 0x10000; |
| | + $res .= substr($base, $off, $sz); |
| | + } else { |
| | + $res .= substr($delta, $pos, $opcode); |
| | + $pos += $opcode; |
| | + } |
| | + } |
| | + return $res; |
| | +} |
| | + |
| | +function readGitObject($repoPath, $hash) { |
| | + $path = getObjectPath($repoPath, $hash); |
| | + if ($path && file_exists($path)) { |
| | + $content = @file_get_contents($path); |
| | + if ($content !== false) { |
| | + $decompressed = @gzuncompress($content); |
| | + if ($decompressed !== false) { |
| | + $nullPos = strpos($decompressed, "\0"); |
| | + $header = substr($decompressed, 0, $nullPos); |
| | + $parts = explode(' ', $header, 2); |
| | + return ['type' => $parts[0] ?? 'unknown', 'content' => substr($decompressed, $nullPos + 1)]; |
| | + } |
| | + } |
| | + } |
| | + return readPackedObject($repoPath, $hash); |
| | +} |
| | + |
| | +function parseTree($content) { |
| | + $entries = []; $offset = 0; $len = strlen($content); |
| | + while ($offset < $len) { |
| | + $spacePos = strpos($content, ' ', $offset); |
| | + if ($spacePos === false) break; |
| | + $mode = substr($content, $offset, $spacePos - $offset); |
| | + $offset = $spacePos + 1; |
| | + $nullPos = strpos($content, "\0", $offset); |
| | + if ($nullPos === false) break; |
| | + $name = substr($content, $offset, $nullPos - $offset); |
| | + $offset = $nullPos + 1; |
| | + $hash = bin2hex(substr($content, $offset, 20)); |
| | + $offset += 20; |
| | + $entries[] = ['mode' => $mode, 'name' => $name, 'hash' => $hash, 'type' => in_array($mode, ['040000', '40000']) ? 'tree' : 'blob']; |
| | + } |
| | + usort($entries, function($a, $b) { return $a['type'] === $b['type'] ? strcasecmp($a['name'], $b['name']) : ($a['type'] === 'tree' ? -1 : 1); }); |
| | + return $entries; |
| | +} |
| | + |
| | +function parseCommit($content) { |
| | + $lines = explode("\n", $content); |
| | + $commit = ['tree' => '', 'parents' => [], 'author' => '', 'committer' => '', 'message' => '']; |
| | + $inMsg = false; $msg = []; |
| | + foreach ($lines as $line) { |
| | + if ($inMsg) { $msg[] = $line; } |
| | + elseif ($line === '') { $inMsg = true; } |
| | + elseif (strpos($line, 'tree ') === 0) { $commit['tree'] = substr($line, 5); } |
| | + elseif (strpos($line, 'parent ') === 0) { $commit['parents'][] = substr($line, 7); } |
| | + elseif (strpos($line, 'author ') === 0) { $commit['author'] = substr($line, 7); } |
| | + elseif (strpos($line, 'committer ') === 0) { $commit['committer'] = substr($line, 10); } |
| | + } |
| | + $commit['message'] = implode("\n", $msg); |
| | + return $commit; |
| | +} |
| | + |
| | +function getHead($repoPath) { |
| | + $headFile = $repoPath . '/HEAD'; |
| | + if (!file_exists($headFile)) return false; |
| | + $content = trim(file_get_contents($headFile)); |
| | + if (preg_match('/^[a-f0-9]{40}$/', $content)) return ['type' => 'detached', 'hash' => $content]; |
| | + if (strpos($content, 'ref: ') === 0) { |
| | + $ref = substr($content, 5); |
| | + $refFile = $repoPath . '/' . $ref; |
| | + if (file_exists($refFile)) return ['type' => 'ref', 'hash' => trim(file_get_contents($refFile)), 'ref' => $ref]; |
| | + } |
| | + return false; |
| | +} |
| | + |
| | +function listRefs($repoPath) { |
| | + $refs = ['branches' => [], 'tags' => []]; |
| | + foreach (['heads' => 'branches', 'tags' => 'tags'] as $dir => $key) { |
| | + $path = $repoPath . '/refs/' . $dir; |
| | + if (is_dir($path)) { |
| | + foreach (glob($path . '/*') as $file) { |
| | + if (is_file($file)) $refs[$key][basename($file)] = trim(file_get_contents($file)); |
| | + } |
| | + } |
| | + } |
| | + return $refs; |
| | +} |
| | + |
| | +function formatDate($line) { |
| | + return preg_match('/(\d+)\s+([\+\-]\d{4})$/', $line, $m) ? date('Y-m-d H:i', $m[1]) : 'Unknown'; |
| | +} |
| | + |
| | +function getAuthor($line) { |
| | + return preg_match('/^([^<]+)/', $line, $m) ? trim($m[1]) : $line; |
| | +} |
| | + |
| | +function getMainBranch($repoPath) { |
| | + $refs = listRefs($repoPath); |
| | + foreach (['main', 'master', 'develop'] as $b) if (isset($refs['branches'][$b])) return ['name' => $b, 'hash' => $refs['branches'][$b]]; |
| | + if (!empty($refs['branches'])) { |
| | + $name = array_key_first($refs['branches']); |
| | + return ['name' => $name, 'hash' => $refs['branches'][$name]]; |
| | + } |
| | + return null; |
| | +} |
| | + |
| | +$action = $_GET['action'] ?? 'home'; |
| | +$hash = sanitizePath($_GET['hash'] ?? ''); |
| | +$currentRepo = getCurrentRepo(); |
| | +$repositories = getRepositories(); |
| | +?> |
| | +<!DOCTYPE html> |
| | +<html lang="en"> |
| | +<head> |
| | + <meta charset="UTF-8"> |
| | + <title><?php echo SITE_TITLE . ($currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''); ?></title> |
| | + <link rel="stylesheet" href="kimi-style.css"> |
| | + <style> |
| | + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; margin: 0; } |
| | + .container { max-width: 1012px; margin: 0 auto; padding: 40px 16px; } |
| | + a { color: #58a6ff; text-decoration: none; } |
| | + a:hover { text-decoration: underline; } |
| | + header { border-bottom: 1px solid #30363d; padding-bottom: 20px; margin-bottom: 20px; } |
| | + .nav { display: flex; gap: 15px; align-items: center; margin-top: 10px; } |
| | + .repo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } |
| | + .repo-card { border: 1px solid #30363d; padding: 16px; border-radius: 6px; } |
| | + .file-list { border: 1px solid #30363d; border-radius: 6px; } |
| | + .file-item { display: flex; padding: 8px 16px; border-bottom: 1px solid #30363d; } |
| | + .file-item:last-child { border-bottom: none; } |
| | + .blob-code { background: #161b22; padding: 16px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; font-family: monospace; } |
| | + .empty-state { padding: 40px; text-align: center; color: #8b949e; border: 1px dashed #30363d; border-radius: 6px; } |
| | + </style> |
| | +</head> |
| | +<body> |
| | + <div class="container"> |
| | + <header> |
| | + <h1><?php echo SITE_TITLE; ?></h1> |
| | + <nav class="nav"> |
| | + <a href="?">Home</a> |
| | + <?php if ($currentRepo): ?> |
| | + <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> |
| | + <option value="">Switch Repo...</option> |
| | + <?php foreach ($repositories as $r): ?> |
| | + <option value="<?php echo htmlspecialchars($r['safe_name']); ?>" <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>><?php echo htmlspecialchars($r['name']); ?></option> |
| | + <?php endforeach; ?> |
| | + </select> |
| | + <?php endif; ?> |
| | + </nav> |
| | + </header> |
| | + |
| | + <?php if (!$currentRepo): ?> |
| | + <div class="repo-grid"> |
| | + <?php foreach ($repositories as $repo): ?> |
| | + <div class="repo-card"> |
| | + <h3><a href="?repo=<?php echo urlencode($repo['safe_name']); ?>"><?php echo htmlspecialchars($repo['name']); ?></a></h3> |
| | + </div> |
| | + <?php endforeach; ?> |
| | + </div> |
| | + <?php else: |
| | + $head = getHead($currentRepo['path']); |
| | + $main = getMainBranch($currentRepo['path']); |
| | + $targetHash = $hash ?: ($head['hash'] ?? ($main['hash'] ?? null)); |
| | + |
| | + if (!$targetHash) { |
| | + echo '<div class="empty-state">Repository is empty or HEAD is missing.</div>'; |
| | + } else { |
| | + $obj = readGitObject($currentRepo['path'], $targetHash); |
| | + if (!$obj) { |
| | + echo '<div class="empty-state">Error reading object: ' . htmlspecialchars($targetHash) . '</div>'; |
| | + } elseif ($obj['type'] === 'commit') { |
| | + $commit = parseCommit($obj['content']); |
| | + $treeObj = readGitObject($currentRepo['path'], $commit['tree']); |
| | + $entries = parseTree($treeObj['content']); |
| | + echo "<h2>Files in " . htmlspecialchars($currentRepo['name']) . "</h2>"; |
| | + echo '<div class="file-list">'; |
| | + foreach ($entries as $e) { |
| | + echo '<div class="file-item"><a href="?repo='.urlencode($currentRepo['safe_name']).'&hash='.$e['hash'].'">'.($e['type'] === 'tree' ? '📁 ' : '📄 ') . htmlspecialchars($e['name']).'</a></div>'; |
| | + } |
| | + echo '</div>'; |
| | + } elseif ($obj['type'] === 'tree') { |
| | + $entries = parseTree($obj['content']); |
| | + echo '<div class="file-list">'; |
| | + foreach ($entries as $e) { |
| | + echo '<div class="file-item"><a href="?repo='.urlencode($currentRepo['safe_name']).'&hash='.$e['hash'].'">'.($e['type'] === 'tree' ? '📁 ' : '📄 ') . htmlspecialchars($e['name']).'</a></div>'; |
| | + } |
| | + echo '</div>'; |
| | + } elseif ($obj['type'] === 'blob') { |
| | + echo '<div class="blob-code">' . htmlspecialchars($obj['content']) . '</div>'; |
| | + } |
| | + } |
| | + endif; ?> |
| | + </div> |
| | +</body> |
| | +</html> |
| | + |
| | |