| +<?php | ||
| +class Config { | ||
| + const SITE_TITLE = "Dave Jarvis' Repositories"; | ||
| + | ||
| + /** | ||
| + * Determine the home directory for repository discovery. | ||
| + */ | ||
| + private static 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 ''; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Returns the full path where repositories are stored. | ||
| + */ | ||
| + public static function getReposPath() { | ||
| + return self::getHomeDirectory() . '/repos'; | ||
| + } | ||
| + | ||
| + /** | ||
| + * Initialize runtime settings (error logging, etc). | ||
| + */ | ||
| + public static function init() { | ||
| + ini_set('display_errors', 0); | ||
| + ini_set('log_errors', 1); | ||
| + ini_set('error_log', __DIR__ . '/error.log'); | ||
| + } | ||
| +} | ||
| + | ||
| +<?php | ||
| +require_once 'MediaTypeSniffer.php'; | ||
| +require_once 'FileRenderer.php'; | ||
| + | ||
| +class File { | ||
| + private string $name; | ||
| + private string $sha; | ||
| + private string $mode; | ||
| + private int $timestamp; | ||
| + private int $size; | ||
| + private bool $isDir; | ||
| + | ||
| + public function __construct(string $name, string $sha, string $mode, int $timestamp = 0, int $size = 0) { | ||
| + $this->name = $name; | ||
| + $this->sha = $sha; | ||
| + $this->mode = $mode; | ||
| + $this->timestamp = $timestamp; | ||
| + $this->size = $size; | ||
| + $this->isDir = ($mode === '40000' || $mode === '040000'); | ||
| + } | ||
| + | ||
| + // New capability: Allow Files to compare themselves to other Files | ||
| + public function compare(File $other): int { | ||
| + // 1. Sort Directories before Files | ||
| + if ($this->isDir !== $other->isDir) { | ||
| + return $this->isDir ? -1 : 1; | ||
| + } | ||
| + // 2. Sort Alphabetically by Name | ||
| + return strcasecmp($this->name, $other->name); | ||
| + } | ||
| + | ||
| + public function render(FileRenderer $renderer): void { | ||
| + $renderer->renderFileItem( | ||
| + $this->name, | ||
| + $this->sha, | ||
| + $this->mode, | ||
| + $this->getIconClass(), | ||
| + $this->getTimeElapsed(), | ||
| + $this->isDir ? '' : $this->getFormattedSize() | ||
| + ); | ||
| + } | ||
| + | ||
| + // ... [Rest of the class methods: getIconClass, getFormattedSize, etc. remain unchanged] ... | ||
| + | ||
| + private function getIconClass(): string { | ||
| + if ($this->isDir) return 'fa-folder'; | ||
| + | ||
| + return match (true) { | ||
| + $this->isType('application/pdf') => 'fa-file-pdf', | ||
| + $this->isCategory(MediaTypeSniffer::CAT_ARCHIVE) => 'fa-file-archive', | ||
| + $this->isCategory(MediaTypeSniffer::CAT_IMAGE) => 'fa-file-image', | ||
| + $this->isCategory(MediaTypeSniffer::CAT_AUDIO) => 'fa-file-audio', | ||
| + $this->isCategory(MediaTypeSniffer::CAT_VIDEO) => 'fa-file-video', | ||
| + $this->isCategory(MediaTypeSniffer::CAT_TEXT) => 'fa-file-code', | ||
| + default => 'fa-file', | ||
| + }; | ||
| + } | ||
| + | ||
| + private function getFormattedSize(): string { | ||
| + if ($this->size <= 0) return '0 B'; | ||
| + $units = ['B', 'KB', 'MB', 'GB']; | ||
| + $i = (int)floor(log($this->size, 1024)); | ||
| + return round($this->size / pow(1024, $i), 1) . ' ' . $units[$i]; | ||
| + } | ||
| + | ||
| + public function isType(string $type): bool { | ||
| + return str_contains(MediaTypeSniffer::isMediaType($this->getSniffBuffer(), $this->name), $type); | ||
| + } | ||
| + | ||
| + public function isCategory(string $category): bool { | ||
| + return MediaTypeSniffer::isCategory($this->getSniffBuffer(), $this->name) === $category; | ||
| + } | ||
| + | ||
| + public function isBinary(): bool { | ||
| + return MediaTypeSniffer::isBinary($this->getSniffBuffer(), $this->name); | ||
| + } | ||
| + | ||
| + private function getSniffBuffer(): string { | ||
| + if ($this->isDir || !file_exists($this->name)) return ''; | ||
| + $handle = @fopen($this->name, 'rb'); | ||
| + if (!$handle) return ''; | ||
| + $read = fread($handle, 12); | ||
| + fclose($handle); | ||
| + return ($read !== false) ? $read : ''; | ||
| + } | ||
| + | ||
| + private function getTimeElapsed(): string { | ||
| + if (!$this->timestamp) return ''; | ||
| + $diff = time() - $this->timestamp; | ||
| + if ($diff < 5) return 'just now'; | ||
| + $tokens = [ | ||
| + 31536000 => 'year', 2592000 => 'month', 604800 => 'week', | ||
| + 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second' | ||
| + ]; | ||
| + foreach ($tokens as $unit => $text) { | ||
| + if ($diff < $unit) continue; | ||
| + $num = floor($diff / $unit); | ||
| + return $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago'; | ||
| + } | ||
| + return 'just now'; | ||
| + } | ||
| +} | ||
| + | ||
| +<?php | ||
| +interface FileRenderer { | ||
| + public function renderFileItem( | ||
| + string $name, | ||
| + string $sha, | ||
| + string $mode, | ||
| + string $iconClass, | ||
| + string $time, | ||
| + string $size = '' | ||
| + ): void; | ||
| +} | ||
| + | ||
| +class HtmlFileRenderer implements FileRenderer { | ||
| + private string $repoSafeName; | ||
| + | ||
| + public function __construct(string $repoSafeName) { | ||
| + $this->repoSafeName = $repoSafeName; | ||
| + } | ||
| + | ||
| + public function renderFileItem( | ||
| + string $name, | ||
| + string $sha, | ||
| + string $mode, | ||
| + string $iconClass, | ||
| + string $time, | ||
| + string $size = '' | ||
| + ): void { | ||
| + // UPDATED: Added '&name=' to the URL | ||
| + $url = '?repo=' . urlencode($this->repoSafeName) . '&hash=' . $sha . '&name=' . urlencode($name); | ||
| + | ||
| + echo '<a href="' . $url . '" class="file-item">'; | ||
| + echo '<span class="file-mode">' . $mode . '</span>'; | ||
| + echo '<span class="file-name">'; | ||
| + echo '<i class="fas ' . $iconClass . '" style="width: 20px; text-align: center; margin-right: 5px; color: #7a828e;"></i>'; | ||
| + echo htmlspecialchars($name); | ||
| + echo '</span>'; | ||
| + | ||
| + if ($size) { | ||
| + echo '<span class="file-size" style="color: #8b949e; font-size: 0.8em; margin-left: 10px;">' . $size . '</span>'; | ||
| + } | ||
| + | ||
| + if ($time) { | ||
| + echo '<span class="file-date" style="color: #8b949e; font-size: 0.8em; margin-left: auto;">' . $time . '</span>'; | ||
| + } | ||
| + | ||
| + echo '</a>'; | ||
| + } | ||
| +} | ||
| + | ||
| <?php | ||
| -class Git { | ||
| - private string $basePath; | ||
| - | ||
| - public function __construct(string $basePath) { | ||
| - $this->basePath = realpath($basePath) ?: ''; | ||
| - if ($this->basePath === '' || !is_dir($this->basePath)) { | ||
| - throw new InvalidArgumentException('Invalid base path'); | ||
| - } | ||
| - } | ||
| - | ||
| - public function listRepositories(): array { | ||
| - $repos = []; | ||
| - $iterator = new DirectoryIterator($this->basePath); | ||
| - foreach ($iterator as $item) { | ||
| - if ($item->isDot()) continue; | ||
| - if ($item->isDir()) { | ||
| - $gitDir = $item->getPathname() . DIRECTORY_SEPARATOR . '.git'; | ||
| - if (is_dir($gitDir)) { | ||
| - $repos[] = $item->getBasename(); | ||
| - } | ||
| - } | ||
| - } | ||
| - sort($repos); | ||
| - return $repos; | ||
| - } | ||
| - | ||
| - public function readRepository(string $name): array { | ||
| - $repoPath = $this->basePath . DIRECTORY_SEPARATOR . $name; | ||
| - $gitPath = $repoPath . DIRECTORY_SEPARATOR . '.git'; | ||
| - | ||
| - if (!is_dir($gitPath)) { | ||
| - throw new InvalidArgumentException('Not a git repository'); | ||
| - } | ||
| - | ||
| - $result = [ | ||
| - 'files' => [], | ||
| - 'directories' => [], | ||
| - 'commits' => [], | ||
| - 'tags' => [] | ||
| - ]; | ||
| - | ||
| - $result['files'] = $this->scanFiles($repoPath); | ||
| - $result['directories'] = $this->scanDirectories($repoPath); | ||
| - $result['commits'] = $this->readCommits($gitPath); | ||
| - $result['tags'] = $this->readTags($gitPath); | ||
| - | ||
| - return $result; | ||
| - } | ||
| - | ||
| - private function scanFiles(string $path): array { | ||
| - $files = []; | ||
| - $iterator = new RecursiveIteratorIterator( | ||
| - new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), | ||
| - RecursiveIteratorIterator::LEAVES_ONLY | ||
| - ); | ||
| - | ||
| - foreach ($iterator as $file) { | ||
| - if ($file->isFile()) { | ||
| - $relative = str_replace($path . DIRECTORY_SEPARATOR, '', $file->getPathname()); | ||
| - if (strpos($relative, '.git' . DIRECTORY_SEPARATOR) !== 0 && $relative !== '.git') { | ||
| - $files[] = $relative; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - sort($files); | ||
| - return $files; | ||
| - } | ||
| - | ||
| - private function scanDirectories(string $path): array { | ||
| - $dirs = []; | ||
| - $iterator = new RecursiveIteratorIterator( | ||
| - new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), | ||
| - RecursiveIteratorIterator::SELF_FIRST | ||
| - ); | ||
| - | ||
| - foreach ($iterator as $dir) { | ||
| - if ($dir->isDir()) { | ||
| - $relative = str_replace($path . DIRECTORY_SEPARATOR, '', $dir->getPathname()); | ||
| - if (strpos($relative, '.git' . DIRECTORY_SEPARATOR) !== 0 && $relative !== '.git') { | ||
| - $dirs[] = $relative; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - sort($dirs); | ||
| - return $dirs; | ||
| - } | ||
| - | ||
| - private function readCommits(string $gitPath): array { | ||
| - $commits = []; | ||
| - $headFile = $gitPath . DIRECTORY_SEPARATOR . 'HEAD'; | ||
| - | ||
| - if (!file_exists($headFile)) { | ||
| - return $commits; | ||
| - } | ||
| - | ||
| - $head = trim(file_get_contents($headFile)); | ||
| - $ref = ''; | ||
| - | ||
| - if (strpos($head, 'ref: ') === 0) { | ||
| - $refPath = $gitPath . DIRECTORY_SEPARATOR . substr($head, 5); | ||
| - if (file_exists($refPath)) { | ||
| - $ref = trim(file_get_contents($refPath)); | ||
| - } else { | ||
| - $packedRefs = $this->readPackedRefs($gitPath, substr($head, 5)); | ||
| - if ($packedRefs !== null) { | ||
| - $ref = $packedRefs; | ||
| - } | ||
| - } | ||
| - } else { | ||
| - $ref = $head; | ||
| - } | ||
| - | ||
| - if ($ref === '' || strlen($ref) !== 40 || !ctype_xdigit($ref)) { | ||
| - return $commits; | ||
| - } | ||
| - | ||
| - $this->walkCommits($gitPath, $ref, $commits, 0); | ||
| - return $commits; | ||
| - } | ||
| - | ||
| - private function readPackedRefs(string $gitPath, string $refName): ?string { | ||
| - $packedPath = $gitPath . DIRECTORY_SEPARATOR . 'packed-refs'; | ||
| - if (!file_exists($packedPath)) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - $content = file_get_contents($packedPath); | ||
| - $lines = explode("\n", $content); | ||
| - | ||
| - foreach ($lines as $line) { | ||
| - $line = trim($line); | ||
| - if ($line === '' || $line[0] === '#') continue; | ||
| - | ||
| - $parts = preg_split('/\s+/', $line, 2); | ||
| - if (count($parts) === 2 && $parts[1] === $refName) { | ||
| - $sha = $parts[0]; | ||
| - if (strlen($sha) === 40 && ctype_xdigit($sha)) { | ||
| - return $sha; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return null; | ||
| - } | ||
| - | ||
| - private function walkCommits(string $gitPath, string $sha, array &$commits, int $depth): void { | ||
| - if ($depth > 1000) return; | ||
| - if (strlen($sha) !== 40 || !ctype_xdigit($sha)) return; | ||
| - | ||
| - foreach ($commits as $existing) { | ||
| - if ($existing['sha'] === $sha) return; | ||
| - } | ||
| - | ||
| - $content = $this->getObject($gitPath, $sha); | ||
| - if ($content === null) return; | ||
| - | ||
| - $nullPos = strpos($content, "\x00"); | ||
| - if ($nullPos === false) return; | ||
| - | ||
| - $header = substr($content, 0, $nullPos); | ||
| - $body = substr($content, $nullPos + 1); | ||
| - | ||
| - if (!str_starts_with($header, 'commit ')) return; | ||
| - | ||
| - $lines = explode("\n", $body); | ||
| - $commit = ['sha' => $sha, 'tree' => '', 'parent' => [], 'author' => '', 'committer' => '', 'message' => '']; | ||
| - $inMessage = false; | ||
| - $messageLines = []; | ||
| - | ||
| - foreach ($lines as $line) { | ||
| - if ($inMessage) { | ||
| - $messageLines[] = $line; | ||
| - } elseif ($line === '') { | ||
| - $inMessage = true; | ||
| - } elseif (str_starts_with($line, 'tree ')) { | ||
| - $commit['tree'] = substr($line, 5); | ||
| - } elseif (str_starts_with($line, 'parent ')) { | ||
| - $parent = substr($line, 7); | ||
| - if (strlen($parent) === 40 && ctype_xdigit($parent)) { | ||
| - $commit['parent'][] = $parent; | ||
| - } | ||
| - } elseif (str_starts_with($line, 'author ')) { | ||
| - $commit['author'] = substr($line, 7); | ||
| - } elseif (str_starts_with($line, 'committer ')) { | ||
| - $commit['committer'] = substr($line, 10); | ||
| - } | ||
| - } | ||
| - | ||
| - $commit['message'] = implode("\n", $messageLines); | ||
| - $commits[] = $commit; | ||
| - | ||
| - foreach ($commit['parent'] as $parent) { | ||
| - $this->walkCommits($gitPath, $parent, $commits, $depth + 1); | ||
| - } | ||
| - } | ||
| - | ||
| - private function readTags(string $gitPath): array { | ||
| - $tags = []; | ||
| - $tagsPath = $gitPath . DIRECTORY_SEPARATOR . 'refs' . DIRECTORY_SEPARATOR . 'tags'; | ||
| - | ||
| - if (is_dir($tagsPath)) { | ||
| - $iterator = new DirectoryIterator($tagsPath); | ||
| - foreach ($iterator as $item) { | ||
| - if ($item->isFile() && !$item->isDot()) { | ||
| - $name = $item->getBasename(); | ||
| - $sha = trim(file_get_contents($item->getPathname())); | ||
| - if (strlen($sha) === 40 && ctype_xdigit($sha)) { | ||
| - $tags[] = ['name' => $name, 'sha' => $sha]; | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - $packedPath = $gitPath . DIRECTORY_SEPARATOR . 'packed-refs'; | ||
| - if (file_exists($packedPath)) { | ||
| - $content = file_get_contents($packedPath); | ||
| - $lines = explode("\n", $content); | ||
| - | ||
| - foreach ($lines as $line) { | ||
| - $line = trim($line); | ||
| - if ($line === '' || $line[0] === '#') continue; | ||
| - | ||
| - $parts = preg_split('/\s+/', $line, 2); | ||
| - if (count($parts) === 2 && str_starts_with($parts[1], 'refs/tags/')) { | ||
| - $name = substr($parts[1], 10); | ||
| - $sha = $parts[0]; | ||
| - if (strlen($sha) === 40 && ctype_xdigit($sha)) { | ||
| - $exists = false; | ||
| - foreach ($tags as $tag) { | ||
| - if ($tag['name'] === $name) { | ||
| - $exists = true; | ||
| - break; | ||
| - } | ||
| - } | ||
| - if (!$exists) { | ||
| - $tags[] = ['name' => $name, 'sha' => $sha]; | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - usort($tags, fn($a, $b) => strcmp($a['name'], $b['name'])); | ||
| - return $tags; | ||
| - } | ||
| - | ||
| - private function getObject(string $gitPath, string $sha): ?string { | ||
| - $loosePath = $gitPath . DIRECTORY_SEPARATOR . 'objects' . DIRECTORY_SEPARATOR . substr($sha, 0, 2) . DIRECTORY_SEPARATOR . substr($sha, 2); | ||
| - if (file_exists($loosePath)) { | ||
| - $content = file_get_contents($loosePath); | ||
| - if ($content === false) return null; | ||
| - $uncompressed = @gzuncompress($content); | ||
| - return $uncompressed !== false ? $uncompressed : null; | ||
| - } | ||
| - | ||
| - return $this->getPackedObject($gitPath, $sha); | ||
| - } | ||
| - | ||
| - private function getPackedObject(string $gitPath, string $sha): ?string { | ||
| - $packDir = $gitPath . DIRECTORY_SEPARATOR . 'objects' . DIRECTORY_SEPARATOR . 'pack'; | ||
| - if (!is_dir($packDir)) return null; | ||
| - | ||
| - foreach (glob($packDir . DIRECTORY_SEPARATOR . '*.idx') as $idxFile) { | ||
| - $content = $this->readIndexFile($idxFile); | ||
| - if ($content === null) continue; | ||
| - | ||
| - $binSha = hex2bin($sha); | ||
| - if (!isset($content[$binSha])) continue; | ||
| - | ||
| - $offset = $content[$binSha]; | ||
| - $packFile = substr($idxFile, 0, -3) . 'pack'; | ||
| - | ||
| - $obj = $this->unpackObject($packFile, $offset, $gitPath); | ||
| - if ($obj !== null) return $obj; | ||
| - } | ||
| - | ||
| - return null; | ||
| - } | ||
| - | ||
| - private function readIndexFile(string $path): ?array { | ||
| - $content = file_get_contents($path); | ||
| - if ($content === false) return null; | ||
| - | ||
| - $offset = 0; | ||
| - $signature = substr($content, 0, 4); | ||
| - | ||
| - if ($signature === "\xFFtOc") { | ||
| - $version = unpack('N', substr($content, 4, 4))[1]; | ||
| - $offset = 8; | ||
| - } else { | ||
| - $version = 1; | ||
| - } | ||
| - | ||
| - if ($version !== 1 && $version !== 2) return null; | ||
| - | ||
| - $fanout = unpack('N*', substr($content, $offset, 256 * 4)); | ||
| - $offset += 256 * 4; | ||
| - $count = $fanout[256]; | ||
| - | ||
| - $result = []; | ||
| - if ($version === 1) { | ||
| - for ($i = 0; $i < $count; $i++) { | ||
| - $sha = substr($content, $offset + 4, 20); | ||
| - $off = unpack('N', substr($content, $offset, 4))[1]; | ||
| - $result[$sha] = $off; | ||
| - $offset += 24; | ||
| - } | ||
| - } else { | ||
| - $shas = []; | ||
| - for ($i = 0; $i < $count; $i++) { | ||
| - $shas[] = substr($content, $offset, 20); | ||
| - $offset += 20; | ||
| - } | ||
| - $offset += $count * 4; | ||
| - for ($i = 0; $i < $count; $i++) { | ||
| - $off = unpack('N', substr($content, $offset, 4))[1]; | ||
| - $result[$shas[$i]] = $off; | ||
| - $offset += 4; | ||
| - } | ||
| - } | ||
| - | ||
| - return $result; | ||
| - } | ||
| - | ||
| - private function unpackObject(string $packFile, int $offset, string $gitPath): ?string { | ||
| - $fp = fopen($packFile, 'rb'); | ||
| - if (!$fp) return null; | ||
| - | ||
| - fseek($fp, $offset, SEEK_SET); | ||
| - $byte = ord(fread($fp, 1)); | ||
| - $type = ($byte >> 4) & 7; | ||
| - $size = $byte & 0xF; | ||
| - $shift = 4; | ||
| - | ||
| - while (($byte & 0x80) !== 0) { | ||
| - $byte = ord(fread($fp, 1)); | ||
| - $size |= ($byte & 0x7F) << $shift; | ||
| - $shift += 7; | ||
| - } | ||
| - | ||
| - switch ($type) { | ||
| - case 1: case 2: case 3: case 4: | ||
| - return $this->readCompressed($fp, $size); | ||
| - case 6: | ||
| - return $this->readOfsDelta($fp, $offset, $size, $gitPath); | ||
| - case 7: | ||
| - return $this->readRefDelta($fp, $size, $gitPath); | ||
| - default: | ||
| - fclose($fp); | ||
| - return null; | ||
| - } | ||
| - } | ||
| - | ||
| - private function readCompressed($fp, int $size): ?string { | ||
| - $data = ''; | ||
| - stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ); | ||
| - while (strlen($data) < $size && !feof($fp)) { | ||
| - $chunk = fread($fp, $size - strlen($data)); | ||
| - if ($chunk === false) break; | ||
| - $data .= $chunk; | ||
| - } | ||
| - fclose($fp); | ||
| - return strlen($data) === $size ? $data : null; | ||
| - } | ||
| - | ||
| - private function readOfsDelta($fp, int $objOffset, int $deltaSize, string $gitPath): ?string { | ||
| - $offset = 0; | ||
| - $byte = ord(fread($fp, 1)); | ||
| - $baseOffset = $byte & 0x7F; | ||
| - while (($byte & 0x80) !== 0) { | ||
| - $byte = ord(fread($fp, 1)); | ||
| - $baseOffset = (($baseOffset + 1) << 7) | ($byte & 0x7F); | ||
| - } | ||
| - | ||
| - $baseOffset = $objOffset - $baseOffset; | ||
| - fclose($fp); | ||
| - | ||
| - $packFile = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['args'][0]; | ||
| - $base = $this->unpackObject($packFile, $baseOffset, $gitPath); | ||
| - if ($base === null) return null; | ||
| - | ||
| - $delta = $this->readDeltaData($packFile, $objOffset + 1 + strlen(decbin($objOffset - $baseOffset)) / 7, $deltaSize); | ||
| - if ($delta === null) return null; | ||
| - | ||
| - return $this->applyDelta($base, $delta); | ||
| - } | ||
| - | ||
| - private function readRefDelta($fp, int $deltaSize, string $gitPath): ?string { | ||
| - $sha = bin2hex(fread($fp, 20)); | ||
| - fclose($fp); | ||
| - | ||
| - $base = $this->getObject($gitPath, $sha); | ||
| - if ($base === null) return null; | ||
| - | ||
| - $packFile = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['args'][0]; | ||
| - $delta = $this->readDeltaData($packFile, ftell($fp), $deltaSize); | ||
| - if ($delta === null) return null; | ||
| - | ||
| - return $this->applyDelta($base, $delta); | ||
| - } | ||
| - | ||
| - private function readDeltaData(string $packFile, int $offset, int $size): ?string { | ||
| - $fp = fopen($packFile, 'rb'); | ||
| - if (!$fp) return null; | ||
| - fseek($fp, $offset, SEEK_SET); | ||
| - | ||
| - stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ); | ||
| - $data = ''; | ||
| - while (strlen($data) < $size && !feof($fp)) { | ||
| - $chunk = fread($fp, $size - strlen($data)); | ||
| - if ($chunk === false) break; | ||
| - $data .= $chunk; | ||
| - } | ||
| - fclose($fp); | ||
| - return strlen($data) === $size ? $data : null; | ||
| - } | ||
| - | ||
| - private function applyDelta(string $base, string $delta): ?string { | ||
| - $pos = 0; | ||
| - $srcSize = 0; | ||
| - do { | ||
| - $byte = ord($delta[$pos++]); | ||
| - $srcSize |= ($byte & 0x7F) << (($pos - 1) * 7); | ||
| - } while (($byte & 0x80) !== 0); | ||
| - | ||
| - $dstSize = 0; | ||
| - $shift = 0; | ||
| - do { | ||
| - $byte = ord($delta[$pos++]); | ||
| - $dstSize |= ($byte & 0x7F) << $shift; | ||
| - $shift += 7; | ||
| - } while (($byte & 0x80) !== 0); | ||
| - | ||
| - if (strlen($base) !== $srcSize) return null; | ||
| - | ||
| - $result = ''; | ||
| - $deltaLen = strlen($delta); | ||
| - | ||
| - while ($pos < $deltaLen) { | ||
| - $byte = ord($delta[$pos++]); | ||
| - | ||
| - if (($byte & 0x80) !== 0) { | ||
| - $cpOff = 0; | ||
| - $cpSize = 0; | ||
| - | ||
| - if ($byte & 0x01) $cpOff |= ord($delta[$pos++]); | ||
| - if ($byte & 0x02) $cpOff |= ord($delta[$pos++]) << 8; | ||
| - if ($byte & 0x04) $cpOff |= ord($delta[$pos++]) << 16; | ||
| - if ($byte & 0x08) $cpOff |= ord($delta[$pos++]) << 24; | ||
| - | ||
| - if ($byte & 0x10) $cpSize |= ord($delta[$pos++]); | ||
| - if ($byte & 0x20) $cpSize |= ord($delta[$pos++]) << 8; | ||
| - if ($byte & 0x40) $cpSize |= ord($delta[$pos++]) << 16; | ||
| - | ||
| - if ($cpSize === 0) $cpSize = 0x10000; | ||
| - | ||
| - $result .= substr($base, $cpOff, $cpSize); | ||
| - } elseif ($byte !== 0) { | ||
| - $result .= substr($delta, $pos, $byte); | ||
| - $pos += $byte; | ||
| - } else { | ||
| - return null; | ||
| - } | ||
| - } | ||
| - | ||
| - return strlen($result) === $dstSize ? $result : null; | ||
| +require_once 'File.php'; | ||
| + | ||
| +class Git { | ||
| + private string $path; | ||
| + private string $objPath; | ||
| + | ||
| + public function __construct(string $repoPath) { | ||
| + $this->path = rtrim($repoPath, '/'); | ||
| + $this->objPath = $this->path . '/objects'; | ||
| + } | ||
| + | ||
| + public function getObjectSize(string $sha): int { | ||
| + $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| + if (file_exists($loose)) { | ||
| + $f = @fopen($loose, 'rb'); | ||
| + if (!$f) return 0; | ||
| + $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| + $data = ''; | ||
| + while (!feof($f)) { | ||
| + $chunk = fread($f, 128); | ||
| + $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| + if ($inflated === false) break; | ||
| + $data .= $inflated; | ||
| + if (strpos($data, "\0") !== false) break; | ||
| + } | ||
| + fclose($f); | ||
| + $header = explode("\0", $data, 2)[0]; | ||
| + $parts = explode(' ', $header); | ||
| + return isset($parts[1]) ? (int)$parts[1] : 0; | ||
| + } | ||
| + return $this->getPackedObjectSize($sha); | ||
| + } | ||
| + | ||
| + private function getPackedObjectSize(string $sha): int { | ||
| + $info = $this->getPackOffset($sha); | ||
| + if (!$info) return 0; | ||
| + | ||
| + $pf = @fopen($info['file'], 'rb'); | ||
| + if (!$pf) return 0; | ||
| + | ||
| + fseek($pf, $info['offset']); | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $type = ($byte >> 4) & 7; | ||
| + $size = $byte & 15; | ||
| + $shift = 4; | ||
| + while ($byte & 128) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $size |= (($byte & 127) << $shift); | ||
| + $shift += 7; | ||
| + } | ||
| + | ||
| + if ($type === 6 || $type === 7) { | ||
| + if ($type === 6) { // OFS_DELTA | ||
| + $byte = ord(fread($pf, 1)); | ||
| + while ($byte & 128) { $byte = ord(fread($pf, 1)); } | ||
| + } else { // REF_DELTA | ||
| + fread($pf, 20); | ||
| + } | ||
| + | ||
| + $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| + $buffer = ''; | ||
| + $found = false; | ||
| + while (!$found && !feof($pf)) { | ||
| + $chunk = fread($pf, 512); | ||
| + $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| + if ($inflated === false) { fclose($pf); return 0; } | ||
| + $buffer .= $inflated; | ||
| + if (strlen($buffer) > 32) $found = true; | ||
| + } | ||
| + | ||
| + $pos = 0; | ||
| + // Skip Source Size | ||
| + if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| + $byte = ord($buffer[$pos++]); | ||
| + while ($byte & 128) { | ||
| + if (!isset($buffer[$pos])) break; | ||
| + $byte = ord($buffer[$pos++]); | ||
| + } | ||
| + // Read Target Size | ||
| + if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| + $byte = ord($buffer[$pos++]); | ||
| + $size = $byte & 127; | ||
| + $shift = 7; | ||
| + while ($byte & 128) { | ||
| + if (!isset($buffer[$pos])) break; | ||
| + $byte = ord($buffer[$pos++]); | ||
| + $size |= (($byte & 127) << $shift); | ||
| + $shift += 7; | ||
| + } | ||
| + } | ||
| + fclose($pf); | ||
| + return $size; | ||
| + } | ||
| + | ||
| + public function eachRepository(callable $callback): void { | ||
| + if (!is_dir($this->path)) return; | ||
| + $repos = []; | ||
| + foreach (glob($this->path . '/*.git') as $path) { | ||
| + if (is_dir($path)) { | ||
| + $name = basename($path, '.git'); | ||
| + $repos[$name] = ['path' => $path, 'name' => urldecode($name), 'safe_name' => $name]; | ||
| + } | ||
| + } | ||
| + uasort($repos, fn($a, $b) => strcasecmp($a['name'], $b['name'])); | ||
| + foreach ($repos as $repo) $callback($repo); | ||
| + } | ||
| + | ||
| + public function getMainBranch(): ?array { | ||
| + $branches = []; | ||
| + $this->eachBranch(function($name, $sha) use (&$branches) { $branches[$name] = $sha; }); | ||
| + foreach (['main', 'master', 'trunk', 'develop'] as $b) { | ||
| + if (isset($branches[$b])) return ['name' => $b, 'hash' => $branches[$b]]; | ||
| + } | ||
| + if (!empty($branches)) { | ||
| + $f = array_key_first($branches); | ||
| + return ['name' => $f, 'hash' => $branches[$f]]; | ||
| + } | ||
| + return null; | ||
| + } | ||
| + | ||
| + public function eachBranch(callable $callback): void { $this->scanRefs('refs/heads', $callback); } | ||
| + public function eachTag(callable $callback): void { $this->scanRefs('refs/tags', $callback); } | ||
| + | ||
| + public function walk(string $refOrSha, callable $callback): void { | ||
| + $sha = $this->resolve($refOrSha); | ||
| + if (!$sha) return; | ||
| + $data = $this->read($sha); | ||
| + if (!$data) return; | ||
| + if (preg_match('/^tree ([0-9a-f]{40})$/m', $data, $m)) { | ||
| + $data = $this->read($m[1]); | ||
| + if (!$data) return; | ||
| + } elseif (!$this->isTreeData($data)) return; | ||
| + | ||
| + $pos = 0; | ||
| + while ($pos < strlen($data)) { | ||
| + $space = strpos($data, ' ', $pos); | ||
| + $null = strpos($data, "\0", $space); | ||
| + if ($space === false || $null === false) break; | ||
| + $mode = substr($data, $pos, $space - $pos); | ||
| + $name = substr($data, $space + 1, $null - $space - 1); | ||
| + $entrySha = bin2hex(substr($data, $null + 1, 20)); | ||
| + | ||
| + // Calculate logic internally to encapsulate File creation | ||
| + $isDir = ($mode === '40000' || $mode === '040000'); | ||
| + $size = $isDir ? 0 : $this->getObjectSize($entrySha); | ||
| + | ||
| + $callback(new File($name, $entrySha, $mode, 0, $size)); | ||
| + | ||
| + $pos = $null + 21; | ||
| + } | ||
| + } | ||
| + | ||
| + private function isTreeData(string $data): bool { | ||
| + if (strlen($data) < 25) return false; | ||
| + if (preg_match('/^(40000|100644|100755|120000) /', $data)) { | ||
| + $null = strpos($data, "\0"); | ||
| + return ($null !== false && ($null + 21 <= strlen($data))); | ||
| + } | ||
| + return false; | ||
| + } | ||
| + | ||
| + public function history(string $refOrSha, int $limit, callable $callback): void { | ||
| + $currentSha = $this->resolve($refOrSha); | ||
| + $count = 0; | ||
| + while ($currentSha && $count < $limit) { | ||
| + $data = $this->read($currentSha); | ||
| + if (!$data) break; | ||
| + $message = (strpos($data, "\n\n") !== false) ? substr($data, strpos($data, "\n\n") + 2) : ''; | ||
| + preg_match('/^author (.*) <(.*)> (\d+)/m', $data, $auth); | ||
| + $callback((object)['sha' => $currentSha, 'message' => trim($message), 'author' => $auth[1] ?? 'Unknown', 'email' => $auth[2] ?? '', 'date' => (int)($auth[3] ?? 0)]); | ||
| + $currentSha = preg_match('/^parent ([0-9a-f]{40})$/m', $data, $m) ? $m[1] : null; | ||
| + $count++; | ||
| + } | ||
| + } | ||
| + | ||
| + public function stream(string $sha, callable $callback): void { | ||
| + $data = $this->read($sha); | ||
| + if ($data) $callback($data); | ||
| + } | ||
| + | ||
| + private function resolve(string $input): ?string { | ||
| + if (preg_match('/^[0-9a-f]{40}$/', $input)) return $input; | ||
| + if ($input === 'HEAD' && file_exists($h = "{$this->path}/HEAD")) { | ||
| + $head = trim(file_get_contents($h)); | ||
| + return (strpos($head, 'ref: ') === 0) ? $this->resolve(substr($head, 5)) : $head; | ||
| + } | ||
| + foreach ([$input, "refs/heads/$input", "refs/tags/$input"] as $p) { | ||
| + if (file_exists($f = "{$this->path}/$p")) return trim(file_get_contents($f)); | ||
| + } | ||
| + if (file_exists($packed = "{$this->path}/packed-refs")) { | ||
| + foreach (file($packed) as $line) { | ||
| + if ($line[0] === '#' || $line[0] === '^') continue; | ||
| + $parts = explode(' ', trim($line)); | ||
| + if (count($parts) >= 2 && ($parts[1] === $input || $parts[1] === "refs/heads/$input" || $parts[1] === "refs/tags/$input")) return $parts[0]; | ||
| + } | ||
| + } | ||
| + return null; | ||
| + } | ||
| + | ||
| + private function read(string $sha): ?string { | ||
| + $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| + if (file_exists($loose)) { | ||
| + $inflated = @gzuncompress(file_get_contents($loose)); | ||
| + return $inflated ? explode("\0", $inflated, 2)[1] : null; | ||
| + } | ||
| + return $this->fromPack($sha); | ||
| + } | ||
| + | ||
| + private function scanRefs(string $prefix, callable $callback): void { | ||
| + $dir = "{$this->path}/$prefix"; | ||
| + if (is_dir($dir)) { | ||
| + foreach (array_diff(scandir($dir), ['.', '..']) as $f) { | ||
| + $callback($f, trim(file_get_contents("$dir/$f"))); | ||
| + } | ||
| + } | ||
| + } | ||
| + | ||
| + private function fromPack(string $sha): ?string { | ||
| + $info = $this->getPackOffset($sha); | ||
| + if (!$info) return null; | ||
| + $pf = @fopen($info['file'], 'rb'); | ||
| + if (!$pf) return null; | ||
| + $data = $this->readPackEntry($pf, $info['offset']); | ||
| + fclose($pf); | ||
| + return $data; | ||
| + } | ||
| + | ||
| + private function readPackEntry($pf, int $offset): ?string { | ||
| + fseek($pf, $offset); | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $type = ($byte >> 4) & 7; | ||
| + $size = $byte & 15; | ||
| + $shift = 4; | ||
| + while ($byte & 128) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $size |= (($byte & 127) << $shift); | ||
| + $shift += 7; | ||
| + } | ||
| + | ||
| + // Type 6: OBJ_OFS_DELTA | ||
| + if ($type === 6) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $negOffset = $byte & 127; | ||
| + while ($byte & 128) { | ||
| + $byte = ord(fread($pf, 1)); | ||
| + $negOffset = (($negOffset + 1) << 7) | ($byte & 127); | ||
| + } | ||
| + $baseOffset = $offset - $negOffset; | ||
| + $base = $this->readPackEntry($pf, $baseOffset); | ||
| + | ||
| + fseek($pf, $offset); | ||
| + $b = ord(fread($pf, 1)); | ||
| + while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| + $b = ord(fread($pf, 1)); | ||
| + while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| + | ||
| + $delta = @gzuncompress(fread($pf, 16777216)); | ||
| + return $this->applyDelta($base, $delta); | ||
| + } | ||
| + | ||
| + // Type 7: OBJ_REF_DELTA | ||
| + if ($type === 7) { | ||
| + $baseSha = bin2hex(fread($pf, 20)); | ||
| + $base = $this->read($baseSha); | ||
| + $delta = @gzuncompress(fread($pf, 16777216)); | ||
| + return $this->applyDelta($base, $delta); | ||
| + } | ||
| + | ||
| + return @gzuncompress(fread($pf, 16777216)); | ||
| + } | ||
| + | ||
| + private function applyDelta(?string $base, ?string $delta): string { | ||
| + if (!$base || !$delta) return ''; | ||
| + $pos = 0; | ||
| + // Skip Source Size | ||
| + $byte = ord($delta[$pos++]); | ||
| + while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| + // Skip Target Size | ||
| + $byte = ord($delta[$pos++]); | ||
| + while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| + | ||
| + $out = ''; | ||
| + while ($pos < strlen($delta)) { | ||
| + $opcode = ord($delta[$pos++]); | ||
| + if ($opcode & 128) { // Copy | ||
| + $off = 0; $len = 0; | ||
| + if ($opcode & 1) $off |= ord($delta[$pos++]); | ||
| + if ($opcode & 2) $off |= ord($delta[$pos++]) << 8; | ||
| + if ($opcode & 4) $off |= ord($delta[$pos++]) << 16; | ||
| + if ($opcode & 8) $off |= ord($delta[$pos++]) << 24; | ||
| + if ($opcode & 16) $len |= ord($delta[$pos++]); | ||
| + if ($opcode & 32) $len |= ord($delta[$pos++]) << 8; | ||
| + if ($opcode & 64) $len |= ord($delta[$pos++]) << 16; | ||
| + if ($len === 0) $len = 0x10000; | ||
| + $out .= substr($base, $off, $len); | ||
| + } else { // Insert | ||
| + $len = $opcode & 127; | ||
| + $out .= substr($delta, $pos, $len); | ||
| + $pos += $len; | ||
| + } | ||
| + } | ||
| + return $out; | ||
| + } | ||
| + | ||
| + private function getPackOffset(string $sha): ?array { | ||
| + $packs = glob("{$this->objPath}/pack/*.idx"); | ||
| + if (!$packs) return null; | ||
| + | ||
| + $binSha = hex2bin($sha); | ||
| + $firstByte = ord($binSha[0]); | ||
| + | ||
| + foreach ($packs as $idxFile) { | ||
| + $f = @fopen($idxFile, 'rb'); | ||
| + if (!$f) continue; | ||
| + | ||
| + $sig = fread($f, 4); | ||
| + $ver = unpack('N', fread($f, 4))[1]; | ||
| + if ($sig !== "\377tOc" || $ver !== 2) { fclose($f); continue; } | ||
| + | ||
| + $fanoutOffset = 8; | ||
| + if ($firstByte > 0) { | ||
| + fseek($f, $fanoutOffset + (($firstByte - 1) * 4)); | ||
| + $start = unpack('N', fread($f, 4))[1]; | ||
| + } else { | ||
| + $start = 0; | ||
| + } | ||
| + fseek($f, $fanoutOffset + ($firstByte * 4)); | ||
| + $end = unpack('N', fread($f, 4))[1]; | ||
| + | ||
| + if ($end <= $start) { fclose($f); continue; } | ||
| + | ||
| + fseek($f, $fanoutOffset + (255 * 4)); | ||
| + $totalObjects = unpack('N', fread($f, 4))[1]; | ||
| + | ||
| + $shaTableOffset = 8 + 1024; | ||
| + fseek($f, $shaTableOffset + ($start * 20)); | ||
| + | ||
| + $foundIdx = -1; | ||
| + for ($i = $start; $i < $end; $i++) { | ||
| + if (fread($f, 20) === $binSha) { $foundIdx = $i; break; } | ||
| + } | ||
| + | ||
| + if ($foundIdx === -1) { fclose($f); continue; } | ||
| + | ||
| + $crcOffset = $shaTableOffset + ($totalObjects * 20); | ||
| + $offsetTableOffset = $crcOffset + ($totalObjects * 4); | ||
| + | ||
| + fseek($f, $offsetTableOffset + ($foundIdx * 4)); | ||
| + $offset32 = unpack('N', fread($f, 4))[1]; | ||
| + | ||
| + if ($offset32 & 0x80000000) { | ||
| + $largeOffsetIdx = $offset32 & 0x7FFFFFFF; | ||
| + $largeOffsetTablePos = $offsetTableOffset + ($totalObjects * 4); | ||
| + fseek($f, $largeOffsetTablePos + ($largeOffsetIdx * 8)); | ||
| + $data = unpack('J', fread($f, 8)); | ||
| + $offset = $data[1]; | ||
| + } else { | ||
| + $offset = $offset32; | ||
| + } | ||
| + | ||
| + fclose($f); | ||
| + return ['file' => str_replace('.idx', '.pack', $idxFile), 'offset' => $offset]; | ||
| + } | ||
| + return null; | ||
| } | ||
| } |
| private const BUFFER = 12; | ||
| private const ANY = -1; | ||
| - private const EOS = -2; | ||
| + | ||
| + // Categories | ||
| + public const CAT_IMAGE = 'image'; | ||
| + public const CAT_VIDEO = 'video'; | ||
| + public const CAT_AUDIO = 'audio'; | ||
| + public const CAT_TEXT = 'text'; | ||
| + public const CAT_ARCHIVE = 'archive'; | ||
| + public const CAT_APP = 'application'; | ||
| + public const CAT_BINARY = 'binary'; | ||
| private const FORMATS = [ | ||
| // Images | ||
| - [0x3C, 0x73, 0x76, 0x67, 0x20] => 'image/svg+xml', | ||
| - [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] => 'image/png', | ||
| - [0xFF, 0xD8, 0xFF, 0xE0] => 'image/jpeg', | ||
| - [0xFF, 0xD8, 0xFF, 0xEE] => 'image/jpeg', | ||
| - [0xFF, 0xD8, 0xFF, 0xE1, self::ANY, self::ANY, 0x45, 0x78, 0x69, 0x66, 0x00] => | ||
| - 'image/jpeg', | ||
| - [0x47, 0x49, 0x46, 0x38] => 'image/gif', | ||
| - [0x42, 0x4D] => 'image/bmp', | ||
| - [0x49, 0x49, 0x2A, 0x00] => 'image/tiff', | ||
| - [0x4D, 0x4D, 0x00, 0x2A] => 'image/tiff', | ||
| - [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, | ||
| - 0x57, 0x45, 0x42, 0x50] => 'image/webp', | ||
| - [0x38, 0x42, 0x50, 0x53, 0x00, 0x01] => 'image/vnd.adobe.photoshop', | ||
| - [0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] => 'video/x-mng', | ||
| - [0x23, 0x64, 0x65, 0x66] => 'image/x-xbitmap', | ||
| - [0x21, 0x20, 0x58, 0x50, 0x4D, 0x32] => 'image/x-xpixmap', | ||
| + [self::CAT_IMAGE, [0x3C, 0x73, 0x76, 0x67, 0x20], 'image/svg+xml'], | ||
| + [self::CAT_IMAGE, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'image/png'], | ||
| + [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xE0], 'image/jpeg'], | ||
| + [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xEE], 'image/jpeg'], | ||
| + [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xE1, self::ANY, self::ANY, 0x45, 0x78, 0x69, 0x66, 0x00], 'image/jpeg'], | ||
| + [self::CAT_IMAGE, [0x47, 0x49, 0x46, 0x38], 'image/gif'], | ||
| + [self::CAT_IMAGE, [0x42, 0x4D], 'image/bmp'], | ||
| + [self::CAT_IMAGE, [0x49, 0x49, 0x2A, 0x00], 'image/tiff'], | ||
| + [self::CAT_IMAGE, [0x4D, 0x4D, 0x00, 0x2A], 'image/tiff'], | ||
| + [self::CAT_IMAGE, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x57, 0x45, 0x42, 0x50], 'image/webp'], | ||
| + [self::CAT_IMAGE, [0x38, 0x42, 0x50, 0x53, 0x00, 0x01], 'image/vnd.adobe.photoshop'], | ||
| + [self::CAT_IMAGE, [0x23, 0x64, 0x65, 0x66], 'image/x-xbitmap'], | ||
| + [self::CAT_IMAGE, [0x21, 0x20, 0x58, 0x50, 0x4D, 0x32], 'image/x-xpixmap'], | ||
| + | ||
| + // Video | ||
| + [self::CAT_VIDEO, [0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'video/x-mng'], | ||
| + [self::CAT_VIDEO, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x41, 0x56, 0x49, 0x20], 'video/x-msvideo'], | ||
| + [self::CAT_VIDEO, [self::ANY, self::ANY, self::ANY, self::ANY, 0x66, 0x74, 0x79, 0x70], 'video/mp4'], | ||
| + [self::CAT_VIDEO, [0x1A, 0x45, 0xDF, 0xA3], 'video/x-matroska'], | ||
| + [self::CAT_VIDEO, [0x00, 0x00, 0x01, 0xBA], 'video/mpeg'], | ||
| + [self::CAT_VIDEO, [0x46, 0x4C, 0x56, 0x01], 'video/x-flv'], | ||
| // Documents/Text | ||
| - [0x3C, 0x21] => 'text/html', | ||
| - [0x3C, 0x68, 0x74, 0x6D, 0x6C] => 'text/html', | ||
| - [0x3C, 0x68, 0x65, 0x61, 0x64] => 'text/html', | ||
| - [0x3C, 0x62, 0x6F, 0x64, 0x79] => 'text/html', | ||
| - [0x3C, 0x48, 0x54, 0x4D, 0x4C] => 'text/html', | ||
| - [0x3C, 0x48, 0x45, 0x41, 0x44] => 'text/html', | ||
| - [0x3C, 0x42, 0x4F, 0x44, 0x59] => 'text/html', | ||
| - [0x3C, 0x3F, 0x78, 0x6D, 0x6C, 0x20] => 'text/xml', | ||
| - [0xFE, 0xFF, 0x00, 0x3C, 0x00, 0x3f, 0x00, 0x78] => 'text/xml', | ||
| - [0xFF, 0xFE, 0x3C, 0x00, 0x3F, 0x00, 0x78, 0x00] => 'text/xml', | ||
| - [0x25, 0x50, 0x44, 0x46, 0x2D] => 'application/pdf', | ||
| - [0x25, 0x21, 0x50, 0x53, 0x2D, 0x41, 0x64, 0x6F, 0x62, 0x65, 0x2D] => | ||
| - 'application/postscript', | ||
| - [0x25, 0x21, 0x50, 0x53] => 'application/postscript', | ||
| + [self::CAT_TEXT, [0x3C, 0x21], 'text/html'], | ||
| + [self::CAT_TEXT, [0x3C, 0x68, 0x74, 0x6D, 0x6C], 'text/html'], | ||
| + [self::CAT_TEXT, [0x3C, 0x68, 0x65, 0x61, 0x64], 'text/html'], | ||
| + [self::CAT_TEXT, [0x3C, 0x62, 0x6F, 0x64, 0x79], 'text/html'], | ||
| + [self::CAT_TEXT, [0x3C, 0x48, 0x54, 0x4D, 0x4C], 'text/html'], | ||
| + [self::CAT_TEXT, [0x3C, 0x48, 0x45, 0x41, 0x44], 'text/html'], | ||
| + [self::CAT_TEXT, [0x3C, 0x42, 0x4F, 0x44, 0x59], 'text/html'], | ||
| + [self::CAT_TEXT, [0x3C, 0x3F, 0x78, 0x6D, 0x6C, 0x20], 'text/xml'], | ||
| + [self::CAT_TEXT, [0xFE, 0xFF, 0x00, 0x3C, 0x00, 0x3f, 0x00, 0x78], 'text/xml'], | ||
| + [self::CAT_TEXT, [0xFF, 0xFE, 0x3C, 0x00, 0x3F, 0x00, 0x78, 0x00], 'text/xml'], | ||
| + [self::CAT_TEXT, [0x25, 0x50, 0x44, 0x46, 0x2D], 'application/pdf'], | ||
| + [self::CAT_TEXT, [0x25, 0x21, 0x50, 0x53, 0x2D, 0x41, 0x64, 0x6F, 0x62, 0x65, 0x2D], 'application/postscript'], | ||
| + [self::CAT_TEXT, [0x25, 0x21, 0x50, 0x53], 'application/postscript'], | ||
| - // Audio/Video | ||
| - [0xFF, 0xFB, self::ANY] => 'audio/mpeg', | ||
| - [0x49, 0x44, 0x33] => 'audio/mpeg', | ||
| - [0x2E, 0x73, 0x6E, 0x64] => 'audio/basic', | ||
| - [0x64, 0x6E, 0x73, 0x2E] => 'audio/basic', | ||
| - [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, | ||
| - 0x57, 0x41, 0x56, 0x45] => 'audio/wav', | ||
| + // Audio | ||
| + [self::CAT_AUDIO, [0xFF, 0xFB, self::ANY], 'audio/mpeg'], | ||
| + [self::CAT_AUDIO, [0x49, 0x44, 0x33], 'audio/mpeg'], | ||
| + [self::CAT_AUDIO, [0x2E, 0x73, 0x6E, 0x64], 'audio/basic'], | ||
| + [self::CAT_AUDIO, [0x64, 0x6E, 0x73, 0x2E], 'audio/basic'], | ||
| + [self::CAT_AUDIO, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x57, 0x41, 0x56, 0x45], 'audio/wav'], | ||
| + [self::CAT_AUDIO, [0x4F, 0x67, 0x67, 0x53], 'audio/ogg'], | ||
| + [self::CAT_AUDIO, [0x66, 0x4C, 0x61, 0x43], 'audio/flac'], | ||
| + [self::CAT_AUDIO, [0x4D, 0x54, 0x68, 0x64], 'audio/midi'], | ||
| + [self::CAT_AUDIO, [0x46, 0x4F, 0x52, 0x4D, self::ANY, self::ANY, self::ANY, self::ANY, 0x41, 0x49, 0x46, 0x46], 'audio/x-aiff'], | ||
| - // Archives/Binaries | ||
| - [0x50, 0x4B, 0x03, 0x04] => 'application/zip', | ||
| - [0x50, 0x4B, 0x05, 0x06] => 'application/zip', | ||
| - [0x50, 0x4B, 0x07, 0x08] => 'application/zip', | ||
| - [0x1F, 0x8B, 0x08] => 'application/gzip', | ||
| - [0x42, 0x5A, 0x68] => 'application/x-bzip2', | ||
| - [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] => 'application/x-xz', | ||
| - [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07] => 'application/vnd.rar', | ||
| - [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C] => 'application/x-7z-compressed', | ||
| + // Archives | ||
| + [self::CAT_ARCHIVE, [0x50, 0x4B, 0x03, 0x04], 'application/zip'], | ||
| + [self::CAT_ARCHIVE, [0x50, 0x4B, 0x05, 0x06], 'application/zip'], | ||
| + [self::CAT_ARCHIVE, [0x50, 0x4B, 0x07, 0x08], 'application/zip'], | ||
| + [self::CAT_ARCHIVE, [0x1F, 0x8B, 0x08], 'application/gzip'], | ||
| + [self::CAT_ARCHIVE, [0x42, 0x5A, 0x68], 'application/x-bzip2'], | ||
| + [self::CAT_ARCHIVE, [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], 'application/x-xz'], | ||
| + [self::CAT_ARCHIVE, [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar'], | ||
| + [self::CAT_ARCHIVE, [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed'], | ||
| - // Executables/System | ||
| - [0x41, 0x43, self::ANY, self::ANY, self::ANY, self::ANY, 0x00, 0x00, | ||
| - 0x00, 0x00, 0x00] => 'application/acad', | ||
| - [0xCA, 0xFE, 0xBA, 0xBE] => 'application/java-vm', | ||
| - [0xAC, 0xED] => 'application/x-java-serialized-object', | ||
| - [0x4D, 0x5A] => 'application/x-msdownload', | ||
| - [0x7F, 0x45, 0x4C, 0x46] => 'application/x-elf', | ||
| - [0xCE, 0xFA, 0xED, 0xFE] => 'application/x-mach-binary', | ||
| - [0xCF, 0xFA, 0xED, 0xFE] => 'application/x-mach-binary', | ||
| - [0xFE, 0xED, 0xFA, 0xCE] => 'application/x-mach-binary', | ||
| - [0xFE, 0xED, 0xFA, 0xCF] => 'application/x-mach-binary', | ||
| + // Applications/System | ||
| + [self::CAT_APP, [0x41, 0x43, self::ANY, self::ANY, self::ANY, self::ANY, 0x00, 0x00, 0x00, 0x00, 0x00], 'application/acad'], | ||
| + [self::CAT_APP, [0xCA, 0xFE, 0xBA, 0xBE], 'application/java-vm'], | ||
| + [self::CAT_APP, [0xAC, 0xED], 'application/x-java-serialized-object'], | ||
| + [self::CAT_APP, [0x4D, 0x5A], 'application/x-msdownload'], | ||
| + [self::CAT_APP, [0x7F, 0x45, 0x4C, 0x46], 'application/x-elf'], | ||
| + [self::CAT_APP, [0xCE, 0xFA, 0xED, 0xFE], 'application/x-mach-binary'], | ||
| + [self::CAT_APP, [0xCF, 0xFA, 0xED, 0xFE], 'application/x-mach-binary'], | ||
| + [self::CAT_APP, [0xFE, 0xED, 0xFA, 0xCE], 'application/x-mach-binary'], | ||
| + [self::CAT_APP, [0xFE, 0xED, 0xFA, 0xCF], 'application/x-mach-binary'], | ||
| ]; | ||
| private const EXTENSION_MAP = [ | ||
| - 'txt' => 'text/plain', 'html' => 'text/html', 'htm' => 'text/html', | ||
| - 'css' => 'text/css', 'js' => 'application/javascript', | ||
| - 'json' => 'application/json', 'xml' => 'application/xml', | ||
| - 'pdf' => 'application/pdf', 'zip' => 'application/zip', | ||
| - 'jar' => 'application/java-archive', 'war' => 'application/java-archive', | ||
| - 'ear' => 'application/java-archive', 'class' => 'application/java-vm', | ||
| - 'gz' => 'application/gzip', 'bz2' => 'application/x-bzip2', | ||
| - 'xz' => 'application/x-xz', 'tar' => 'application/x-tar', | ||
| - 'rar' => 'application/vnd.rar', '7z' => 'application/x-7z-compressed', | ||
| - 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', | ||
| - 'gif' => 'image/gif', 'svg' => 'image/svg+xml', 'webp' => 'image/webp', | ||
| - 'bmp' => 'image/bmp', 'tiff' => 'image/tiff', 'tif' => 'image/tiff', | ||
| - 'ico' => 'image/x-icon', 'mp4' => 'video/mp4', 'avi' => 'video/x-msvideo', | ||
| - 'mov' => 'video/quicktime', 'wmv' => 'video/x-ms-wmv', | ||
| - 'flv' => 'video/x-flv', 'webm' => 'video/webm', 'mp3' => 'audio/mpeg', | ||
| - 'wav' => 'audio/wav', 'ogg' => 'audio/ogg', 'flac' => 'audio/flac', | ||
| - 'aac' => 'audio/aac', 'php' => 'application/x-php', | ||
| - 'py' => 'text/x-python', 'java' => 'text/x-java', 'c' => 'text/x-c', | ||
| - 'cpp' => 'text/x-c++', 'h' => 'text/x-c', 'hpp' => 'text/x-c++', | ||
| - 'cs' => 'text/x-csharp', 'go' => 'text/x-go', 'rs' => 'text/x-rust', | ||
| - 'rb' => 'text/x-ruby', 'pl' => 'text/x-perl', 'sh' => 'application/x-sh', | ||
| - 'bat' => 'application/x-bat', 'ps1' => 'application/x-powershell', | ||
| - 'md' => 'text/markdown', 'yaml' => 'text/yaml', 'yml' => 'text/yaml', | ||
| - 'toml' => 'application/toml', 'ini' => 'text/plain', 'cfg' => 'text/plain', | ||
| - 'conf' => 'text/plain', | ||
| + 'txt' => [self::CAT_TEXT, 'text/plain'], | ||
| + 'url' => [self::CAT_TEXT, 'text/plain'], | ||
| + 'html' => [self::CAT_TEXT, 'text/html'], | ||
| + 'htm' => [self::CAT_TEXT, 'text/html'], | ||
| + 'css' => [self::CAT_TEXT, 'text/css'], | ||
| + 'js' => [self::CAT_TEXT, 'application/javascript'], | ||
| + 'json' => [self::CAT_TEXT, 'application/json'], | ||
| + 'xml' => [self::CAT_TEXT, 'application/xml'], | ||
| + 'pdf' => [self::CAT_TEXT, 'application/pdf'], | ||
| + 'zip' => [self::CAT_ARCHIVE, 'application/zip'], | ||
| + 'jar' => [self::CAT_ARCHIVE, 'application/java-archive'], | ||
| + 'war' => [self::CAT_ARCHIVE, 'application/java-archive'], | ||
| + 'ear' => [self::CAT_ARCHIVE, 'application/java-archive'], | ||
| + 'class' => [self::CAT_APP, 'application/java-vm'], | ||
| + 'gz' => [self::CAT_ARCHIVE, 'application/gzip'], | ||
| + 'bz2' => [self::CAT_ARCHIVE, 'application/x-bzip2'], | ||
| + 'xz' => [self::CAT_ARCHIVE, 'application/x-xz'], | ||
| + 'tar' => [self::CAT_ARCHIVE, 'application/x-tar'], | ||
| + 'rar' => [self::CAT_ARCHIVE, 'application/vnd.rar'], | ||
| + '7z' => [self::CAT_ARCHIVE, 'application/x-7z-compressed'], | ||
| + 'jpg' => [self::CAT_IMAGE, 'image/jpeg'], | ||
| + 'jpeg' => [self::CAT_IMAGE, 'image/jpeg'], | ||
| + 'png' => [self::CAT_IMAGE, 'image/png'], | ||
| + 'gif' => [self::CAT_IMAGE, 'image/gif'], | ||
| + 'svg' => [self::CAT_IMAGE, 'image/svg+xml'], | ||
| + 'webp' => [self::CAT_IMAGE, 'image/webp'], | ||
| + 'bmp' => [self::CAT_IMAGE, 'image/bmp'], | ||
| + 'tiff' => [self::CAT_IMAGE, 'image/tiff'], | ||
| + 'tif' => [self::CAT_IMAGE, 'image/tiff'], | ||
| + 'ico' => [self::CAT_IMAGE, 'image/x-icon'], | ||
| + 'mp4' => [self::CAT_VIDEO, 'video/mp4'], | ||
| + 'avi' => [self::CAT_VIDEO, 'video/x-msvideo'], | ||
| + 'mov' => [self::CAT_VIDEO, 'video/quicktime'], | ||
| + 'wmv' => [self::CAT_VIDEO, 'video/x-ms-wmv'], | ||
| + 'flv' => [self::CAT_VIDEO, 'video/x-flv'], | ||
| + 'webm' => [self::CAT_VIDEO, 'video/webm'], | ||
| + 'mp3' => [self::CAT_AUDIO, 'audio/mpeg'], | ||
| + 'wav' => [self::CAT_AUDIO, 'audio/wav'], | ||
| + 'ogg' => [self::CAT_AUDIO, 'audio/ogg'], | ||
| + 'flac' => [self::CAT_AUDIO, 'audio/flac'], | ||
| + 'aac' => [self::CAT_AUDIO, 'audio/aac'], | ||
| + 'php' => [self::CAT_TEXT, 'application/x-php'], | ||
| + 'py' => [self::CAT_TEXT, 'text/x-python'], | ||
| + 'java' => [self::CAT_TEXT, 'text/x-java'], | ||
| + 'c' => [self::CAT_TEXT, 'text/x-c'], | ||
| + 'cpp' => [self::CAT_TEXT, 'text/x-c++'], | ||
| + 'h' => [self::CAT_TEXT, 'text/x-c'], | ||
| + 'hpp' => [self::CAT_TEXT, 'text/x-c++'], | ||
| + 'cs' => [self::CAT_TEXT, 'text/x-csharp'], | ||
| + 'go' => [self::CAT_TEXT, 'text/x-go'], | ||
| + 'rs' => [self::CAT_TEXT, 'text/x-rust'], | ||
| + 'rb' => [self::CAT_TEXT, 'text/x-ruby'], | ||
| + 'pl' => [self::CAT_TEXT, 'text/x-perl'], | ||
| + 'sh' => [self::CAT_APP, 'application/x-sh'], | ||
| + 'bat' => [self::CAT_APP, 'application/x-bat'], | ||
| + 'ps1' => [self::CAT_APP, 'application/x-powershell'], | ||
| + 'md' => [self::CAT_TEXT, 'text/markdown'], | ||
| + 'yaml' => [self::CAT_TEXT, 'text/yaml'], | ||
| + 'yml' => [self::CAT_TEXT, 'text/yaml'], | ||
| + 'toml' => [self::CAT_TEXT, 'application/toml'], | ||
| + 'ini' => [self::CAT_TEXT, 'text/plain'], | ||
| + 'cfg' => [self::CAT_TEXT, 'text/plain'], | ||
| + 'conf' => [self::CAT_TEXT, 'text/plain'], | ||
| ]; | ||
| /** | ||
| - * Sniffs the media type based on magic bytes (the first few bytes) | ||
| - * of the data. This internal method is the primary detection. | ||
| - * | ||
| - * @param string $data The raw binary data (a string of bytes). | ||
| - * @return string The determined media type (MIME type). | ||
| + * Internal helper to resolve category and mime type. | ||
| + * Guaranteed to return a non-empty array. | ||
| */ | ||
| - private static function sniff( $data ): string { | ||
| - $mediaType = 'application/octet-stream'; | ||
| + private static function getTypeInfo( string $data, string $filePath ): array { | ||
| + $info = self::sniff( $data ); | ||
| - if( !empty( $data ) ) { | ||
| - $dataLength = strlen( $data ); | ||
| - $maxScan = min( $dataLength, self::BUFFER ); | ||
| - $sourceBytes = []; | ||
| + if ( empty( $info ) && !empty( $filePath ) ) { | ||
| + $info = self::getInfoByExtension( $filePath ); | ||
| + } | ||
| - for( $i = 0; $i < $maxScan; $i++ ) { | ||
| - $sourceBytes[$i] = ord( $data[$i] ) & 0xFF; | ||
| - } | ||
| + return !empty( $info ) ? $info : [self::CAT_BINARY, 'application/octet-stream']; | ||
| + } | ||
| - foreach( self::FORMATS as $pattern => $type ) { | ||
| - $patternLength = count( $pattern ); | ||
| + private static function sniff( string $data ): array { | ||
| + if( empty( $data ) ) return []; | ||
| - if( $patternLength > $dataLength ) { | ||
| - continue; | ||
| - } | ||
| + $dataLength = strlen( $data ); | ||
| + $maxScan = min( $dataLength, self::BUFFER ); | ||
| + $sourceBytes = []; | ||
| - $matches = true; | ||
| + for( $i = 0; $i < $maxScan; $i++ ) { | ||
| + $sourceBytes[$i] = ord( $data[$i] ) & 0xFF; | ||
| + } | ||
| - for( $i = 0; $i < $patternLength; $i++ ) { | ||
| - $patternByte = $pattern[$i]; | ||
| - $sourceByte = $sourceBytes[$i]; | ||
| + foreach( self::FORMATS as [$category, $pattern, $type] ) { | ||
| + $patternLength = count( $pattern ); | ||
| - if( $patternByte !== self::ANY && $patternByte !== $sourceByte ) { | ||
| - $matches = false; | ||
| - break; | ||
| - } | ||
| - } | ||
| + if( $patternLength > $dataLength ) continue; | ||
| - if( $matches ) { | ||
| - $mediaType = $type; | ||
| + $matches = true; | ||
| + | ||
| + for( $i = 0; $i < $patternLength; $i++ ) { | ||
| + if( $pattern[$i] !== self::ANY && $pattern[$i] !== $sourceBytes[$i] ) { | ||
| + $matches = false; | ||
| break; | ||
| } | ||
| } | ||
| + | ||
| + if( $matches ) return [$category, $type]; | ||
| } | ||
| - return $mediaType; | ||
| + return []; | ||
| } | ||
| - /** | ||
| - * Determines the media type based purely on the file extension. | ||
| - * | ||
| - * @param string $filePath The path to the file. | ||
| - * @return string The determined media type (MIME type). | ||
| - */ | ||
| - private static function getMediaTypeByExtension( $filePath ): string { | ||
| + private static function getInfoByExtension( string $filePath ): array { | ||
| $extension = strtolower( pathinfo( $filePath, PATHINFO_EXTENSION ) ); | ||
| + return self::EXTENSION_MAP[$extension] ?? [self::CAT_BINARY, 'application/octet-stream']; | ||
| + } | ||
| - return self::EXTENSION_MAP[$extension] ?? 'application/octet-stream'; | ||
| + public static function isMediaType( string $data, string $filePath = '' ): string { | ||
| + return self::getTypeInfo( $data, $filePath )[1]; | ||
| } | ||
| - /** | ||
| - * Public method to get the media type, prioritizing byte analysis and | ||
| - * falling back to extension. | ||
| - * | ||
| - * @param string $data The raw binary data (file content). | ||
| - * @param string $filePath The file path (used for extension fallback). | ||
| - * @return string The determined media type (MIME type). | ||
| - */ | ||
| - public static function getMediaType( $data, $filePath = '' ): string { | ||
| - $sniffed = self::sniff( $data ); | ||
| + public static function isCategory( string $data, string $filePath = '' ): string { | ||
| + return self::getTypeInfo( $data, $filePath )[0]; | ||
| + } | ||
| - return ($sniffed === 'application/octet-stream' && !empty( $filePath )) | ||
| - ? self::getMediaTypeByExtension( $filePath ) | ||
| - : $sniffed; | ||
| + public static function isBinary( string $data, string $filePath = '' ): bool { | ||
| + [$category, $type] = self::getTypeInfo( $data, $filePath ); | ||
| + return $category !== self::CAT_TEXT && !str_starts_with( $type, 'text/' ); | ||
| } | ||
| } | ||
| ?> | ||
| + | ||
| +<?php | ||
| +interface Page { | ||
| + public function render(); | ||
| +} | ||
| +<?php | ||
| +require_once 'Views.php'; | ||
| + | ||
| +class Router { | ||
| + private $repositories; | ||
| + | ||
| + public function __construct(array $repositories) { | ||
| + $this->repositories = $repositories; | ||
| + } | ||
| + | ||
| + public function route(): Page { | ||
| + $reqRepo = $_GET['repo'] ?? ''; | ||
| + $action = $_GET['action'] ?? 'home'; | ||
| + $hash = $this->sanitizePath($_GET['hash'] ?? ''); | ||
| + | ||
| + // Find the specific repository object | ||
| + $currentRepo = null; | ||
| + $decoded = urldecode($reqRepo); | ||
| + foreach ($this->repositories as $repo) { | ||
| + if ($repo['safe_name'] === $reqRepo || $repo['name'] === $decoded) { | ||
| + $currentRepo = $repo; | ||
| + break; | ||
| + } | ||
| + } | ||
| + | ||
| + // Inject the full list ($this->repositories) into every page | ||
| + if (!$currentRepo) { | ||
| + return new HomePage($this->repositories); | ||
| + } | ||
| + | ||
| + $git = new Git($currentRepo['path']); | ||
| + | ||
| + // UPDATED: Handle 'raw' action for media/downloads | ||
| + if ($action === 'raw') { | ||
| + return new RawPage($git, $hash); | ||
| + } | ||
| + | ||
| + if ($action === 'commits') { | ||
| + return new CommitsPage($this->repositories, $currentRepo, $git, $hash); | ||
| + } | ||
| + | ||
| + return new FilePage($this->repositories, $currentRepo, $git, $hash); | ||
| + } | ||
| + | ||
| + private function sanitizePath($path) { | ||
| + $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); | ||
| + return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); | ||
| + } | ||
| +} | ||
| + | ||
| +<?php | ||
| +require_once 'File.php'; | ||
| +require_once 'FileRenderer.php'; | ||
| + | ||
| +abstract class BasePage implements Page { | ||
| + protected $repositories; | ||
| + protected $title; | ||
| + | ||
| + public function __construct(array $repositories) { | ||
| + $this->repositories = $repositories; | ||
| + } | ||
| + | ||
| + protected function renderLayout($contentCallback, $currentRepo = null) { | ||
| + ?> | ||
| + <!DOCTYPE html> | ||
| + <html lang="en"> | ||
| + <head> | ||
| + <meta charset="UTF-8"> | ||
| + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| + <title><?php echo Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title> | ||
| + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | ||
| + <link rel="stylesheet" href="repo.css"> | ||
| + </head> | ||
| + <body> | ||
| + <div class="container"> | ||
| + <header> | ||
| + <h1><?php echo Config::SITE_TITLE; ?></h1> | ||
| + <nav class="nav"> | ||
| + <a href="?">Home</a> | ||
| + <?php if ($currentRepo): | ||
| + $safeName = urlencode($currentRepo['safe_name']); ?> | ||
| + <a href="?repo=<?php echo $safeName; ?>">Files</a> | ||
| + <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a> | ||
| + <a href="?action=refs&repo=<?php echo $safeName; ?>">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 ($this->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> | ||
| + </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 call_user_func($contentCallback); ?> | ||
| + | ||
| + </div> | ||
| + </body> | ||
| + </html> | ||
| + <?php | ||
| + } | ||
| + | ||
| + protected function time_elapsed_string($timestamp) { | ||
| + if (!$timestamp) return 'never'; | ||
| + $diff = time() - $timestamp; | ||
| + if ($diff < 5) return 'just now'; | ||
| + $tokens = [31536000 => 'year', 2592000 => 'month', 604800 => 'week', 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second']; | ||
| + foreach ($tokens as $unit => $text) { | ||
| + if ($diff < $unit) continue; | ||
| + $num = floor($diff / $unit); | ||
| + return $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago'; | ||
| + } | ||
| + return 'just now'; | ||
| + } | ||
| +} | ||
| + | ||
| +class HomePage extends BasePage { | ||
| + public function render() { | ||
| + $this->renderLayout(function() { | ||
| + echo '<h2>Repositories</h2>'; | ||
| + if (empty($this->repositories)) { | ||
| + echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(Config::getReposPath()) . '</div>'; | ||
| + return; | ||
| + } | ||
| + echo '<div class="repo-grid">'; | ||
| + foreach ($this->repositories as $repo) { | ||
| + $this->renderRepoCard($repo); | ||
| + } | ||
| + echo '</div>'; | ||
| + }); | ||
| + } | ||
| + | ||
| + private function renderRepoCard($repo) { | ||
| + $git = new Git($repo['path']); | ||
| + $main = $git->getMainBranch(); | ||
| + | ||
| + $stats = ['branches' => 0, 'tags' => 0]; | ||
| + $git->eachBranch(function() use (&$stats) { $stats['branches']++; }); | ||
| + $git->eachTag(function() use (&$stats) { $stats['tags']++; }); | ||
| + | ||
| + echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | ||
| + echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | ||
| + if ($main) echo '<p>Branch: ' . htmlspecialchars($main['name']) . '</p>'; | ||
| + echo '<p>' . $stats['branches'] . ' branches, ' . $stats['tags'] . ' tags</p>'; | ||
| + | ||
| + if ($main) { | ||
| + $git->history('HEAD', 1, function($c) { | ||
| + echo '<p style="margin-top: 8px; color: #58a6ff;">' . $this->time_elapsed_string($c->date) . '</p>'; | ||
| + }); | ||
| + } | ||
| + echo '</a>'; | ||
| + } | ||
| +} | ||
| + | ||
| +class CommitsPage extends BasePage { | ||
| + private $currentRepo; | ||
| + private $git; | ||
| + private $hash; | ||
| + | ||
| + public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| + parent::__construct($allRepos); | ||
| + $this->currentRepo = $currentRepo; | ||
| + $this->git = $git; | ||
| + $this->hash = $hash; | ||
| + $this->title = $currentRepo['name']; | ||
| + } | ||
| + | ||
| + public function render() { | ||
| + $this->renderLayout(function() { | ||
| + $main = $this->git->getMainBranch(); | ||
| + if (!$main) { | ||
| + echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>'; | ||
| + return; | ||
| + } | ||
| + | ||
| + $this->renderBreadcrumbs(); | ||
| + echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| + echo '<div class="commit-list">'; | ||
| + | ||
| + $start = $this->hash ?: $main['hash']; | ||
| + $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| + | ||
| + $this->git->history($start, 100, function($commit) use ($repoParam) { | ||
| + $msg = htmlspecialchars(explode("\n", $commit->message)[0]); | ||
| + echo '<div class="commit-row">'; | ||
| + echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>'; | ||
| + echo '<span class="message">' . $msg . '</span>'; | ||
| + echo '<span class="meta">' . htmlspecialchars($commit->author) . ' • ' . date('Y-m-d', $commit->date) . '</span>'; | ||
| + echo '</div>'; | ||
| + }); | ||
| + echo '</div>'; | ||
| + }, $this->currentRepo); | ||
| + } | ||
| + | ||
| + private function renderBreadcrumbs() { | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a><span>/</span>'; | ||
| + echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a><span>/</span>'; | ||
| + echo '<span>Commits</span></div>'; | ||
| + } | ||
| +} | ||
| + | ||
| +class FilePage extends BasePage { | ||
| + private $currentRepo; | ||
| + private $git; | ||
| + private $hash; | ||
| + | ||
| + public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| + parent::__construct($allRepos); | ||
| + $this->currentRepo = $currentRepo; | ||
| + $this->git = $git; | ||
| + $this->hash = $hash; | ||
| + $this->title = $currentRepo['name']; | ||
| + } | ||
| + | ||
| + public function render() { | ||
| + $this->renderLayout(function() { | ||
| + $main = $this->git->getMainBranch(); | ||
| + if (!$main) { | ||
| + echo '<div class="empty-state"><h3>No branches</h3></div>'; | ||
| + return; | ||
| + } | ||
| + | ||
| + $target = $this->hash ?: $main['hash']; | ||
| + $entries = []; | ||
| + | ||
| + // Entries are now File objects | ||
| + $this->git->walk($target, function($file) use (&$entries) { | ||
| + $entries[] = $file; | ||
| + }); | ||
| + | ||
| + if (!empty($entries)) { | ||
| + $this->renderTree($main, $target, $entries); | ||
| + } else { | ||
| + $this->renderBlob($target); | ||
| + } | ||
| + }, $this->currentRepo); | ||
| + } | ||
| + | ||
| + private function renderTree($main, $targetHash, $entries) { | ||
| + $this->renderBreadcrumbs($targetHash, 'Tree'); | ||
| + echo '<h2>' . htmlspecialchars($this->currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| + | ||
| + // Encapsulated sorting via File::compare | ||
| + usort($entries, function($a, $b) { | ||
| + return $a->compare($b); | ||
| + }); | ||
| + | ||
| + echo '<div class="file-list">'; | ||
| + $renderer = new HtmlFileRenderer($this->currentRepo['safe_name']); | ||
| + | ||
| + foreach ($entries as $file) { | ||
| + $file->render($renderer); | ||
| + } | ||
| + | ||
| + echo '</div>'; | ||
| + } | ||
| + | ||
| + private function renderBlob($targetHash) { | ||
| + $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| + | ||
| + $size = $this->git->getObjectSize($targetHash); | ||
| + | ||
| + $buffer = ''; | ||
| + $this->git->stream($targetHash, function($d) use (&$buffer) { | ||
| + if (strlen($buffer) < 12) $buffer .= $d; | ||
| + }); | ||
| + | ||
| + $filename = $_GET['name'] ?? ''; | ||
| + $category = MediaTypeSniffer::isCategory($buffer, $filename); | ||
| + $mimeType = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| + | ||
| + $this->renderBreadcrumbs($targetHash, 'File'); | ||
| + | ||
| + $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode($filename); | ||
| + | ||
| + if ($category === MediaTypeSniffer::CAT_VIDEO) { | ||
| + echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #000;">'; | ||
| + echo '<video controls style="max-width: 100%; max-height: 80vh;">'; | ||
| + echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| + echo 'Your browser does not support the video element.'; | ||
| + echo '</video>'; | ||
| + echo '</div>'; | ||
| + | ||
| + } elseif ($category === MediaTypeSniffer::CAT_AUDIO) { | ||
| + echo '<div class="blob-content" style="text-align:center; padding: 40px; background: #f6f8fa;">'; | ||
| + echo '<audio controls style="width: 100%; max-width: 600px;">'; | ||
| + echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| + echo 'Your browser does not support the audio element.'; | ||
| + echo '</audio>'; | ||
| + echo '</div>'; | ||
| + | ||
| + } elseif ($category === MediaTypeSniffer::CAT_IMAGE) { | ||
| + echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #f6f8fa;">'; | ||
| + echo '<img src="' . $rawUrl . '" style="max-width: 100%; border: 1px solid #dfe2e5;">'; | ||
| + echo '</div>'; | ||
| + | ||
| + } elseif ($category === MediaTypeSniffer::CAT_TEXT) { | ||
| + if ($size > 524288) { | ||
| + $this->renderDownloadState($targetHash, "File is too large to display (" . $this->formatSize($size) . ")."); | ||
| + } else { | ||
| + $content = ''; | ||
| + $this->git->stream($targetHash, function($d) use (&$content) { $content .= $d; }); | ||
| + echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars($content) . '</pre></div>'; | ||
| + } | ||
| + | ||
| + } else { | ||
| + $this->renderDownloadState($targetHash, "This is a binary file."); | ||
| + } | ||
| + } | ||
| + | ||
| + private function renderDownloadState($hash, $reason) { | ||
| + $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| + echo '<div class="empty-state" style="text-align: center; padding: 40px; border: 1px solid #e1e4e8; border-radius: 6px; margin-top: 10px;">'; | ||
| + echo '<p style="margin-bottom: 20px; color: #586069;">' . htmlspecialchars($reason) . '</p>'; | ||
| + echo '<a href="' . $url . '" style="display: inline-block; padding: 6px 16px; background: #0366d6; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Download Raw File</a>'; | ||
| + echo '</div>'; | ||
| + } | ||
| + | ||
| + private function formatSize($size) { | ||
| + if ($size <= 0) return '0 B'; | ||
| + $units = ['B', 'KB', 'MB', 'GB']; | ||
| + $i = (int)floor(log($size, 1024)); | ||
| + return round($size / pow(1024, $i), 1) . ' ' . $units[$i]; | ||
| + } | ||
| + | ||
| + private function renderBreadcrumbs($hash, $type) { | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a><span>/</span>'; | ||
| + echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>'; | ||
| + if ($this->hash) echo '<span>/</span><span>' . $type . ' ' . substr($hash, 0, 7) . '</span>'; | ||
| + echo '</div>'; | ||
| + } | ||
| +} | ||
| + | ||
| +class RawPage implements Page { | ||
| + private $git; | ||
| + private $hash; | ||
| + | ||
| + public function __construct($git, $hash) { | ||
| + $this->git = $git; | ||
| + $this->hash = $hash; | ||
| + } | ||
| + | ||
| + public function render() { | ||
| + while (ob_get_level()) ob_end_clean(); | ||
| + | ||
| + $size = $this->git->getObjectSize($this->hash); | ||
| + $filename = $_GET['name'] ?? 'file'; | ||
| + | ||
| + $buffer = ''; | ||
| + $this->git->stream($this->hash, function($d) use (&$buffer) { | ||
| + if (strlen($buffer) < 12) $buffer .= $d; | ||
| + }); | ||
| + | ||
| + $mime = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| + if (!$mime) $mime = 'application/octet-stream'; | ||
| + | ||
| + header('Content-Type: ' . $mime); | ||
| + header('Content-Length: ' . $size); | ||
| + header('Content-Disposition: inline; filename="' . basename($filename) . '"'); | ||
| + | ||
| + $this->git->stream($this->hash, function($data) { | ||
| + echo $data; | ||
| + }); | ||
| + | ||
| + exit; | ||
| + } | ||
| +} | ||
| + | ||
| -<?php | ||
| -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"); | ||
| -define('CACHE_DIR', __DIR__ . '/cache'); | ||
| -define('CACHE_EXPIRY', 3600); | ||
| - | ||
| -if (!is_dir(CACHE_DIR)) { | ||
| - mkdir(CACHE_DIR, 0777, true); | ||
| -} | ||
| - | ||
| -error_reporting(E_ALL); | ||
| -ini_set('display_errors', 0); | ||
| -ini_set('log_errors', 1); | ||
| -ini_set('error_log', __DIR__ . '/error.log'); | ||
| -body { | ||
| - color: #e6edf3; | ||
| - background: #0d1117; | ||
| -} | ||
| - | ||
| -.header { | ||
| - background: #161b22; | ||
| - color: white; | ||
| - border-bottom: 1px solid #30363d; | ||
| -} | ||
| - | ||
| -.header a { | ||
| - color: #fff; | ||
| -} | ||
| - | ||
| -.breadcrumb { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.breadcrumb a { | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.card { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| -} | ||
| - | ||
| -.card-header { | ||
| - border-bottom: 1px solid #30363d; | ||
| - background: #0d1117; | ||
| -} | ||
| - | ||
| -.repo-item { | ||
| - border: 1px solid #30363d; | ||
| - background: #161b22; | ||
| -} | ||
| - | ||
| -.repo-item:last-child { | ||
| - border-bottom: 1px solid #30363d; | ||
| -} | ||
| - | ||
| -.repo-item:hover { | ||
| - box-shadow: 0 4px 12px rgba(0,0,0,0.4); | ||
| - border-color: #58a6ff; | ||
| -} | ||
| - | ||
| -.repo-name a { | ||
| - color: #e6edf3; | ||
| -} | ||
| - | ||
| -.repo-name a:hover { | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.repo-desc { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.repo-meta { | ||
| - color: #8b949e; | ||
| - border-top: 1px solid #21262d; | ||
| -} | ||
| - | ||
| -.nav-tabs { | ||
| - border-bottom: 1px solid #30363d; | ||
| - background: #161b22; | ||
| -} | ||
| - | ||
| -.nav-tab { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.nav-tab:hover { | ||
| - color: #e6edf3; | ||
| - border-bottom-color: #6e7681; | ||
| -} | ||
| - | ||
| -.nav-tab.active { | ||
| - color: #e6edf3; | ||
| - border-bottom-color: #f78166; | ||
| -} | ||
| - | ||
| -.commit-item { | ||
| - border-bottom: 1px solid #30363d; | ||
| -} | ||
| - | ||
| -.commit-item:hover { | ||
| - background: #0d1117; | ||
| -} | ||
| - | ||
| -.commit-message a { | ||
| - color: #e6edf3; | ||
| -} | ||
| - | ||
| -.commit-message a:hover { | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.commit-meta { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.commit-hash { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.commit-hash a { | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.file-item { | ||
| - border-bottom: 1px solid #30363d; | ||
| -} | ||
| - | ||
| -.file-item:hover { | ||
| - background: #0d1117; | ||
| -} | ||
| - | ||
| -.file-name a { | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.file-meta { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.code-block { | ||
| - background: #0d1117; | ||
| - border: 1px solid #30363d; | ||
| -} | ||
| - | ||
| -.code-block pre { | ||
| - color: #e6edf3; | ||
| -} | ||
| - | ||
| -.diff-add { | ||
| - background: #0b4821; | ||
| - color: #3fb950; | ||
| -} | ||
| - | ||
| -.diff-del { | ||
| - background: #5a1e1e; | ||
| - color: #f85149; | ||
| -} | ||
| - | ||
| -.diff-header { | ||
| - background: #161b22; | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.commit-info { | ||
| - background: #0d1117; | ||
| - border: 1px solid #30363d; | ||
| -} | ||
| - | ||
| -.commit-info-label { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.empty-state { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.btn { | ||
| - background: #21262d; | ||
| - border: 1px solid #30363d; | ||
| - color: #e6edf3; | ||
| -} | ||
| - | ||
| -.btn:hover { | ||
| - background: #30363d; | ||
| - border-color: #6e7681; | ||
| -} | ||
| - | ||
| -.image-preview { | ||
| - background: #0d1117; | ||
| -} | ||
| - | ||
| -.image-preview img { | ||
| - border: 1px solid #30363d; | ||
| - background: #161b22; | ||
| -} | ||
| - | ||
| -.theme-toggle { | ||
| - background: #f6f8fa; | ||
| - border: 1px solid #e1e4e8; | ||
| - color: #24292e; | ||
| -} | ||
| - | ||
| -.theme-toggle:hover { | ||
| - background: #e1e4e8; | ||
| - border-color: #d1d5da; | ||
| -} | ||
| - | ||
| <?php | ||
| -require_once __DIR__ . '/config.php'; | ||
| -require_once __DIR__ . '/security.php'; | ||
| - | ||
| -require_once __DIR__ . '/includes/git_functions.php'; | ||
| -require_once __DIR__ . '/includes/repo_functions.php'; | ||
| -require_once __DIR__ . '/includes/helpers.php'; | ||
| - | ||
| -$action = $_GET['action'] ?? 'list'; | ||
| -$repo = sanitizeRepoName($_GET['repo'] ?? ''); | ||
| -$ref = $_GET['ref'] ?? 'HEAD'; | ||
| -$path = sanitizePath($_GET['path'] ?? ''); | ||
| -$hash = sanitizeHash($_GET['hash'] ?? ''); | ||
| - | ||
| -$allowed_actions = ['list', 'repo', 'commit', 'blob', 'raw']; | ||
| - | ||
| -if (!in_array($action, $allowed_actions, true)) { | ||
| - $action = 'list'; | ||
| -} | ||
| - | ||
| -$allowed_themes = ['dark', 'light']; | ||
| -$current_theme = $_GET['theme'] ?? 'dark'; | ||
| - | ||
| -if (!in_array($current_theme, $allowed_themes, true)) { | ||
| - $current_theme = 'dark'; | ||
| -} | ||
| - | ||
| -$css_file = ($current_theme === 'dark') ? 'dark.css' : 'light.css'; | ||
| - | ||
| -if ($action === 'raw' && !empty($repo) && !empty($hash)) { | ||
| - $repoPath = REPOS_PATH . '/' . $repo; | ||
| - if (!is_dir($repoPath)) { | ||
| - http_response_code(404); | ||
| - exit('Repository not found'); | ||
| - } | ||
| - | ||
| - $name = sanitizeFilename($_GET['name'] ?? 'file'); | ||
| - | ||
| - try { | ||
| - $content = getBlobBinary($repo, $hash); | ||
| - | ||
| - header('Content-Type: application/octet-stream'); | ||
| - header('Content-Disposition: attachment; filename="' . $name . '"'); | ||
| - header('Content-Length: ' . strlen($content)); | ||
| - header('X-Content-Type-Options: nosniff'); | ||
| - echo $content; | ||
| - } catch (Exception $e) { | ||
| - http_response_code(404); | ||
| - exit('File not found'); | ||
| - } | ||
| - | ||
| - exit; | ||
| -} | ||
| - | ||
| -$page_title = $action === 'list' | ||
| - ? SITE_TITLE | ||
| - : htmlspecialchars($repo, ENT_QUOTES, 'UTF-8') . ' - ' . SITE_TITLE; | ||
| - | ||
| -include __DIR__ . '/views/header.php'; | ||
| - | ||
| -switch ($action) { | ||
| - case 'list': | ||
| - include __DIR__ . '/views/list.php'; | ||
| - break; | ||
| - | ||
| - case 'repo': | ||
| - include __DIR__ . '/views/repo.php'; | ||
| - break; | ||
| +require_once 'Git.php'; | ||
| +require_once 'Config.php'; | ||
| +require_once 'Page.php'; | ||
| +require_once 'Router.php'; | ||
| - case 'commit': | ||
| - include __DIR__ . '/views/commit.php'; | ||
| - break; | ||
| +Config::init(); | ||
| - case 'blob': | ||
| - include __DIR__ . '/views/blob.php'; | ||
| - break; | ||
| +$repoRoot = new Git(Config::getReposPath()); | ||
| +$repositories = []; | ||
| +$repoRoot->eachRepository(function($repo) use (&$repositories) { | ||
| + $repositories[] = $repo; | ||
| +}); | ||
| - default: | ||
| - include __DIR__ . '/views/list.php'; | ||
| - break; | ||
| -} | ||
| +$router = new Router($repositories); | ||
| +$page = $router->route(); | ||
| +$page->render(); | ||
| -include __DIR__ . '/views/footer.php'; | ||
| -body { | ||
| - color: #24292e; | ||
| - background: #f6f8fa; | ||
| -} | ||
| - | ||
| -.header { | ||
| - background: #24292e; | ||
| - color: white; | ||
| -} | ||
| - | ||
| -.header a { | ||
| - color: #fff; | ||
| -} | ||
| - | ||
| -.breadcrumb { | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.breadcrumb a { | ||
| - color: #0366d6; | ||
| -} | ||
| - | ||
| -.card { | ||
| - background: white; | ||
| - border: 1px solid #e1e4e8; | ||
| -} | ||
| - | ||
| -.card-header { | ||
| - border-bottom: 1px solid #e1e4e8; | ||
| - background: #f6f8fa; | ||
| -} | ||
| - | ||
| -.repo-item { | ||
| - border: 1px solid #e1e4e8; | ||
| - background: white; | ||
| -} | ||
| - | ||
| -.repo-item:last-child { | ||
| - border-bottom: 1px solid #e1e4e8; | ||
| -} | ||
| - | ||
| -.repo-item:hover { | ||
| - box-shadow: 0 4px 12px rgba(0,0,0,0.1); | ||
| - border-color: #0366d6; | ||
| -} | ||
| - | ||
| -.repo-name a { | ||
| - color: #24292e; | ||
| -} | ||
| - | ||
| -.repo-name a:hover { | ||
| - color: #0366d6; | ||
| -} | ||
| - | ||
| -.repo-desc { | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.repo-meta { | ||
| - color: #586069; | ||
| - border-top: 1px solid #f6f8fa; | ||
| -} | ||
| - | ||
| -.nav-tabs { | ||
| - border-bottom: 1px solid #e1e4e8; | ||
| - background: white; | ||
| -} | ||
| - | ||
| -.nav-tab { | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.nav-tab:hover { | ||
| - color: #24292e; | ||
| - border-bottom-color: #d1d5da; | ||
| -} | ||
| - | ||
| -.nav-tab.active { | ||
| - color: #24292e; | ||
| - border-bottom-color: #f9826c; | ||
| -} | ||
| - | ||
| -.commit-item { | ||
| - border-bottom: 1px solid #e1e4e8; | ||
| -} | ||
| - | ||
| -.commit-item:hover { | ||
| - background: #f6f8fa; | ||
| -} | ||
| - | ||
| -.commit-message a { | ||
| - color: #24292e; | ||
| -} | ||
| - | ||
| -.commit-message a:hover { | ||
| - color: #0366d6; | ||
| -} | ||
| - | ||
| -.commit-meta { | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.commit-hash { | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.commit-hash a { | ||
| - color: #0366d6; | ||
| -} | ||
| - | ||
| -.file-item { | ||
| - border-bottom: 1px solid #e1e4e8; | ||
| -} | ||
| - | ||
| -.file-item:hover { | ||
| - background: #f6f8fa; | ||
| -} | ||
| - | ||
| -.file-name a { | ||
| - color: #0366d6; | ||
| -} | ||
| - | ||
| -.file-meta { | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.code-block { | ||
| - background: #f6f8fa; | ||
| - border: 1px solid #e1e4e8; | ||
| -} | ||
| - | ||
| -.diff-add { | ||
| - background: #e6ffed; | ||
| - color: #22863a; | ||
| -} | ||
| - | ||
| -.diff-del { | ||
| - background: #ffeef0; | ||
| - color: #cb2431; | ||
| -} | ||
| - | ||
| -.diff-header { | ||
| - background: #f6f8fa; | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.commit-info { | ||
| - background: #f6f8fa; | ||
| -} | ||
| - | ||
| -.empty-state { | ||
| - color: #586069; | ||
| -} | ||
| - | ||
| -.btn { | ||
| - background: #f6f8fa; | ||
| - border: 1px solid #e1e4e8; | ||
| - color: #24292e; | ||
| -} | ||
| - | ||
| -.btn:hover { | ||
| - background: #e1e4e8; | ||
| - border-color: #d1d5da; | ||
| -} | ||
| - | ||
| -.image-preview { | ||
| - background: #f6f8fa; | ||
| -} | ||
| - | ||
| -.image-preview img { | ||
| - border: 1px solid #e1e4e8; | ||
| - background: white; | ||
| -} | ||
| - | ||
| -.theme-toggle { | ||
| - background: #f6f8fa; | ||
| - border: 1px solid #e1e4e8; | ||
| - color: #24292e; | ||
| -} | ||
| - | ||
| -.theme-toggle:hover { | ||
| - background: #e1e4e8; | ||
| - border-color: #d1d5da; | ||
| -} | ||
| - | ||
| -* { | ||
| - margin: 0; | ||
| - padding: 0; | ||
| - box-sizing: border-box; | ||
| -} | ||
| - | ||
| -body { | ||
| - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; | ||
| - line-height: 1.6; | ||
| -} | ||
| - | ||
| -.header { | ||
| - padding: 1rem 2rem; | ||
| - box-shadow: 0 2px 4px rgba(0,0,0,0.1); | ||
| - display: flex; | ||
| - justify-content: space-between; | ||
| - align-items: center; | ||
| -} | ||
| - | ||
| -.header h1 { | ||
| - font-size: 1.5rem; | ||
| - font-weight: 600; | ||
| - margin: 0; | ||
| -} | ||
| - | ||
| -.header a { | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.header a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.theme-switcher { | ||
| - display: flex; | ||
| - align-items: center; | ||
| -} | ||
| - | ||
| -.theme-toggle { | ||
| - padding: 0.5rem 1rem; | ||
| - border-radius: 6px; | ||
| - text-decoration: none; | ||
| - font-size: 0.9rem; | ||
| - font-weight: 500; | ||
| - transition: all 0.15s; | ||
| - display: inline-flex; | ||
| - align-items: center; | ||
| - gap: 0.5rem; | ||
| -} | ||
| - | ||
| -.theme-toggle:hover { | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.container { | ||
| - max-width: 1200px; | ||
| - margin: 2rem auto; | ||
| - padding: 0 2rem; | ||
| -} | ||
| - | ||
| -.breadcrumb { | ||
| - margin-bottom: 1.5rem; | ||
| - font-size: 0.95rem; | ||
| -} | ||
| - | ||
| -.breadcrumb a { | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.breadcrumb a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.breadcrumb span { | ||
| - margin: 0 0.5rem; | ||
| -} | ||
| - | ||
| -.card { | ||
| - border-radius: 6px; | ||
| - margin-bottom: 1rem; | ||
| - overflow: hidden; | ||
| -} | ||
| - | ||
| -.card-header { | ||
| - padding: 1rem 1.5rem; | ||
| - font-weight: 600; | ||
| -} | ||
| - | ||
| -.card-body { | ||
| - padding: 1.5rem; | ||
| -} | ||
| - | ||
| -.repo-list { | ||
| - list-style: none; | ||
| - display: grid; | ||
| - grid-template-columns: repeat(3, 1fr); | ||
| - gap: 1.5rem; | ||
| - padding: 0; | ||
| -} | ||
| - | ||
| -@media (max-width: 1024px) { | ||
| - .repo-list { | ||
| - grid-template-columns: repeat(2, 1fr); | ||
| - } | ||
| -} | ||
| - | ||
| -@media (max-width: 640px) { | ||
| - .repo-list { | ||
| - grid-template-columns: 1fr; | ||
| - } | ||
| -} | ||
| - | ||
| -.repo-item { | ||
| - padding: 1.5rem; | ||
| - border-radius: 6px; | ||
| - transition: all 0.2s; | ||
| - display: flex; | ||
| - flex-direction: column; | ||
| -} | ||
| - | ||
| -.repo-item:hover { | ||
| - transform: translateY(-2px); | ||
| - box-shadow: 0 4px 12px rgba(0,0,0,0.1); | ||
| -} | ||
| - | ||
| -.repo-name { | ||
| - font-size: 1.15rem; | ||
| - font-weight: 600; | ||
| - margin-bottom: 0.75rem; | ||
| -} | ||
| - | ||
| -.repo-name a { | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.repo-desc { | ||
| - font-size: 0.9rem; | ||
| - margin-bottom: 1rem; | ||
| - flex: 1; | ||
| - line-height: 1.5; | ||
| -} | ||
| - | ||
| -.repo-meta { | ||
| - font-size: 0.85rem; | ||
| - display: flex; | ||
| - gap: 0.5rem; | ||
| - flex-wrap: wrap; | ||
| - align-items: center; | ||
| - margin-top: auto; | ||
| - padding-top: 0.5rem; | ||
| -} | ||
| - | ||
| -.repo-meta span { | ||
| - display: flex; | ||
| - align-items: center; | ||
| - gap: 0.25rem; | ||
| -} | ||
| - | ||
| -.nav-tabs { | ||
| - display: flex; | ||
| - gap: 0.5rem; | ||
| - margin-bottom: 1.5rem; | ||
| - padding: 0 1.5rem; | ||
| - border-radius: 6px 6px 0 0; | ||
| -} | ||
| - | ||
| -.nav-tab { | ||
| - padding: 1rem 1.25rem; | ||
| - text-decoration: none; | ||
| - border-bottom: 2px solid transparent; | ||
| - transition: all 0.15s; | ||
| - font-weight: 500; | ||
| -} | ||
| - | ||
| -.commit-list { | ||
| - list-style: none; | ||
| -} | ||
| - | ||
| -.commit-item { | ||
| - padding: 1rem 1.5rem; | ||
| - display: flex; | ||
| - gap: 1rem; | ||
| - transition: background 0.15s; | ||
| -} | ||
| - | ||
| -.commit-item:last-child { | ||
| - border-bottom: none; | ||
| -} | ||
| - | ||
| -.commit-message { | ||
| - flex: 1; | ||
| -} | ||
| - | ||
| -.commit-message a { | ||
| - text-decoration: none; | ||
| - font-weight: 500; | ||
| -} | ||
| - | ||
| -.commit-message a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.commit-meta { | ||
| - font-size: 0.85rem; | ||
| - margin-top: 0.25rem; | ||
| -} | ||
| - | ||
| -.commit-hash { | ||
| - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| - font-size: 0.85rem; | ||
| - white-space: nowrap; | ||
| -} | ||
| - | ||
| -.commit-hash a { | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.file-tree { | ||
| - list-style: none; | ||
| -} | ||
| - | ||
| -.file-item { | ||
| - padding: 0.75rem 1.5rem; | ||
| - display: flex; | ||
| - align-items: center; | ||
| - gap: 0.75rem; | ||
| - transition: background 0.15s; | ||
| -} | ||
| - | ||
| -.file-item:last-child { | ||
| - border-bottom: none; | ||
| -} | ||
| - | ||
| -.file-icon { | ||
| - width: 16px; | ||
| - height: 16px; | ||
| - flex-shrink: 0; | ||
| -} | ||
| - | ||
| -.file-name { | ||
| - flex: 1; | ||
| -} | ||
| - | ||
| -.file-name a { | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.file-name a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.file-meta { | ||
| - display: flex; | ||
| - gap: 1.5rem; | ||
| - font-size: 0.85rem; | ||
| -} | ||
| - | ||
| -.file-size { | ||
| - min-width: 80px; | ||
| - text-align: right; | ||
| -} | ||
| - | ||
| -.file-date { | ||
| - min-width: 130px; | ||
| - text-align: right; | ||
| -} | ||
| - | ||
| -.code-block { | ||
| - border-radius: 6px; | ||
| - overflow-x: auto; | ||
| - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| - font-size: 0.85rem; | ||
| -} | ||
| - | ||
| -.code-block pre { | ||
| - padding: 1rem; | ||
| - margin: 0; | ||
| - overflow-x: auto; | ||
| -} | ||
| - | ||
| -.diff { | ||
| - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| - font-size: 0.85rem; | ||
| -} | ||
| - | ||
| -.diff-line { | ||
| - padding: 0.1rem 1rem; | ||
| - white-space: pre; | ||
| -} | ||
| - | ||
| -.diff-header { | ||
| - font-weight: 600; | ||
| -} | ||
| - | ||
| -.commit-detail { | ||
| - padding: 1.5rem; | ||
| -} | ||
| - | ||
| -.commit-title { | ||
| - font-size: 1.25rem; | ||
| - font-weight: 600; | ||
| - margin-bottom: 1rem; | ||
| -} | ||
| - | ||
| -.commit-info { | ||
| - padding: 1rem; | ||
| - border-radius: 6px; | ||
| - margin-bottom: 1.5rem; | ||
| - font-size: 0.9rem; | ||
| -} | ||
| - | ||
| -.commit-info-row { | ||
| - display: flex; | ||
| - gap: 1rem; | ||
| - margin-bottom: 0.5rem; | ||
| -} | ||
| - | ||
| -.commit-info-row:last-child { | ||
| - margin-bottom: 0; | ||
| -} | ||
| - | ||
| -.commit-info-label { | ||
| - font-weight: 600; | ||
| - min-width: 80px; | ||
| -} | ||
| - | ||
| -.empty-state { | ||
| - text-align: center; | ||
| - padding: 3rem; | ||
| -} | ||
| - | ||
| -.empty-state-icon { | ||
| - font-size: 3rem; | ||
| - margin-bottom: 1rem; | ||
| - opacity: 0.5; | ||
| -} | ||
| - | ||
| -.file-actions { | ||
| - display: flex; | ||
| - gap: 0.5rem; | ||
| - margin-bottom: 1rem; | ||
| - padding: 0 1.5rem; | ||
| - padding-top: 1rem; | ||
| -} | ||
| - | ||
| -.btn { | ||
| - padding: 0.5rem 1rem; | ||
| - border-radius: 6px; | ||
| - text-decoration: none; | ||
| - font-size: 0.9rem; | ||
| - font-weight: 500; | ||
| - transition: all 0.15s; | ||
| - display: inline-flex; | ||
| - align-items: center; | ||
| - gap: 0.5rem; | ||
| -} | ||
| - | ||
| -.image-preview { | ||
| - padding: 1.5rem; | ||
| - text-align: center; | ||
| -} | ||
| - | ||
| -.image-preview img { | ||
| - max-width: 100%; | ||
| - height: auto; | ||
| - border-radius: 6px; | ||
| -} | ||
| - | ||
| -<?php | ||
| -class Config { | ||
| - const SITE_TITLE = "Dave Jarvis' Repositories"; | ||
| - | ||
| - /** | ||
| - * Determine the home directory for repository discovery. | ||
| - */ | ||
| - private static 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 ''; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Returns the full path where repositories are stored. | ||
| - */ | ||
| - public static function getReposPath() { | ||
| - return self::getHomeDirectory() . '/repos'; | ||
| - } | ||
| - | ||
| - /** | ||
| - * Initialize runtime settings (error logging, etc). | ||
| - */ | ||
| - public static function init() { | ||
| - ini_set('display_errors', 0); | ||
| - ini_set('log_errors', 1); | ||
| - ini_set('error_log', __DIR__ . '/error.log'); | ||
| - } | ||
| -} | ||
| - | ||
| -<?php | ||
| -require_once 'MediaTypeSniffer.php'; | ||
| -require_once 'FileRenderer.php'; | ||
| - | ||
| -class File { | ||
| - private string $name; | ||
| - private string $sha; | ||
| - private string $mode; | ||
| - private int $timestamp; | ||
| - private int $size; | ||
| - private bool $isDir; | ||
| - | ||
| - public function __construct(string $name, string $sha, string $mode, int $timestamp = 0, int $size = 0) { | ||
| - $this->name = $name; | ||
| - $this->sha = $sha; | ||
| - $this->mode = $mode; | ||
| - $this->timestamp = $timestamp; | ||
| - $this->size = $size; | ||
| - $this->isDir = ($mode === '40000' || $mode === '040000'); | ||
| - } | ||
| - | ||
| - // New capability: Allow Files to compare themselves to other Files | ||
| - public function compare(File $other): int { | ||
| - // 1. Sort Directories before Files | ||
| - if ($this->isDir !== $other->isDir) { | ||
| - return $this->isDir ? -1 : 1; | ||
| - } | ||
| - // 2. Sort Alphabetically by Name | ||
| - return strcasecmp($this->name, $other->name); | ||
| - } | ||
| - | ||
| - public function render(FileRenderer $renderer): void { | ||
| - $renderer->renderFileItem( | ||
| - $this->name, | ||
| - $this->sha, | ||
| - $this->mode, | ||
| - $this->getIconClass(), | ||
| - $this->getTimeElapsed(), | ||
| - $this->isDir ? '' : $this->getFormattedSize() | ||
| - ); | ||
| - } | ||
| - | ||
| - // ... [Rest of the class methods: getIconClass, getFormattedSize, etc. remain unchanged] ... | ||
| - | ||
| - private function getIconClass(): string { | ||
| - if ($this->isDir) return 'fa-folder'; | ||
| - | ||
| - return match (true) { | ||
| - $this->isType('application/pdf') => 'fa-file-pdf', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_ARCHIVE) => 'fa-file-archive', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_IMAGE) => 'fa-file-image', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_AUDIO) => 'fa-file-audio', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_VIDEO) => 'fa-file-video', | ||
| - $this->isCategory(MediaTypeSniffer::CAT_TEXT) => 'fa-file-code', | ||
| - default => 'fa-file', | ||
| - }; | ||
| - } | ||
| - | ||
| - private function getFormattedSize(): string { | ||
| - if ($this->size <= 0) return '0 B'; | ||
| - $units = ['B', 'KB', 'MB', 'GB']; | ||
| - $i = (int)floor(log($this->size, 1024)); | ||
| - return round($this->size / pow(1024, $i), 1) . ' ' . $units[$i]; | ||
| - } | ||
| - | ||
| - public function isType(string $type): bool { | ||
| - return str_contains(MediaTypeSniffer::isMediaType($this->getSniffBuffer(), $this->name), $type); | ||
| - } | ||
| - | ||
| - public function isCategory(string $category): bool { | ||
| - return MediaTypeSniffer::isCategory($this->getSniffBuffer(), $this->name) === $category; | ||
| - } | ||
| - | ||
| - public function isBinary(): bool { | ||
| - return MediaTypeSniffer::isBinary($this->getSniffBuffer(), $this->name); | ||
| - } | ||
| - | ||
| - private function getSniffBuffer(): string { | ||
| - if ($this->isDir || !file_exists($this->name)) return ''; | ||
| - $handle = @fopen($this->name, 'rb'); | ||
| - if (!$handle) return ''; | ||
| - $read = fread($handle, 12); | ||
| - fclose($handle); | ||
| - return ($read !== false) ? $read : ''; | ||
| - } | ||
| - | ||
| - private function getTimeElapsed(): string { | ||
| - if (!$this->timestamp) return ''; | ||
| - $diff = time() - $this->timestamp; | ||
| - if ($diff < 5) return 'just now'; | ||
| - $tokens = [ | ||
| - 31536000 => 'year', 2592000 => 'month', 604800 => 'week', | ||
| - 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second' | ||
| - ]; | ||
| - foreach ($tokens as $unit => $text) { | ||
| - if ($diff < $unit) continue; | ||
| - $num = floor($diff / $unit); | ||
| - return $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago'; | ||
| - } | ||
| - return 'just now'; | ||
| - } | ||
| -} | ||
| - | ||
| -<?php | ||
| -interface FileRenderer { | ||
| - public function renderFileItem( | ||
| - string $name, | ||
| - string $sha, | ||
| - string $mode, | ||
| - string $iconClass, | ||
| - string $time, | ||
| - string $size = '' | ||
| - ): void; | ||
| -} | ||
| - | ||
| -class HtmlFileRenderer implements FileRenderer { | ||
| - private string $repoSafeName; | ||
| - | ||
| - public function __construct(string $repoSafeName) { | ||
| - $this->repoSafeName = $repoSafeName; | ||
| - } | ||
| - | ||
| - public function renderFileItem( | ||
| - string $name, | ||
| - string $sha, | ||
| - string $mode, | ||
| - string $iconClass, | ||
| - string $time, | ||
| - string $size = '' | ||
| - ): void { | ||
| - // UPDATED: Added '&name=' to the URL | ||
| - $url = '?repo=' . urlencode($this->repoSafeName) . '&hash=' . $sha . '&name=' . urlencode($name); | ||
| - | ||
| - echo '<a href="' . $url . '" class="file-item">'; | ||
| - echo '<span class="file-mode">' . $mode . '</span>'; | ||
| - echo '<span class="file-name">'; | ||
| - echo '<i class="fas ' . $iconClass . '" style="width: 20px; text-align: center; margin-right: 5px; color: #7a828e;"></i>'; | ||
| - echo htmlspecialchars($name); | ||
| - echo '</span>'; | ||
| - | ||
| - if ($size) { | ||
| - echo '<span class="file-size" style="color: #8b949e; font-size: 0.8em; margin-left: 10px;">' . $size . '</span>'; | ||
| - } | ||
| - | ||
| - if ($time) { | ||
| - echo '<span class="file-date" style="color: #8b949e; font-size: 0.8em; margin-left: auto;">' . $time . '</span>'; | ||
| - } | ||
| - | ||
| - echo '</a>'; | ||
| - } | ||
| -} | ||
| - | ||
| -<?php | ||
| -require_once 'File.php'; | ||
| - | ||
| -class Git { | ||
| - private string $path; | ||
| - private string $objPath; | ||
| - | ||
| - public function __construct(string $repoPath) { | ||
| - $this->path = rtrim($repoPath, '/'); | ||
| - $this->objPath = $this->path . '/objects'; | ||
| - } | ||
| - | ||
| - public function getObjectSize(string $sha): int { | ||
| - $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| - if (file_exists($loose)) { | ||
| - $f = @fopen($loose, 'rb'); | ||
| - if (!$f) return 0; | ||
| - $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| - $data = ''; | ||
| - while (!feof($f)) { | ||
| - $chunk = fread($f, 128); | ||
| - $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| - if ($inflated === false) break; | ||
| - $data .= $inflated; | ||
| - if (strpos($data, "\0") !== false) break; | ||
| - } | ||
| - fclose($f); | ||
| - $header = explode("\0", $data, 2)[0]; | ||
| - $parts = explode(' ', $header); | ||
| - return isset($parts[1]) ? (int)$parts[1] : 0; | ||
| - } | ||
| - return $this->getPackedObjectSize($sha); | ||
| - } | ||
| - | ||
| - private function getPackedObjectSize(string $sha): int { | ||
| - $info = $this->getPackOffset($sha); | ||
| - if (!$info) return 0; | ||
| - | ||
| - $pf = @fopen($info['file'], 'rb'); | ||
| - if (!$pf) return 0; | ||
| - | ||
| - fseek($pf, $info['offset']); | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $type = ($byte >> 4) & 7; | ||
| - $size = $byte & 15; | ||
| - $shift = 4; | ||
| - while ($byte & 128) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $size |= (($byte & 127) << $shift); | ||
| - $shift += 7; | ||
| - } | ||
| - | ||
| - if ($type === 6 || $type === 7) { | ||
| - if ($type === 6) { // OFS_DELTA | ||
| - $byte = ord(fread($pf, 1)); | ||
| - while ($byte & 128) { $byte = ord(fread($pf, 1)); } | ||
| - } else { // REF_DELTA | ||
| - fread($pf, 20); | ||
| - } | ||
| - | ||
| - $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); | ||
| - $buffer = ''; | ||
| - $found = false; | ||
| - while (!$found && !feof($pf)) { | ||
| - $chunk = fread($pf, 512); | ||
| - $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); | ||
| - if ($inflated === false) { fclose($pf); return 0; } | ||
| - $buffer .= $inflated; | ||
| - if (strlen($buffer) > 32) $found = true; | ||
| - } | ||
| - | ||
| - $pos = 0; | ||
| - // Skip Source Size | ||
| - if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| - $byte = ord($buffer[$pos++]); | ||
| - while ($byte & 128) { | ||
| - if (!isset($buffer[$pos])) break; | ||
| - $byte = ord($buffer[$pos++]); | ||
| - } | ||
| - // Read Target Size | ||
| - if (!isset($buffer[$pos])) { fclose($pf); return 0; } | ||
| - $byte = ord($buffer[$pos++]); | ||
| - $size = $byte & 127; | ||
| - $shift = 7; | ||
| - while ($byte & 128) { | ||
| - if (!isset($buffer[$pos])) break; | ||
| - $byte = ord($buffer[$pos++]); | ||
| - $size |= (($byte & 127) << $shift); | ||
| - $shift += 7; | ||
| - } | ||
| - } | ||
| - fclose($pf); | ||
| - return $size; | ||
| - } | ||
| - | ||
| - public function eachRepository(callable $callback): void { | ||
| - if (!is_dir($this->path)) return; | ||
| - $repos = []; | ||
| - foreach (glob($this->path . '/*.git') as $path) { | ||
| - if (is_dir($path)) { | ||
| - $name = basename($path, '.git'); | ||
| - $repos[$name] = ['path' => $path, 'name' => urldecode($name), 'safe_name' => $name]; | ||
| - } | ||
| - } | ||
| - uasort($repos, fn($a, $b) => strcasecmp($a['name'], $b['name'])); | ||
| - foreach ($repos as $repo) $callback($repo); | ||
| - } | ||
| - | ||
| - public function getMainBranch(): ?array { | ||
| - $branches = []; | ||
| - $this->eachBranch(function($name, $sha) use (&$branches) { $branches[$name] = $sha; }); | ||
| - foreach (['main', 'master', 'trunk', 'develop'] as $b) { | ||
| - if (isset($branches[$b])) return ['name' => $b, 'hash' => $branches[$b]]; | ||
| - } | ||
| - if (!empty($branches)) { | ||
| - $f = array_key_first($branches); | ||
| - return ['name' => $f, 'hash' => $branches[$f]]; | ||
| - } | ||
| - return null; | ||
| - } | ||
| - | ||
| - public function eachBranch(callable $callback): void { $this->scanRefs('refs/heads', $callback); } | ||
| - public function eachTag(callable $callback): void { $this->scanRefs('refs/tags', $callback); } | ||
| - | ||
| - public function walk(string $refOrSha, callable $callback): void { | ||
| - $sha = $this->resolve($refOrSha); | ||
| - if (!$sha) return; | ||
| - $data = $this->read($sha); | ||
| - if (!$data) return; | ||
| - if (preg_match('/^tree ([0-9a-f]{40})$/m', $data, $m)) { | ||
| - $data = $this->read($m[1]); | ||
| - if (!$data) return; | ||
| - } elseif (!$this->isTreeData($data)) return; | ||
| - | ||
| - $pos = 0; | ||
| - while ($pos < strlen($data)) { | ||
| - $space = strpos($data, ' ', $pos); | ||
| - $null = strpos($data, "\0", $space); | ||
| - if ($space === false || $null === false) break; | ||
| - $mode = substr($data, $pos, $space - $pos); | ||
| - $name = substr($data, $space + 1, $null - $space - 1); | ||
| - $entrySha = bin2hex(substr($data, $null + 1, 20)); | ||
| - | ||
| - // Calculate logic internally to encapsulate File creation | ||
| - $isDir = ($mode === '40000' || $mode === '040000'); | ||
| - $size = $isDir ? 0 : $this->getObjectSize($entrySha); | ||
| - | ||
| - $callback(new File($name, $entrySha, $mode, 0, $size)); | ||
| - | ||
| - $pos = $null + 21; | ||
| - } | ||
| - } | ||
| - | ||
| - private function isTreeData(string $data): bool { | ||
| - if (strlen($data) < 25) return false; | ||
| - if (preg_match('/^(40000|100644|100755|120000) /', $data)) { | ||
| - $null = strpos($data, "\0"); | ||
| - return ($null !== false && ($null + 21 <= strlen($data))); | ||
| - } | ||
| - return false; | ||
| - } | ||
| - | ||
| - public function history(string $refOrSha, int $limit, callable $callback): void { | ||
| - $currentSha = $this->resolve($refOrSha); | ||
| - $count = 0; | ||
| - while ($currentSha && $count < $limit) { | ||
| - $data = $this->read($currentSha); | ||
| - if (!$data) break; | ||
| - $message = (strpos($data, "\n\n") !== false) ? substr($data, strpos($data, "\n\n") + 2) : ''; | ||
| - preg_match('/^author (.*) <(.*)> (\d+)/m', $data, $auth); | ||
| - $callback((object)['sha' => $currentSha, 'message' => trim($message), 'author' => $auth[1] ?? 'Unknown', 'email' => $auth[2] ?? '', 'date' => (int)($auth[3] ?? 0)]); | ||
| - $currentSha = preg_match('/^parent ([0-9a-f]{40})$/m', $data, $m) ? $m[1] : null; | ||
| - $count++; | ||
| - } | ||
| - } | ||
| - | ||
| - public function stream(string $sha, callable $callback): void { | ||
| - $data = $this->read($sha); | ||
| - if ($data) $callback($data); | ||
| - } | ||
| - | ||
| - private function resolve(string $input): ?string { | ||
| - if (preg_match('/^[0-9a-f]{40}$/', $input)) return $input; | ||
| - if ($input === 'HEAD' && file_exists($h = "{$this->path}/HEAD")) { | ||
| - $head = trim(file_get_contents($h)); | ||
| - return (strpos($head, 'ref: ') === 0) ? $this->resolve(substr($head, 5)) : $head; | ||
| - } | ||
| - foreach ([$input, "refs/heads/$input", "refs/tags/$input"] as $p) { | ||
| - if (file_exists($f = "{$this->path}/$p")) return trim(file_get_contents($f)); | ||
| - } | ||
| - if (file_exists($packed = "{$this->path}/packed-refs")) { | ||
| - foreach (file($packed) as $line) { | ||
| - if ($line[0] === '#' || $line[0] === '^') continue; | ||
| - $parts = explode(' ', trim($line)); | ||
| - if (count($parts) >= 2 && ($parts[1] === $input || $parts[1] === "refs/heads/$input" || $parts[1] === "refs/tags/$input")) return $parts[0]; | ||
| - } | ||
| - } | ||
| - return null; | ||
| - } | ||
| - | ||
| - private function read(string $sha): ?string { | ||
| - $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); | ||
| - if (file_exists($loose)) { | ||
| - $inflated = @gzuncompress(file_get_contents($loose)); | ||
| - return $inflated ? explode("\0", $inflated, 2)[1] : null; | ||
| - } | ||
| - return $this->fromPack($sha); | ||
| - } | ||
| - | ||
| - private function scanRefs(string $prefix, callable $callback): void { | ||
| - $dir = "{$this->path}/$prefix"; | ||
| - if (is_dir($dir)) { | ||
| - foreach (array_diff(scandir($dir), ['.', '..']) as $f) { | ||
| - $callback($f, trim(file_get_contents("$dir/$f"))); | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - private function fromPack(string $sha): ?string { | ||
| - $info = $this->getPackOffset($sha); | ||
| - if (!$info) return null; | ||
| - $pf = @fopen($info['file'], 'rb'); | ||
| - if (!$pf) return null; | ||
| - $data = $this->readPackEntry($pf, $info['offset']); | ||
| - fclose($pf); | ||
| - return $data; | ||
| - } | ||
| - | ||
| - private function readPackEntry($pf, int $offset): ?string { | ||
| - fseek($pf, $offset); | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $type = ($byte >> 4) & 7; | ||
| - $size = $byte & 15; | ||
| - $shift = 4; | ||
| - while ($byte & 128) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $size |= (($byte & 127) << $shift); | ||
| - $shift += 7; | ||
| - } | ||
| - | ||
| - // Type 6: OBJ_OFS_DELTA | ||
| - if ($type === 6) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $negOffset = $byte & 127; | ||
| - while ($byte & 128) { | ||
| - $byte = ord(fread($pf, 1)); | ||
| - $negOffset = (($negOffset + 1) << 7) | ($byte & 127); | ||
| - } | ||
| - $baseOffset = $offset - $negOffset; | ||
| - $base = $this->readPackEntry($pf, $baseOffset); | ||
| - | ||
| - fseek($pf, $offset); | ||
| - $b = ord(fread($pf, 1)); | ||
| - while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| - $b = ord(fread($pf, 1)); | ||
| - while ($b & 128) { $b = ord(fread($pf, 1)); } | ||
| - | ||
| - $delta = @gzuncompress(fread($pf, 16777216)); | ||
| - return $this->applyDelta($base, $delta); | ||
| - } | ||
| - | ||
| - // Type 7: OBJ_REF_DELTA | ||
| - if ($type === 7) { | ||
| - $baseSha = bin2hex(fread($pf, 20)); | ||
| - $base = $this->read($baseSha); | ||
| - $delta = @gzuncompress(fread($pf, 16777216)); | ||
| - return $this->applyDelta($base, $delta); | ||
| - } | ||
| - | ||
| - return @gzuncompress(fread($pf, 16777216)); | ||
| - } | ||
| - | ||
| - private function applyDelta(?string $base, ?string $delta): string { | ||
| - if (!$base || !$delta) return ''; | ||
| - $pos = 0; | ||
| - // Skip Source Size | ||
| - $byte = ord($delta[$pos++]); | ||
| - while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| - // Skip Target Size | ||
| - $byte = ord($delta[$pos++]); | ||
| - while ($byte & 128) { $byte = ord($delta[$pos++]); } | ||
| - | ||
| - $out = ''; | ||
| - while ($pos < strlen($delta)) { | ||
| - $opcode = ord($delta[$pos++]); | ||
| - if ($opcode & 128) { // Copy | ||
| - $off = 0; $len = 0; | ||
| - if ($opcode & 1) $off |= ord($delta[$pos++]); | ||
| - if ($opcode & 2) $off |= ord($delta[$pos++]) << 8; | ||
| - if ($opcode & 4) $off |= ord($delta[$pos++]) << 16; | ||
| - if ($opcode & 8) $off |= ord($delta[$pos++]) << 24; | ||
| - if ($opcode & 16) $len |= ord($delta[$pos++]); | ||
| - if ($opcode & 32) $len |= ord($delta[$pos++]) << 8; | ||
| - if ($opcode & 64) $len |= ord($delta[$pos++]) << 16; | ||
| - if ($len === 0) $len = 0x10000; | ||
| - $out .= substr($base, $off, $len); | ||
| - } else { // Insert | ||
| - $len = $opcode & 127; | ||
| - $out .= substr($delta, $pos, $len); | ||
| - $pos += $len; | ||
| - } | ||
| - } | ||
| - return $out; | ||
| - } | ||
| - | ||
| - private function getPackOffset(string $sha): ?array { | ||
| - $packs = glob("{$this->objPath}/pack/*.idx"); | ||
| - if (!$packs) return null; | ||
| - | ||
| - $binSha = hex2bin($sha); | ||
| - $firstByte = ord($binSha[0]); | ||
| - | ||
| - foreach ($packs as $idxFile) { | ||
| - $f = @fopen($idxFile, 'rb'); | ||
| - if (!$f) continue; | ||
| - | ||
| - $sig = fread($f, 4); | ||
| - $ver = unpack('N', fread($f, 4))[1]; | ||
| - if ($sig !== "\377tOc" || $ver !== 2) { fclose($f); continue; } | ||
| - | ||
| - $fanoutOffset = 8; | ||
| - if ($firstByte > 0) { | ||
| - fseek($f, $fanoutOffset + (($firstByte - 1) * 4)); | ||
| - $start = unpack('N', fread($f, 4))[1]; | ||
| - } else { | ||
| - $start = 0; | ||
| - } | ||
| - fseek($f, $fanoutOffset + ($firstByte * 4)); | ||
| - $end = unpack('N', fread($f, 4))[1]; | ||
| - | ||
| - if ($end <= $start) { fclose($f); continue; } | ||
| - | ||
| - fseek($f, $fanoutOffset + (255 * 4)); | ||
| - $totalObjects = unpack('N', fread($f, 4))[1]; | ||
| - | ||
| - $shaTableOffset = 8 + 1024; | ||
| - fseek($f, $shaTableOffset + ($start * 20)); | ||
| - | ||
| - $foundIdx = -1; | ||
| - for ($i = $start; $i < $end; $i++) { | ||
| - if (fread($f, 20) === $binSha) { $foundIdx = $i; break; } | ||
| - } | ||
| - | ||
| - if ($foundIdx === -1) { fclose($f); continue; } | ||
| - | ||
| - $crcOffset = $shaTableOffset + ($totalObjects * 20); | ||
| - $offsetTableOffset = $crcOffset + ($totalObjects * 4); | ||
| - | ||
| - fseek($f, $offsetTableOffset + ($foundIdx * 4)); | ||
| - $offset32 = unpack('N', fread($f, 4))[1]; | ||
| - | ||
| - if ($offset32 & 0x80000000) { | ||
| - $largeOffsetIdx = $offset32 & 0x7FFFFFFF; | ||
| - $largeOffsetTablePos = $offsetTableOffset + ($totalObjects * 4); | ||
| - fseek($f, $largeOffsetTablePos + ($largeOffsetIdx * 8)); | ||
| - $data = unpack('J', fread($f, 8)); | ||
| - $offset = $data[1]; | ||
| - } else { | ||
| - $offset = $offset32; | ||
| - } | ||
| - | ||
| - fclose($f); | ||
| - return ['file' => str_replace('.idx', '.pack', $idxFile), 'offset' => $offset]; | ||
| - } | ||
| - return null; | ||
| - } | ||
| -} | ||
| - | ||
| -<?php | ||
| -class MediaTypeSniffer { | ||
| - private const BUFFER = 12; | ||
| - private const ANY = -1; | ||
| - | ||
| - // Categories | ||
| - public const CAT_IMAGE = 'image'; | ||
| - public const CAT_VIDEO = 'video'; | ||
| - public const CAT_AUDIO = 'audio'; | ||
| - public const CAT_TEXT = 'text'; | ||
| - public const CAT_ARCHIVE = 'archive'; | ||
| - public const CAT_APP = 'application'; | ||
| - public const CAT_BINARY = 'binary'; | ||
| - | ||
| - private const FORMATS = [ | ||
| - // Images | ||
| - [self::CAT_IMAGE, [0x3C, 0x73, 0x76, 0x67, 0x20], 'image/svg+xml'], | ||
| - [self::CAT_IMAGE, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'image/png'], | ||
| - [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xE0], 'image/jpeg'], | ||
| - [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xEE], 'image/jpeg'], | ||
| - [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xE1, self::ANY, self::ANY, 0x45, 0x78, 0x69, 0x66, 0x00], 'image/jpeg'], | ||
| - [self::CAT_IMAGE, [0x47, 0x49, 0x46, 0x38], 'image/gif'], | ||
| - [self::CAT_IMAGE, [0x42, 0x4D], 'image/bmp'], | ||
| - [self::CAT_IMAGE, [0x49, 0x49, 0x2A, 0x00], 'image/tiff'], | ||
| - [self::CAT_IMAGE, [0x4D, 0x4D, 0x00, 0x2A], 'image/tiff'], | ||
| - [self::CAT_IMAGE, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x57, 0x45, 0x42, 0x50], 'image/webp'], | ||
| - [self::CAT_IMAGE, [0x38, 0x42, 0x50, 0x53, 0x00, 0x01], 'image/vnd.adobe.photoshop'], | ||
| - [self::CAT_IMAGE, [0x23, 0x64, 0x65, 0x66], 'image/x-xbitmap'], | ||
| - [self::CAT_IMAGE, [0x21, 0x20, 0x58, 0x50, 0x4D, 0x32], 'image/x-xpixmap'], | ||
| - | ||
| - // Video | ||
| - [self::CAT_VIDEO, [0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'video/x-mng'], | ||
| - [self::CAT_VIDEO, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x41, 0x56, 0x49, 0x20], 'video/x-msvideo'], | ||
| - [self::CAT_VIDEO, [self::ANY, self::ANY, self::ANY, self::ANY, 0x66, 0x74, 0x79, 0x70], 'video/mp4'], | ||
| - [self::CAT_VIDEO, [0x1A, 0x45, 0xDF, 0xA3], 'video/x-matroska'], | ||
| - [self::CAT_VIDEO, [0x00, 0x00, 0x01, 0xBA], 'video/mpeg'], | ||
| - [self::CAT_VIDEO, [0x46, 0x4C, 0x56, 0x01], 'video/x-flv'], | ||
| - | ||
| - // Documents/Text | ||
| - [self::CAT_TEXT, [0x3C, 0x21], 'text/html'], | ||
| - [self::CAT_TEXT, [0x3C, 0x68, 0x74, 0x6D, 0x6C], 'text/html'], | ||
| - [self::CAT_TEXT, [0x3C, 0x68, 0x65, 0x61, 0x64], 'text/html'], | ||
| - [self::CAT_TEXT, [0x3C, 0x62, 0x6F, 0x64, 0x79], 'text/html'], | ||
| - [self::CAT_TEXT, [0x3C, 0x48, 0x54, 0x4D, 0x4C], 'text/html'], | ||
| - [self::CAT_TEXT, [0x3C, 0x48, 0x45, 0x41, 0x44], 'text/html'], | ||
| - [self::CAT_TEXT, [0x3C, 0x42, 0x4F, 0x44, 0x59], 'text/html'], | ||
| - [self::CAT_TEXT, [0x3C, 0x3F, 0x78, 0x6D, 0x6C, 0x20], 'text/xml'], | ||
| - [self::CAT_TEXT, [0xFE, 0xFF, 0x00, 0x3C, 0x00, 0x3f, 0x00, 0x78], 'text/xml'], | ||
| - [self::CAT_TEXT, [0xFF, 0xFE, 0x3C, 0x00, 0x3F, 0x00, 0x78, 0x00], 'text/xml'], | ||
| - [self::CAT_TEXT, [0x25, 0x50, 0x44, 0x46, 0x2D], 'application/pdf'], | ||
| - [self::CAT_TEXT, [0x25, 0x21, 0x50, 0x53, 0x2D, 0x41, 0x64, 0x6F, 0x62, 0x65, 0x2D], 'application/postscript'], | ||
| - [self::CAT_TEXT, [0x25, 0x21, 0x50, 0x53], 'application/postscript'], | ||
| - | ||
| - // Audio | ||
| - [self::CAT_AUDIO, [0xFF, 0xFB, self::ANY], 'audio/mpeg'], | ||
| - [self::CAT_AUDIO, [0x49, 0x44, 0x33], 'audio/mpeg'], | ||
| - [self::CAT_AUDIO, [0x2E, 0x73, 0x6E, 0x64], 'audio/basic'], | ||
| - [self::CAT_AUDIO, [0x64, 0x6E, 0x73, 0x2E], 'audio/basic'], | ||
| - [self::CAT_AUDIO, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x57, 0x41, 0x56, 0x45], 'audio/wav'], | ||
| - [self::CAT_AUDIO, [0x4F, 0x67, 0x67, 0x53], 'audio/ogg'], | ||
| - [self::CAT_AUDIO, [0x66, 0x4C, 0x61, 0x43], 'audio/flac'], | ||
| - [self::CAT_AUDIO, [0x4D, 0x54, 0x68, 0x64], 'audio/midi'], | ||
| - [self::CAT_AUDIO, [0x46, 0x4F, 0x52, 0x4D, self::ANY, self::ANY, self::ANY, self::ANY, 0x41, 0x49, 0x46, 0x46], 'audio/x-aiff'], | ||
| - | ||
| - // Archives | ||
| - [self::CAT_ARCHIVE, [0x50, 0x4B, 0x03, 0x04], 'application/zip'], | ||
| - [self::CAT_ARCHIVE, [0x50, 0x4B, 0x05, 0x06], 'application/zip'], | ||
| - [self::CAT_ARCHIVE, [0x50, 0x4B, 0x07, 0x08], 'application/zip'], | ||
| - [self::CAT_ARCHIVE, [0x1F, 0x8B, 0x08], 'application/gzip'], | ||
| - [self::CAT_ARCHIVE, [0x42, 0x5A, 0x68], 'application/x-bzip2'], | ||
| - [self::CAT_ARCHIVE, [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], 'application/x-xz'], | ||
| - [self::CAT_ARCHIVE, [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar'], | ||
| - [self::CAT_ARCHIVE, [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed'], | ||
| - | ||
| - // Applications/System | ||
| - [self::CAT_APP, [0x41, 0x43, self::ANY, self::ANY, self::ANY, self::ANY, 0x00, 0x00, 0x00, 0x00, 0x00], 'application/acad'], | ||
| - [self::CAT_APP, [0xCA, 0xFE, 0xBA, 0xBE], 'application/java-vm'], | ||
| - [self::CAT_APP, [0xAC, 0xED], 'application/x-java-serialized-object'], | ||
| - [self::CAT_APP, [0x4D, 0x5A], 'application/x-msdownload'], | ||
| - [self::CAT_APP, [0x7F, 0x45, 0x4C, 0x46], 'application/x-elf'], | ||
| - [self::CAT_APP, [0xCE, 0xFA, 0xED, 0xFE], 'application/x-mach-binary'], | ||
| - [self::CAT_APP, [0xCF, 0xFA, 0xED, 0xFE], 'application/x-mach-binary'], | ||
| - [self::CAT_APP, [0xFE, 0xED, 0xFA, 0xCE], 'application/x-mach-binary'], | ||
| - [self::CAT_APP, [0xFE, 0xED, 0xFA, 0xCF], 'application/x-mach-binary'], | ||
| - ]; | ||
| - | ||
| - private const EXTENSION_MAP = [ | ||
| - 'txt' => [self::CAT_TEXT, 'text/plain'], | ||
| - 'url' => [self::CAT_TEXT, 'text/plain'], | ||
| - 'html' => [self::CAT_TEXT, 'text/html'], | ||
| - 'htm' => [self::CAT_TEXT, 'text/html'], | ||
| - 'css' => [self::CAT_TEXT, 'text/css'], | ||
| - 'js' => [self::CAT_TEXT, 'application/javascript'], | ||
| - 'json' => [self::CAT_TEXT, 'application/json'], | ||
| - 'xml' => [self::CAT_TEXT, 'application/xml'], | ||
| - 'pdf' => [self::CAT_TEXT, 'application/pdf'], | ||
| - 'zip' => [self::CAT_ARCHIVE, 'application/zip'], | ||
| - 'jar' => [self::CAT_ARCHIVE, 'application/java-archive'], | ||
| - 'war' => [self::CAT_ARCHIVE, 'application/java-archive'], | ||
| - 'ear' => [self::CAT_ARCHIVE, 'application/java-archive'], | ||
| - 'class' => [self::CAT_APP, 'application/java-vm'], | ||
| - 'gz' => [self::CAT_ARCHIVE, 'application/gzip'], | ||
| - 'bz2' => [self::CAT_ARCHIVE, 'application/x-bzip2'], | ||
| - 'xz' => [self::CAT_ARCHIVE, 'application/x-xz'], | ||
| - 'tar' => [self::CAT_ARCHIVE, 'application/x-tar'], | ||
| - 'rar' => [self::CAT_ARCHIVE, 'application/vnd.rar'], | ||
| - '7z' => [self::CAT_ARCHIVE, 'application/x-7z-compressed'], | ||
| - 'jpg' => [self::CAT_IMAGE, 'image/jpeg'], | ||
| - 'jpeg' => [self::CAT_IMAGE, 'image/jpeg'], | ||
| - 'png' => [self::CAT_IMAGE, 'image/png'], | ||
| - 'gif' => [self::CAT_IMAGE, 'image/gif'], | ||
| - 'svg' => [self::CAT_IMAGE, 'image/svg+xml'], | ||
| - 'webp' => [self::CAT_IMAGE, 'image/webp'], | ||
| - 'bmp' => [self::CAT_IMAGE, 'image/bmp'], | ||
| - 'tiff' => [self::CAT_IMAGE, 'image/tiff'], | ||
| - 'tif' => [self::CAT_IMAGE, 'image/tiff'], | ||
| - 'ico' => [self::CAT_IMAGE, 'image/x-icon'], | ||
| - 'mp4' => [self::CAT_VIDEO, 'video/mp4'], | ||
| - 'avi' => [self::CAT_VIDEO, 'video/x-msvideo'], | ||
| - 'mov' => [self::CAT_VIDEO, 'video/quicktime'], | ||
| - 'wmv' => [self::CAT_VIDEO, 'video/x-ms-wmv'], | ||
| - 'flv' => [self::CAT_VIDEO, 'video/x-flv'], | ||
| - 'webm' => [self::CAT_VIDEO, 'video/webm'], | ||
| - 'mp3' => [self::CAT_AUDIO, 'audio/mpeg'], | ||
| - 'wav' => [self::CAT_AUDIO, 'audio/wav'], | ||
| - 'ogg' => [self::CAT_AUDIO, 'audio/ogg'], | ||
| - 'flac' => [self::CAT_AUDIO, 'audio/flac'], | ||
| - 'aac' => [self::CAT_AUDIO, 'audio/aac'], | ||
| - 'php' => [self::CAT_TEXT, 'application/x-php'], | ||
| - 'py' => [self::CAT_TEXT, 'text/x-python'], | ||
| - 'java' => [self::CAT_TEXT, 'text/x-java'], | ||
| - 'c' => [self::CAT_TEXT, 'text/x-c'], | ||
| - 'cpp' => [self::CAT_TEXT, 'text/x-c++'], | ||
| - 'h' => [self::CAT_TEXT, 'text/x-c'], | ||
| - 'hpp' => [self::CAT_TEXT, 'text/x-c++'], | ||
| - 'cs' => [self::CAT_TEXT, 'text/x-csharp'], | ||
| - 'go' => [self::CAT_TEXT, 'text/x-go'], | ||
| - 'rs' => [self::CAT_TEXT, 'text/x-rust'], | ||
| - 'rb' => [self::CAT_TEXT, 'text/x-ruby'], | ||
| - 'pl' => [self::CAT_TEXT, 'text/x-perl'], | ||
| - 'sh' => [self::CAT_APP, 'application/x-sh'], | ||
| - 'bat' => [self::CAT_APP, 'application/x-bat'], | ||
| - 'ps1' => [self::CAT_APP, 'application/x-powershell'], | ||
| - 'md' => [self::CAT_TEXT, 'text/markdown'], | ||
| - 'yaml' => [self::CAT_TEXT, 'text/yaml'], | ||
| - 'yml' => [self::CAT_TEXT, 'text/yaml'], | ||
| - 'toml' => [self::CAT_TEXT, 'application/toml'], | ||
| - 'ini' => [self::CAT_TEXT, 'text/plain'], | ||
| - 'cfg' => [self::CAT_TEXT, 'text/plain'], | ||
| - 'conf' => [self::CAT_TEXT, 'text/plain'], | ||
| - ]; | ||
| - | ||
| - /** | ||
| - * Internal helper to resolve category and mime type. | ||
| - * Guaranteed to return a non-empty array. | ||
| - */ | ||
| - private static function getTypeInfo( string $data, string $filePath ): array { | ||
| - $info = self::sniff( $data ); | ||
| - | ||
| - if ( empty( $info ) && !empty( $filePath ) ) { | ||
| - $info = self::getInfoByExtension( $filePath ); | ||
| - } | ||
| - | ||
| - return !empty( $info ) ? $info : [self::CAT_BINARY, 'application/octet-stream']; | ||
| - } | ||
| - | ||
| - private static function sniff( string $data ): array { | ||
| - if( empty( $data ) ) return []; | ||
| - | ||
| - $dataLength = strlen( $data ); | ||
| - $maxScan = min( $dataLength, self::BUFFER ); | ||
| - $sourceBytes = []; | ||
| - | ||
| - for( $i = 0; $i < $maxScan; $i++ ) { | ||
| - $sourceBytes[$i] = ord( $data[$i] ) & 0xFF; | ||
| - } | ||
| - | ||
| - foreach( self::FORMATS as [$category, $pattern, $type] ) { | ||
| - $patternLength = count( $pattern ); | ||
| - | ||
| - if( $patternLength > $dataLength ) continue; | ||
| - | ||
| - $matches = true; | ||
| - | ||
| - for( $i = 0; $i < $patternLength; $i++ ) { | ||
| - if( $pattern[$i] !== self::ANY && $pattern[$i] !== $sourceBytes[$i] ) { | ||
| - $matches = false; | ||
| - break; | ||
| - } | ||
| - } | ||
| - | ||
| - if( $matches ) return [$category, $type]; | ||
| - } | ||
| - | ||
| - return []; | ||
| - } | ||
| - | ||
| - private static function getInfoByExtension( string $filePath ): array { | ||
| - $extension = strtolower( pathinfo( $filePath, PATHINFO_EXTENSION ) ); | ||
| - return self::EXTENSION_MAP[$extension] ?? [self::CAT_BINARY, 'application/octet-stream']; | ||
| - } | ||
| - | ||
| - public static function isMediaType( string $data, string $filePath = '' ): string { | ||
| - return self::getTypeInfo( $data, $filePath )[1]; | ||
| - } | ||
| - | ||
| - public static function isCategory( string $data, string $filePath = '' ): string { | ||
| - return self::getTypeInfo( $data, $filePath )[0]; | ||
| - } | ||
| - | ||
| - public static function isBinary( string $data, string $filePath = '' ): bool { | ||
| - [$category, $type] = self::getTypeInfo( $data, $filePath ); | ||
| - return $category !== self::CAT_TEXT && !str_starts_with( $type, 'text/' ); | ||
| - } | ||
| -} | ||
| -?> | ||
| - | ||
| -<?php | ||
| -interface Page { | ||
| - public function render(); | ||
| -} | ||
| -<?php | ||
| -require_once 'Views.php'; | ||
| - | ||
| -class Router { | ||
| - private $repositories; | ||
| - | ||
| - public function __construct(array $repositories) { | ||
| - $this->repositories = $repositories; | ||
| - } | ||
| - | ||
| - public function route(): Page { | ||
| - $reqRepo = $_GET['repo'] ?? ''; | ||
| - $action = $_GET['action'] ?? 'home'; | ||
| - $hash = $this->sanitizePath($_GET['hash'] ?? ''); | ||
| - | ||
| - // Find the specific repository object | ||
| - $currentRepo = null; | ||
| - $decoded = urldecode($reqRepo); | ||
| - foreach ($this->repositories as $repo) { | ||
| - if ($repo['safe_name'] === $reqRepo || $repo['name'] === $decoded) { | ||
| - $currentRepo = $repo; | ||
| - break; | ||
| - } | ||
| - } | ||
| - | ||
| - // Inject the full list ($this->repositories) into every page | ||
| - if (!$currentRepo) { | ||
| - return new HomePage($this->repositories); | ||
| - } | ||
| - | ||
| - $git = new Git($currentRepo['path']); | ||
| - | ||
| - // UPDATED: Handle 'raw' action for media/downloads | ||
| - if ($action === 'raw') { | ||
| - return new RawPage($git, $hash); | ||
| - } | ||
| - | ||
| - if ($action === 'commits') { | ||
| - return new CommitsPage($this->repositories, $currentRepo, $git, $hash); | ||
| - } | ||
| - | ||
| - return new FilePage($this->repositories, $currentRepo, $git, $hash); | ||
| - } | ||
| - | ||
| - private function sanitizePath($path) { | ||
| - $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); | ||
| - return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); | ||
| - } | ||
| -} | ||
| - | ||
| -<?php | ||
| -require_once 'File.php'; | ||
| -require_once 'FileRenderer.php'; | ||
| - | ||
| -abstract class BasePage implements Page { | ||
| - protected $repositories; | ||
| - protected $title; | ||
| - | ||
| - public function __construct(array $repositories) { | ||
| - $this->repositories = $repositories; | ||
| - } | ||
| - | ||
| - protected function renderLayout($contentCallback, $currentRepo = null) { | ||
| - ?> | ||
| - <!DOCTYPE html> | ||
| - <html lang="en"> | ||
| - <head> | ||
| - <meta charset="UTF-8"> | ||
| - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| - <title><?php echo Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title> | ||
| - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | ||
| - <link rel="stylesheet" href="repo.css"> | ||
| - </head> | ||
| - <body> | ||
| - <div class="container"> | ||
| - <header> | ||
| - <h1><?php echo Config::SITE_TITLE; ?></h1> | ||
| - <nav class="nav"> | ||
| - <a href="?">Home</a> | ||
| - <?php if ($currentRepo): | ||
| - $safeName = urlencode($currentRepo['safe_name']); ?> | ||
| - <a href="?repo=<?php echo $safeName; ?>">Files</a> | ||
| - <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a> | ||
| - <a href="?action=refs&repo=<?php echo $safeName; ?>">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 ($this->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> | ||
| - </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 call_user_func($contentCallback); ?> | ||
| - | ||
| - </div> | ||
| - </body> | ||
| - </html> | ||
| - <?php | ||
| - } | ||
| - | ||
| - protected function time_elapsed_string($timestamp) { | ||
| - if (!$timestamp) return 'never'; | ||
| - $diff = time() - $timestamp; | ||
| - if ($diff < 5) return 'just now'; | ||
| - $tokens = [31536000 => 'year', 2592000 => 'month', 604800 => 'week', 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second']; | ||
| - foreach ($tokens as $unit => $text) { | ||
| - if ($diff < $unit) continue; | ||
| - $num = floor($diff / $unit); | ||
| - return $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago'; | ||
| - } | ||
| - return 'just now'; | ||
| - } | ||
| -} | ||
| - | ||
| -class HomePage extends BasePage { | ||
| - public function render() { | ||
| - $this->renderLayout(function() { | ||
| - echo '<h2>Repositories</h2>'; | ||
| - if (empty($this->repositories)) { | ||
| - echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(Config::getReposPath()) . '</div>'; | ||
| - return; | ||
| - } | ||
| - echo '<div class="repo-grid">'; | ||
| - foreach ($this->repositories as $repo) { | ||
| - $this->renderRepoCard($repo); | ||
| - } | ||
| - echo '</div>'; | ||
| - }); | ||
| - } | ||
| - | ||
| - private function renderRepoCard($repo) { | ||
| - $git = new Git($repo['path']); | ||
| - $main = $git->getMainBranch(); | ||
| - | ||
| - $stats = ['branches' => 0, 'tags' => 0]; | ||
| - $git->eachBranch(function() use (&$stats) { $stats['branches']++; }); | ||
| - $git->eachTag(function() use (&$stats) { $stats['tags']++; }); | ||
| - | ||
| - echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; | ||
| - echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; | ||
| - if ($main) echo '<p>Branch: ' . htmlspecialchars($main['name']) . '</p>'; | ||
| - echo '<p>' . $stats['branches'] . ' branches, ' . $stats['tags'] . ' tags</p>'; | ||
| - | ||
| - if ($main) { | ||
| - $git->history('HEAD', 1, function($c) { | ||
| - echo '<p style="margin-top: 8px; color: #58a6ff;">' . $this->time_elapsed_string($c->date) . '</p>'; | ||
| - }); | ||
| - } | ||
| - echo '</a>'; | ||
| - } | ||
| -} | ||
| - | ||
| -class CommitsPage extends BasePage { | ||
| - private $currentRepo; | ||
| - private $git; | ||
| - private $hash; | ||
| - | ||
| - public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| - parent::__construct($allRepos); | ||
| - $this->currentRepo = $currentRepo; | ||
| - $this->git = $git; | ||
| - $this->hash = $hash; | ||
| - $this->title = $currentRepo['name']; | ||
| - } | ||
| - | ||
| - public function render() { | ||
| - $this->renderLayout(function() { | ||
| - $main = $this->git->getMainBranch(); | ||
| - if (!$main) { | ||
| - echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>'; | ||
| - return; | ||
| - } | ||
| - | ||
| - $this->renderBreadcrumbs(); | ||
| - echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| - echo '<div class="commit-list">'; | ||
| - | ||
| - $start = $this->hash ?: $main['hash']; | ||
| - $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| - | ||
| - $this->git->history($start, 100, function($commit) use ($repoParam) { | ||
| - $msg = htmlspecialchars(explode("\n", $commit->message)[0]); | ||
| - echo '<div class="commit-row">'; | ||
| - echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>'; | ||
| - echo '<span class="message">' . $msg . '</span>'; | ||
| - echo '<span class="meta">' . htmlspecialchars($commit->author) . ' • ' . date('Y-m-d', $commit->date) . '</span>'; | ||
| - echo '</div>'; | ||
| - }); | ||
| - echo '</div>'; | ||
| - }, $this->currentRepo); | ||
| - } | ||
| - | ||
| - private function renderBreadcrumbs() { | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a><span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a><span>/</span>'; | ||
| - echo '<span>Commits</span></div>'; | ||
| - } | ||
| -} | ||
| - | ||
| -class FilePage extends BasePage { | ||
| - private $currentRepo; | ||
| - private $git; | ||
| - private $hash; | ||
| - | ||
| - public function __construct($allRepos, $currentRepo, $git, $hash) { | ||
| - parent::__construct($allRepos); | ||
| - $this->currentRepo = $currentRepo; | ||
| - $this->git = $git; | ||
| - $this->hash = $hash; | ||
| - $this->title = $currentRepo['name']; | ||
| - } | ||
| - | ||
| - public function render() { | ||
| - $this->renderLayout(function() { | ||
| - $main = $this->git->getMainBranch(); | ||
| - if (!$main) { | ||
| - echo '<div class="empty-state"><h3>No branches</h3></div>'; | ||
| - return; | ||
| - } | ||
| - | ||
| - $target = $this->hash ?: $main['hash']; | ||
| - $entries = []; | ||
| - | ||
| - // Entries are now File objects | ||
| - $this->git->walk($target, function($file) use (&$entries) { | ||
| - $entries[] = $file; | ||
| - }); | ||
| - | ||
| - if (!empty($entries)) { | ||
| - $this->renderTree($main, $target, $entries); | ||
| - } else { | ||
| - $this->renderBlob($target); | ||
| - } | ||
| - }, $this->currentRepo); | ||
| - } | ||
| - | ||
| - private function renderTree($main, $targetHash, $entries) { | ||
| - $this->renderBreadcrumbs($targetHash, 'Tree'); | ||
| - echo '<h2>' . htmlspecialchars($this->currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>'; | ||
| - | ||
| - // Encapsulated sorting via File::compare | ||
| - usort($entries, function($a, $b) { | ||
| - return $a->compare($b); | ||
| - }); | ||
| - | ||
| - echo '<div class="file-list">'; | ||
| - $renderer = new HtmlFileRenderer($this->currentRepo['safe_name']); | ||
| - | ||
| - foreach ($entries as $file) { | ||
| - $file->render($renderer); | ||
| - } | ||
| - | ||
| - echo '</div>'; | ||
| - } | ||
| - | ||
| - private function renderBlob($targetHash) { | ||
| - $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| - | ||
| - $size = $this->git->getObjectSize($targetHash); | ||
| - | ||
| - $buffer = ''; | ||
| - $this->git->stream($targetHash, function($d) use (&$buffer) { | ||
| - if (strlen($buffer) < 12) $buffer .= $d; | ||
| - }); | ||
| - | ||
| - $filename = $_GET['name'] ?? ''; | ||
| - $category = MediaTypeSniffer::isCategory($buffer, $filename); | ||
| - $mimeType = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| - | ||
| - $this->renderBreadcrumbs($targetHash, 'File'); | ||
| - | ||
| - $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode($filename); | ||
| - | ||
| - if ($category === MediaTypeSniffer::CAT_VIDEO) { | ||
| - echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #000;">'; | ||
| - echo '<video controls style="max-width: 100%; max-height: 80vh;">'; | ||
| - echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| - echo 'Your browser does not support the video element.'; | ||
| - echo '</video>'; | ||
| - echo '</div>'; | ||
| - | ||
| - } elseif ($category === MediaTypeSniffer::CAT_AUDIO) { | ||
| - echo '<div class="blob-content" style="text-align:center; padding: 40px; background: #f6f8fa;">'; | ||
| - echo '<audio controls style="width: 100%; max-width: 600px;">'; | ||
| - echo '<source src="' . $rawUrl . '" type="' . $mimeType . '">'; | ||
| - echo 'Your browser does not support the audio element.'; | ||
| - echo '</audio>'; | ||
| - echo '</div>'; | ||
| - | ||
| - } elseif ($category === MediaTypeSniffer::CAT_IMAGE) { | ||
| - echo '<div class="blob-content" style="text-align:center; padding: 20px; background: #f6f8fa;">'; | ||
| - echo '<img src="' . $rawUrl . '" style="max-width: 100%; border: 1px solid #dfe2e5;">'; | ||
| - echo '</div>'; | ||
| - | ||
| - } elseif ($category === MediaTypeSniffer::CAT_TEXT) { | ||
| - if ($size > 524288) { | ||
| - $this->renderDownloadState($targetHash, "File is too large to display (" . $this->formatSize($size) . ")."); | ||
| - } else { | ||
| - $content = ''; | ||
| - $this->git->stream($targetHash, function($d) use (&$content) { $content .= $d; }); | ||
| - echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars($content) . '</pre></div>'; | ||
| - } | ||
| - | ||
| - } else { | ||
| - $this->renderDownloadState($targetHash, "This is a binary file."); | ||
| - } | ||
| - } | ||
| - | ||
| - private function renderDownloadState($hash, $reason) { | ||
| - $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode($this->currentRepo['safe_name']); | ||
| - echo '<div class="empty-state" style="text-align: center; padding: 40px; border: 1px solid #e1e4e8; border-radius: 6px; margin-top: 10px;">'; | ||
| - echo '<p style="margin-bottom: 20px; color: #586069;">' . htmlspecialchars($reason) . '</p>'; | ||
| - echo '<a href="' . $url . '" style="display: inline-block; padding: 6px 16px; background: #0366d6; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Download Raw File</a>'; | ||
| - echo '</div>'; | ||
| - } | ||
| - | ||
| - private function formatSize($size) { | ||
| - if ($size <= 0) return '0 B'; | ||
| - $units = ['B', 'KB', 'MB', 'GB']; | ||
| - $i = (int)floor(log($size, 1024)); | ||
| - return round($size / pow(1024, $i), 1) . ' ' . $units[$i]; | ||
| - } | ||
| - | ||
| - private function renderBreadcrumbs($hash, $type) { | ||
| - echo '<div class="breadcrumb">'; | ||
| - echo '<a href="?">Repositories</a><span>/</span>'; | ||
| - echo '<a href="?repo=' . urlencode($this->currentRepo['safe_name']) . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>'; | ||
| - if ($this->hash) echo '<span>/</span><span>' . $type . ' ' . substr($hash, 0, 7) . '</span>'; | ||
| - echo '</div>'; | ||
| - } | ||
| -} | ||
| - | ||
| -class RawPage implements Page { | ||
| - private $git; | ||
| - private $hash; | ||
| - | ||
| - public function __construct($git, $hash) { | ||
| - $this->git = $git; | ||
| - $this->hash = $hash; | ||
| - } | ||
| - | ||
| - public function render() { | ||
| - while (ob_get_level()) ob_end_clean(); | ||
| - | ||
| - $size = $this->git->getObjectSize($this->hash); | ||
| - $filename = $_GET['name'] ?? 'file'; | ||
| - | ||
| - $buffer = ''; | ||
| - $this->git->stream($this->hash, function($d) use (&$buffer) { | ||
| - if (strlen($buffer) < 12) $buffer .= $d; | ||
| - }); | ||
| - | ||
| - $mime = MediaTypeSniffer::isMediaType($buffer, $filename); | ||
| - if (!$mime) $mime = 'application/octet-stream'; | ||
| - | ||
| - header('Content-Type: ' . $mime); | ||
| - header('Content-Length: ' . $size); | ||
| - header('Content-Disposition: inline; filename="' . basename($filename) . '"'); | ||
| - | ||
| - $this->git->stream($this->hash, function($data) { | ||
| - echo $data; | ||
| - }); | ||
| - | ||
| - exit; | ||
| - } | ||
| -} | ||
| - | ||
| -<?php | ||
| -require_once 'Git.php'; | ||
| -require_once 'Config.php'; | ||
| -require_once 'Page.php'; | ||
| -require_once 'Router.php'; | ||
| - | ||
| -Config::init(); | ||
| - | ||
| -$repoRoot = new Git(Config::getReposPath()); | ||
| -$repositories = []; | ||
| -$repoRoot->eachRepository(function($repo) use (&$repositories) { | ||
| - $repositories[] = $repo; | ||
| -}); | ||
| - | ||
| -$router = new Router($repositories); | ||
| -$page = $router->route(); | ||
| -$page->render(); | ||
| - | ||
| -* { | ||
| - margin: 0; | ||
| - padding: 0; | ||
| - box-sizing: border-box; | ||
| -} | ||
| - | ||
| -body { | ||
| - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; | ||
| - background: #0d1117; | ||
| - color: #c9d1d9; | ||
| - line-height: 1.6; | ||
| -} | ||
| - | ||
| -.container { | ||
| - max-width: 1200px; | ||
| - margin: 0 auto; | ||
| - padding: 20px; | ||
| -} | ||
| - | ||
| -header { | ||
| - border-bottom: 1px solid #30363d; | ||
| - padding-bottom: 20px; | ||
| - margin-bottom: 30px; | ||
| -} | ||
| - | ||
| -h1 { | ||
| - color: #f0f6fc; | ||
| - font-size: 1.8rem; | ||
| - margin-bottom: 10px; | ||
| -} | ||
| - | ||
| -h2 { | ||
| - color: #f0f6fc; | ||
| - font-size: 1.4rem; | ||
| - margin: 20px 0 15px; | ||
| - padding-bottom: 10px; | ||
| - border-bottom: 1px solid #21262d; | ||
| -} | ||
| - | ||
| -h3 { | ||
| - color: #f0f6fc; | ||
| - font-size: 1.1rem; | ||
| - margin: 15px 0 10px; | ||
| -} | ||
| - | ||
| -.nav { | ||
| - margin-top: 10px; | ||
| - display: flex; | ||
| - gap: 20px; | ||
| - flex-wrap: wrap; | ||
| - align-items: center; | ||
| -} | ||
| - | ||
| -.nav a { | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.nav a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.repo-selector { | ||
| - margin-left: auto; | ||
| - display: flex; | ||
| - align-items: center; | ||
| - gap: 10px; | ||
| -} | ||
| - | ||
| -.repo-selector label { | ||
| - color: #8b949e; | ||
| - font-size: 0.875rem; | ||
| -} | ||
| - | ||
| -.repo-selector select { | ||
| - background: #21262d; | ||
| - color: #f0f6fc; | ||
| - border: 1px solid #30363d; | ||
| - padding: 6px 12px; | ||
| - border-radius: 6px; | ||
| - font-size: 0.875rem; | ||
| - cursor: pointer; | ||
| -} | ||
| - | ||
| -.repo-selector select:hover { | ||
| - border-color: #58a6ff; | ||
| -} | ||
| - | ||
| -.commit-list { | ||
| - list-style: none; | ||
| -} | ||
| - | ||
| -.commit-item { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 16px; | ||
| - margin-bottom: 12px; | ||
| - transition: border-color 0.2s; | ||
| -} | ||
| - | ||
| -.commit-item:hover { | ||
| - border-color: #58a6ff; | ||
| -} | ||
| - | ||
| -.commit-hash { | ||
| - font-family: 'SFMono-Regular', Consolas, monospace; | ||
| - font-size: 0.85rem; | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.commit-hash:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.commit-meta { | ||
| - font-size: 0.875rem; | ||
| - color: #8b949e; | ||
| - margin-top: 8px; | ||
| -} | ||
| - | ||
| -.commit-author { | ||
| - color: #f0f6fc; | ||
| - font-weight: 500; | ||
| -} | ||
| - | ||
| -.commit-date { | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.commit-message { | ||
| - margin-top: 8px; | ||
| - color: #c9d1d9; | ||
| - white-space: pre-wrap; | ||
| -} | ||
| - | ||
| -.file-list { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - overflow: hidden; | ||
| -} | ||
| - | ||
| -.file-item { | ||
| - display: flex; | ||
| - align-items: center; | ||
| - padding: 12px 16px; | ||
| - border-bottom: 1px solid #21262d; | ||
| - text-decoration: none; | ||
| - color: #c9d1d9; | ||
| - transition: background 0.2s; | ||
| -} | ||
| - | ||
| -.file-item:last-child { | ||
| - border-bottom: none; | ||
| -} | ||
| - | ||
| -.file-item:hover { | ||
| - background: #1f242c; | ||
| -} | ||
| - | ||
| -.file-mode { | ||
| - font-family: monospace; | ||
| - color: #8b949e; | ||
| - width: 80px; | ||
| - font-size: 0.875rem; | ||
| -} | ||
| - | ||
| -.file-name { | ||
| - flex: 1; | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.file-item:hover .file-name { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.breadcrumb { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 12px 16px; | ||
| - margin-bottom: 20px; | ||
| -} | ||
| - | ||
| -.breadcrumb a { | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.breadcrumb a:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.breadcrumb span { | ||
| - color: #8b949e; | ||
| - margin: 0 8px; | ||
| -} | ||
| - | ||
| -.blob-content { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - overflow: hidden; | ||
| -} | ||
| - | ||
| -.blob-header { | ||
| - background: #21262d; | ||
| - padding: 12px 16px; | ||
| - border-bottom: 1px solid #30363d; | ||
| - font-size: 0.875rem; | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.blob-code { | ||
| - padding: 16px; | ||
| - overflow-x: auto; | ||
| - font-family: 'SFMono-Regular', Consolas, monospace; | ||
| - font-size: 0.875rem; | ||
| - line-height: 1.6; | ||
| - white-space: pre; | ||
| -} | ||
| - | ||
| -.refs-list { | ||
| - display: grid; | ||
| - gap: 10px; | ||
| -} | ||
| - | ||
| -.ref-item { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 12px 16px; | ||
| - display: flex; | ||
| - align-items: center; | ||
| - gap: 12px; | ||
| -} | ||
| - | ||
| -.ref-type { | ||
| - background: #238636; | ||
| - color: white; | ||
| - padding: 2px 8px; | ||
| - border-radius: 12px; | ||
| - font-size: 0.75rem; | ||
| - font-weight: 600; | ||
| - text-transform: uppercase; | ||
| -} | ||
| - | ||
| -.ref-type.tag { | ||
| - background: #8957e5; | ||
| -} | ||
| - | ||
| -.ref-name { | ||
| - font-weight: 600; | ||
| - color: #f0f6fc; | ||
| -} | ||
| - | ||
| -.empty-state { | ||
| - text-align: center; | ||
| - padding: 60px 20px; | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.commit-details { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 6px; | ||
| - padding: 20px; | ||
| - margin-bottom: 20px; | ||
| -} | ||
| - | ||
| -.commit-header { | ||
| - margin-bottom: 20px; | ||
| -} | ||
| - | ||
| -.commit-title { | ||
| - font-size: 1.25rem; | ||
| - color: #f0f6fc; | ||
| - margin-bottom: 10px; | ||
| -} | ||
| - | ||
| -.commit-info { | ||
| - display: grid; | ||
| - gap: 8px; | ||
| - font-size: 0.875rem; | ||
| -} | ||
| - | ||
| -.commit-info-row { | ||
| - display: flex; | ||
| - gap: 10px; | ||
| -} | ||
| - | ||
| -.commit-info-label { | ||
| - color: #8b949e; | ||
| - width: 80px; | ||
| - flex-shrink: 0; | ||
| -} | ||
| - | ||
| -.commit-info-value { | ||
| - color: #c9d1d9; | ||
| - font-family: monospace; | ||
| -} | ||
| - | ||
| -.parent-link { | ||
| - color: #58a6ff; | ||
| - text-decoration: none; | ||
| -} | ||
| - | ||
| -.parent-link:hover { | ||
| - text-decoration: underline; | ||
| -} | ||
| - | ||
| -.repo-grid { | ||
| - display: grid; | ||
| - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | ||
| - gap: 16px; | ||
| - margin-top: 20px; | ||
| -} | ||
| - | ||
| -.repo-card { | ||
| - background: #161b22; | ||
| - border: 1px solid #30363d; | ||
| - border-radius: 8px; | ||
| - padding: 20px; | ||
| - text-decoration: none; | ||
| - color: inherit; | ||
| - transition: border-color 0.2s, transform 0.1s; | ||
| -} | ||
| - | ||
| -.repo-card:hover { | ||
| - border-color: #58a6ff; | ||
| - transform: translateY(-2px); | ||
| -} | ||
| - | ||
| -.repo-card h3 { | ||
| - color: #58a6ff; | ||
| - margin-bottom: 8px; | ||
| - font-size: 1.1rem; | ||
| -} | ||
| - | ||
| -.repo-card p { | ||
| - color: #8b949e; | ||
| - font-size: 0.875rem; | ||
| - margin: 0; | ||
| -} | ||
| - | ||
| -.current-repo { | ||
| - background: #21262d; | ||
| - border: 1px solid #58a6ff; | ||
| - padding: 8px 16px; | ||
| - border-radius: 6px; | ||
| - font-size: 0.875rem; | ||
| - color: #f0f6fc; | ||
| -} | ||
| - | ||
| -.current-repo strong { | ||
| - color: #58a6ff; | ||
| -} | ||
| - | ||
| -.dir-icon, .file-icon { | ||
| - display: inline-block; | ||
| - width: 20px; | ||
| - text-align: center; | ||
| - margin-right: 8px; | ||
| - color: #8b949e; | ||
| -} | ||
| - | ||
| -.branch-badge { | ||
| - background: #238636; | ||
| - color: white; | ||
| - padding: 2px 8px; | ||
| - border-radius: 12px; | ||
| - font-size: 0.75rem; | ||
| - font-weight: 600; | ||
| - margin-left: 10px; | ||
| -} | ||
| - | ||
| -.commit-list { margin-top: 20px; } | ||
| -.commit-row { display: flex; padding: 10px 0; border-bottom: 1px solid #eee; gap: 15px; align-items: baseline; } | ||
| -.commit-row:last-child { border-bottom: none; } | ||
| -.commit-row .sha { font-family: monospace; color: #0366d6; text-decoration: none; } | ||
| -.commit-row .message { flex: 1; font-weight: 500; } | ||
| -.commit-row .meta { font-size: 0.85em; color: #666; white-space: nowrap; } | ||
| - | ||
| +* { | ||
| + margin: 0; | ||
| + padding: 0; | ||
| + box-sizing: border-box; | ||
| +} | ||
| + | ||
| +body { | ||
| + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; | ||
| + background: #0d1117; | ||
| + color: #c9d1d9; | ||
| + line-height: 1.6; | ||
| +} | ||
| + | ||
| +.container { | ||
| + max-width: 1200px; | ||
| + margin: 0 auto; | ||
| + padding: 20px; | ||
| +} | ||
| + | ||
| +header { | ||
| + border-bottom: 1px solid #30363d; | ||
| + padding-bottom: 20px; | ||
| + margin-bottom: 30px; | ||
| +} | ||
| + | ||
| +h1 { | ||
| + color: #f0f6fc; | ||
| + font-size: 1.8rem; | ||
| + margin-bottom: 10px; | ||
| +} | ||
| + | ||
| +h2 { | ||
| + color: #f0f6fc; | ||
| + font-size: 1.4rem; | ||
| + margin: 20px 0 15px; | ||
| + padding-bottom: 10px; | ||
| + border-bottom: 1px solid #21262d; | ||
| +} | ||
| + | ||
| +h3 { | ||
| + color: #f0f6fc; | ||
| + font-size: 1.1rem; | ||
| + margin: 15px 0 10px; | ||
| +} | ||
| + | ||
| +.nav { | ||
| + margin-top: 10px; | ||
| + display: flex; | ||
| + gap: 20px; | ||
| + flex-wrap: wrap; | ||
| + align-items: center; | ||
| +} | ||
| + | ||
| +.nav a { | ||
| + color: #58a6ff; | ||
| + text-decoration: none; | ||
| +} | ||
| + | ||
| +.nav a:hover { | ||
| + text-decoration: underline; | ||
| +} | ||
| + | ||
| +.repo-selector { | ||
| + margin-left: auto; | ||
| + display: flex; | ||
| + align-items: center; | ||
| + gap: 10px; | ||
| +} | ||
| + | ||
| +.repo-selector label { | ||
| + color: #8b949e; | ||
| + font-size: 0.875rem; | ||
| +} | ||
| + | ||
| +.repo-selector select { | ||
| + background: #21262d; | ||
| + color: #f0f6fc; | ||
| + border: 1px solid #30363d; | ||
| + padding: 6px 12px; | ||
| + border-radius: 6px; | ||
| + font-size: 0.875rem; | ||
| + cursor: pointer; | ||
| +} | ||
| + | ||
| +.repo-selector select:hover { | ||
| + border-color: #58a6ff; | ||
| +} | ||
| + | ||
| +.commit-list { | ||
| + list-style: none; | ||
| +} | ||
| + | ||
| +.commit-item { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + padding: 16px; | ||
| + margin-bottom: 12px; | ||
| + transition: border-color 0.2s; | ||
| +} | ||
| + | ||
| +.commit-item:hover { | ||
| + border-color: #58a6ff; | ||
| +} | ||
| + | ||
| +.commit-hash { | ||
| + font-family: 'SFMono-Regular', Consolas, monospace; | ||
| + font-size: 0.85rem; | ||
| + color: #58a6ff; | ||
| + text-decoration: none; | ||
| +} | ||
| + | ||
| +.commit-hash:hover { | ||
| + text-decoration: underline; | ||
| +} | ||
| + | ||
| +.commit-meta { | ||
| + font-size: 0.875rem; | ||
| + color: #8b949e; | ||
| + margin-top: 8px; | ||
| +} | ||
| + | ||
| +.commit-author { | ||
| + color: #f0f6fc; | ||
| + font-weight: 500; | ||
| +} | ||
| + | ||
| +.commit-date { | ||
| + color: #8b949e; | ||
| +} | ||
| + | ||
| +.commit-message { | ||
| + margin-top: 8px; | ||
| + color: #c9d1d9; | ||
| + white-space: pre-wrap; | ||
| +} | ||
| + | ||
| +.file-list { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + overflow: hidden; | ||
| +} | ||
| + | ||
| +.file-item { | ||
| + display: flex; | ||
| + align-items: center; | ||
| + padding: 12px 16px; | ||
| + border-bottom: 1px solid #21262d; | ||
| + text-decoration: none; | ||
| + color: #c9d1d9; | ||
| + transition: background 0.2s; | ||
| +} | ||
| + | ||
| +.file-item:last-child { | ||
| + border-bottom: none; | ||
| +} | ||
| + | ||
| +.file-item:hover { | ||
| + background: #1f242c; | ||
| +} | ||
| + | ||
| +.file-mode { | ||
| + font-family: monospace; | ||
| + color: #8b949e; | ||
| + width: 80px; | ||
| + font-size: 0.875rem; | ||
| +} | ||
| + | ||
| +.file-name { | ||
| + flex: 1; | ||
| + color: #58a6ff; | ||
| +} | ||
| + | ||
| +.file-item:hover .file-name { | ||
| + text-decoration: underline; | ||
| +} | ||
| + | ||
| +.breadcrumb { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + padding: 12px 16px; | ||
| + margin-bottom: 20px; | ||
| +} | ||
| + | ||
| +.breadcrumb a { | ||
| + color: #58a6ff; | ||
| + text-decoration: none; | ||
| +} | ||
| + | ||
| +.breadcrumb a:hover { | ||
| + text-decoration: underline; | ||
| +} | ||
| + | ||
| +.breadcrumb span { | ||
| + color: #8b949e; | ||
| + margin: 0 8px; | ||
| +} | ||
| + | ||
| +.blob-content { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + overflow: hidden; | ||
| +} | ||
| + | ||
| +.blob-header { | ||
| + background: #21262d; | ||
| + padding: 12px 16px; | ||
| + border-bottom: 1px solid #30363d; | ||
| + font-size: 0.875rem; | ||
| + color: #8b949e; | ||
| +} | ||
| + | ||
| +.blob-code { | ||
| + padding: 16px; | ||
| + overflow-x: auto; | ||
| + font-family: 'SFMono-Regular', Consolas, monospace; | ||
| + font-size: 0.875rem; | ||
| + line-height: 1.6; | ||
| + white-space: pre; | ||
| +} | ||
| + | ||
| +.refs-list { | ||
| + display: grid; | ||
| + gap: 10px; | ||
| +} | ||
| + | ||
| +.ref-item { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + padding: 12px 16px; | ||
| + display: flex; | ||
| + align-items: center; | ||
| + gap: 12px; | ||
| +} | ||
| + | ||
| +.ref-type { | ||
| + background: #238636; | ||
| + color: white; | ||
| + padding: 2px 8px; | ||
| + border-radius: 12px; | ||
| + font-size: 0.75rem; | ||
| + font-weight: 600; | ||
| + text-transform: uppercase; | ||
| +} | ||
| + | ||
| +.ref-type.tag { | ||
| + background: #8957e5; | ||
| +} | ||
| + | ||
| +.ref-name { | ||
| + font-weight: 600; | ||
| + color: #f0f6fc; | ||
| +} | ||
| + | ||
| +.empty-state { | ||
| + text-align: center; | ||
| + padding: 60px 20px; | ||
| + color: #8b949e; | ||
| +} | ||
| + | ||
| +.commit-details { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + padding: 20px; | ||
| + margin-bottom: 20px; | ||
| +} | ||
| + | ||
| +.commit-header { | ||
| + margin-bottom: 20px; | ||
| +} | ||
| + | ||
| +.commit-title { | ||
| + font-size: 1.25rem; | ||
| + color: #f0f6fc; | ||
| + margin-bottom: 10px; | ||
| +} | ||
| + | ||
| +.commit-info { | ||
| + display: grid; | ||
| + gap: 8px; | ||
| + font-size: 0.875rem; | ||
| +} | ||
| + | ||
| +.commit-info-row { | ||
| + display: flex; | ||
| + gap: 10px; | ||
| +} | ||
| + | ||
| +.commit-info-label { | ||
| + color: #8b949e; | ||
| + width: 80px; | ||
| + flex-shrink: 0; | ||
| +} | ||
| + | ||
| +.commit-info-value { | ||
| + color: #c9d1d9; | ||
| + font-family: monospace; | ||
| +} | ||
| + | ||
| +.parent-link { | ||
| + color: #58a6ff; | ||
| + text-decoration: none; | ||
| +} | ||
| + | ||
| +.parent-link:hover { | ||
| + text-decoration: underline; | ||
| +} | ||
| + | ||
| +.repo-grid { | ||
| + display: grid; | ||
| + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | ||
| + gap: 16px; | ||
| + margin-top: 20px; | ||
| +} | ||
| + | ||
| +.repo-card { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 8px; | ||
| + padding: 20px; | ||
| + text-decoration: none; | ||
| + color: inherit; | ||
| + transition: border-color 0.2s, transform 0.1s; | ||
| +} | ||
| + | ||
| +.repo-card:hover { | ||
| + border-color: #58a6ff; | ||
| + transform: translateY(-2px); | ||
| +} | ||
| + | ||
| +.repo-card h3 { | ||
| + color: #58a6ff; | ||
| + margin-bottom: 8px; | ||
| + font-size: 1.1rem; | ||
| +} | ||
| + | ||
| +.repo-card p { | ||
| + color: #8b949e; | ||
| + font-size: 0.875rem; | ||
| + margin: 0; | ||
| +} | ||
| + | ||
| +.current-repo { | ||
| + background: #21262d; | ||
| + border: 1px solid #58a6ff; | ||
| + padding: 8px 16px; | ||
| + border-radius: 6px; | ||
| + font-size: 0.875rem; | ||
| + color: #f0f6fc; | ||
| +} | ||
| + | ||
| +.current-repo strong { | ||
| + color: #58a6ff; | ||
| +} | ||
| + | ||
| +.dir-icon, .file-icon { | ||
| + display: inline-block; | ||
| + width: 20px; | ||
| + text-align: center; | ||
| + margin-right: 8px; | ||
| + color: #8b949e; | ||
| +} | ||
| + | ||
| +.branch-badge { | ||
| + background: #238636; | ||
| + color: white; | ||
| + padding: 2px 8px; | ||
| + border-radius: 12px; | ||
| + font-size: 0.75rem; | ||
| + font-weight: 600; | ||
| + margin-left: 10px; | ||
| +} | ||
| + | ||
| +.commit-list { margin-top: 20px; } | ||
| +.commit-row { display: flex; padding: 10px 0; border-bottom: 1px solid #eee; gap: 15px; align-items: baseline; } | ||
| +.commit-row:last-child { border-bottom: none; } | ||
| +.commit-row .sha { font-family: monospace; color: #0366d6; text-decoration: none; } | ||
| +.commit-row .message { flex: 1; font-weight: 500; } | ||
| +.commit-row .meta { font-size: 0.85em; color: #666; white-space: nowrap; } | ||
| + | ||
| -<?php | ||
| -function sanitizeRepoName($repo) { | ||
| - $repo = basename($repo); | ||
| - if (preg_match('/^[a-zA-Z0-9._-]+$/', $repo)) { | ||
| - return $repo; | ||
| - } | ||
| - return ''; | ||
| -} | ||
| - | ||
| -function sanitizePath($path) { | ||
| - $path = str_replace(['../', '..\\', "\0"], '', $path); | ||
| - $path = ltrim($path, '/'); | ||
| - return $path; | ||
| -} | ||
| - | ||
| -function sanitizeHash($hash) { | ||
| - if (preg_match('/^[a-f0-9]{40}$/i', $hash)) { | ||
| - return $hash; | ||
| - } | ||
| - return ''; | ||
| -} | ||
| - | ||
| -function sanitizeFilename($filename) { | ||
| - $filename = basename($filename); | ||
| - $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename); | ||
| - return $filename; | ||
| -} | ||
| - | ||
| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-02-08 22:22:49 GMT-0800 |
| Commit | f4271bcd58068e26b7c1d0477fb891097ca1e2e6 |
| Parent | e6216b5 |
| Delta | 1518 lines added, 3060 lines removed, 1542-line decrease |