A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
<?php
declare(strict_types=1);
/*
* foxygit — a mostly-read-only git web frontend. No database, no deps.
*
* Reads bare repos by shelling out to the `git` binary. git push/pull itself
* is NOT handled here — that's SSH (see server/) or authenticated HTTPS via
* git-http-backend (see server/git-http-backend-auth). This app is the
* "shop window" (browse repos, log in, manage API keys, admin repo
* create/delete) plus the one shared source of truth (inc/auth.php) that
* the HTTPS-push auth check also reads from.
*
* This file is the router only: it resolves input, asks inc/functions.php
* to talk to git, and hands plain-array results to views/*.php to render.
* No HTML lives here — see views/ for that, and assets/+themes/ for CSS.
*
* Deploy: point php-fpm + Caddy at this file, set REPO_BASE in inc/config.php,
* make sure the php-fpm pool user can READ the bare repos (see deploy.sh).
*/
require __DIR__ . '/inc/config.php';
require __DIR__ . '/inc/functions.php';
require __DIR__ . '/inc/render.php';
require __DIR__ . '/inc/auth.php';
/* ---------------------------------------------------------------- routing */
$repoName = $_GET['r'] ?? null;
$repo = $repoName !== null ? resolve_repo($repoName) : null;
// A repo with no explicit view lands on Files (tree), the way GitHub opens on Code.
$action = $_GET['a'] ?? ($repoName !== null ? 'tree' : 'index');
// Resume a session if the visitor already has one (so the nav can show who's
// logged in on any page), but never create one just for anonymous browsing —
// that stays exactly as cookie-light as it's always been. The auth routes
// below force a session into existence (needed for CSRF tokens + login itself).
auth_session_start();
if (in_array($action, ['setup', 'login', 'logout', 'account', 'admin'], true)) {
auth_session_start(force: true);
}
if ($repoName !== null && $repo === null) {
http_response_code(404);
$action = 'notfound';
}
$themes = available_themes();
$theme = current_theme();
if (isset($_GET['theme']) && $_GET['theme'] === $theme) { // explicit, valid choice -> remember it
setcookie('foxygit_theme', $theme, [
'expires' => time() + 31536000, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
]);
}
/* ----------------------------------------------------------------- views */
if ($action === 'help') { // global page, works with or without ?r=
render('partials/head', ['title' => SITE_NAME . ' · help', 'theme' => $theme, 'repoName' => null]);
render('help');
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
if ($action === 'notfound') {
render('partials/head', ['title' => '404', 'theme' => $theme, 'repoName' => null]);
render('notfound');
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
/* -------------------------------------------------------- accounts/admin */
if ($action === 'setup') { // one-time: create the first (admin) account
if (has_any_users()) { header('Location: ?a=login'); exit; }
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
check_csrf();
$username = trim((string) ($_POST['username'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$confirm = (string) ($_POST['confirm'] ?? '');
if ($password !== $confirm) {
$error = 'Passwords do not match.';
} else {
$result = create_user($username, $password, true);
if ($result === true) {
log_in_session($username, true);
header('Location: ?a=account');
exit;
}
$error = $result;
}
}
render('partials/head', ['title' => SITE_NAME . ' · set up', 'theme' => $theme, 'repoName' => null]);
render('setup', ['error' => $error, 'csrf' => csrf_token()]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
if ($action === 'login') {
if (!has_any_users()) { header('Location: ?a=setup'); exit; }
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
check_csrf();
$username = trim((string) ($_POST['username'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if (verify_login($username, $password)) {
$users = list_users();
$isAdmin = (bool) ($users[$username]['is_admin'] ?? false);
log_in_session($username, $isAdmin);
// only ever redirect to a same-page query string (must start with "?") --
// anything else (an absolute/protocol-relative URL) is rejected so a
// crafted ?next= can't turn a real login into an open redirect.
$next = $_POST['next'] ?? '';
$target = is_string($next) && str_starts_with($next, '?') ? $next : '?';
header('Location: ' . $target);
exit;
}
// deliberately vague — doesn't reveal whether the username exists
$error = 'Invalid username or password.';
}
render('partials/head', ['title' => SITE_NAME . ' · log in', 'theme' => $theme, 'repoName' => null]);
render('login', ['error' => $error, 'csrf' => csrf_token(), 'next' => (string) ($_GET['next'] ?? '')]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
if ($action === 'logout') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
check_csrf();
log_out_session();
}
header('Location: ?');
exit;
}
if ($action === 'account') {
require_login();
$username = (string) current_username();
$newKey = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
check_csrf();
$sub = $_POST['sub'] ?? '';
if ($sub === 'create-key') {
$label = trim((string) ($_POST['label'] ?? ''));
$newKey = generate_api_key($username, $label);
} elseif ($sub === 'revoke-key') {
$hash = (string) ($_POST['hash'] ?? '');
if (!revoke_api_key($hash, $username, is_admin())) {
$error = 'Could not revoke that key.';
}
}
}
render('partials/head', ['title' => SITE_NAME . ' · account', 'theme' => $theme, 'repoName' => null]);
render('account', [
'username' => $username, 'isAdmin' => is_admin(), 'keys' => list_api_keys($username),
'newKey' => $newKey, 'error' => $error, 'csrf' => csrf_token(),
]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
if ($action === 'admin') {
require_admin();
$username = (string) current_username();
$error = null;
$success = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
check_csrf();
$sub = $_POST['sub'] ?? '';
if ($sub === 'create-repo') {
$name = trim((string) ($_POST['name'] ?? ''));
$result = create_bare_repo($name);
if ($result === true) { $success = "Created \"$name\"."; } else { $error = $result; }
} elseif ($sub === 'delete-repo') {
$name = trim((string) ($_POST['name'] ?? ''));
$confirm = trim((string) ($_POST['confirm'] ?? ''));
if ($name === '' || $name !== $confirm) {
$error = 'Repo name did not match its confirmation — nothing deleted.';
} else {
$result = delete_bare_repo($name);
if ($result === true) { $success = "Deleted \"$name\"."; } else { $error = $result; }
}
} elseif ($sub === 'add-user') {
$newUsername = trim((string) ($_POST['username'] ?? ''));
$newPassword = (string) ($_POST['password'] ?? '');
$newIsAdmin = isset($_POST['is_admin']);
$result = create_user($newUsername, $newPassword, $newIsAdmin);
if ($result === true) { $success = "Created account \"$newUsername\"."; } else { $error = $result; }
} elseif ($sub === 'delete-user') {
$targetUsername = trim((string) ($_POST['username'] ?? ''));
$result = delete_user($targetUsername, $username);
if ($result === true) { $success = "Deleted account \"$targetUsername\"."; } else { $error = $result; }
}
}
render('partials/head', ['title' => SITE_NAME . ' · admin', 'theme' => $theme, 'repoName' => null]);
render('admin', [
'repos' => list_repos(), 'users' => list_users(), 'currentUsername' => $username,
'error' => $error, 'success' => $success, 'csrf' => csrf_token(),
]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
if ($repo === null) { // repo index
render('partials/head', [
'title' => SITE_NAME, 'theme' => $theme, 'repoName' => null, 'description' => SITE_DESCRIPTION,
]);
render('repo-index', ['repos' => list_repos()]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
/* --- repo is valid & resolved from here on --- */
$description = repo_description($repo);
if ($action === 'raw') { // raw/plain file download — no view, just bytes
$ref = $_GET['ref'] ?? 'HEAD';
$blob = $_GET['blob'] ?? '';
if (!safe_ref($ref)) $ref = 'HEAD';
if (!safe_ref($blob)) { http_response_code(400); exit('Invalid path.'); }
$spec = $ref . ':' . $blob;
$sizeOut = git($repo, ['cat-file', '-s', $spec]);
if (!isset($sizeOut[0])) { http_response_code(404); exit('No such file.'); }
$content = git_bytes($repo, ['show', $spec]);
$mime = 'application/octet-stream';
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
if ($fi !== false) {
$detected = finfo_buffer($fi, $content);
finfo_close($fi);
if ($detected) $mime = $detected;
}
}
$disposition = isset($_GET['dl']) ? 'attachment' : 'inline';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string) strlen($content));
header('Content-Disposition: ' . $disposition . '; filename="' . basename($blob) . '"');
header('X-Content-Type-Options: nosniff');
echo $content;
exit;
}
if ($action === 'atom' || $action === 'atom-tags') {
header('Content-Type: application/atom+xml; charset=utf-8');
if ($action === 'atom') {
$ref = $_GET['ref'] ?? 'HEAD';
if (!safe_ref($ref)) $ref = 'HEAD';
render('atom', ['repoName' => $repoName, 'entries' => atom_commit_entries($repo, $ref)]);
} else {
render('atom-tags', ['repoName' => $repoName, 'entries' => atom_tag_entries($repo)]);
}
exit;
}
if ($action === 'commit') {
$hash = $_GET['h'] ?? '';
render('partials/head', [
'title' => repo_display_name($repoName) . ' · commit', 'theme' => $theme, 'repoName' => $repoName,
'tab' => 'log', 'description' => $description,
]);
if (!preg_match('~^[0-9a-f]{4,64}$~i', $hash)) { // only hex object ids
http_response_code(400);
render('partials/error', ['message' => 'Invalid commit id.']);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
$diffRaw = git_raw($repo, ['show', '--stat', '-p', '--pretty=fuller', $hash]);
$truncated = strlen($diffRaw) > MAX_DIFF_BYTES;
if ($truncated) $diffRaw = substr($diffRaw, 0, MAX_DIFF_BYTES);
render('commit', ['diff' => colorize_diff($diffRaw), 'truncated' => $truncated]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
if ($action === 'refs') {
// for-each-ref's --format doesn't understand pretty-format's %xNN hex escapes (only
// `git log`/`show --pretty=format:` do) -- needs a real \x1f byte in the PHP string itself.
$refs = parse_refs(git($repo, ['for-each-ref', '--sort=-creatordate',
"--format=%(refname:short)\x1f%(refname)", 'refs/heads', 'refs/tags']));
render('partials/head', [
'title' => repo_display_name($repoName) . ' · refs', 'theme' => $theme, 'repoName' => $repoName,
'tab' => 'refs', 'description' => $description,
]);
render('partials/repo-subnav', [
'repoName' => $repoName, 'curAction' => 'refs', 'curRef' => 'HEAD', 'branches' => [],
]);
render('refs', ['repoName' => $repoName, 'refs' => $refs]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
if ($action === 'log') {
$ref = $_GET['ref'] ?? 'HEAD';
if (!safe_ref($ref)) $ref = 'HEAD';
$skip = isset($_GET['skip']) ? max(0, (int) $_GET['skip']) : 0;
$lines = git($repo, ['log', '-n', (string) (LOG_COUNT + 1), '--skip', (string) $skip,
'--pretty=format:%H%x1f%h%x1f%an%x1f%at%x1f%s', $ref]);
$hasMore = count($lines) > LOG_COUNT;
$entries = parse_log_lines(array_slice($lines, 0, LOG_COUNT));
render('partials/head', [
'title' => repo_display_name($repoName) . ' · commits', 'theme' => $theme, 'repoName' => $repoName,
'tab' => 'log', 'description' => $description,
]);
render('partials/repo-subnav', [
'repoName' => $repoName, 'curAction' => 'log', 'curRef' => $ref, 'branches' => list_branches($repo),
]);
render('log', [
'repoName' => $repoName, 'ref' => $ref, 'skip' => $skip,
'dateGroups' => group_log_by_date($entries), 'hasMore' => $hasMore,
]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
/* default: tree (Files) */
$ref = $_GET['ref'] ?? 'HEAD';
if (!safe_ref($ref)) $ref = 'HEAD';
render('partials/head', [
'title' => repo_display_name($repoName) . ' · files', 'theme' => $theme, 'repoName' => $repoName,
'tab' => 'tree', 'description' => $description,
]);
render('partials/repo-subnav', [
'repoName' => $repoName, 'curAction' => 'tree', 'curRef' => $ref, 'branches' => list_branches($repo),
]);
if (isset($_GET['blob'])) { // single file contents
$blob = $_GET['blob'];
if (!safe_ref($blob)) {
http_response_code(400);
render('partials/error', ['message' => 'Invalid path.']);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
$spec = $ref . ':' . $blob;
$sizeOut = git($repo, ['cat-file', '-s', $spec]);
if (!isset($sizeOut[0])) {
http_response_code(404);
render('partials/error', ['message' => 'No such file.']);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
$size = (int) $sizeOut[0];
$isImage = is_image_ext($blob);
$isMarkdown = is_markdown_ext($blob);
// images are shown via <img src="raw-url">, never dumped inline as text,
// so the size cap (meant to protect the "paste it into a <pre>" path) doesn't apply to them
$tooLarge = !$isImage && $size > MAX_BLOB_BYTES;
$content = ($tooLarge || $isImage) ? '' : git_bytes($repo, ['show', $spec]);
$isBinary = !$tooLarge && !$isImage && strpos($content, "\0") !== false;
$dir = dirname($blob);
$renderedHtml = ($isMarkdown && !$tooLarge && !$isBinary)
? markdown_to_html($content, $repoName, $ref, $dir === '.' ? '' : $dir)
: null;
render('blob', [
'repoName' => $repoName, 'ref' => $ref, 'blob' => $blob, 'size' => $size,
'tooLarge' => $tooLarge, 'isBinary' => $isBinary, 'isImage' => $isImage,
'content' => $content, 'renderedHtml' => $renderedHtml,
]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
}
$path = $_GET['path'] ?? '';
if ($path !== '' && !safe_ref($path)) $path = '';
$treeish = $path === '' ? $ref : "$ref:$path";
$rawTree = git_bytes($repo, ['ls-tree', '--long', '-z', $treeish]);
// README/LICENSE belong to the repo root, so they only show on the top-level tree
$docs = $path === '' ? root_docs($repo, $repoName, $ref) : ['readmeName' => null, 'readmeHtml' => null, 'licenseName' => null];
render('tree', [
'repoName' => $repoName, 'ref' => $ref, 'path' => $path,
'entries' => annotate_last_commits($repo, $ref, parse_tree($rawTree, $path)),
] + $docs);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
/*
* -------------------------------------------------------------- DEPLOYMENT
*
* This server's bare repos live at /var/git/repos, owned git:git, mode 0700
* (set up by server/setup-server.sh). php-fpm's default pool runs as
* www-data, which can't read them — so this needs its own pool running as
* the `git` user instead. See deploy.sh in this directory, which does the
* whole thing (pool + web root incl. inc/, views/, assets/, themes/ + Caddy
* block + reload):
*
* sudo bash /home/mrfox/foxygit/deploy.sh
*
* Make sure exec() and proc_open() are not in php.ini's disable_functions
* (they aren't by default on Debian). That's the whole thing.
*/