Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
Config.php
+<?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');
+ }
+}
+
File.php
+<?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';
+ }
+}
+
FileRenderer.php
+<?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>';
+ }
+}
+
Git.php
<?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;
}
}
MediaTypeSniffer.php
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/' );
}
}
?>
+
Page.php
+<?php
+interface Page {
+ public function render();
+}
Router.php
+<?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);
+ }
+}
+
Views.php
+<?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) . ' &bull; ' . 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;
+ }
+}
+
config.php
-<?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');
dark.css
-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;
-}
-
favicon.ico
index.php
<?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';
light.css
-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;
-}
-
main.css
-* {
- 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;
-}
-
new/Config.php
-<?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');
- }
-}
-
new/File.php
-<?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';
- }
-}
-
new/FileRenderer.php
-<?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>';
- }
-}
-
new/Git.php
-<?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;
- }
-}
-
new/MediaTypeSniffer.php
-<?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/' );
- }
-}
-?>
-
new/Page.php
-<?php
-interface Page {
- public function render();
-}
new/Router.php
-<?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);
- }
-}
-
new/Views.php
-<?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) . ' &bull; ' . 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;
- }
-}
-
new/index.php
-<?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();
-
new/repo.css
-* {
- 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; }
-
repo.css
+* {
+ 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; }
+
security.php
-<?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;
-}
-

Uses new repo viewer

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