A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
<?php
declare(strict_types=1);
/*
* Accounts, API keys, and sessions — the one part of foxygit that isn't
* read-only. Everything lives in one JSON file (AUTH_STORE, see
* inc/config.php), read-modify-written under an flock() so concurrent
* requests can't clobber each other.
*
* Split in two halves on purpose:
* - the data layer (load/save/create_user/verify_login/api keys) has no
* dependency on $_SESSION or any web-only PHP feature, so
* server/verify-api-key.php can require() this file and call
* verify_api_key() from plain CLI — the HTTPS-push auth check reuses
* the exact same hashing/lookup code the web app uses.
* - the session/CSRF half is web-only and only ever touched by the new
* login/account/admin routes in index.php, never by plain repo
* browsing — so browsing a repo still sets no cookie beyond the
* existing foxygit_theme one.
*/
/** Open AUTH_STORE, lock it, hand the decoded array to $mutator by
* reference, then write back whatever $mutator left in it. $mutator's
* return value is passed through — this is the only way callers read or
* write the store, so every operation is atomic against concurrent requests. */
function with_auth_store(callable $mutator) {
$dir = dirname(AUTH_STORE);
if (!is_dir($dir)) @mkdir($dir, 0700, true);
$fh = fopen(AUTH_STORE, 'c+');
if ($fh === false) {
throw new RuntimeException('cannot open auth store: ' . AUTH_STORE);
}
flock($fh, LOCK_EX);
$raw = stream_get_contents($fh);
$data = $raw !== false && $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($data)) $data = [];
if (!isset($data['users']) || !is_array($data['users'])) $data['users'] = [];
if (!isset($data['keys']) || !is_array($data['keys'])) $data['keys'] = [];
$result = $mutator($data);
ftruncate($fh, 0);
rewind($fh);
fwrite($fh, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
fflush($fh);
flock($fh, LOCK_UN);
fclose($fh);
@chmod(AUTH_STORE, 0600);
return $result;
}
function has_any_users(): bool {
return with_auth_store(function (array &$data): bool {
return $data['users'] !== [];
});
}
function valid_username(string $name): bool {
return (bool) preg_match('~^[A-Za-z0-9_-]{2,32}$~', $name);
}
/** @return true|string true on success, an error message otherwise. */
function create_user(string $username, string $password, bool $isAdmin) {
if (!valid_username($username)) {
return 'Username must be 2-32 characters: letters, digits, "_" or "-".';
}
if (strlen($password) < 8) {
return 'Password must be at least 8 characters.';
}
return with_auth_store(function (array &$data) use ($username, $password, $isAdmin) {
if (isset($data['users'][$username])) {
return 'That username is already taken.';
}
$data['users'][$username] = [
'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => PASSWORD_COST]),
'is_admin' => $isAdmin,
'created' => time(),
];
return true;
});
}
/* Bcrypt cost, pinned rather than left to PASSWORD_DEFAULT so that the work
* factor of a real verify and of the dummy verify below can't drift apart —
* PHP raised the bcrypt default from 10 to 12 in 8.4, and a dummy hash left
* at the older cost verifies ~4x faster, which is exactly the timing signal
* it exists to suppress. Raise this to make hashing slower; if you do,
* regenerate DUMMY_PASSWORD_HASH at the same cost:
* php -r 'echo password_hash(bin2hex(random_bytes(32)), PASSWORD_BCRYPT, ["cost" => 12]);'
* Existing stored hashes keep working either way — password_verify() reads
* the cost out of the hash itself. */
const PASSWORD_COST = 12;
// A valid bcrypt hash (of a random string) that nothing will ever match — run
// through password_verify() when a username doesn't exist, so a login attempt
// against an unknown user costs the same as one against a real user with a
// wrong password, and timing doesn't reveal which usernames exist.
const DUMMY_PASSWORD_HASH = '$2y$12$JjwvL8rDRDdE9lf2XI548eGjcRu9scv6tNsXmzjcExnQztbg3zGIm';
function verify_login(string $username, string $password): bool {
return with_auth_store(function (array &$data) use ($username, $password): bool {
$user = $data['users'][$username] ?? null;
$hash = $user['password_hash'] ?? DUMMY_PASSWORD_HASH;
$ok = password_verify($password, $hash);
return $user !== null && $ok;
});
}
function list_users(): array {
return with_auth_store(function (array &$data): array {
$out = $data['users'];
uasort($out, fn(array $a, array $b): int => $a['created'] <=> $b['created']);
return $out;
});
}
/** @return true|string true on success, an error message otherwise. */
function delete_user(string $username, string $requester) {
if ($username === $requester) {
return "You can't delete your own account while logged in as it.";
}
return with_auth_store(function (array &$data) use ($username): bool {
if (!isset($data['users'][$username])) return false;
unset($data['users'][$username]);
foreach ($data['keys'] as $hash => $meta) {
if ($meta['user'] === $username) unset($data['keys'][$hash]);
}
return true;
}) ? true : 'No such user.';
}
/** Returns the raw key — shown to the user exactly once. Only its SHA-256
* hash is ever stored, the same way GitHub/GitLab handle personal access
* tokens (a random high-entropy token is looked up by exact hash, unlike a
* user-chosen low-entropy password which needs bcrypt's per-hash salt). */
function generate_api_key(string $username, string $label): string {
$raw = 'fxg_' . bin2hex(random_bytes(24));
$hash = hash('sha256', $raw);
$label = $label !== '' ? $label : 'unnamed key';
with_auth_store(function (array &$data) use ($username, $label, $hash): void {
$data['keys'][$hash] = [
'user' => $username,
'label' => $label,
'created' => time(),
'last_used' => null,
];
});
return $raw;
}
function list_api_keys(string $username): array {
return with_auth_store(function (array &$data) use ($username): array {
$out = array_filter($data['keys'], fn(array $k): bool => $k['user'] === $username);
uasort($out, fn(array $a, array $b): int => $b['created'] <=> $a['created']);
return $out;
});
}
function revoke_api_key(string $hash, string $requester, bool $requesterIsAdmin): bool {
return with_auth_store(function (array &$data) use ($hash, $requester, $requesterIsAdmin): bool {
$key = $data['keys'][$hash] ?? null;
if ($key === null) return false;
if (!$requesterIsAdmin && $key['user'] !== $requester) return false;
unset($data['keys'][$hash]);
return true;
});
}
/** Validates a raw API key (as sent as an HTTP Basic auth password) and
* bumps its last_used timestamp. Used by the web app AND by
* server/verify-api-key.php from plain CLI — the one shared source of
* truth for "is this key valid". @return the owning username, or null. */
function verify_api_key(string $rawKey): ?string {
$rawKey = trim($rawKey);
if ($rawKey === '') return null;
$hash = hash('sha256', $rawKey);
return with_auth_store(function (array &$data) use ($hash): ?string {
$key = $data['keys'][$hash] ?? null;
if ($key === null) return null;
$data['keys'][$hash]['last_used'] = time();
return $key['user'];
});
}
/* ------------------------------------------------------------- sessions */
/* Everything below is web-only and assumes a normal PHP request/response
* lifecycle ($_SESSION, headers). Only called from the login/account/admin
* routes in index.php — never from plain repo browsing. */
const SESSION_COOKIE_NAME = 'foxygit_sess';
/** Called once per request (index.php) with $force = false: resumes a
* session if the visitor already has one (so the nav can show "logged in
* as ..." everywhere), but never creates one for a plain anonymous
* browsing visitor — that's the whole "no cookies beyond the theme one"
* guarantee. The login/setup/logout/account/admin routes call it again
* with $force = true, which guarantees a session exists (creating one on
* a visitor's very first hit to e.g. ?a=login). */
function auth_session_start(bool $force = false): void {
if (session_status() === PHP_SESSION_ACTIVE) return;
if (!$force && !isset($_COOKIE[SESSION_COOKIE_NAME])) return;
session_name(SESSION_COOKIE_NAME);
session_set_cookie_params([
'lifetime' => 0, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
]);
session_start();
}
function current_username(): ?string {
if (session_status() !== PHP_SESSION_ACTIVE) return null;
return $_SESSION['username'] ?? null;
}
function is_logged_in(): bool {
return current_username() !== null;
}
function is_admin(): bool {
if (session_status() !== PHP_SESSION_ACTIVE) return false;
return (bool) ($_SESSION['is_admin'] ?? false);
}
function log_in_session(string $username, bool $isAdmin): void {
session_regenerate_id(true);
$_SESSION['username'] = $username;
$_SESSION['is_admin'] = $isAdmin;
}
function log_out_session(): void {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
}
function require_login(): void {
if (!is_logged_in()) {
header('Location: ?a=login');
exit;
}
}
function require_admin(): void {
require_login();
if (!is_admin()) {
http_response_code(403);
exit('Forbidden — admin only.');
}
}
function csrf_token(): string {
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function check_csrf(): void {
$token = $_POST['csrf'] ?? '';
if (!is_string($token) || $token === '' || !hash_equals($_SESSION['csrf'] ?? '', $token)) {
http_response_code(400);
exit('Form expired or invalid (CSRF check failed) — go back and try again.');
}
}