Git-Server git · main
gitsync + this read-only git browser
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.gzFix lfs-backup
README.md | 16 +- gitsync | 77 +++++++- gitsync.conf | 3 +- index.php | 569 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/index.php | 2 +- 5 files changed, 663 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5b45c7..4593501 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Prefix a config line with `!` to publish it under `/private` instead of the publ ``` [Private] -!~/LFS/lfs-config | My LFS config | lfs +!~/Git/notes | My private notes | notes ``` Everything below `/private` (pages, raw files, tarballs, `git clone`) is protected by HTTP Basic auth in @@ -113,6 +113,20 @@ sudo htpasswd -cB /var/www/html/git.christianimmanuel.de/.htpasswd NAME # -c ``` Fetch with `wget --user=NAME --ask-password URL` or `git clone https://NAME@git.christianimmanuel.de/private/cat/name.git`. + +### lfs-backup config + +Publish exactly the files an `lfs-backup` config lists, always private: + +``` +[Private] +!lfs-backup < /etc/pkgusr/backup.conf | My LFS system configuration | lfs, config +``` + +- Layout on the site: `SECTION/path`, e.g. `user-shell/.bashrc`, `system-boot/etc/fstab`. +- Globs, directories, symlinks and `!` exclusions work like in `lfs-backup`. +- Secret files (private keys, `psk=`/`password=` lines) are left out and listed. `-f` takes them. +- gitsync runs as your user: files only root can read (`[root:*]`, host keys) are left out and listed. Apache enforces this in `-common.conf`; `index.php` additionally checks the login itself against `.htpasswd` (bcrypt), so pages, raw files and tarballs stay closed even with an outdated vhost. ## Scripts collection diff --git a/gitsync b/gitsync index 2dd4d62..b6852ec 100755 --- a/gitsync +++ b/gitsync @@ -3,7 +3,7 @@ # 100% Vibecode but tested. set -eu -VERSION="1.9.3" +VERSION="1.10.1" CONF="${GITSYNC_CONF:-$HOME/.config/gitsync/gitsync.conf}" STAGE="${GITSYNC_STAGE:-$HOME/.cache/gitsync}" DRYRUN=0 LOCAL=0 VERBOSE=0 FORCE=0 LIST=0 QUIET=0 SSH_KEY="" @@ -30,6 +30,8 @@ gitsync $VERSION - sync repos listed in $CONF Config: HOST=user@server, REMOTE_DIR=/var/www/html/site, then [Category] sections with lines: /path | description | tag1, tag2 Prefix a line with ! to publish it under /private (HTTP auth, no secret scan). +A line !NAME < /etc/pkgusr/backup.conf publishes the files an lfs-backup config lists +(private; secret files left out unless -f). Optional: SSH_KEY=~/.ssh/gitsync_ed25519 (key for the upload, no password prompt), SCRIPTS=~/Bash-Public (script collection, subdirs = groups), EXCLUDE=a,b (extra excludes), MAX_SIZE=50m (skip bigger files), @@ -131,6 +133,69 @@ sync_plain() { # src dst desc tags write_meta "$dst/.gitsync.meta" "$3" "$4" "" "$created" "$modified" "$langs" } +# --- lfs-backup projects: !NAME < /etc/pkgusr/backup.conf ------------------ +# Publishes the files a backup.conf lists, privately. Layout: SECTION/path, +# e.g. user-shell/.bashrc, system-boot/etc/fstab. Secret files are left out (-f takes them). +is_secret() { # file + printf '%s\n' "$1" | grep -qiE "$SECRET_FILES" && return 0 + grep -qiIE -e "$SECRET_RE" -e '^[[:space:]]*(psk|password|private_key_passwd)[[:space:]]*=' "$1" 2>/dev/null +} + +sync_backup() { # backup.conf dst desc tags + local conf="$1" dst="$2" line sec="" home="" rhome p m f rel pat d skip n=0 ex=() secrets=() unread=() + rhome=$(getent passwd root | cut -d: -f6); rhome="${rhome:-/root}" + while IFS= read -r line || [ -n "$line" ]; do + line=$(trim "$line"); [ "${line:0:1}" = "!" ] && ex+=("${line#!}") + done < "$conf" + rm -rf "$dst"; mkdir -p "$dst" + shopt -s dotglob + while IFS= read -r line || [ -n "$line" ]; do + line=$(trim "$line") + case "$line" in + ''|\#*|\!*) continue ;; + \[user:*\]) sec="user-${line:6:-1}"; home="$HOME" ;; + \[root:*\]) sec="root-${line:6:-1}"; home="$rhome" + [ -r "$rhome" ] && [ -x "$rhome" ] || unread+=("[root:${line:6:-1}]") ;; + \[system:*\]) sec="system-${line:8:-1}"; home="" ;; + \[*) warn "$(basename "$conf"): unknown section $line"; sec="" ;; + *) + [ -n "$sec" ] || continue + case "$sec" in root-*) [ -r "$rhome" ] && [ -x "$rhome" ] || continue ;; esac + p="$line"; [ -n "$home" ] && p="${p/#\~/$home}" + m=$(compgen -G "$p" || true) + if [ -z "$m" ]; then + d=$(dirname "$p"); [ -d "$d" ] && { [ -r "$d" ] && [ -x "$d" ] || unread+=("$line"); } + continue + fi + while IFS= read -r m; do + while IFS= read -r -d '' f; do + skip=0 + for pat in "${ex[@]:-}"; do + [ -n "$pat" ] || continue + pat="${pat/#\~/${home:-$HOME}}" + # shellcheck disable=SC2053 + [[ "$f" == $pat ]] && { skip=1; break; } + done + [ "$skip" = 1 ] && continue + if [ ! -r "$f" ]; then unread+=("$f"); continue; fi + if [ "$FORCE" = 0 ] && is_secret "$f"; then secrets+=("$f"); continue; fi + rel="${f#"$home"/}"; rel="${rel#/}" + mkdir -p "$dst/$sec/$(dirname "$rel")" + cp -L --preserve=timestamps "$f" "$dst/$sec/$rel" && n=$((n + 1)) + done < <(find -L "$m" -type f -print0 2>/dev/null) + done <<< "$m" ;; + esac + done < "$conf" + shopt -u dotglob + [ "${#secrets[@]}" -gt 0 ] && warn "$(basename "$dst"): ${#secrets[@]} secret file(s) left out (-f takes them): $(printf '%s\n' "${secrets[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)" + [ "${#unread[@]}" -gt 0 ] && warn "$(basename "$dst"): not readable as $(id -un), left out: $(printf '%s\n' "${unread[@]}" | sed "s|^$HOME/|~/|" | paste -sd' ' -)" + [ "$VERBOSE" = 1 ] && log "$(basename "$dst"): $n files" + write_meta "$dst/.gitsync.meta" "$3" "$4" "" \ + "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | head -n1 | cut -d. -f1)" \ + "$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | tail -n1 | cut -d. -f1)" \ + "$(find "$dst" -type f -printf '%s %p\n' | lang_stats)" +} + HOST="" REMOTE_DIR="" SCRIPTS="" cat="" cslug="" BLOCKED=0 mkdir -p "$STAGE" : > "$STAGE/.categories.new" @@ -158,6 +223,16 @@ while IFS= read -r line || [ -n "$line" ]; do IFS='|' read -r path desc tags <<< "$line" path=$(trim "$path"); desc=$(trim "${desc:-}"); tags=$(trim "${tags:-}") tags=$(printf '%s' "$tags" | tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -v '^$' | tr '[:upper:]' '[:lower:]' | paste -sd, - || true) + if [[ "$path" == *"<"* ]]; then # NAME < backup.conf: always private + bconf=$(trim "${path#*<}"); bconf="${bconf/#\~/$HOME}"; name=$(trim "${path%%<*}") + [ -n "$name" ] || name=$(basename "$bconf" .conf) + [ -r "$bconf" ] || { warn "missing or not readable: $bconf"; continue; } + if [ "$LIST" = 1 ]; then printf '%-22s %-45s %-40s %s [private, backup.conf]\n' "[$cat]" "$name < $bconf" "$desc" "$tags"; continue; fi + dest="$STAGE/.private/$cslug/$name"; mkdir -p "$STAGE/.private/$cslug" + log "$cat / $name (backup.conf, private)" + [ "$DRYRUN" = 1 ] || sync_backup "$bconf" "$dest" "$desc" "$tags" + KEEP+=("$dest"); continue + fi path="${path/#\~/$HOME}" name=$(basename "$path") if [ ! -d "$path" ]; then warn "missing: $path"; continue; fi diff --git a/gitsync.conf b/gitsync.conf index a635f8b..6535d37 100644 --- a/gitsync.conf +++ b/gitsync.conf @@ -65,7 +65,8 @@ SCRIPTS=~/Bash-Public [Private] # ! = only reachable via /private with the password from .htpasswd; not scanned for secrets -!~/LFS/lfs-config | My LFS system configuration | lfs, config, dotfiles +# NAME < backup.conf = the files your lfs-backup config lists (secret files left out, -f takes them) +!lfs-backup < /etc/pkgusr/backup.conf | My LFS system configuration | lfs, config, dotfiles [Mirrors & Forks] ~/Git/jhalfs | jhalfs LFS build automation (mirror) | lfs, mirror, bash diff --git a/index.php b/index.php new file mode 100644 index 0000000..661c598 --- /dev/null +++ b/index.php @@ -0,0 +1,569 @@ +<?php +declare(strict_types=1); +/* git.christianimmanuel.de - read-only git browser. 100% Vibecode but tested. Works in lynx. */ +const VERSION = '1.10.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(); diff --git a/web/index.php b/web/index.php index 74aa30d..47e36b3 100644 --- a/web/index.php +++ b/web/index.php @@ -1,7 +1,7 @@ <?php declare(strict_types=1); /* git.christianimmanuel.de - read-only git browser. 100% Vibecode but tested. Works in lynx. */ -const VERSION = '1.9.3'; +const VERSION = '1.10.1'; const SITE = 'Nimbin[12]?'; const LEGAL = 'https://christianimmanuel.de'; $ROOT = __DIR__ . '/repos';