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.gzinit
Makefile | 54 +++ README.md | 134 +++++++ apache/git.christianimmanuel.de-common.conf | 53 +++ apache/git.christianimmanuel.de-le-ssl.conf | 10 + apache/git.christianimmanuel.de.conf | 8 + cron/gitsync | 19 + gitsync | 218 +++++++++++ gitsync.conf | 75 ++++ web/index.php | 569 ++++++++++++++++++++++++++++ web/style.css | 97 +++++ 10 files changed, 1237 insertions(+) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..151599c --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +# gitsync - 100% Vibecode but tested +PREFIX ?= /usr +BINDIR = $(PREFIX)/bin +CONF = $(HOME)/.config/gitsync/gitsync.conf +HOST ?= debian@162.19.227.194 +REMOTE ?= /var/www/html/git.christianimmanuel.de + +.PHONY: install uninstall config config-update deploy deploy-web deploy-apache clean ssh-key cron cron-daily uncron +KEY = $(HOME)/.ssh/gitsync_ed25519 +CRON = 0 12 * * * ping -c1 -W5 162.19.227.194 >/dev/null 2>&1 && $(BINDIR)/gitsync -q + +install: ## install gitsync to /usr/bin (needs root) + install -Dm755 gitsync $(DESTDIR)$(BINDIR)/gitsync + +uninstall: ## remove gitsync + rm -f $(DESTDIR)$(BINDIR)/gitsync + +config: ## copy example config to ~/.config/gitsync (does not overwrite) + @test -f $(CONF) && echo "$(CONF) exists, not touching it" || (install -Dm644 gitsync.conf $(CONF) && echo "created $(CONF)") + +config-update: ## overwrite ~/.config/gitsync/gitsync.conf with the shipped one (keeps a .bak) + @test -f $(CONF) && cp $(CONF) $(CONF).bak && echo "backup at $(CONF).bak" || true + install -Dm644 gitsync.conf $(CONF) + +deploy-web: ## upload only index.php + style.css + rsync -a web/index.php web/style.css $(HOST):$(REMOTE)/ + +deploy-apache: ## upload apache configs (http, https, shared) to /tmp on the server + scp apache/git.christianimmanuel.de.conf apache/git.christianimmanuel.de-le-ssl.conf apache/git.christianimmanuel.de-common.conf $(HOST):/tmp/ + @echo "on the server: sudo mv /tmp/git.christianimmanuel.de*.conf /etc/apache2/sites-available/ && sudo apache2ctl configtest && sudo systemctl reload apache2" + +deploy: deploy-web deploy-apache + +ssh-key: ## create the upload key and print what to do on the server + @test -f $(KEY) || ssh-keygen -t ed25519 -N "" -C gitsync -f $(KEY) + @echo; echo "on the server (as root):"; echo " adduser --disabled-password --gecos '' git-local" + @echo " install -d -m700 -o git-local -g git-local /home/git-local/.ssh" + @echo " echo 'restrict,port-forwarding $$(cat $(KEY).pub)' > /home/git-local/.ssh/authorized_keys" + @echo " chown git-local:git-local /home/git-local/.ssh/authorized_keys; chmod 600 /home/git-local/.ssh/authorized_keys" + @echo " chown -R git-local:www-data $(REMOTE)/repos" + @echo; echo "then: ssh -i $(KEY) git-local@162.19.227.194 true" + +cron: ## sync daily at 12:00 when the server is reachable + @( crontab -l 2>/dev/null | grep -v '/gitsync -q'; echo "$(CRON)" ) | crontab - + @crontab -l | grep gitsync + +cron-daily: ## system-wide alternative: /etc/cron.daily/gitsync (run as root, set USER= inside) + install -m755 cron/gitsync /etc/cron.daily/gitsync + +uncron: ## remove the cron job + @crontab -l 2>/dev/null | grep -v '/gitsync -q' | crontab - ; echo "removed" + +clean: ## remove local staging cache + rm -rf $(HOME)/.cache/gitsync diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5b45c7 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# gitsync + git browser + +Self-hosted, read-only git browser for `git.christianimmanuel.de`. +Repos are grouped in categories, browsable like GitHub/GitLab, and cloneable via HTTPS. +Nothing is pushed to the server with git; `gitsync` on your laptop publishes the projects you list. + +**100% Vibecode but tested.** + +## Parts + +| File | What | +|---|---| +| `gitsync` | laptop script: stages listed projects, rsyncs them to the server | +| `gitsync.conf` | example config with categories | +| `web/index.php`, `web/style.css` | the browser (PHP, no database) | +| `apache/*.conf` | vhosts: `-common.conf` holds everything (browser, clones, private auth), the `:80` and `-le-ssl` vhosts just include it | +| `Makefile` | install / uninstall / config / deploy / clean | + +## Server (Debian) + +```sh +sudo apt install apache2 git libapache2-mod-php rsync +sudo a2enmod rewrite cgi env +sudo mkdir -p /var/www/html/git.christianimmanuel.de/repos +sudo chown -R debian:www-data /var/www/html/git.christianimmanuel.de +``` + +From the laptop: `make deploy`, then on the server: + +```sh +sudo mv /tmp/git.christianimmanuel.de*.conf /etc/apache2/sites-available/ +sudo a2ensite git.christianimmanuel.de && sudo systemctl reload apache2 +sudo certbot --apache -d git.christianimmanuel.de # first time only; afterwards keep our -le-ssl.conf +sudo a2ensite git.christianimmanuel.de-le-ssl && sudo systemctl reload apache2 +``` + +Both vhosts only `Include` `git.christianimmanuel.de-common.conf`; edit that one file for changes. + +The `repos/` directory is only reachable through `index.php` and `git-http-backend`. +Only `git-upload-pack` is exposed, so clones work and pushes do not. + +## Laptop + +```sh +sudo make install # /usr/bin/gitsync +make config # ~/.config/gitsync/gitsync.conf (only if missing) +make config-update # overwrite it with the shipped config (.bak is kept) +gitsync # publish +``` + +Config format: + +``` +HOST=debian@162.19.227.194 +REMOTE_DIR=/var/www/html/git.christianimmanuel.de + +[SDL & Graphics] +~/Git/sdl_3d | 3D rendering experiments | sdl, 3d, c +``` + +- Git projects become bare repos (`category/name.git`) with all branches and tags. Clone URL: `https://git.christianimmanuel.de/category/name.git` +- Plain directories are copied as files, shown without commits. `.gitignore` files are honoured (per directory, like git). Symlinks are followed, the target's content is published (dangling links are skipped with a warning). Build junk (`CMakeFiles`, `*.o`, `*.swp`, `__pycache__`, ...), compiled binaries and files over `MAX_SIZE` (default 50m) are skipped; `EXCLUDE=a,b` in the config adds more patterns. `STAGE=` moves the staging dir (default `~/.cache/gitsync`). +- Category order on the page = order in the config. +- "Recently updated" on the index sorts by the `modified` date from each project's meta file: last commit for git projects, newest file mtime for plain directories (rsync keeps mtimes). +- Removing a line removes the repo from the server on the next run. +- Tags are optional (third column), searchable on the site. +- Origin remote (GitHub/Codeberg) is shown as upstream link if the project has one. + +### Automatic daily sync + +```sh +make ssh-key # creates ~/.ssh/gitsync_ed25519 and prints the server-side commands (user git-local) +make cron # crontab entry: 12:00 daily, only if the server answers a ping +make uncron # remove it +sudo make cron-daily # alternative without a user crontab: /etc/cron.daily/gitsync (edit USER= in cron/gitsync first) +``` + +Set `HOST=git-local@…` and `SSH_KEY=~/.ssh/gitsync_ed25519` in the config (default). The upload runs with `BatchMode`, so it never hangs on a password prompt. + +Options: `-n` dry run, `-q` quiet, `-l` stage only, `-c` list config, `-f` ignore secret scan, `-v` verbose, `-V` version. + +### Secret scan + +Before staging, every project is checked for private keys, AWS/GitHub/GitLab/Slack/OpenAI tokens, +`api_key = "…"` / `password: '…'` style lines with a quoted literal value containing a digit (placeholders like `"your_api_key"` are ignored), and files like `.env`, `id_rsa`, `*.pem`. +Git projects: tracked files and the whole history are scanned (untracked/ignored files are never published anyway). Plain directories: exactly the staged files are scanned, so ignored files don't trigger it. Files named `*example*`, `*sample*`, `*template*` or `*public*` are exempt from the filename check. A hit skips that project and prints where it was found. +If it is a false positive, publish with `gitsync -f`. If it is real: rotate the key, then remove it from history: + +```sh +pip install git-filter-repo +git filter-repo --invert-paths --path path/to/client.key --path path/to/server.key +git push --force origin --all # if the project has a remote +``` + +`gitsync` checks the whole history because a bare clone ships every commit, not just HEAD. +Staging dir is `~/.cache/gitsync` (`make clean` removes it). + +## Private projects + +Prefix a config line with `!` to publish it under `/private` instead of the public tree: + +``` +[Private] +!~/LFS/lfs-config | My LFS config | lfs +``` + +Everything below `/private` (pages, raw files, tarballs, `git clone`) is protected by HTTP Basic auth in +Apache. Private projects are not listed on the public pages and are **not** secret-scanned. Create users on the server: + +```sh +sudo apt install apache2-utils +sudo htpasswd -cB /var/www/html/git.christianimmanuel.de/.htpasswd NAME # -c only for the first user +``` + +Fetch with `wget --user=NAME --ask-password URL` or `git clone https://NAME@git.christianimmanuel.de/private/cat/name.git`. +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 + +`SCRIPTS=~/Bash-Public` in the config publishes a directory of standalone scripts at `/scripts`. +Every subdirectory is a group, every file a script. Lines starting with `### ` at the top of a file +are shown as description (`######` lines are ignored). Each script gets a `wget … && chmod 740` line, +a source view and a raw URL (`/scripts/raw/<group>/<file>`). Same excludes, `.gitignore` and secret scan as plain directories. + +## Browser features + +Categories, cards with description, tags, language bar and last change. Search by name/description/tag. +File tree, file view, Markdown README (rendered server side), raw files, commit log with diffs, +commit activity graph (per repo and site wide), `git clone` and `wget` tarball commands, dark mode. +Everything is plain HTML, so it works in lynx; JavaScript only adds syntax highlighting and copy buttons. + +## Make targets + +`install`, `uninstall`, `config`, `config-update`, `ssh-key`, `cron`, `uncron`, `deploy-web`, `deploy-apache`, `deploy`, `clean` diff --git a/apache/git.christianimmanuel.de-common.conf b/apache/git.christianimmanuel.de-common.conf new file mode 100644 index 0000000..e3272d9 --- /dev/null +++ b/apache/git.christianimmanuel.de-common.conf @@ -0,0 +1,53 @@ +# /etc/apache2/sites-available/git.christianimmanuel.de-common.conf +# Shared part of the git.christianimmanuel.de vhosts. Included by the :80 and the :443 (certbot) vhost. +# needs: a2enmod rewrite cgi env (and php module) + +DocumentRoot /var/www/html/git.christianimmanuel.de + +# --- clone via smart HTTP (read-only: no receive-pack is exposed) --- +SetEnv GIT_PROJECT_ROOT /var/www/html/git.christianimmanuel.de/repos +SetEnv GIT_HTTP_EXPORT_ALL 1 +SetEnv GIT_CONFIG_COUNT 1 +SetEnv GIT_CONFIG_KEY_0 safe.directory +SetEnv GIT_CONFIG_VALUE_0 * +ScriptAliasMatch \ + "(?x)^/([^/]+/[^/]+\.git/(HEAD|info/refs|objects/(info/[^/]+|[0-9a-f]{2}/[0-9a-f]{38,62}|pack/pack-[0-9a-f]{40,64}\.(pack|idx))|git-upload-pack))$" \ + /usr/lib/git-core/git-http-backend/$1 +ScriptAliasMatch \ + "(?x)^/private/([^/]+/[^/]+\.git/(HEAD|info/refs|objects/(info/[^/]+|[0-9a-f]{2}/[0-9a-f]{38,62}|pack/pack-[0-9a-f]{40,64}\.(pack|idx))|git-upload-pack))$" \ + /usr/lib/git-core/git-http-backend/.private/$1 + +<Directory /usr/lib/git-core> + Options +ExecCGI + Require all granted +</Directory> + +# --- private area: everything below /private needs a password (browse, raw, tarball, clone) --- +# users: htpasswd -cB /var/www/html/git.christianimmanuel.de/.htpasswd NAME (-c only the first time) +<Location /private> + AuthType Basic + AuthName "private" + AuthUserFile /var/www/html/git.christianimmanuel.de/.htpasswd + Require valid-user +</Location> + +# --- browser --- +<Directory /var/www/html/git.christianimmanuel.de> + Options -Indexes + AllowOverride None + Require all granted + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] +</Directory> +<FilesMatch "^\."> + Require all denied +</FilesMatch> + +# repos are only reachable through git-http-backend or index.php +<Directory /var/www/html/git.christianimmanuel.de/repos> + Require all denied +</Directory> + +ErrorLog ${APACHE_LOG_DIR}/git.christianimmanuel.de-error.log +CustomLog ${APACHE_LOG_DIR}/git.christianimmanuel.de-access.log combined diff --git a/apache/git.christianimmanuel.de-le-ssl.conf b/apache/git.christianimmanuel.de-le-ssl.conf new file mode 100644 index 0000000..5f2eb8a --- /dev/null +++ b/apache/git.christianimmanuel.de-le-ssl.conf @@ -0,0 +1,10 @@ +# /etc/apache2/sites-available/git.christianimmanuel.de-le-ssl.conf (https, certs from certbot) +<IfModule mod_ssl.c> +<VirtualHost *:443> + ServerName git.christianimmanuel.de + Include /etc/apache2/sites-available/git.christianimmanuel.de-common.conf + SSLCertificateFile /etc/letsencrypt/live/git.christianimmanuel.de/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/git.christianimmanuel.de/privkey.pem + Include /etc/letsencrypt/options-ssl-apache.conf +</VirtualHost> +</IfModule> diff --git a/apache/git.christianimmanuel.de.conf b/apache/git.christianimmanuel.de.conf new file mode 100644 index 0000000..35478bc --- /dev/null +++ b/apache/git.christianimmanuel.de.conf @@ -0,0 +1,8 @@ +# /etc/apache2/sites-available/git.christianimmanuel.de.conf (plain http: redirect to https) +<VirtualHost *:80> + ServerName git.christianimmanuel.de + Include /etc/apache2/sites-available/git.christianimmanuel.de-common.conf + RewriteEngine On + RewriteCond %{SERVER_NAME} =git.christianimmanuel.de + RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] +</VirtualHost> diff --git a/cron/gitsync b/cron/gitsync new file mode 100755 index 0000000..7fb0c6c --- /dev/null +++ b/cron/gitsync @@ -0,0 +1,19 @@ +#!/bin/sh +# /etc/cron.daily/gitsync - publish repos once a day, as the normal user. +# Runs as root via cron.daily, drops to USER (config, ssh key and repos live in that home). +# If the server is not reachable it retries a few times, then gives up until tomorrow. +USER=n76310 +HOST=162.19.227.194 +RETRIES=6 # attempts +WAIT=600 # seconds between attempts (10 min) + +i=0 +while [ "$i" -lt "$RETRIES" ]; do + if ping -c1 -W5 "$HOST" >/dev/null 2>&1 && su - "$USER" -c '/usr/bin/gitsync -q'; then + exit 0 + fi + i=$((i + 1)) + [ "$i" -lt "$RETRIES" ] && sleep "$WAIT" +done +echo "gitsync: server $HOST not reachable after $RETRIES attempts, giving up until tomorrow" >&2 +exit 0 diff --git a/gitsync b/gitsync new file mode 100755 index 0000000..202613b --- /dev/null +++ b/gitsync @@ -0,0 +1,218 @@ +#!/bin/bash +# gitsync - push marked local repos to a read-only git browser server. +# 100% Vibecode but tested. +set -eu + +VERSION="1.9.2" +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="" +# always the real git binary, never a shell alias/function/wrapper (GIT= in config overrides) +GIT=$(command -v /usr/bin/git || command -v /bin/git || echo git) +# build junk never published from plain (non-git) directories; EXCLUDE= in the config adds more +MAX_SIZE="50m" # plain dirs: files above this are skipped (MAX_SIZE= in config) +EXCLUDES=".git CMakeFiles CMakeCache.txt cmake_install.cmake *.o *.a *.so *.swp *.swo *.pyc __pycache__ .idea .ipynb_checkpoints massif.out.* core" + +usage() { +cat <<USAGE +gitsync $VERSION - sync repos listed in $CONF + + gitsync stage all repos and upload to server + gitsync -n dry run (show what would happen) + gitsync -l only stage locally, no upload + gitsync -c list configured repos + gitsync -f publish even if the secret scan finds something + gitsync -v verbose rsync/git output + gitsync -q quiet (only warnings; for cron) + gitsync -h this help + gitsync -V version + +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). +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), + STAGE=~/other/dir (staging dir, default ~/.cache/gitsync) +USAGE +} + +log() { [ "$QUIET" = 1 ] || printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } +q() { [ "$VERBOSE" = 1 ] && echo "" || echo "-q"; } +slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-//; s/-$//'; } +trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; } + +case "${1:-}" in --version|-V) echo "gitsync $VERSION"; exit 0 ;; --help) usage; exit 0 ;; esac +while getopts "nlcfvqh" o; do + case $o in + n) DRYRUN=1 ;; l) LOCAL=1 ;; c) LIST=1 ;; f) FORCE=1 ;; v) VERBOSE=1 ;; q) QUIET=1 ;; + h) usage; exit 0 ;; *) usage; exit 1 ;; + esac +done +[ -f "$CONF" ] || die "no config at $CONF (see gitsync.conf example)" +log "config: $CONF" + +# --- secret scan ------------------------------------------------------------- +# content patterns (case-insensitive ERE) and file name patterns +SECRET_RE='(AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|ghp_[A-Za-z0-9]{36}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,}|(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|password|passwd)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{8,}["'"'"'])' +SECRET_FILES='(^|/)(\.env([./].*)?|id_(rsa|dsa|ecdsa|ed25519)|.*\.(pem|key|p12|pfx|kdbx)|.*(secret|credential)s?[^/]*)$' + +scan_secrets() { # src kind -> prints findings, returns 1 if any + local src="$1" kind="$2" hits + if [ "$kind" = git ]; then + hits=$( { "$GIT" -C "$src" ls-files | grep -iE "$SECRET_FILES" | grep -viE 'public|example|sample|template' | sed 's/^/file: /'; + "$GIT" -C "$src" grep -I -i -n -E -e "$SECRET_RE" $("$GIT" -C "$src" rev-list --all 2>/dev/null | head -500) -- . 2>/dev/null | cut -c1-160 | sort -u | head -20; } || true ) + else + hits=$( { find "$src" -type f -not -name .gitsync.meta | sed "s|^$src/||" | grep -iE "$SECRET_FILES" | grep -viE 'public|example|sample|template' | sed 's/^/file: /'; + grep -rIinE --exclude-dir=.git -e "$SECRET_RE" "$src" 2>/dev/null | sed "s|^$src/||" | cut -c1-160 | head -20; } || true ) + fi + hits=$(printf '%s\n' "$hits" | grep -vE '[=:][[:space:]]*["'"'"']?[A-Z][A-Z0-9_]{4,}["'"'"']?[,;)]?[[:space:]]*$' \ + | grep -E 'file: |BEGIN |AKIA|ghp_|glpat-|xox[baprs]-|sk-|[=:][[:space:]]*["'"'"'][A-Za-z_/+.=-]*[0-9]' || true) + [ -z "$hits" ] && return 0 + printf '%s\n' "$hits" | sed 's/^/ /' >&2 + return 1 +} + +# --- language stats ----------------------------------------------------------- +lang_stats() { # reads "size path" lines on stdin -> "C:1234,Shell:99" + awk ' + function lang(p, b, e) { b=p; sub(/.*\//,"",b); e=tolower(b); sub(/.*\./,"",e); + if (tolower(b)=="makefile"||e=="mk") return "Makefile"; + if (e=="c"||e=="h") return "C"; if (e=="cpp"||e=="cc"||e=="cxx"||e=="hpp"||e=="hh") return "C++"; + if (e=="py") return "Python"; if (e=="sh"||e=="bash") return "Shell"; if (e=="js"||e=="mjs") return "JavaScript"; + if (e=="ts") return "TypeScript"; if (e=="php") return "PHP"; if (e=="html"||e=="htm") return "HTML"; + if (e=="css") return "CSS"; if (e=="rs") return "Rust"; if (e=="go") return "Go"; if (e=="java") return "Java"; + if (e=="vim") return "Vim Script"; if (e=="lua") return "Lua"; if (e=="glsl"||e=="vert"||e=="frag") return "GLSL"; + if (e=="s"||e=="asm") return "Assembly"; if (e=="md") return "Markdown"; if (e=="rb") return "Ruby"; + if (e=="pl") return "Perl"; if (e=="cs") return "C#"; if (e=="kt") return "Kotlin"; if (e=="swift") return "Swift"; + if (e=="tex") return "TeX"; return "" } + { l=lang($2); if (l!="") s[l]+=$1 } + END { for (l in s) printf "%s:%d\n", l, s[l] }' | sort -t: -k2 -nr | paste -sd, - +} + +write_meta() { # file desc tags origin created modified languages + printf 'description=%s\ntags=%s\norigin=%s\ncreated=%s\nmodified=%s\nlanguages=%s\nsynced=%s\n' "$2" "$3" "$4" "$5" "$6" "$7" "$(date +%s)" > "$1" +} + +sync_git() { # src bare desc tags + local src="$1" bare="$2" branch origin created modified langs + [ -d "$bare" ] || { "$GIT" init -q --bare "$bare"; rm -f "$bare"/hooks/*.sample; } + "$GIT" -C "$src" push $(q) --force --prune "$bare" 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*' \ + || { warn "push failed for $src (no commits?)"; return 0; } + branch=$("$GIT" -C "$src" symbolic-ref --short HEAD 2>/dev/null || true) + [ -n "$branch" ] || branch=$("$GIT" -C "$bare" for-each-ref --format='%(refname:short)' refs/heads | head -n1) + [ -n "$branch" ] && "$GIT" -C "$bare" symbolic-ref HEAD "refs/heads/$branch" + "$GIT" -C "$bare" update-server-info + origin=$("$GIT" -C "$src" remote get-url origin 2>/dev/null | sed -E 's#^(ssh://)?git@([^:/]+)[:/]#https://\2/#; s#\.git$##' || true) + created=$("$GIT" -C "$src" log --reverse --format=%at | head -n1) + modified=$("$GIT" -C "$src" log -1 --format=%at) + langs=$("$GIT" -C "$src" ls-tree -r -l HEAD | awk '{print $4, $5}' | lang_stats) + write_meta "$bare/gitsync.meta" "$3" "$4" "$origin" "$created" "$modified" "$langs" +} + +sync_plain() { # src dst desc tags + local src="$1" dst="$2" created modified langs ex=() f + mkdir -p "$dst" + for f in $EXCLUDES; do ex+=(--exclude="$f"); done + # compiled binaries (ELF) are skipped too + while IFS= read -r -d '' f; do + [ "$(head -c4 "$f" 2>/dev/null | tail -c3 | tr -d '\0')" = "ELF" ] && ex+=(--exclude="/${f#"$src/"}") + done < <(find -L "$src" -type f -not -path '*/.git/*' -size +0 -print0 2>/dev/null) + # honour .gitignore files like git would; symlinks are followed and their target content copied + rsync -aL --delete --max-size="$MAX_SIZE" --filter=':- .gitignore' "${ex[@]}" "$src/" "$dst/" \ + || { rc=$?; [ "$rc" = 23 ] || [ "$rc" = 24 ] || return "$rc"; warn "$(basename "$src"): some files skipped (dangling symlink?)"; } + big=$(find -L "$src" -type f -not -path '*/.git/*' -size +"$MAX_SIZE" 2>/dev/null | wc -l) + [ "$big" -gt 0 ] && warn "$(basename "$src"): $big file(s) over $MAX_SIZE skipped" + created=$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | head -n1 | cut -d. -f1) + modified=$(find "$dst" -type f -not -name .gitsync.meta -printf '%T@\n' | sort -n | tail -n1 | cut -d. -f1) + langs=$(find "$dst" -type f -printf '%s %p\n' | lang_stats) + write_meta "$dst/.gitsync.meta" "$3" "$4" "" "$created" "$modified" "$langs" +} + +HOST="" REMOTE_DIR="" SCRIPTS="" cat="" cslug="" BLOCKED=0 +mkdir -p "$STAGE" +: > "$STAGE/.categories.new" +declare -a KEEP=() + +while IFS= read -r line || [ -n "$line" ]; do + line="${line%%#*}"; line=$(trim "$line") + [ -z "$line" ] && continue + case "$line" in + HOST=*) HOST="${line#HOST=}" ;; + REMOTE_DIR=*) REMOTE_DIR="${line#REMOTE_DIR=}" ;; + MAX_SIZE=*) MAX_SIZE="${line#MAX_SIZE=}" ;; + GIT=*) GIT="${line#GIT=}" ;; + SSH_KEY=*) SSH_KEY="${line#SSH_KEY=}"; SSH_KEY="${SSH_KEY/#\~/$HOME}" ;; + SCRIPTS=*) SCRIPTS="${line#SCRIPTS=}"; SCRIPTS="${SCRIPTS/#\~/$HOME}" ;; + STAGE=*) STAGE="${line#STAGE=}"; STAGE="${STAGE/#\~/$HOME}"; mkdir -p "$STAGE" ;; + EXCLUDE=*) EXCLUDES="$EXCLUDES $(printf '%s' "${line#EXCLUDE=}" | tr ',' ' ')" ;; + \[*\]) + cat=$(trim "${line#[}"); cat=$(trim "${cat%]}"); cslug=$(slug "$cat") + printf '%s|%s\n' "$cslug" "$cat" >> "$STAGE/.categories.new" + mkdir -p "$STAGE/$cslug" ;; + *) + [ -n "$cslug" ] || die "repo line before any [Category]: $line" + private=0; [ "${line:0:1}" = "!" ] && { private=1; line="${line#!}"; } + 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) + path="${path/#\~/$HOME}" + name=$(basename "$path") + if [ ! -d "$path" ]; then warn "missing: $path"; continue; fi + if [ "$LIST" = 1 ]; then printf '%-22s %-45s %-40s %s%s\n' "[$cat]" "$path" "$desc" "$tags" "$([ $private = 1 ] && echo ' [private]')"; continue; fi + root="$STAGE"; [ "$private" = 1 ] && { root="$STAGE/.private"; mkdir -p "$root/$cslug"; } + if [ -d "$path/.git" ]; then dest="$root/$cslug/$name.git"; kind="git" + else dest="$root/$cslug/$name"; kind="files"; fi + log "$cat / $name ($kind$([ $private = 1 ] && echo ', private'))" + # git: scan tracked files + history; files: stage first, then scan exactly what would be published + if [ "$kind" = git ]; then scan_target="$path"; else [ "$DRYRUN" = 1 ] || sync_plain "$path" "$dest" "$desc" "$tags"; scan_target="$dest"; fi + if [ "$private" = 0 ] && [ -d "$scan_target" ] && ! scan_secrets "$scan_target" "$kind"; then + if [ "$FORCE" = 1 ]; then warn "possible secrets in $name, publishing anyway (-f)" + else warn "possible secrets in $name, SKIPPED (fix it or use -f)"; BLOCKED=1; rm -rf "$dest"; continue; fi + fi + KEEP+=("$dest") + [ "$DRYRUN" = 1 ] && continue + [ "$kind" = git ] && sync_git "$path" "$dest" "$desc" "$tags" + ;; + esac +done < "$CONF" + +# scripts collection (SCRIPTS=dir): subdirs = groups, published as files under /scripts +if [ -n "$SCRIPTS" ] && [ -d "$SCRIPTS" ]; then + log "scripts ($SCRIPTS)" + if [ "$DRYRUN" = 0 ]; then + sync_plain "$SCRIPTS" "$STAGE/.scripts" "Scripts" "" + if ! scan_secrets "$STAGE/.scripts" files; then + if [ "$FORCE" = 1 ]; then warn "possible secrets in scripts, publishing anyway (-f)" + else warn "possible secrets in scripts, SKIPPED (fix it or use -f)"; BLOCKED=1; rm -rf "$STAGE/.scripts"; fi + fi + fi +elif [ -z "$SCRIPTS" ]; then rm -rf "$STAGE/.scripts"; fi + +[ "$LIST" = 1 ] && { rm -f "$STAGE/.categories.new"; exit 0; } + +if [ "$DRYRUN" = 0 ]; then + mv "$STAGE/.categories.new" "$STAGE/.categories" + date +%s > "$STAGE/.synced" + while IFS= read -r d; do + keep=0; for k in "${KEEP[@]:-}"; do [ "$k" = "$d" ] && keep=1; done + [ "$keep" = 0 ] && { log "prune $(basename "$(dirname "$d")")/$(basename "$d")"; rm -rf "$d"; } + done < <(find "$STAGE" -mindepth 2 -maxdepth 2 -type d -not -path "$STAGE/.scripts/*" -not -path "$STAGE/.private/*"; [ -d "$STAGE/.private" ] && find "$STAGE/.private" -mindepth 2 -maxdepth 2 -type d) + while IFS= read -r d; do + grep -q "^$(basename "$d")|" "$STAGE/.categories" || rm -rf "$d" + done < <(find "$STAGE" -mindepth 1 -maxdepth 1 -type d -not -name .scripts -not -name .private) +else + rm -f "$STAGE/.categories.new" +fi + +[ "$BLOCKED" = 1 ] && warn "some repos were skipped because of the secret scan" +[ "$LOCAL" = 1 ] && { log "staged in $STAGE"; exit 0; } +[ -n "$HOST" ] && [ -n "$REMOTE_DIR" ] || die "HOST and REMOTE_DIR must be set in config" + +log "upload to $HOST:$REMOTE_DIR/repos/" +extra=""; [ "$DRYRUN" = 1 ] && extra="-n"; [ "$VERBOSE" = 1 ] && extra="$extra -v" +SSH="ssh -o BatchMode=yes"; [ -n "$SSH_KEY" ] && SSH="$SSH -i $SSH_KEY" +rsync -az --delete --chmod=D755,F644 -e "$SSH" $extra "$STAGE/" "$HOST:$REMOTE_DIR/repos/" +log "done" diff --git a/gitsync.conf b/gitsync.conf new file mode 100644 index 0000000..a635f8b --- /dev/null +++ b/gitsync.conf @@ -0,0 +1,75 @@ +# gitsync config (~/.config/gitsync/gitsync.conf) +# Format: [Category] then /path/to/project | description | tag1, tag2 +# a leading ! marks a private project (HTTP auth under /private, no secret scan) +# Only what is listed here gets published. Git projects are pushed as bare +# repos (cloneable), plain directories are copied as files (.gitignore is +# honoured, build junk and binaries are skipped). Every project is scanned +# for keys before upload. + +HOST=git-local@162.19.227.194 +REMOTE_DIR=/var/www/html/git.christianimmanuel.de +# ssh key for the upload user (make ssh-key creates it), lets cron run without a password +SSH_KEY=~/.ssh/gitsync_ed25519 +# extra excludes for non-git directories (defaults already cover CMakeFiles, *.o, *.swp, ...) +EXCLUDE=imdb_gz,local,final_output.txt,selected_results.db +# files bigger than this are not published from non-git dirs (default 50m) +MAX_SIZE=50m +# script collection: every subdir is a group, every file a script (### lines at the top = description) +SCRIPTS=~/Bash-Public +# staging dir, default ~/.cache/gitsync - move it if your home is small +#STAGE=~/.cache/gitsync + +[Embedded] +~/Git/snake_compiler | Snake compiled to a bare-metal RISC-V microcontroller | riscv, embedded, bare-metal, c, snake +~/Git/esp_garden | Distributed ESP sensor and actuator system (garden) | esp32, esp-idf, iot, sensors, c++, sql +~/Git/esp32-wifi-enterprise | WPA2 enterprise wifi setup on an ESP32 | esp32, esp-idf, wifi, enterprise, c + +[Sway] +~/Git/SwAS | SwAS - app switcher for sway (SVG/PNG icons, themeable) | sway, wayland, c, launcher +~/Git/SwBr | SwBr - status bar for sway | sway, wayland, c, bar +~/Git/SWOv | SWOv - window overview for sway | sway, wayland, c, overview +~/Git/seatd-n76310 | seatd launcher for the n76310 | seatd, wayland, c +~/Git/seatd-u_vimb | seatd launcher for vimb | seatd, wayland, c, vimb + +[SDL & Graphics] +~/Git/sdl3_3d_oop_basic_game | SDL3 3D OOP basic game (entities, systems, CMake) | sdl3, 3d, c++, game, cmake +~/Git/sdl_runtime_compiler | SDL3 game for running and compiling code at runtime | sdl3, c++, compiler, dlopen, cmake +~/Git/mapcreator | SDL3 2.5D game and engine using assets, with map editor | sdl3, c++, game, engine, map-editor, cmake +~/Git/sdl3_opengl | SDL3 + OpenGL function loading | sdl3, opengl, c +~/Git/blender_sdl | Render Blender models (sphere) in SDL | sdl, blender, 3d, c +~/Git/draw_trad_img | Live viewer for streamed trading chart images (SSL client) | c, images, trading, ssl +~/CodeBerg/SDL_Game_CPP | SDL game in C++ | sdl, game, c++ + +[Games] +~/Git/Entropie | Entropie festival game; first steps | sdl, c, game, entropie, cmake +~/Git/strichmaennchen | Entropie festival game; first steps, second try | opengl, glsl, c, 3d, game, entropie +~/Git/2048_bash | 2048 in pure bash with highscores | game, bash, terminal +~/Git/vier_gewinnt | Vier gewinnt (Connect Four) in the terminal | game, c, terminal +~/CodeBerg/Sokoban_in_C | Sokoban in C | game, c, terminal +~/Git/ps5_vibration_tester | PS5 controller vibration tester | ps5, controller, c + +[Linux & System] +~/Git/Linux-disable-Middle-Mouse-Click | Disable middle mouse click paste (evdev + sysv script) | linux, evdev, mouse, c +~/Git/webcam-loopback-manipulation-screensharing-and-stuff | vcam: v4l2 loopback with overlays, filters, OpenCV and RVM matting | webcam, v4l2, opencv, onnx, c + +[Linux From Scratch] +~/Git/Packagemanager-LFS-PackageUser-System | Package-user based package manager for LFS/BLFS | lfs, blfs, package-manager, bash +~/CodeBerg/dependency_collecter_for_lfs_packageuser_system | Dependency collector for the package-user system | lfs, blfs, package-manager, dependencies + +[Web & Tools] +~/Git/Git-Server | gitsync + this read-only git browser | git, php, bash, apache, self-hosted +~/Git/Browser | Minimal browser in C (mini and big variant) | browser, c +~/Git/dienstplancreator | Dienstplan (roster) creator, PHP/JS web app | php, javascript, roster, web +~/Git/dvd | DVD collection database with IMDB lookup | c, imdb, movies, cmake +~/Git/typing_10_fingers | 10 finger typing trainer (CMake) | typing, terminal, c, cmake + +[Private] +# ! = only reachable via /private with the password from .htpasswd; not scanned for secrets +!~/LFS/lfs-config | My LFS system configuration | lfs, config, dotfiles + +[Mirrors & Forks] +~/Git/jhalfs | jhalfs LFS build automation (mirror) | lfs, mirror, bash +~/Git/syntastic | syntastic vim plugin (fork) | vim, fork +~/Git/riscv.vim | RISC-V assembly syntax for vim | vim, riscv, assembly + +# not listed on purpose: binance, binance_idk (API keys), back.*/old.* backups, pointer.c diff --git a/web/index.php b/web/index.php new file mode 100644 index 0000000..2528418 --- /dev/null +++ b/web/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.9.2'; +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/style.css b/web/style.css new file mode 100644 index 0000000..19208f8 --- /dev/null +++ b/web/style.css @@ -0,0 +1,97 @@ +:root{--bg:#f3f5f9;--surface:#ffffff;--surface2:#e9edf3;--fg:#1a1d23;--muted:#6b7280;--line:#d9dfe8;--shadow:0 1px 2px rgba(20,30,50,.05);--accent:#e8590c;--accent-soft:#fdeee4;--link:#0b6e99;--add:#e3f7e8;--del:#fde8e8; + --l0:#e2e6ed;--l1:#c3e6c8;--l2:#83cc92;--l3:#3ea55b;--l4:#1f6b37;--radius:14px} +@media(prefers-color-scheme:dark){:root{--bg:#0f1218;--surface:#171b23;--surface2:#1f2530;--fg:#e6e9ef;--muted:#8b93a3;--line:#2a3140;--shadow:0 1px 2px rgba(0,0,0,.3);--accent:#ff7a2e;--accent-soft:#33221a;--link:#62c1e6;--add:#13301c;--del:#3a1417; + --l0:#24272c;--l1:#1f4d2c;--l2:#2b7a3f;--l3:#3fa55a;--l4:#6fdc8c}} +*{box-sizing:border-box;min-width:0} +body{margin:0;font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--fg);-webkit-font-smoothing:antialiased} +a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} +code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px} +h1,h2,h3,.name,.desc,code,td{overflow-wrap:anywhere} +header{position:-webkit-sticky;position:sticky;top:0;z-index:10;display:flex;flex-wrap:wrap;align-items:center;gap:6px;background:var(--surface);padding:12px 28px;border-bottom:1px solid var(--line);-webkit-backface-visibility:hidden;backface-visibility:hidden;will-change:transform} +header .brand{font-weight:700;color:var(--fg);letter-spacing:-.01em;margin-right:10px} +.topnav{display:inline-flex;gap:2px;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:3px;margin-right:12px} +.topnav a{color:var(--muted);font-size:13px;font-weight:600;padding:4px 14px;border-radius:999px} +.topnav a.on{background:var(--surface);color:var(--fg);border:1px solid var(--line)}.topnav a:hover{color:var(--accent);text-decoration:none} +header .crumbs{font-size:14px;color:var(--muted)}header .crumbs a{color:var(--fg)}header .crumbs b{color:var(--fg)}header .brand small{font-weight:400;color:var(--muted);margin-left:8px;font-size:12px}header .sep{color:var(--muted)} +header .search{margin-left:auto;display:flex;gap:6px} +header input[type=text]{border:1px solid var(--line);border-radius:999px;padding:7px 14px;background:var(--surface2);color:var(--fg);width:240px;max-width:60vw;font:inherit;font-size:14px} +header input[type=submit],.copy,.btn{font:inherit;font-size:13px;cursor:pointer;background:var(--surface2);border:0;border-radius:999px;padding:6px 14px;color:var(--fg)} +header input[type=submit]:hover,.copy:hover,.btn:hover{background:var(--accent-soft);color:var(--accent);text-decoration:none} +main{max-width:1120px;margin:0 auto;padding:28px 28px 40px} +footer{text-align:center;color:var(--muted);font-size:12px;padding:30px;line-height:1.9}footer a{color:var(--muted)} +h1{font-size:26px;font-weight:700;letter-spacing:-.02em;margin:0 0 14px} +h2{font-size:15px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:36px 0 14px;scroll-margin-top:70px}h2 small{font-weight:400;margin-left:6px} +.badge{flex:none;align-self:flex-start;font-size:11px;font-weight:600;color:var(--muted);background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:2px 9px;line-height:1.6;white-space:nowrap} +h1 .badge{vertical-align:middle;display:inline-block} +.meta,.desc{color:var(--muted)}.meta{font-size:13px} +.tag{display:inline-block;font-size:12px;background:hsl(var(--h,210),45%,93%);border:1px solid hsl(var(--h,210),35%,85%);border-radius:999px;padding:1px 10px;color:hsl(var(--h,210),45%,28%);margin:2px 2px 2px 0} +.tag:hover,.tag.on{background:hsl(var(--h,210),55%,86%);border-color:hsl(var(--h,210),50%,55%);text-decoration:none}.tag small{opacity:.6} +.tag.more{background:var(--surface2);border-color:var(--line);color:var(--muted)} +@media(prefers-color-scheme:dark){.tag{background:hsl(var(--h,210),25%,20%);border-color:hsl(var(--h,210),25%,28%);color:hsl(var(--h,210),55%,78%)}.tag:hover,.tag.on{background:hsl(var(--h,210),30%,28%);border-color:hsl(var(--h,210),45%,55%)}} +.tagcloud{line-height:1.9;margin:0 0 4px}.tagcloud .tag{font-size:11px;padding:0 8px}.tag.more{color:var(--muted)} +.catnav{margin:0 0 10px;font-size:13px}.catnav a{margin-right:14px;color:var(--fg);font-weight:600;white-space:nowrap}.catnav a:hover{color:var(--accent);text-decoration:none} +.tagt{font-size:12px;color:hsl(var(--h,210),45%,40%)}.tagt:hover{text-decoration:underline} +@media(prefers-color-scheme:dark){.tagt{color:hsl(var(--h,210),50%,70%)}} +.lead{font-size:16px;color:var(--muted);max-width:720px;margin:0 0 18px} +.intro .card .name{font-size:18px} +.commits.feed td:nth-child(2){overflow-wrap:anywhere}.commits.feed .repo{font-weight:600;color:var(--fg);margin-right:6px} +.card .tags{position:relative;z-index:1;font-size:12px;color:var(--muted)} +.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:14px} +.card{position:relative;display:flex;flex-direction:column;gap:5px;border-radius:var(--radius);padding:16px 18px;background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);transition:border-color .2s,box-shadow .2s} +.card:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(0,0,0,.06)} +.recent .card{box-shadow:inset 3px 0 0 var(--accent),var(--shadow)} +.recent .card:hover{box-shadow:inset 0 0 0 0 var(--accent),0 4px 14px rgba(0,0,0,.06)} +.card .top{display:flex;justify-content:space-between;gap:8px;align-items:flex-start} +.card .name{font-weight:600;font-size:16px;color:var(--fg)} +.card .name::after{content:"";position:absolute;inset:0;border-radius:var(--radius)} +.card:hover .name{color:var(--accent);text-decoration:none} +.card .tag,.card .tagt,.card .badge{position:relative;z-index:1} +.card p{margin:0}.card .desc{font-size:14px}.card .meta{margin-top:auto;padding-top:6px} +.langbar{display:flex;height:6px;border-radius:999px;overflow:hidden;background:var(--l0);margin:8px 0 2px} +.langbar>span{display:block} +.langs{display:block;font-size:12px;color:var(--muted);margin-bottom:8px}.lang{margin-right:12px;white-space:nowrap} +.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px} +.repohead{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);padding:20px 24px 16px;margin-bottom:18px} +.repohead h1 a{color:var(--fg)}.repohead h1{margin-bottom:4px}.repohead .desc{margin:0 0 8px;font-size:15px}.repohead p.meta{margin:0 0 8px} +.tabs{display:flex;gap:4px;margin:10px 0 12px;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:3px;width:max-content} +.tabs a{color:var(--muted);padding:5px 16px;border-radius:999px;font-size:14px}.tabs a.on{background:var(--surface);color:var(--fg);font-weight:600;border:1px solid var(--line)}.tabs a:hover{text-decoration:none;color:var(--fg)} +.get{display:flex;flex-wrap:wrap;gap:8px;align-items:center} +.getopts{display:inline-flex;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:2px} +.getopts button{font:inherit;font-size:12px;border:0;background:none;color:var(--muted);border-radius:999px;padding:4px 12px;cursor:pointer}.getopts button.on{background:var(--surface);color:var(--fg);font-weight:600;border:1px solid var(--line)} +.cmd{display:inline-flex;gap:8px;align-items:center;background:var(--surface2);border:1px solid var(--line);border-radius:10px;padding:6px 12px;max-width:100%} +.cmd code{overflow-wrap:anywhere} +table{border-collapse:collapse;width:100%} +.tree td,.commits td{padding:9px 14px;border-top:1px solid var(--line)}.tree tr:first-child td,.commits tr:first-child td{border-top:0} +.tree,.commits{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);overflow:hidden} +.tree tr:hover td,.commits tr:hover td{background:var(--surface2)} +.tree .ico{width:28px;text-align:center;color:var(--muted);font-family:monospace}.tree .size,.commits .meta{text-align:right;color:var(--muted);white-space:nowrap} +.commits.last{margin-bottom:14px}.hash{font-family:monospace} +.readme{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);margin-top:18px;padding:20px 28px}.readme h3{margin-top:0;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em} +.markdown img{max-width:100%;border-radius:8px}.markdown pre{background:var(--surface2);border:1px solid var(--line);padding:12px 14px;border-radius:10px;overflow:auto} +.markdown code{background:var(--surface2);padding:1px 5px;border-radius:5px}.markdown pre code{padding:0} +.markdown blockquote{border-left:3px solid var(--accent);margin:0;padding:2px 14px;color:var(--muted)} +.markdown table.md{width:auto}.markdown table.md td,.markdown table.md th{border:1px solid var(--line);padding:5px 12px} +.markdown h1,.markdown h2,.markdown h3{text-transform:none;letter-spacing:0;color:var(--fg);margin:20px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:18px}.markdown h3{font-size:15px} +.filehead{display:flex;flex-wrap:wrap;gap:12px;align-items:center;background:var(--surface2);border:1px solid var(--line);border-radius:var(--radius) var(--radius) 0 0;padding:10px 16px} +.filehead+pre,.filehead+div{background:var(--surface);border:1px solid var(--line);border-top:0;border-radius:0 0 var(--radius) var(--radius);margin:0;padding:14px 16px;overflow:auto} +pre{margin:0;overflow:auto;white-space:pre} +.preview{max-width:100%;border-radius:8px} +.commit h2{text-transform:none;letter-spacing:0;color:var(--fg);font-size:20px;margin:0}.commit .body{background:var(--surface2);padding:12px;border-radius:10px;margin:10px 0} +.diff{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px;line-height:1.45} +.diff .a{background:var(--add);display:block}.diff .d{background:var(--del);display:block} +.diff .r{color:var(--link);display:block}.diff .f,.diff .h{font-weight:700;display:block} +.activitybox{background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:var(--radius);padding:16px 20px;margin:18px 0}.activitybox p{margin:0 0 6px} +.activity{width:100%;max-width:760px;display:block} +.activity text{font-size:9px;fill:var(--muted)} +.activity .l0{fill:var(--l0)}.activity .l1{fill:var(--l1)}.activity .l2{fill:var(--l2)}.activity .l3{fill:var(--l3)}.activity .l4{fill:var(--l4)} +@media(max-width:640px){ +header{padding:10px 14px}header .search{margin-left:0;width:100%}header input[type=text]{width:100%;max-width:none;flex:1} +main{padding:16px 14px 30px}h1{font-size:22px} +.cards{grid-template-columns:1fr} +.repohead{padding:14px 16px 12px}.tabs{width:100%}.tabs a{flex:1;text-align:center} +.tree td,.commits td{padding:8px 10px}.commits .meta{display:none} +.activity{max-width:none} +} +.hint{white-space:pre-wrap} +.totop{position:fixed;right:22px;bottom:22px;width:40px;height:40px;border-radius:50%;background:var(--surface);border:1px solid var(--line);box-shadow:0 4px 14px rgba(0,0,0,.12);color:var(--fg);font-size:18px;line-height:38px;text-align:center;opacity:0;pointer-events:none;transition:opacity .2s;z-index:9} +.totop.show{opacity:1;pointer-events:auto}.totop:hover{color:var(--accent);border-color:var(--accent);text-decoration:none}