Nimbin[12]?Web & Tools / Git-Server / web/index.php

Git-Server git · main

gitsync + this read-only git browser

git php bash apache self-hosted · first commit 2026-10-05 · last commit 2026-10-06 (3 days ago) · synced 3 days ago

PHP 77.1% CSS 11.4% Markdown 8.6% Makefile 2.9%
git clone https://git.christianimmanuel.de/web-tools/Git-Server.gitwget https://git.christianimmanuel.de/web-tools/Git-Server/archive/Git-Server.tar.gz
web/index.php 37.1 KB · 569 lines raw
<?php
declare(strict_types=1);
/* git.christianimmanuel.de - read-only git browser. 100% Vibecode but tested. Works in lynx. */
const VERSION = '1.12.0';
const SITE = 'Nimbin[12]?';
const LEGAL = 'https://christianimmanuel.de';
$ROOT   = __DIR__ . '/repos';
$PREFIX = '';                                   /* '/private' when browsing the protected area */
$HOST   = $_SERVER['HTTP_HOST'] ?? 'git.christianimmanuel.de';
$SCHEME = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$BASE   = "$SCHEME://$HOST";
$LANG   = ['c'=>'c','h'=>'c','cpp'=>'cpp','cc'=>'cpp','hpp'=>'cpp','py'=>'python','sh'=>'bash','bash'=>'bash',
           'php'=>'php','js'=>'javascript','ts'=>'typescript','html'=>'xml','xml'=>'xml','css'=>'css','json'=>'json',
           'yml'=>'yaml','yaml'=>'yaml','rs'=>'rust','go'=>'go','java'=>'java','vim'=>'vim','mk'=>'makefile',
           'makefile'=>'makefile','ini'=>'ini','conf'=>'ini','toml'=>'ini','sql'=>'sql','lua'=>'lua','glsl'=>'glsl',
           'vert'=>'glsl','frag'=>'glsl','s'=>'x86asm','asm'=>'x86asm','diff'=>'diff','patch'=>'diff'];
$MIME   = ['png'=>'image/png','jpg'=>'image/jpeg','jpeg'=>'image/jpeg','gif'=>'image/gif','svg'=>'image/svg+xml',
           'webp'=>'image/webp','pdf'=>'application/pdf','ico'=>'image/x-icon'];
$LCOLOR = ['C'=>'#555555','C++'=>'#f34b7d','Python'=>'#3572A5','Shell'=>'#89e051','JavaScript'=>'#f1e05a','TypeScript'=>'#3178c6',
           'PHP'=>'#4F5D95','HTML'=>'#e34c26','CSS'=>'#663399','Rust'=>'#dea584','Go'=>'#00ADD8','Java'=>'#b07219',
           'Vim Script'=>'#199f4b','Lua'=>'#000080','GLSL'=>'#5686a5','Assembly'=>'#6E4C13','Markdown'=>'#083fa1',
           'Makefile'=>'#427819','Ruby'=>'#701516','Perl'=>'#0298c3','C#'=>'#178600','Kotlin'=>'#A97BFF','Swift'=>'#F05138','TeX'=>'#3D6117'];

function h(?string $s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
function valid(string $s): bool { return $s !== '.' && $s !== '..' && preg_match('/^[A-Za-z0-9._ +-]+$/', $s) === 1; }
function git(string $dir, string ...$args): string {
    $cmd = 'git -c safe.directory=* -C ' . escapeshellarg($dir);
    foreach ($args as $a) $cmd .= ' ' . escapeshellarg($a);
    return (string)shell_exec($cmd . ' 2>/dev/null');
}
function safe_path(array $segs): ?string {
    foreach ($segs as $s) if (!valid($s)) return null;
    return implode('/', $segs);
}
function ago(int $t): string {
    if ($t <= 0) return '';
    $d = time() - $t;
    foreach ([31536000=>'year', 2592000=>'month', 86400=>'day', 3600=>'hour', 60=>'minute'] as $s => $n)
        if ($d >= $s) { $v = intdiv($d, $s); return "$v $n" . ($v > 1 ? 's' : '') . ' ago'; }
    return 'just now';
}
function ext(string $name): string {
    $b = strtolower(basename($name));
    return $b === 'makefile' ? 'makefile' : strtolower(pathinfo($b, PATHINFO_EXTENSION));
}
function fmt_size(int $b): string {
    if ($b < 1024) return "$b B";
    if ($b < 1048576) return round($b / 1024, 1) . ' KB';
    return round($b / 1048576, 1) . ' MB';
}
function lcolor(string $l): string { global $LCOLOR; return $LCOLOR[$l] ?? '#' . substr(md5($l), 0, 6); }

/* ---------- data ---------- */
function categories(): array {
    global $ROOT; $cats = [];
    $f = "$ROOT/.categories";
    if (is_file($f)) foreach (file($f, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $l) {
        [$slug, $title] = array_pad(explode('|', $l, 2), 2, '');
        if (valid($slug) && is_dir("$ROOT/$slug")) $cats[$slug] = $title !== '' ? $title : $slug;
    }
    if (is_dir($ROOT)) foreach (scandir($ROOT) as $d)
        if ($d[0] !== '.' && is_dir("$ROOT/$d") && !isset($cats[$d])) $cats[$d] = $d;
    return $cats;
}
function load_repo(string $cat, string $name): ?array {
    global $ROOT, $BASE, $PREFIX, $SCHEME, $HOST;
    if (!valid($cat) || !valid($name)) return null;
    if (is_dir("$ROOT/$cat/$name.git"))   { $dir = "$ROOT/$cat/$name.git"; $isgit = true;  $mf = "$dir/gitsync.meta"; }
    elseif (is_dir("$ROOT/$cat/$name"))   { $dir = "$ROOT/$cat/$name";     $isgit = false; $mf = "$dir/.gitsync.meta"; }
    else return null;
    $m = ['description'=>'', 'tags'=>'', 'origin'=>'', 'created'=>'0', 'modified'=>'0', 'languages'=>'', 'synced'=>'0'];
    if (is_file($mf)) foreach (file($mf, FILE_IGNORE_NEW_LINES) as $l)
        if (str_contains($l, '=')) { [$k, $v] = explode('=', $l, 2); $m[$k] = trim($v); }
    $langs = []; $total = 0;
    foreach (array_filter(explode(',', $m['languages'])) as $p) { [$l, $b] = array_pad(explode(':', $p), 2, '0'); $langs[$l] = (int)$b; $total += (int)$b; }
    foreach ($langs as $l => $b) $langs[$l] = $total ? $b / $total * 100 : 0;
    $url = $PREFIX . '/' . rawurlencode($cat) . '/' . rawurlencode($name);
    return ['cat'=>$cat, 'name'=>$name, 'dir'=>$dir, 'git'=>$isgit, 'desc'=>$m['description'], 'url'=>$url,
            'tags'=>array_values(array_filter(explode(',', $m['tags']))), 'origin'=>$m['origin'],
            'created'=>(int)$m['created'], 'modified'=>(int)$m['modified'] ?: ($isgit ? 0 : (int)filemtime($dir)), 'synced'=>(int)$m['synced'],
            'langs'=>$langs, 'private'=>$PREFIX !== '',
            'clone'=>($PREFIX ? "$SCHEME://USER@$HOST" : $BASE) . "$url.git", 'archive'=>"$BASE$url/archive/" . rawurlencode($name) . '.tar.gz'];
}
function repos_in(string $cat): array {
    global $ROOT; $out = [];
    foreach (scandir("$ROOT/$cat") as $d) {
        if ($d[0] === '.' || !is_dir("$ROOT/$cat/$d")) continue;
        $r = load_repo($cat, preg_replace('/\.git$/', '', $d));
        if ($r) $out[$r['name']] = $r;
    }
    ksort($out, SORT_NATURAL | SORT_FLAG_CASE);
    return $out;
}
function parse_commit(string $l): ?array {
    if ($l === '') return null;
    [$H, $h, $an, $at, $s] = array_pad(explode("\x1f", $l), 5, '');
    return ['H'=>$H, 'h'=>$h, 'author'=>$an, 'time'=>(int)$at, 'subject'=>$s];
}
const LOGFMT = '--format=%H%x1f%h%x1f%an%x1f%at%x1f%s';
function last_commit(array $r): ?array { return $r['git'] ? parse_commit(trim(git($r['dir'], 'log', '-1', LOGFMT))) : null; }
function branch(array $r): string { $b = trim(git($r['dir'], 'symbolic-ref', '--short', 'HEAD')); return $b !== '' ? $b : 'HEAD'; }
function obj_type(array $r, string $p): string {
    if ($r['git']) return trim(git($r['dir'], 'cat-file', '-t', 'HEAD:' . $p));
    $f = "{$r['dir']}/$p";
    return is_dir($f) ? 'tree' : (is_file($f) ? 'blob' : '');
}
function tree(array $r, string $p): array {
    $items = [];
    if ($r['git']) {
        $spec = $p === '' ? 'HEAD' : "HEAD:$p";
        foreach (explode("\n", trim(git($r['dir'], 'ls-tree', '-l', $spec))) as $l) {
            if ($l === '' || !preg_match('/^(\d+) (\w+) (\w+) +(-|\d+)\t(.+)$/', $l, $m)) continue;
            $items[] = ['name'=>$m[5], 'dir'=>$m[2] === 'tree', 'size'=>$m[4] === '-' ? 0 : (int)$m[4]];
        }
    } else {
        $base = rtrim("{$r['dir']}/$p", '/');
        foreach (scandir($base) as $f) {
            if ($f === '.' || $f === '..' || $f === '.gitsync.meta') continue;
            $items[] = ['name'=>$f, 'dir'=>is_dir("$base/$f"), 'size'=>is_file("$base/$f") ? (int)filesize("$base/$f") : 0];
        }
    }
    usort($items, fn($a, $b) => [$b['dir'], strtolower($a['name'])] <=> [$a['dir'], strtolower($b['name'])]);
    return $items;
}
function blob(array $r, string $p): string {
    return $r['git'] ? git($r['dir'], 'cat-file', 'blob', "HEAD:$p") : (string)file_get_contents("{$r['dir']}/$p");
}
function find_readme(array $r): ?string {
    foreach (tree($r, '') as $i) if (!$i['dir'] && preg_match('/^readme(\.(md|markdown|txt))?$/i', $i['name'])) return $i['name'];
    return null;
}
function activity_days(array $r): array {           /* day => commits, last 53 weeks */
    $days = [];
    foreach (explode("\n", trim(git($r['dir'], 'log', '--all', '--since=53 weeks ago', '--format=%at'))) as $t)
        if ($t !== '') { $k = date('Y-m-d', (int)$t); $days[$k] = ($days[$k] ?? 0) + 1; }
    return $days;
}

/* ---------- markdown (server side, keeps lynx happy) ---------- */
function md_inline(string $s, string $rawbase): string {
    $s = h($s);
    $s = preg_replace_callback('/`([^`]+)`/', fn($m) => '<code>' . $m[1] . '</code>', $s);
    $fix = fn($u) => preg_match('#^([a-z]+:|/|\#)#i', $u) ? $u : $rawbase . ltrim($u, './');
    $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/', fn($m) => '<img src="' . h($fix($m[2])) . '" alt="' . $m[1] . '">', $s);
    $s = preg_replace_callback('/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/', fn($m) => '<a href="' . h($fix($m[2])) . '">' . $m[1] . '</a>', $s);
    $s = preg_replace('#(?<![">])\bhttps?://[^\s<]+#', '<a href="$0">$0</a>', $s);
    $s = preg_replace('/(\*\*|__)(.+?)\1/', '<b>$2</b>', $s);
    $s = preg_replace('/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/', '<i>$2</i>', $s);
    $s = preg_replace('/~~(.+?)~~/', '<s>$1</s>', $s);
    return $s;
}
function md(string $src, string $rawbase): string {
    $lines = explode("\n", str_replace("\r", '', $src)); $n = count($lines); $out = ''; $i = 0;
    $list = ''; $para = [];
    $close_list = function () use (&$list, &$out) { if ($list) { $out .= "</$list>"; $list = ''; } };
    $flush = function () use (&$para, &$out, $rawbase) { if ($para) { $out .= '<p>' . md_inline(implode("\n", $para), $rawbase) . '</p>'; $para = []; } };
    while ($i < $n) {
        $l = $lines[$i];
        if (preg_match('/^\s*(```|~~~)\s*(\w*)/', $l, $m)) {
            $flush(); $close_list(); $buf = []; $i++;
            while ($i < $n && !preg_match('/^\s*' . preg_quote($m[1]) . '/', $lines[$i])) $buf[] = $lines[$i++];
            $i++; $out .= '<pre><code' . ($m[2] ? ' class="language-' . h($m[2]) . '"' : '') . '>' . h(implode("\n", $buf)) . "</code></pre>"; continue;
        }
        if (preg_match('/^(#{1,6})\s+(.*?)\s*#*$/', $l, $m)) { $flush(); $close_list(); $k = strlen($m[1]); $out .= "<h$k>" . md_inline($m[2], $rawbase) . "</h$k>"; }
        elseif (preg_match('/^\s*[-*+]\s+(.*)/', $l, $m)) { $flush(); if ($list !== 'ul') { $close_list(); $out .= '<ul>'; $list = 'ul'; } $out .= '<li>' . md_inline($m[1], $rawbase) . '</li>'; }
        elseif (preg_match('/^\s*\d+[.)]\s+(.*)/', $l, $m)) { $flush(); if ($list !== 'ol') { $close_list(); $out .= '<ol>'; $list = 'ol'; } $out .= '<li>' . md_inline($m[1], $rawbase) . '</li>'; }
        elseif (preg_match('/^>\s?(.*)/', $l, $m)) { $flush(); $close_list(); $out .= '<blockquote>' . md_inline($m[1], $rawbase) . '</blockquote>'; }
        elseif (preg_match('/^\s*([-*_])(\s*\1){2,}\s*$/', $l)) { $flush(); $close_list(); $out .= '<hr>'; }
        elseif (str_starts_with(trim($l), '|')) {
            $flush(); $close_list(); $rows = [];
            while ($i < $n && str_starts_with(trim($lines[$i]), '|')) { $rows[] = array_map('trim', explode('|', trim(trim($lines[$i]), '|'))); $i++; }
            $out .= '<table class="md">';
            foreach ($rows as $ri => $cells) {
                if ($ri === 1 && preg_match('/^:?-+:?$/', $cells[0] ?? 'x')) continue;
                $tag = $ri === 0 ? 'th' : 'td';
                $out .= '<tr>' . implode('', array_map(fn($c) => "<$tag>" . md_inline($c, $rawbase) . "</$tag>", $cells)) . '</tr>';
            }
            $out .= '</table>'; continue;
        }
        elseif (preg_match('/^(    |\t)(.*)/', $l, $m) && !$para && !$list) {
            $buf = []; while ($i < $n && preg_match('/^(    |\t)(.*)/', $lines[$i], $m2)) { $buf[] = $m2[2]; $i++; }
            $out .= '<pre><code>' . h(implode("\n", $buf)) . '</code></pre>'; continue;
        }
        elseif (trim($l) === '') { $flush(); $close_list(); }
        else $para[] = $l;
        $i++;
    }
    $flush(); $close_list();
    return $out;
}

/* ---------- html ---------- */
function page_start(string $title, array $crumbs = []): void {
    global $HOST;
    echo "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">";
    echo '<title>' . h($title) . ' · ' . h(SITE) . '</title><link rel="stylesheet" href="/style.css">';
    echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">';
    echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" media="(prefers-color-scheme: light)">';
    $path0 = (string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
    $onscripts = str_starts_with($path0, '/scripts'); $onhome = $path0 === '/' || str_starts_with($path0, '/search'); $onpriv = str_starts_with($path0, '/private');
    echo '</head><body><header><a class="brand" href="/">' . h(SITE) . '</a>';
    echo '<nav class="topnav"><a class="' . ($onscripts || $onhome || $onpriv ? '' : 'on') . '" href="/projects">projects</a>';
    if (is_dir($GLOBALS['ROOT'] . '/.scripts')) echo '<a class="' . ($onscripts ? 'on' : '') . '" href="/scripts">scripts</a>';
    if (is_dir(__DIR__ . '/repos/.private')) echo '<a class="' . ($onpriv ? 'on' : '') . '" href="/private">private</a>';
    echo '</nav>';
    if ($crumbs) echo '<span class="crumbs">';
    $i = 0; foreach ($crumbs as $text => $href) echo ($i++ ? ' <span class="sep">/</span> ' : '') . ($href ? '<a href="' . h($href) . '">' . h((string)$text) . '</a>' : '<b>' . h((string)$text) . '</b>');
    if ($crumbs) echo '</span>';
    echo '<form class="search" action="/search" method="get"><input type="text" name="q" placeholder="search projects and scripts" value="' . h($_GET['q'] ?? '') . '"><input type="submit" value="search"></form>';
    echo '</header><main>';
}
function page_end(): void {
    global $ROOT; $sy = is_file("$ROOT/.synced") ? (int)file_get_contents("$ROOT/.synced") : 0;
    echo '</main><footer>' . ($sy ? 'last synced ' . date('Y-m-d H:i', $sy) . ' (' . ago($sy) . ') · ' : '') . '<a href="' . LEGAL . '">Impressum</a> · <a href="' . LEGAL . '">Datenschutz</a> · <a href="' . LEGAL . '">christianimmanuel.de</a><br>read-only git browser · 100% Vibecode but tested · v' . VERSION . '</footer>';
    echo '<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script><script>
document.querySelectorAll("pre code").forEach(e=>hljs.highlightElement(e));
document.querySelectorAll(".cmd").forEach(c=>{const b=document.createElement("button");b.className="copy";b.textContent="copy";b.onclick=()=>{navigator.clipboard.writeText(c.querySelector("code").textContent);b.textContent="copied";setTimeout(()=>b.textContent="copy",1500);};c.appendChild(b);});
const tt=document.createElement("a");tt.href="#";tt.className="totop";tt.textContent="↑";tt.title="back to top";tt.onclick=e=>{e.preventDefault();window.scrollTo({top:0,behavior:"smooth"});};document.body.appendChild(tt);
addEventListener("scroll",()=>tt.classList.toggle("show",scrollY>400),{passive:true});
document.querySelectorAll(".get").forEach(g=>{const cmds=[...g.querySelectorAll(".cmd")];if(cmds.length<2)return;const sw=document.createElement("div");sw.className="getopts";cmds.forEach((c,i)=>{const b=document.createElement("button");b.textContent=c.dataset.label;b.className=i?"":"on";b.onclick=()=>{cmds.forEach((x,j)=>{x.style.display=j===i?"":"none";sw.children[j].className=j===i?"on":"";});};sw.appendChild(b);if(i)c.style.display="none";});g.prepend(sw);});
</script></body></html>';
}
function not_found(): void { http_response_code(404); page_start('404'); echo '<h1>404</h1><p>Not found.</p>'; page_end(); exit; }
function hue(string $t): int { return hexdec(substr(md5($t), 0, 2)) * 360 >> 8; }
function tag_links(array $tags, string $cls = 'tag'): string {
    return implode(' ', array_map(fn($t) => '<a class="' . $cls . '" style="--h:' . hue($t) . '" href="/projects?tag=' . rawurlencode($t) . '">' . h($t) . '</a>', $tags));
}
function lang_bar(array $langs, bool $text): string {
    if (!$langs) return '';
    $bar = '<span class="langbar">';
    foreach ($langs as $l => $p) if ($p >= 0.5) $bar .= '<span style="width:' . round($p, 1) . '%;background:' . lcolor($l) . '" title="' . h($l) . '"></span>';
    $bar .= '</span>';
    if ($text) { $parts = []; foreach ($langs as $l => $p) if ($p >= 1) $parts[] = '<span class="lang"><span class="dot" style="background:' . lcolor($l) . '"></span>' . h($l) . ' ' . round($p, 1) . '%</span>';
                 $bar .= '<span class="langs">' . implode(' ', $parts) . '</span>'; }
    return $bar;
}
function cmd_box(string $cmd, string $label): string {
    return '<span class="cmd" data-label="' . h($label) . '"><code>' . h($cmd) . '</code></span>';
}
function activity_svg(array $days, string $label): string {
    $total = array_sum($days);
    $start = strtotime('monday this week') - 52 * 7 * 86400;
    $max = max(1, ...array_values($days ?: [0]));
    $svg = '<svg class="activity" viewBox="0 0 ' . (53 * 13 + 30) . ' 110" role="img" aria-label="' . h($label) . '">';
    for ($w = 0; $w < 53; $w++) for ($d = 0; $d < 7; $d++) {
        $t = $start + ($w * 7 + $d) * 86400;
        if ($t > time()) continue;
        $k = date('Y-m-d', $t); $c = $days[$k] ?? 0;
        $lvl = $c === 0 ? 0 : min(4, (int)ceil($c / $max * 4));
        $svg .= '<rect x="' . ($w * 13 + 28) . '" y="' . ($d * 13 + 14) . '" width="11" height="11" rx="2" class="l' . $lvl . '"><title>' . $k . ': ' . $c . ' commits</title></rect>';
        if ($d === 0 && date('j', $t) <= 7) $svg .= '<text x="' . ($w * 13 + 28) . '" y="10">' . date('M', $t) . '</text>';
    }
    foreach ([1=>'Mon', 3=>'Wed', 5=>'Fri'] as $d => $n) $svg .= '<text x="0" y="' . ($d * 13 + 23) . '">' . $n . '</text>';
    return '<div class="activitybox"><p class="meta">' . h($label) . ': <b>' . $total . '</b> commits in the last year</p>' . $svg . '</svg></div>';
}
function repo_header(array $r, string $active): void {
    echo '<div class="repohead"><h1><a href="' . h($r['url']) . '">' . h($r['name']) . '</a> <span class="badge">' . ($r['git'] ? 'git · ' . h(branch($r)) : 'files') . '</span></h1>';
    if ($r['desc'] !== '') echo '<p class="desc">' . h($r['desc']) . '</p>';
    echo '<p class="meta">';
    if ($r['tags']) echo tag_links($r['tags']) . ' · ';
    if ($r['created']) echo ($r['git'] ? 'first commit ' : 'oldest file ') . date('Y-m-d', $r['created']) . ' · ';
    if ($r['modified']) echo ($r['git'] ? 'last commit ' : 'last change ') . date('Y-m-d', $r['modified']) . ' (' . ago($r['modified']) . ')';
    if ($r['synced']) echo ' · synced ' . ago($r['synced']);
    if ($r['origin'] !== '') echo ' · upstream: <a href="' . h($r['origin']) . '">' . h(preg_replace('#^https?://#', '', $r['origin'])) . '</a>';
    echo '</p>' . lang_bar($r['langs'], true);
    echo '<nav class="tabs">';
    foreach (['files'=>$r['url'], 'commits'=>$r['url'] . '/commits'] as $t => $u) {
        if ($t === 'commits' && !$r['git']) continue;
        echo '<a class="' . ($t === $active ? 'on' : '') . '" href="' . h($u) . '">' . $t . '</a>';
    }
    echo '</nav><div class="get">';
    if ($r['git']) echo cmd_box('git clone ' . $r['clone'], 'git clone');
    echo cmd_box('wget ' . ($r['private'] ? '--user=USER --ask-password ' : '') . $r['archive'], 'wget tar.gz');
    echo '</div></div>';
}
function tree_link(array $r, string $p, string $kind): string {
    return $r['url'] . "/$kind/" . implode('/', array_map('rawurlencode', $p === '' ? [] : explode('/', $p)));
}
function render_tree(array $r, string $p): void {
    $items = tree($r, $p);
    echo '<table class="tree">';
    if ($p !== '') echo '<tr><td class="ico">..</td><td><a href="' . h(dirname($p) === '.' ? $r['url'] : tree_link($r, dirname($p), 'tree')) . '">parent directory</a></td><td></td></tr>';
    foreach ($items as $i) {
        $sub = ($p === '' ? '' : "$p/") . $i['name'];
        $href = tree_link($r, $sub, $i['dir'] ? 'tree' : 'blob');
        echo '<tr><td class="ico">' . ($i['dir'] ? 'd' : '-') . '</td><td><a href="' . h($href) . '">' . h($i['name']) . ($i['dir'] ? '/' : '') . '</a></td><td class="size">' . ($i['dir'] ? '' : fmt_size($i['size'])) . '</td></tr>';
    }
    if (!$items) echo '<tr><td colspan="3"><i>empty</i></td></tr>';
    echo '</table>';
}
function render_readme(array $r): void {
    $f = find_readme($r);
    if ($f === null) return;
    $c = blob($r, $f);
    echo '<section class="readme"><h3>' . h($f) . '</h3>';
    if (preg_match('/\.(md|markdown)$/i', $f)) echo '<div class="markdown">' . md($c, rtrim(tree_link($r, '', 'raw'), '/') . '/') . '</div>';
    else echo '<pre>' . h($c) . '</pre>';
    echo '</section>';
}
function commit_row(array $r, array $c): void {
    echo '<tr><td><a class="hash" href="' . h($r['url'] . '/commit/' . $c['H']) . '">' . h($c['h']) . '</a></td><td>' . h($c['subject']) . '</td><td class="meta">' . h($c['author']) . ' · ' . ago($c['time']) . '</td></tr>';
}
function card(array $r): void {
    echo '<div class="card"><div class="top"><a class="name" href="' . h($r['url']) . '">' . h($r['name']) . '</a><span class="badge">' . ($r['git'] ? 'git' : 'files') . '</span></div>';
    if ($r['desc'] !== '') echo '<p class="desc">' . h($r['desc']) . '</p>';
    if ($r['tags']) echo '<p class="tags">' . tag_links($r['tags'], 'tagt') . '</p>';
    echo lang_bar($r['langs'], false);
    echo '<p class="meta">' . ($r['modified'] ? ($r['git'] ? 'last commit ' : 'last change ') . ago($r['modified']) : '');
    $top = array_key_first($r['langs']); if ($top) echo ' · ' . h($top);
    echo '</p></div>';
}

/* ---------- scripts collection ---------- */
function script_hint(string $file): string {
    $out = ''; $in = false;
    foreach (file($file, FILE_IGNORE_NEW_LINES) as $l) {
        if (str_starts_with($l, '###') && !str_starts_with($l, '######')) { $out .= substr($l, 4) . "\n"; $in = true; }
        elseif ($in) break;
    }
    return trim($out);
}
function script_interp(string $file): string {
    $l = (string)fgets(fopen($file, 'r'));
    if (!str_starts_with($l, '#!')) return 'text';
    $i = basename(trim(explode(' ', trim(substr($l, 2)))[0]));
    if ($i === 'env') { $p = preg_split('/\s+/', trim(substr($l, 2))); $i = basename($p[1] ?? 'sh'); }
    return preg_replace('/[0-9.]+$/', '', $i) ?: $i;
}
function script_groups(): array {
    global $ROOT; $g = [];
    $base = "$ROOT/.scripts";
    if (!is_dir($base)) return $g;
    foreach (scandir($base) as $d) {
        if ($d[0] === '.' || !is_dir("$base/$d") || !valid($d)) continue;
        $files = [];
        foreach (scandir("$base/$d") as $f) if ($f[0] !== '.' && is_file("$base/$d/$f") && valid($f)) $files[] = $f;
        if ($files) $g[$d] = $files;
    }
    return $g;
}
function script_url(string $g, string $f, string $kind = ''): string {
    return '/scripts/' . ($kind ? "$kind/" : '') . rawurlencode($g) . '/' . rawurlencode($f);
}
function scripts_page(array $seg): void {
    global $ROOT, $BASE, $LANG;
    $base = "$ROOT/.scripts";
    if (!is_dir($base)) not_found();
    $groups = script_groups();
    if (isset($seg[1]) && $seg[1] === 'raw' && isset($seg[2], $seg[3]) && valid($seg[2]) && valid($seg[3]) && is_file("$base/$seg[2]/$seg[3]")) {
        header('Content-Type: text/plain; charset=utf-8'); header('X-Content-Type-Options: nosniff');
        readfile("$base/$seg[2]/$seg[3]"); exit;
    }
    if (isset($seg[1], $seg[2]) && valid($seg[1]) && valid($seg[2]) && is_file("$base/$seg[1]/$seg[2]")) {   /* one script */
        [$g, $f] = [$seg[1], $seg[2]]; $c = (string)file_get_contents("$base/$g/$f");
        page_start($f, [$g => '/scripts#' . rawurlencode($g), $f => '']);
        echo '<div class="repohead"><h1>' . h($f) . ' <span class="badge">' . h($g) . '</span></h1>';
        $hint = script_hint("$base/$g/$f"); if ($hint) echo '<p class="desc hint">' . nl2br(h($hint)) . '</p>';
        echo '<div class="get">' . cmd_box("wget $BASE" . script_url($g, $f, 'raw') . " && chmod 740 " . $f, 'wget') . '</div></div>';
        echo '<div class="filehead"><b>' . h($f) . '</b> <span class="meta">' . fmt_size(strlen($c)) . ' · ' . substr_count($c, "\n") . ' lines</span> <a class="btn" href="' . h(script_url($g, $f, 'raw')) . '">raw</a></div>';
        echo '<pre><code class="language-bash">' . h($c) . '</code></pre>';
        page_end(); exit;
    }
    if (isset($seg[1])) not_found();
    page_start('Scripts');
    echo '<h1>Scripts</h1><p class="meta">Standalone shell scripts. Every entry shows its header comment; grab one with the wget line or view the source.</p>';
    echo '<p class="catnav">' . implode(' ', array_map(fn($g) => '<a href="#' . h($g) . '">' . h($g) . ' <small>' . count($groups[$g]) . '</small></a>', array_keys($groups))) . '</p>';
    foreach ($groups as $g => $files) {
        echo '<h2 id="' . h($g) . '">' . h($g) . ' <small>' . count($files) . '</small></h2><div class="cards">';
        foreach ($files as $f) {
            $hint = script_hint("$base/$g/$f"); $short = trim(explode("\n", $hint)[0]);
            $c = (string)file_get_contents("$base/$g/$f");
            echo '<div class="card"><div class="top"><a class="name" href="' . h(script_url($g, $f)) . '">' . h($f) . '</a><span class="badge">' . h(script_interp("$base/$g/$f")) . '</span></div>';
            if ($short !== '') echo '<p class="desc">' . h($short) . '</p>';
            echo '<p class="meta">' . substr_count($c, "\n") . ' lines · ' . fmt_size(strlen($c)) . ' · ' . date('Y-m-d', (int)filemtime("$base/$g/$f")) . '</p></div>';
        }
        echo '</div>';
    }
    page_end(); exit;
}

/* ---------- routing ---------- */
$path = rawurldecode((string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH));
$seg  = array_values(array_filter(explode('/', $path), fn($s) => $s !== ''));
if ($seg && $seg[0] === 'private') {           /* protected area */
    /* Apache should already have asked for the password (<Location /private>); this is the safety net
       in case the vhost is old: verify HTTP Basic auth against .htpasswd (bcrypt entries, htpasswd -B). */
    $ok = false; $u = $_SERVER['PHP_AUTH_USER'] ?? ''; $pw = $_SERVER['PHP_AUTH_PW'] ?? '';
    if (!empty($_SERVER['REMOTE_USER'])) $ok = true;                       /* Apache did the auth */
    elseif ($u !== '' && is_file(__DIR__ . '/.htpasswd'))
        foreach (file(__DIR__ . '/.htpasswd', FILE_IGNORE_NEW_LINES) as $l) { [$n, $h] = array_pad(explode(':', $l, 2), 2, ''); if ($n === $u && $h !== '' && password_verify($pw, $h)) { $ok = true; break; } }
    if (!$ok) { header('WWW-Authenticate: Basic realm="private"'); http_response_code(401); echo '401 - private area, login required'; exit; }
    $ROOT .= '/.private'; $PREFIX = '/private'; array_shift($seg);
    if (!is_dir($ROOT)) not_found();
    if (!$seg) {
        page_start('Private', ['private' => '']);
        echo '<h1>Private</h1><p class="meta">Only for logged-in users. Clone with <code>git clone https://USER@' . h($HOST) . '/private/…</code>, download with <code>wget --user=USER --ask-password …</code>.</p>';
        $n = 0;
        foreach (categories() as $slug => $title) { $rs = repos_in($slug); if (!$rs) continue;
            echo '<h2 id="' . h($slug) . '">' . h($title) . ' <small>' . count($rs) . '</small></h2><div class="cards">'; foreach ($rs as $r) { $n++; card($r); } echo '</div>'; }
        if (!$n) echo '<p>Nothing here.</p>';
        page_end(); exit;
    }
    if (count($seg) === 1) not_found();
}
$cats = categories();


function latest_commits(array $all, int $n = 12): array {
    $out = [];
    foreach ($all as $rs) foreach ($rs as $r) if ($r['git'])
        foreach (explode("\n", trim(git($r['dir'], 'log', '-5', LOGFMT))) as $l) if ($c = parse_commit($l)) { $c['repo'] = $r; $out[] = $c; }
    usort($out, fn($a, $b) => $b['time'] <=> $a['time']);
    return array_slice($out, 0, $n);
}
if (!$seg) {                                  /* landing page */
    $all = []; foreach ($cats as $slug => $title) $all[$slug] = repos_in($slug);
    $repos = array_merge(...array_values($all ?: [[]]));
    $groups = script_groups(); $nscripts = array_sum(array_map('count', $groups));
    page_start(SITE);
    echo '<h1>' . h(SITE) . '</h1>';
    echo '<div class="cards intro"><div class="card"><div class="top"><a class="name" href="/projects">Projects</a><span class="badge">' . count($repos) . '</span></div><p class="desc">Git repositories and plain file dumps, sorted by category, with tags, languages and commit history.</p><p class="meta">' . implode(' · ', array_map(fn($t) => h($t), $cats)) . '</p></div>';
    if ($nscripts) echo '<div class="card"><div class="top"><a class="name" href="/scripts">Scripts</a><span class="badge">' . $nscripts . '</span></div><p class="desc">Standalone shell scripts with a wget line each.</p><p class="meta">' . implode(' · ', array_map(fn($g) => h($g), array_keys($groups))) . '</p></div></div>';
    else echo '</div>';
    $commits = latest_commits($all);
    if ($commits) {
        echo '<h2>Latest commits</h2><table class="commits feed">';
        foreach ($commits as $c) echo '<tr><td><a class="hash" href="' . h($c['repo']['url'] . '/commit/' . $c['H']) . '">' . h($c['h']) . '</a></td><td><a class="repo" href="' . h($c['repo']['url']) . '">' . h($c['repo']['name']) . '</a> ' . h($c['subject']) . '</td><td class="meta">' . ago($c['time']) . '</td></tr>';
        echo '</table>';
    }
    usort($repos, fn($a, $b) => $b['modified'] <=> $a['modified']);
    $recent = array_slice(array_filter($repos, fn($r) => $r['modified'] > 0), 0, 6);
    if ($recent) { echo '<h2>Recently updated</h2><div class="cards recent">'; foreach ($recent as $r) card($r); echo '</div>'; }
    $days = []; foreach ($repos as $r) if ($r['git']) foreach (activity_days($r) as $k => $c) $days[$k] = ($days[$k] ?? 0) + $c;
    if ($days) echo activity_svg($days, 'All repositories');
    page_end(); exit;
}
if ($seg[0] === 'search') {                    /* global search */
    $q = strtolower(trim($_GET['q'] ?? ''));
    page_start("search: $q");
    echo '<h1>Search</h1>';
    if ($q === '') { echo '<p class="meta">Type something into the search box.</p>'; page_end(); exit; }
    echo '<p class="meta">results for <b>' . h($q) . '</b></p>';
    $n = 0;
    foreach ($cats as $slug => $title) {
        $rs = array_filter(repos_in($slug), fn($r) => str_contains(strtolower($r['name'] . ' ' . $r['desc'] . ' ' . implode(' ', $r['tags']) . ' ' . implode(' ', array_keys($r['langs']))), $q));
        if (!$rs) continue;
        echo '<h2>' . h($title) . ' <small>' . count($rs) . '</small></h2><div class="cards">'; foreach ($rs as $r) { $n++; card($r); } echo '</div>';
    }
    $base = "$ROOT/.scripts";
    foreach (script_groups() as $g => $files) {
        $hits = array_filter($files, fn($f) => str_contains(strtolower($f . ' ' . script_hint("$base/$g/$f")), $q));
        if (!$hits) continue;
        echo '<h2>scripts / ' . h($g) . ' <small>' . count($hits) . '</small></h2><div class="cards">';
        foreach ($hits as $f) { $n++; $hint = script_hint("$base/$g/$f"); $short = trim(explode("\n", $hint)[0]);
            echo '<div class="card"><div class="top"><a class="name" href="' . h(script_url($g, $f)) . '">' . h($f) . '</a><span class="badge">' . h(script_interp("$base/$g/$f")) . '</span></div>' . ($short !== '' ? '<p class="desc">' . h($short) . '</p>' : '') . '</div>'; }
        echo '</div>';
    }
    if (!$n) echo '<p>Nothing found.</p>';
    page_end(); exit;
}
if ($seg[0] === 'scripts') scripts_page($seg);
if ($seg && $seg[0] === 'projects' && count($seg) === 1) {   /* projects index */
    $q = strtolower(trim($_GET['q'] ?? '')); $tag = strtolower(trim($_GET['tag'] ?? ''));
    $all = []; foreach ($cats as $slug => $title) $all[$slug] = repos_in($slug);
    $tagcount = []; foreach ($all as $rs) foreach ($rs as $r) foreach ($r['tags'] as $t) $tagcount[$t] = ($tagcount[$t] ?? 0) + 1;
    arsort($tagcount);
    page_start($tag ? "tag: $tag" : 'Projects');
    echo '<h1>Projects</h1>';
    $nav = []; foreach ($all as $slug => $rs) if ($rs) $nav[] = '<a href="#' . h($slug) . '">' . h($cats[$slug]) . '</a>';
    if ($nav && !$q && !$tag) echo '<p class="catnav">' . implode(' ', $nav) . '</p>';
    $alltags = isset($_GET['tags']); $shown = $alltags ? $tagcount : array_slice($tagcount, 0, 18, true);
    if ($tag && !isset($shown[$tag])) $shown[$tag] = $tagcount[$tag] ?? 0;
    if ($tagcount) echo '<p class="tagcloud">' . implode(' ', array_map(fn($t, $c) => '<a class="tag' . ($t === $tag ? ' on' : '') . '" style="--h:' . hue($t) . '" href="/projects?tag=' . rawurlencode($t) . '">' . h($t) . ' <small>' . $c . '</small></a>', array_keys($shown), $shown))
        . (!$alltags && count($tagcount) > count($shown) ? ' <a class="tag more" href="/projects?tags">+' . (count($tagcount) - count($shown)) . ' more</a>' : '') . '</p>';
    if ($q || $tag) echo '<p class="meta">filter: <b>' . h($q ?: "tag $tag") . '</b> · <a href="/projects">show all</a></p>';
    $n = 0; $days = [];
    foreach ($all as $slug => $rs) {
        $rs = array_filter($rs, function ($r) use ($q, $tag) {
            if ($tag && !in_array($tag, $r['tags'])) return false;
            return !$q || str_contains(strtolower($r['name'] . ' ' . $r['desc'] . ' ' . implode(' ', $r['tags']) . ' ' . implode(' ', array_keys($r['langs']))), $q);
        });
        if (!$rs) continue;
        echo '<h2 id="' . h($slug) . '">' . h($cats[$slug]) . ' <small>' . count($rs) . '</small></h2><div class="cards">';
        foreach ($rs as $r) {
            $n++; card($r);
        }
        echo '</div>';
    }
    if (!$n) echo '<p>Nothing found.</p>';
    page_end(); exit;
}

$cat = $seg[0];
$name = preg_replace('/\.git$/', '', $seg[1] ?? '');
if (count($seg) === 1) {                      /* category → index anchor */
    if (!isset($cats[$cat])) not_found();
    header('Location: /projects#' . rawurlencode($cat)); exit;
}
$r = load_repo($cat, $name);
if (!$r) not_found();
$title = $cats[$cat] ?? $cat;
$crumbs = [$title => ($PREFIX ?: '/projects') . '#' . rawurlencode($cat), $r['name'] => $r['url']];
$action = $seg[2] ?? 'tree';
$sub = safe_path(array_slice($seg, 3));
if ($sub === null) not_found();

if ($action === 'archive') {
    header('Content-Type: application/gzip');
    header('Content-Disposition: attachment; filename="' . $r['name'] . '.tar.gz"');
    if ($r['git']) passthru('git -c safe.directory=* -C ' . escapeshellarg($r['dir']) . ' archive --format=tar.gz --prefix=' . escapeshellarg($r['name'] . '/') . ' HEAD');
    else passthru('tar -C ' . escapeshellarg(dirname($r['dir'])) . ' --exclude=.gitsync.meta -czf - ' . escapeshellarg($r['name']));
    exit;
}
if ($action === 'raw') {
    if (obj_type($r, $sub) !== 'blob') not_found();
    header('Content-Type: ' . ($MIME[ext($sub)] ?? 'text/plain; charset=utf-8'));
    header('X-Content-Type-Options: nosniff');
    echo blob($r, $sub); exit;
}
if ($action === 'commits' && $r['git']) {
    page_start($r['name'] . ' commits', $crumbs + ['commits' => '']);
    repo_header($r, 'commits');
    echo activity_svg(activity_days($r), $r['name']);
    echo '<table class="commits">';
    foreach (explode("\n", trim(git($r['dir'], 'log', '-100', LOGFMT))) as $l) if ($c = parse_commit($l)) commit_row($r, $c);
    echo '</table>';
    page_end(); exit;
}
if ($action === 'commit' && $r['git'] && preg_match('/^[0-9a-f]{4,64}$/', $sub)) {
    $c = parse_commit(trim(git($r['dir'], 'log', '-1', LOGFMT, $sub)));
    if (!$c) not_found();
    page_start($c['h'], $crumbs + ['commits' => $r['url'] . '/commits', $c['h'] => '']);
    repo_header($r, 'commits');
    echo '<div class="commit"><h2>' . h($c['subject']) . '</h2><p class="meta">' . h($c['author']) . ' · ' . date('Y-m-d H:i', $c['time']) . ' · <code>' . h($c['H']) . '</code></p>';
    $body = trim(git($r['dir'], 'log', '-1', '--format=%b', $sub));
    if ($body !== '') echo '<pre class="body">' . h($body) . '</pre>';
    echo '<pre class="diff">';
    foreach (explode("\n", git($r['dir'], 'show', '--format=', '--stat', '-p', $sub)) as $l) {
        $cls = match (true) { str_starts_with($l, '+++') || str_starts_with($l, '---') => 'h', str_starts_with($l, '+') => 'a',
                              str_starts_with($l, '-') => 'd', str_starts_with($l, '@@') => 'r', str_starts_with($l, 'diff ') => 'f', default => '' };
        echo $cls ? '<span class="' . $cls . '">' . h($l) . "</span>\n" : h($l) . "\n";
    }
    echo '</pre></div>';
    page_end(); exit;
}
if ($action === 'blob') {
    if (obj_type($r, $sub) !== 'blob') not_found();
    $c = blob($r, $sub); $e = ext($sub);
    page_start(basename($sub), $crumbs + [$sub => '']);
    repo_header($r, 'files');
    $raw = tree_link($r, $sub, 'raw');
    echo '<div class="filehead"><b>' . h($sub) . '</b> <span class="meta">' . fmt_size(strlen($c)) . ' · ' . substr_count($c, "\n") . ' lines</span> <a class="btn" href="' . h($raw) . '">raw</a></div>';
    if (isset($MIME[$e]) && $MIME[$e] !== 'application/pdf') echo '<p><img class="preview" src="' . h($raw) . '" alt=""></p>';
    elseif (strlen($c) > 1048576) echo '<p><i>File too large to display.</i></p>';
    elseif (str_contains(substr($c, 0, 8000), "\0")) echo '<p><i>Binary file.</i></p>';
    elseif (in_array($e, ['md', 'markdown'])) echo '<div class="markdown readme">' . md($c, rtrim(tree_link($r, dirname($sub) === '.' ? '' : dirname($sub), 'raw'), '/') . '/') . '</div>';
    else echo '<pre><code class="' . (isset($LANG[$e]) ? 'language-' . $LANG[$e] : 'nohighlight') . '">' . h($c) . '</code></pre>';
    page_end(); exit;
}
if ($action === 'tree') {                     /* repo overview / subtree */
    if ($sub !== '' && obj_type($r, $sub) !== 'tree') not_found();
    page_start($r['name'] . ($sub ? "/$sub" : ''), $crumbs + ($sub ? [$sub => ''] : []));
    repo_header($r, 'files');
    if ($sub === '' && ($c = last_commit($r))) { echo '<table class="commits last">'; commit_row($r, $c); echo '</table>'; }
    render_tree($r, $sub);
    if ($sub === '') render_readme($r);
    page_end(); exit;
}
not_found();