SITE_TITLE, 'registration_open' => true, 'invite_only' => false];
$saved = json_decode(@file_get_contents(DATA_PATH . '/settings.json'), true) ?? [];
return array_merge($defaults, $saved);
}
function session_user(): ?string {
return $_SESSION['gitgram_user'] ?? null;
}
function session_is_admin(): bool {
if (!session_user()) return false;
$users = load_users();
return ($users[session_user()]['role'] ?? '') === 'admin';
}
function site_url(string $path = ''): string {
if (SITE_URL !== '') return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
return $scheme . '://' . $host . $base . '/' . ltrim($path, '/');
}
/**
* Run a shell command using the best available method:
* proc_open (preferred) → exec → shell_exec.
* Returns ['out' => string, 'rc' => int].
*/
function run_cmd(string $cmd): array {
if (function_exists('proc_open')) {
$proc = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w']], $pipes);
if (is_resource($proc)) {
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$rc = proc_close($proc);
return ['out' => trim($out), 'rc' => $rc];
}
}
if (function_exists('exec')) {
exec($cmd, $lines, $rc);
return ['out' => trim(implode("\n", $lines)), 'rc' => $rc];
}
if (function_exists('shell_exec')) {
$out = shell_exec($cmd) ?? '';
return ['out' => trim($out), 'rc' => 0];
}
return ['out' => '', 'rc' => -1];
}
function shell_available(): bool {
return function_exists('proc_open')
|| function_exists('exec')
|| function_exists('shell_exec');
}
function git_run(string $repo_dir, array $args): string {
$cmd = array_map('escapeshellarg', array_merge([GIT_BIN, '--git-dir=' . $repo_dir], $args));
$r = run_cmd(implode(' ', $cmd) . ' 2>/dev/null');
return $r['rc'] === 0 ? $r['out'] : '';
}
function find_git_backend(): string|false {
foreach (GIT_HTTP_BACKEND_PATHS as $p) {
if (is_executable($p)) return $p;
}
$found = trim(shell_exec('which git-http-backend 2>/dev/null') ?? '');
return $found ?: false;
}
function repo_list(): array {
$repos = [];
if (!is_dir(REPO_PATH)) return $repos;
foreach (glob(REPO_PATH . '/*.git', GLOB_ONLYDIR) as $dir) {
$name = basename($dir, '.git');
$desc = @file_get_contents($dir . '/description') ?: '';
if (str_starts_with($desc, 'Unnamed repository')) $desc = '';
$repos[] = ['name' => $name, 'dir' => $dir, 'desc' => trim($desc)];
}
return $repos;
}
function repo_dir(string $name): string {
return REPO_PATH . '/' . $name . '.git';
}
// ── Upload / commit helpers ───────────────────────────────────────────────────
function sanitize_git_path(string $path): string {
$parts = explode('/', str_replace('\\', '/', $path));
$clean = [];
foreach ($parts as $p) {
if ($p === '' || $p === '.' || $p === '..') continue;
$clean[] = $p;
}
return implode('/', $clean);
}
function recursive_rmdir(string $dir): void {
if (!is_dir($dir)) return;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
$f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
}
rmdir($dir);
}
/**
* Commit $files ([relative_path => content_string]) to $branch of $bare_dir.
* Returns ['ok' => bool, 'error' => string].
*/
function git_commit_files(string $bare_dir, string $branch, array $files,
string $message, string $author_name, string $author_email): array {
// Pure-PHP path: no shell required
if (!shell_available()) {
return gl_write_files($bare_dir, $branch, $files, $message, $author_name, $author_email);
}
$work = rtrim(sys_get_temp_dir() ?: '/tmp', '/') . '/gitgram_' . bin2hex(random_bytes(8));
@mkdir($work, 0755, true);
if (!is_dir($work)) {
return ['ok' => false, 'error' => 'Could not create temp working directory.'];
}
// Pass author identity via -c; avoids needing putenv() or a global git config
$g = escapeshellarg(GIT_BIN);
$cw = escapeshellarg($work);
$id = '-c ' . escapeshellarg('user.name=' . $author_name)
. ' -c ' . escapeshellarg('user.email=' . $author_email);
// Try clone — fails on a brand-new empty bare repo (exit 128), that's fine
$clone = run_cmd("$g clone " . escapeshellarg($bare_dir) . " $cw 2>&1");
$is_empty = ($clone['rc'] !== 0);
if ($is_empty) {
recursive_rmdir($work);
@mkdir($work, 0755, true);
run_cmd("$g -C $cw init 2>&1");
// symbolic-ref sets the default branch before any commits exist
run_cmd("$g -C $cw symbolic-ref HEAD " . escapeshellarg("refs/heads/$branch") . ' 2>&1');
run_cmd("$g -C $cw remote add origin " . escapeshellarg($bare_dir) . ' 2>&1');
} else {
$co = run_cmd("$g -C $cw checkout " . escapeshellarg($branch) . ' 2>&1');
if ($co['rc'] !== 0) {
run_cmd("$g -C $cw checkout -b " . escapeshellarg($branch) . ' 2>&1');
}
}
foreach ($files as $rel => $content) {
$clean = sanitize_git_path($rel);
if (!$clean) continue;
$full = $work . '/' . $clean;
@mkdir(dirname($full), 0755, true);
file_put_contents($full, $content);
}
run_cmd("$g $id -C $cw add -A 2>&1");
$commit = run_cmd("$g $id -C $cw commit -m " . escapeshellarg($message) . ' 2>&1');
if ($commit['rc'] !== 0) {
recursive_rmdir($work);
if (str_contains($commit['out'], 'nothing to commit')) {
return ['ok' => false, 'error' => 'No changes to commit.'];
}
return ['ok' => false, 'error' => $commit['out']];
}
$up = $is_empty ? '--set-upstream ' : '';
$push = run_cmd("$g -C $cw push {$up}origin " . escapeshellarg($branch) . ' 2>&1');
recursive_rmdir($work);
return $push['rc'] === 0
? ['ok' => true, 'error' => '']
: ['ok' => false, 'error' => $push['out']];
}
function user_can_write(string $repo): bool {
return session_is_admin() || check_repo_access($repo, 'W', session_user());
}
function user_is_owner(string $repo): bool {
if (session_is_admin()) return true;
$cfg = load_repos_config();
return ($cfg[$repo]['owner'] ?? null) === session_user();
}
function load_repos_config(): array {
return json_decode(@file_get_contents(DATA_PATH . '/repos.json'), true) ?? [];
}
function save_repos_config(array $data): void {
file_put_contents(DATA_PATH . '/repos.json',
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function require_login(): void {
if (!session_user()) {
$uri = $_SERVER['REQUEST_URI'] ?? '/';
header('Location: ' . site_url('login') . '?next=' . urlencode($uri));
exit;
}
}
// ── Avatar helpers ────────────────────────────────────────────────────────────
function avatar_path(string $username): string {
return AVATAR_PATH . '/' . preg_replace('/[^a-z0-9_\-]/i', '_', $username) . '.jpg';
}
function avatar_url(string $username): string {
$path = avatar_path($username);
$bust = file_exists($path) ? '?v=' . filemtime($path) : '';
return $bust ? site_url('avatars/' . basename($path) . $bust) : '';
}
/**
* Resize & center-crop an uploaded image to 128×128 JPEG.
* Returns true on success or an error string.
*/
function process_avatar(string $tmp_path, string $dest_path): bool|string {
if (!function_exists('imagecreatefromjpeg')) {
return 'GD extension is not available on this server.';
}
$info = @getimagesize($tmp_path);
if (!$info) return 'Could not read image file.';
$src = match ($info['mime']) {
'image/jpeg' => @imagecreatefromjpeg($tmp_path),
'image/png' => @imagecreatefrompng($tmp_path),
'image/gif' => @imagecreatefromgif($tmp_path),
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($tmp_path) : false,
default => false,
};
if (!$src) return 'Unsupported image format. Use JPEG, PNG, GIF, or WebP.';
$sw = imagesx($src);
$sh = imagesy($src);
$dim = min($sw, $sh); // largest square that fits
$sx = (int)(($sw - $dim) / 2); // center-crop X offset
$sy = (int)(($sh - $dim) / 2); // center-crop Y offset
$dst = imagecreatetruecolor(128, 128);
// Preserve transparency for PNG sources before resampling
imagealphablending($dst, false);
imagesavealpha($dst, true);
$white = imagecolorallocate($dst, 255, 255, 255);
imagefill($dst, 0, 0, $white);
imagecopyresampled($dst, $src, 0, 0, $sx, $sy, 128, 128, $dim, $dim);
imagedestroy($src);
@mkdir(dirname($dest_path), 0755, true);
$ok = imagejpeg($dst, $dest_path, 90);
imagedestroy($dst);
return $ok ? true : 'Failed to write avatar file.';
}
/**
* Generate a simple initials avatar when no photo is uploaded.
* Produces a 128×128 JPEG with a coloured background.
*/
function generate_initials_avatar(string $username, string $display_name, string $dest_path): void {
if (!function_exists('imagecreatetruecolor')) return;
// Pick a deterministic background colour from the username
$hue = crc32($username) % 360;
[$r, $g, $b] = hsl_to_rgb($hue / 360, 0.55, 0.45);
$img = imagecreatetruecolor(128, 128);
$bg = imagecolorallocate($img, $r, $g, $b);
$fg = imagecolorallocate($img, 255, 255, 255);
imagefill($img, 0, 0, $bg);
// Draw initials using built-in font (size 5 = 9×15px per char)
$initials = strtoupper(substr($display_name ?: $username, 0, 1));
if (preg_match('/\s+(\S)/', $display_name, $m)) $initials .= strtoupper($m[1]);
$fw = 9 * strlen($initials);
$fh = 15;
imagestring($img, 5, (int)((128 - $fw) / 2), (int)((128 - $fh) / 2), $initials, $fg);
@mkdir(dirname($dest_path), 0755, true);
imagejpeg($img, $dest_path, 90);
imagedestroy($img);
}
function hsl_to_rgb(float $h, float $s, float $l): array {
$c = (1 - abs(2 * $l - 1)) * $s;
$x = $c * (1 - abs(fmod($h * 6, 2) - 1));
$m = $l - $c / 2;
$hi = (int)($h * 6) % 6;
[$r, $g, $b] = match($hi) {
0 => [$c, $x, 0], 1 => [$x, $c, 0], 2 => [0, $c, $x],
3 => [0, $x, $c], 4 => [$x, 0, $c], default => [$c, 0, $x],
};
return [(int)(($r + $m) * 255), (int)(($g + $m) * 255), (int)(($b + $m) * 255)];
}
function repo_default_branch(string $dir): string {
$head = @file_get_contents($dir . '/HEAD');
if ($head && preg_match('#ref: refs/heads/(.+)#', $head, $m)) return trim($m[1]);
return 'main';
}
function repo_branches(string $dir): array {
if (!shell_available()) {
return array_keys(gl_list_branches($dir));
}
$out = git_run($dir, ['branch', '--format=%(refname:short)']);
return $out ? array_filter(explode("\n", $out)) : [];
}
function _time_ago(int $ts): string {
$diff = max(0, time() - $ts);
if ($diff < 60) return $diff . ' seconds ago';
if ($diff < 3600) return (int)($diff/60) . ' minutes ago';
if ($diff < 86400) return (int)($diff/3600) . ' hours ago';
if ($diff < 2592000) return (int)($diff/86400) . ' days ago';
return date('Y-m-d', $ts);
}
function repo_last_commit(string $dir, string $ref = 'HEAD'): array {
if (!shell_available()) {
$r = gl_last_commit($dir, $ref === 'HEAD' ? 'HEAD' : "refs/heads/$ref");
if (!$r) return [];
preg_match('/^(.+?) ', $r['author'], $am);
return [
'hash' => $r['sha'],
'subject' => $r['message'],
'author' => $am[1] ?? $r['author'],
'when' => _time_ago($r['time']),
];
}
$fmt = '%H|%s|%an|%ar';
$line = git_run($dir, ['log', '-1', '--format=' . $fmt, $ref]);
if (!$line) return [];
[$hash, $subject, $author, $when] = explode('|', $line, 4);
return compact('hash', 'subject', 'author', 'when');
}
function repo_log(string $dir, string $ref, int $n = 30, int $skip = 0): array {
if (!shell_available()) {
$gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref";
$all = gl_log($dir, $gl_ref, $n + $skip);
$slice = array_slice($all, $skip, $n);
$result = [];
foreach ($slice as $c) {
preg_match('/^(.+?) ', $c['author'], $am);
$result[] = [
'hash' => $c['sha'],
'subject' => $c['message'],
'author' => $am[1] ?? $c['author'],
'when' => _time_ago($c['time']),
'date' => date('Y-m-d', $c['time']),
];
}
return $result;
}
$fmt = '%H|%s|%an|%ar|%ad';
$out = git_run($dir, ['log', '-' . $n, '--skip=' . $skip, '--format=' . $fmt, '--date=short', $ref]);
if (!$out) return [];
$commits = [];
foreach (explode("\n", $out) as $line) {
if (!$line) continue;
[$hash, $subject, $author, $when, $date] = explode('|', $line, 5);
$commits[] = compact('hash', 'subject', 'author', 'when', 'date');
}
return $commits;
}
function repo_tree(string $dir, string $ref, string $path = ''): array {
if (!shell_available()) {
$gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref";
$entries = gl_ls_tree($dir, $gl_ref, $path);
if (!$entries) return [];
$items = [];
foreach ($entries as $e) {
$items[] = [
'mode' => $e['mode'], 'type' => $e['type'],
'hash' => $e['sha'], 'size' => '-', 'name' => $e['name'],
];
}
usort($items, fn($a, $b) => ($a['type'] === 'tree' ? -1 : 1) <=> ($b['type'] === 'tree' ? -1 : 1));
return $items;
}
$target = $path ? $ref . ':' . $path : $ref . ':';
$out = git_run($dir, ['ls-tree', '--long', $target]);
if (!$out) return [];
$items = [];
foreach (explode("\n", $out) as $line) {
if (!$line) continue;
preg_match('/^(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/', $line, $m);
if (!$m) continue;
$items[] = [
'mode' => $m[1], 'type' => $m[2], 'hash' => $m[3],
'size' => $m[4], 'name' => $m[5],
];
}
usort($items, fn($a, $b) => ($a['type'] === 'tree' ? -1 : 1) <=> ($b['type'] === 'tree' ? -1 : 1));
return $items;
}
function repo_blob(string $dir, string $ref, string $path): string {
if (!shell_available()) {
$gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref";
return gl_cat_file($dir, $gl_ref, $path) ?? '';
}
return git_run($dir, ['show', $ref . ':' . $path]);
}
function repo_commit(string $dir, string $hash): array {
if (!shell_available()) {
$obj = gl_read_object($dir, $hash);
if (!$obj || $obj['type'] !== 'commit') return [];
$c = gl_parse_commit($obj['data']);
preg_match('/^(.+?) <(.+?)> (\d+)/', $c['author'], $am);
preg_match('/^(.+?) <(.+?)> (\d+)/', $c['committer'], $cm);
return [
'hash' => $hash,
'subject' => trim(explode("\n", $c['message'])[0]),
'body' => implode("\n", array_slice(explode("\n", $c['message']), 1)),
'author' => $am[1] ?? '',
'email' => $am[2] ?? '',
'date' => isset($am[3]) ? date('Y-m-d H:i:s', (int)$am[3]) : '',
'parents' => implode(' ', $c['parents']),
'diff' => '',
];
}
$fmt = '%H|%s|%b|%an|%ae|%ad|%P';
$line = git_run($dir, ['show', '-s', '--format=' . $fmt, '--date=format:%Y-%m-%d %H:%M:%S', $hash]);
if (!$line) return [];
$parts = explode('|', $line, 7);
$diff = git_run($dir, ['show', '--stat', '--format=', $hash]);
return [
'hash' => $parts[0], 'subject' => $parts[1], 'body' => $parts[2],
'author' => $parts[3], 'email' => $parts[4], 'date' => $parts[5],
'parents' => $parts[6] ?? '', 'diff' => $diff,
];
}
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
/**
* Return an HTML link to a user's public profile, with their display name.
* Falls back to the username if no display name is set.
*/
function owner_link(string $username): string {
if (!$username) return '—';
$users = load_users();
$display = $users[$username]['name'] ?? $username;
$url = site_url('user/' . rawurlencode($username));
return '' . h($display) . ' ';
}
function ext_lang(string $filename): string {
$map = [
'php' => 'php', 'js' => 'javascript', 'ts' => 'typescript',
'py' => 'python', 'rb' => 'ruby', 'go' => 'go', 'rs' => 'rust',
'c' => 'c', 'cpp' => 'cpp', 'h' => 'c', 'java' => 'java',
'sh' => 'bash', 'css' => 'css', 'html' => 'html', 'xml' => 'xml',
'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml', 'md' => 'markdown',
'sql' => 'sql', 'ini' => 'ini', 'toml' => 'toml',
];
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return $map[$ext] ?? 'plaintext';
}
// ── Git HTTP backend ──────────────────────────────────────────────────────────
function handle_git_http(string $repo_name, string $path_info): void {
$dir = repo_dir($repo_name);
if (!is_dir($dir)) { http_response_code(404); echo "Repository not found"; return; }
$is_push = str_contains($path_info, 'git-receive-pack') ||
(isset($_GET['service']) && $_GET['service'] === 'git-receive-pack');
$action = $is_push ? 'W' : 'R';
// Try credentials if supplied (doesn't force a 401 yet)
[$authed_user, $ok] = try_basic_auth();
// Anonymous read — check @all R access first
if (!$ok && !check_repo_access($repo_name, $action, null)) {
// Need credentials
[$authed_user, $ok] = [null, false];
demand_auth();
return;
}
// Authenticated — verify permission
if ($ok && !check_repo_access($repo_name, $action, $authed_user)) {
http_response_code(403);
echo 'Forbidden';
return;
}
// Pure-PHP path: no shell / git-http-backend needed
if (!shell_available()) {
ob_end_clean();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$service = $_GET['service'] ?? '';
if ($path_info === 'info/refs' && $method === 'GET') {
$svc = in_array($service, ['git-upload-pack','git-receive-pack'], true)
? $service : 'git-upload-pack';
gl_http_info_refs($dir, $svc);
} elseif ($path_info === 'git-upload-pack' && $method === 'POST') {
gl_http_upload_pack($dir);
} elseif ($path_info === 'git-receive-pack' && $method === 'POST') {
gl_http_receive_pack($dir);
} else {
http_response_code(404); echo "Not found";
}
exit;
}
$backend = find_git_backend();
if (!$backend) {
http_response_code(500);
echo "git-http-backend not found on this server.";
return;
}
$env = array_merge(getenv() ?: [], [
'GIT_HTTP_EXPORT_ALL' => '1',
'GIT_PROJECT_ROOT' => REPO_PATH,
'PATH_INFO' => '/' . $repo_name . '.git/' . $path_info,
'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '',
'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'],
'CONTENT_TYPE' => $_SERVER['CONTENT_TYPE'] ?? '',
'CONTENT_LENGTH' => $_SERVER['CONTENT_LENGTH'] ?? '',
'HTTP_GIT_PROTOCOL' => $_SERVER['HTTP_GIT_PROTOCOL'] ?? '',
'SERVER_PROTOCOL' => $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1',
'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'] ?? '',
]);
$proc = proc_open($backend, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['file', '/dev/null', 'w'],
], $pipes, null, $env);
if (!is_resource($proc)) { http_response_code(500); return; }
stream_copy_to_stream(fopen('php://input', 'r'), $pipes[0]);
fclose($pipes[0]);
$response = stream_get_contents($pipes[1]);
fclose($pipes[1]);
proc_close($proc);
// Parse CGI response headers then body
if (preg_match('/\A(.*?)\r?\n\r?\n(.*)/s', $response, $m)) {
foreach (explode("\n", $m[1]) as $hdr) {
$hdr = trim($hdr);
if (!$hdr) continue;
if (preg_match('/^Status:\s*(\d+)/i', $hdr, $s)) {
http_response_code((int)$s[1]);
} else {
header($hdr);
}
}
echo $m[2];
} else {
echo $response;
}
}
// ── Access control (Gitolite-inspired) ───────────────────────────────────────
//
// repos.json access rules mirror Gitolite's permission model:
// "R" = read only
// "RW" = read + write (push)
// "RW+" = read + write + force-push / tag deletion
// "-" = explicit deny (overrides group grants)
//
// Rule keys: "@all" (everyone incl. anonymous), "@groupname", or "username".
// Evaluation order: explicit deny > username > group > @all.
// If a repo has no entry in repos.json it is private (admin-only).
function load_users(): array {
return json_decode(@file_get_contents(DATA_PATH . '/users.json'), true) ?? [];
}
function user_groups(string $username, array $users): array {
$groups = [];
if (isset($users[$username]['groups'])) {
$groups = (array)$users[$username]['groups'];
}
if (isset($users[$username]['role']) && $users[$username]['role'] === 'admin') {
$groups[] = 'admins';
}
return $groups;
}
/**
* Check whether $username (null = anonymous) has $action ('R' or 'W') on $repo.
* Returns true/false.
*/
function check_repo_access(string $repo, string $action, ?string $username): bool {
$config = load_repos_config();
$users = load_users();
// Admins always have full access
if ($username && isset($users[$username]['role']) && $users[$username]['role'] === 'admin') {
return true;
}
// No repo config = private, only admins
if (!isset($config[$repo]['access'])) return false;
$rules = $config[$repo]['access'];
$groups = $username ? user_groups($username, $users) : [];
// Collect grants in priority order: @all < @group < username
// Explicit deny ("-") at any level blocks access immediately.
$grant = null; // null = no rule matched yet
// @all
if (isset($rules['@all'])) {
if ($rules['@all'] === '-') return false;
$grant = $rules['@all'];
}
// @group
foreach ($groups as $g) {
$key = '@' . $g;
if (!isset($rules[$key])) continue;
if ($rules[$key] === '-') return false;
$grant = $rules[$key];
}
// specific username
if ($username && isset($rules[$username])) {
if ($rules[$username] === '-') return false;
$grant = $rules[$username];
}
if ($grant === null) return false;
return match($action) {
'R' => in_array($grant, ['R', 'RW', 'RW+'], true),
'W' => in_array($grant, ['RW', 'RW+'], true),
'W+' => $grant === 'RW+',
default => false,
};
}
/**
* Attempt HTTP Basic auth. Returns [username, true] on success or [null, false].
* Does NOT send a 401 — caller decides whether auth is required.
*/
function try_basic_auth(): array {
$users = load_users();
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$pass = $_SERVER['PHP_AUTH_PW'] ?? '';
if ($user && isset($users[$user]) && password_verify($pass, $users[$user]['password_hash'])) {
return [$user, true];
}
return [null, false];
}
function demand_auth(string $realm = 'GitGram'): void {
header('WWW-Authenticate: Basic realm="' . $realm . '"');
http_response_code(401);
echo 'Authentication required';
}
// ── Router ────────────────────────────────────────────────────────────────────
$_request_uri = $_SERVER['REQUEST_URI'] ?? $_SERVER['REDIRECT_URL'] ?? '/';
$request_path = trim(parse_url($_request_uri, PHP_URL_PATH), '/');
$parts = $request_path ? explode('/', $request_path) : [];
// Remove script-name prefix if running in a subdirectory
$script_dir = trim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/');
if ($script_dir && str_starts_with($request_path, $script_dir)) {
$request_path = ltrim(substr($request_path, strlen($script_dir)), '/');
$parts = $request_path ? explode('/', $request_path) : [];
}
// Git smart HTTP: /reponame.git/path
if (!empty($parts[0]) && str_ends_with($parts[0], '.git')) {
$repo_name = basename($parts[0], '.git');
array_shift($parts);
handle_git_http($repo_name, implode('/', $parts));
exit;
}
match (true) {
empty($parts[0]) => page_home(),
$parts[0] === 'admin' => (function(){ require __DIR__ . '/admin.php'; exit; })(),
$parts[0] === 'login' => page_login(),
$parts[0] === 'logout' => page_logout(),
$parts[0] === 'register' => page_register(),
$parts[0] === 'readme' => page_site_readme(),
$parts[0] === 'end-of-internet' => page_end_of_internet(),
isset($parts[1]) && $parts[1] === 'tree' => page_tree($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))),
isset($parts[1]) && $parts[1] === 'blob' => page_blob($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))),
isset($parts[1]) && $parts[1] === 'raw' => page_raw($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))),
isset($parts[1]) && $parts[1] === 'archive' => page_archive($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))),
isset($parts[1]) && $parts[1] === 'commits' => page_log($parts[0], $parts[2] ?? 'HEAD'),
isset($parts[1]) && $parts[1] === 'commit' => page_commit($parts[0], $parts[2] ?? ''),
isset($parts[1]) && $parts[1] === 'fork' => page_fork($parts[0]),
isset($parts[1]) && $parts[1] === 'upload' => page_upload($parts[0]),
isset($parts[1]) && $parts[1] === 'settings' && count($parts) === 2 => page_repo_settings($parts[0]),
$parts[0] === 'dashboard' => page_dashboard(),
$parts[0] === 'new' => page_new_repo(),
$parts[0] === 'profile' => page_profile(),
$parts[0] === 'user' && isset($parts[1]) => page_user_profile($parts[1]),
count($parts) === 1 => page_repo($parts[0]),
default => page_404(),
};
// ── Page handlers ─────────────────────────────────────────────────────────────
function page_home(): void {
[$authed_user] = try_basic_auth();
$all_repos = repo_list();
// Only show repos the current visitor can read
$repos = array_filter($all_repos, fn($r) => check_repo_access($r['name'], 'R', $authed_user));
html_open('Repositories — ' . SITE_TITLE);
echo '
Repositories
';
echo '';
if (empty($repos)) {
echo '
No repositories yet. See the setup guide .
';
} else {
$cfg = load_repos_config();
echo '
';
echo 'Name Owner Description Last commit When ';
foreach ($repos as $r) {
$last = repo_last_commit($r['dir']);
$owner = $cfg[$r['name']]['owner'] ?? '';
$forked_from = $cfg[$r['name']]['forked_from'] ?? '';
$url = site_url($r['name']);
echo '';
echo '' . h($r['name']) . ' '
. ($forked_from ? ' ⑂ ' : '')
. ' ';
echo '' . ($owner ? owner_link($owner) : '— ') . ' ';
echo '' . h($r['desc']) . ' ';
echo '' . h($last['subject'] ?? '—') . ' ';
echo '' . h($last['when'] ?? '') . ' ';
echo ' ';
}
echo '
';
}
echo '
';
html_close();
}
function page_repo(string $name): void {
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$branch = repo_default_branch($dir);
$commits = repo_log($dir, $branch, COMMIT_PREVIEW);
$branches = repo_branches($dir);
$cfg = load_repos_config();
$owner = $cfg[$name]['owner'] ?? '';
$forked_from = $cfg[$name]['forked_from'] ?? '';
$viewer = session_user();
// Count forks of this repo
$fork_count = 0;
foreach ($cfg as $rname => $rcfg) {
if (($rcfg['forked_from'] ?? '') === $name) $fork_count++;
}
// Can the viewer fork? Must be logged in and not the owner
$can_fork = $viewer && $viewer !== $owner && check_repo_access($name, 'R', $viewer);
// Has the viewer already forked this repo?
$viewer_fork = null;
if ($viewer) {
foreach ($cfg as $rname => $rcfg) {
if (($rcfg['forked_from'] ?? '') === $name && ($rcfg['owner'] ?? '') === $viewer) {
$viewer_fork = $rname; break;
}
}
}
html_open(h($name) . ' — ' . SITE_TITLE);
echo '';
echo '';
// Forked-from banner
if ($forked_from) {
$from_owner = $cfg[$forked_from]['owner'] ?? '';
echo '
⑂ Forked from ';
if (is_dir(repo_dir($forked_from))) {
echo '
' . h($forked_from) . ' ';
} else {
echo '
' . h($forked_from) . ' (deleted) ';
}
if ($from_owner) echo ' by ' . owner_link($from_owner);
echo '
';
}
// Clone URL + owner + fork count
$clone = site_url($name . '.git');
echo '
Clone ';
echo ' ';
if ($owner) echo 'by ' . owner_link($owner) . ' ';
if ($fork_count) echo '⑂ ' . $fork_count . ' fork' . ($fork_count !== 1 ? 's' : '') . ' ';
echo '
';
// README
$readme = repo_blob($dir, $branch, 'README.md')
?: repo_blob($dir, $branch, 'README');
if ($readme) {
echo '
README
';
echo '
' . h($readme) . ' ';
}
// Recent commits
if ($commits) {
echo '
Recent commits
';
echo '
';
foreach ($commits as $c) {
$url = site_url($name . '/commit/' . $c['hash']);
echo '';
echo '' . substr($c['hash'], 0, 8) . ' ';
echo '' . h($c['subject']) . ' ';
echo '' . h($c['author']) . ' ';
echo '' . h($c['when']) . ' ';
echo ' ';
}
echo '
';
echo '
View all commits →
';
}
echo '
';
html_close();
}
// ── Fork ──────────────────────────────────────────────────────────────────────
function page_fork(string $name): void {
require_login();
$viewer = session_user();
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$cfg = load_repos_config();
$owner = $cfg[$name]['owner'] ?? '';
// Must be readable by the viewer
if (!check_repo_access($name, 'R', $viewer)) {
http_response_code(403);
html_open('Forbidden — ' . SITE_TITLE);
echo 'You do not have read access to this repository.
';
html_close(); return;
}
// Cannot fork your own repo
if ($viewer === $owner) {
header('Location: ' . site_url($name)); exit;
}
// Already forked?
foreach ($cfg as $rname => $rcfg) {
if (($rcfg['forked_from'] ?? '') === $name && ($rcfg['owner'] ?? '') === $viewer) {
header('Location: ' . site_url($rname)); exit;
}
}
$errors = [];
$fork_name = '';
$fork_desc = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fork_name = trim($_POST['fork_name'] ?? '');
$fork_desc = trim($_POST['fork_desc'] ?? ($cfg[$name]['description'] ?? ''));
if (!preg_match('/^[a-zA-Z0-9_\-\.]{1,64}$/', $fork_name))
$errors[] = 'Repository name must be 1–64 characters: letters, numbers, hyphens, dots, underscores.';
elseif (is_dir(repo_dir($fork_name)))
$errors[] = "A repository named \"$fork_name\" already exists. Choose a different name.";
if (!$errors) {
$dst = repo_dir($fork_name);
if (!gl_fork_repo($dir, $dst)) {
$errors[] = 'Fork failed — could not copy repository data. Check directory permissions.';
} else {
// Write description
if ($fork_desc) file_put_contents($dst . '/description', $fork_desc . "\n");
// Register in repos.json — inherit access model but set new owner
$src_access = $cfg[$name]['access'] ?? [];
$new_access = [$viewer => 'RW+']; // owner always has full access
// Keep @all R if source was public
if (isset($src_access['@all']) && $src_access['@all'] !== '-')
$new_access = array_merge(['@all' => 'R'], $new_access);
$cfg[$fork_name] = [
'description' => $fork_desc,
'owner' => $viewer,
'forked_from' => $name,
'access' => $new_access,
];
save_repos_config($cfg);
header('Location: ' . site_url($fork_name)); exit;
}
}
} else {
// Pre-fill name: use original name if free, else append username
$fork_name = !is_dir(repo_dir($name)) ? $name
: (is_dir(repo_dir($name . '-' . $viewer)) ? $name . '-fork' : $name . '-' . $viewer);
$fork_desc = $cfg[$name]['description'] ?? '';
}
$users = load_users();
$src_owner_display = isset($owner) && $owner
? ($users[$owner]['name'] ?? $owner)
: 'unknown';
html_open('Fork ' . h($name) . ' — ' . SITE_TITLE);
echo 'Fork Repository
';
echo '';
// Source info card
echo '
';
echo '
⑂
';
echo '
';
echo '
' . h($name) . '
';
echo '
by ' . h($src_owner_display) . '
';
if ($cfg[$name]['description'] ?? '') echo '
' . h($cfg[$name]['description']) . '
';
echo '
';
echo '
';
echo '
Creating a fork copies the entire repository into your account. '
. 'You can commit to it independently and later propose changes back to the original.
';
foreach ($errors as $e) echo '
' . h($e) . '
';
echo '
';
echo '
';
html_close();
}
function page_tree(string $name, string $ref, string $path): void {
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$items = repo_tree($dir, $ref, $path);
html_open(h($name) . '/' . h($path) . ' — ' . SITE_TITLE);
echo '';
echo '';
echo '
';
echo ' ';
// Parent dir link
if ($path) {
$parent = dirname($path);
$url = site_url($name . '/tree/' . $ref . ($parent !== '.' ? '/' . $parent : ''));
echo '.. ';
}
foreach ($items as $item) {
$item_path = $path ? $path . '/' . $item['name'] : $item['name'];
if ($item['type'] === 'tree') {
$url = site_url($name . '/tree/' . $ref . '/' . $item_path);
$icon = '📁';
$size_col = 'ZIP ';
} else {
$url = site_url($name . '/blob/' . $ref . '/' . $item_path);
$icon = '📄';
$sz = is_numeric($item['size']) ? number_format((int)$item['size']) . ' B' : '';
$size_col = '' . $sz . ' ';
}
$dl_url = $item['type'] === 'blob'
? site_url($name . '/raw/' . $ref . '/' . $item_path)
: site_url($name . '/archive/' . $ref . '/' . $item_path);
echo '';
echo '' . $icon . ' ';
echo '' . h($item['name']) . ' ';
echo '' . $size_col . ' ';
echo '↓ ';
echo ' ';
}
echo '
';
html_close();
}
function page_blob(string $name, string $ref, string $path): void {
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$content = repo_blob($dir, $ref, $path);
$lang = ext_lang($path);
// Detect binary files (null bytes in first 8 KB)
$is_binary = str_contains(substr($content, 0, 8192), "\0");
$raw_url = site_url($name . '/raw/' . $ref . '/' . $path);
$size_str = number_format(strlen($content)) . ' B';
html_open(h(basename($path)) . ' — ' . SITE_TITLE, ['prism' => !$is_binary]);
echo '';
echo '';
if ($is_binary) {
echo '
Binary file — download raw
';
} else {
echo '
' . h($content) . '';
}
echo '
';
html_close();
}
// ── Raw file download ─────────────────────────────────────────────────────────
function page_raw(string $name, string $ref, string $path): void {
if (!check_repo_access($name, 'R', session_user())) {
[$u, $ok] = try_basic_auth();
if (!$ok || !check_repo_access($name, 'R', $u)) { demand_auth(); return; }
}
$dir = repo_dir($name);
if (!is_dir($dir)) { http_response_code(404); echo 'Not found'; return; }
$content = repo_blob($dir, $ref, $path);
if ($content === '' && !is_string($content)) { http_response_code(404); echo 'File not found'; return; }
// Detect MIME from extension; fall back to octet-stream for unknown/binary
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime_map = [
'txt'=>'text/plain','md'=>'text/plain','html'=>'text/html','htm'=>'text/html',
'css'=>'text/css','js'=>'text/javascript','ts'=>'text/plain','json'=>'application/json',
'xml'=>'text/xml','svg'=>'image/svg+xml','php'=>'text/plain','py'=>'text/plain',
'rb'=>'text/plain','sh'=>'text/plain','go'=>'text/plain','rs'=>'text/plain',
'c'=>'text/plain','cpp'=>'text/plain','h'=>'text/plain','java'=>'text/plain',
'jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif',
'webp'=>'image/webp','ico'=>'image/x-icon','pdf'=>'application/pdf',
'zip'=>'application/zip','tar'=>'application/x-tar','gz'=>'application/gzip',
];
$is_binary = str_contains(substr($content, 0, 8192), "\0");
$mime = $mime_map[$ext] ?? ($is_binary ? 'application/octet-stream' : 'text/plain');
ob_end_clean();
header('Content-Type: ' . $mime);
header('Content-Length: ' . strlen($content));
if ($is_binary || !in_array($mime, ['text/plain','text/html','text/css','text/javascript','application/json','image/svg+xml'], true)) {
header('Content-Disposition: attachment; filename="' . addslashes(basename($path)) . '"');
}
header('Cache-Control: private, max-age=3600');
echo $content;
exit;
}
// ── ZIP archive download ──────────────────────────────────────────────────────
/**
* Recursively collect all blob paths+SHAs under a tree SHA.
* $prefix: path prefix to prepend (for subdirectory downloads).
*/
function _collect_tree_files(string $repo, string $tree_sha, string $prefix, array &$out): void
{
$obj = gl_read_object($repo, $tree_sha);
if (!$obj || $obj['type'] !== 'tree') return;
foreach (gl_parse_tree($obj['data']) as $e) {
$full = $prefix ? $prefix . '/' . $e['name'] : $e['name'];
if ($e['mode'] === '40000' || $e['mode'] === '040000') {
_collect_tree_files($repo, $e['sha'], $full, $out);
} else {
$out[$full] = $e['sha'];
}
}
}
function page_archive(string $name, string $ref, string $path): void {
if (!check_repo_access($name, 'R', session_user())) {
[$u, $ok] = try_basic_auth();
if (!$ok || !check_repo_access($name, 'R', $u)) { demand_auth(); return; }
}
$dir = repo_dir($name);
if (!is_dir($dir)) { http_response_code(404); echo 'Not found'; return; }
if (!class_exists('ZipArchive')) {
http_response_code(500); echo 'ZipArchive extension is not available on this server.'; return;
}
// Resolve tree SHA for the requested ref + path
$head_sha = gl_read_ref($dir, ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref");
if (!$head_sha) { http_response_code(404); echo 'Ref not found'; return; }
$commit_obj = gl_read_object($dir, $head_sha);
if (!$commit_obj || $commit_obj['type'] !== 'commit') { http_response_code(404); echo 'Commit not found'; return; }
$commit = gl_parse_commit($commit_obj['data']);
$tree_sha = $commit['tree'];
if ($path !== '') {
$res = gl_resolve_path($dir, $tree_sha, $path);
if (!$res || $res['type'] !== 'tree') { http_response_code(404); echo 'Path not found or not a directory'; return; }
$tree_sha = $res['sha'];
}
// Collect all blobs
$files = [];
_collect_tree_files($dir, $tree_sha, '', $files);
// Build ZIP in a temp file
$slug = preg_replace('/[^a-z0-9_\-]/i', '-', $name);
$ref_slug = preg_replace('/[^a-z0-9_\-]/i', '-', $ref);
$path_slug = $path ? '-' . preg_replace('/[^a-z0-9_\/\-]/i', '-', str_replace('/', '-', $path)) : '';
$zip_name = $slug . '-' . $ref_slug . $path_slug . '.zip';
$prefix = $slug . '-' . $ref_slug . ($path ? '/' . $path : '') . '/';
$tmp = tempnam(sys_get_temp_dir(), 'gitgram_zip_');
$zip = new ZipArchive();
if ($zip->open($tmp, ZipArchive::OVERWRITE) !== true) {
http_response_code(500); echo 'Could not create archive.'; return;
}
foreach ($files as $file_path => $sha) {
$obj = gl_read_object($dir, $sha);
if (!$obj) continue;
$zip->addFromString($prefix . $file_path, $obj['data']);
}
$zip->close();
ob_end_clean();
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . addslashes($zip_name) . '"');
header('Content-Length: ' . filesize($tmp));
header('Cache-Control: private, max-age=60');
readfile($tmp);
unlink($tmp);
exit;
}
function page_log(string $name, string $ref): void {
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$page = max(0, (int)($_GET['p'] ?? 0));
$commits = repo_log($dir, $ref, 30, $page * 30);
html_open('Commits — ' . h($name) . ' — ' . SITE_TITLE);
echo 'Commits: ' . h($name) . ' [' . h($ref) . ']
';
echo '';
echo '
';
foreach ($commits as $c) {
$url = site_url($name . '/commit/' . $c['hash']);
echo '';
echo '' . substr($c['hash'], 0, 8) . ' ';
echo '' . h($c['subject']) . ' ';
echo '' . h($c['author']) . ' ';
echo '' . h($c['date']) . ' ';
echo ' ';
}
echo '
';
if (count($commits) === 30) {
echo '
Older → ';
}
echo '
';
html_close();
}
function page_commit(string $name, string $hash): void {
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$c = repo_commit($dir, $hash);
if (!$c) { page_404(); return; }
html_open('Commit ' . substr($hash, 0, 8) . ' — ' . SITE_TITLE);
echo 'Commit: ' . h(substr($c['hash'], 0, 12)) . '
';
echo '';
echo '
';
echo '
' . h($c['diff']) . ' ';
echo '
';
html_close();
}
function page_site_readme(): void {
html_open('How to Use Git — ' . SITE_TITLE);
echo 'How to Use GitGram
';
echo '';
$clone_example = site_url('yourrepo.git');
echo <<Getting Started
GitGram hosts bare git repositories over HTTP. You can clone, push, and pull using standard
git commands. No special software is needed beyond git itself.
Clone a Repository
git clone {$clone_example}
Push to a Repository
Pushing requires your username and password (set in data/users.json).
git remote add origin {$clone_example}
git push origin main
Git will prompt for credentials. To cache them:
git config credential.helper store
Creating a New Repository (Server Side)
SSH into your server and run:
cd /path/to/gitgram/repos
git init --bare yourrepo.git
echo "My new repository" > yourrepo.git/description
The repo will appear on the home page immediately.
First Push of a Local Repo
cd my-project
git init
git add .
git commit -m "Initial commit"
git remote add origin {$clone_example}
git push -u origin main
Setting Your Password
Generate a bcrypt hash:
php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);"
Paste the output into data/users.json:
{
"yourusername": {
"name": "Your Name",
"password_hash": "\$2y\$12\$...",
"role": "admin"
}
}
Checking git-http-backend
If push/pull over HTTP fails, verify the backend is available:
which git-http-backend
ls /usr/lib/git-core/git-http-backend
Update GIT_HTTP_BACKEND_PATHS in config.php if yours is in a different location.
SSH (Alternative)
If your host provides SSH access, you can also push over SSH directly to the bare repo path.
No special GitGram configuration needed — just standard git+ssh.
git remote add ssh-origin ssh://user@host/path/to/repos/yourrepo.git
git push ssh-origin main
Access Control (Gitolite-inspired)
GitGram uses a permission model modelled on
Gitolite .
Access rules live in data/repos.json — no database needed.
Permission levels
Symbol Meaning
RRead-only (clone, fetch)
RWRead + write (push)
RW+Read + write + force-push / tag deletion
-Explicit deny (overrides any group grant)
Rule subjects
Subject Meaning
@allEveryone, including anonymous visitors
@groupnameAll users whose groups array includes groupname
usernameA specific user
Priority (highest wins): explicit deny - > username > @group > @all.
Admin-role users always have full access regardless of rules.
Example data/repos.json
{
"public-project": {
"description": "Anyone can read, devs can write",
"access": {
"@all": "R",
"@devs": "RW",
"alice": "RW+"
}
},
"private-project": {
"description": "Invite-only",
"access": {
"bob": "RW",
"charlie": "R"
}
},
"locked-repo": {
"description": "Archived — no pushes",
"access": {
"@all": "R",
"@devs": "R",
"alice": "-"
}
}
}
Example data/users.json with groups
{
"alice": {
"name": "Alice",
"password_hash": "\$2y\$12\$...",
"role": "admin",
"groups": ["devs", "leads"]
},
"bob": {
"name": "Bob",
"password_hash": "\$2y\$12\$...",
"role": "user",
"groups": ["devs"]
},
"charlie": {
"name": "Charlie",
"password_hash": "\$2y\$12\$...",
"role": "user",
"groups": []
}
}
Repos with no entry in repos.json are private (admin-only) by default —
the same safe default as Gitolite.
Differences from Gitolite
Gitolite uses SSH keys exclusively; GitGram uses HTTP Basic auth (passwords in users.json)
Gitolite manages repos via a special gitolite-admin push; GitGram uses JSON files edited directly
Gitolite supports branch-level rules (refs/heads/main); GitGram access is repo-level only
Gitolite runs as its own Unix user; GitGram runs under your web server's PHP process
HTML;
echo '
';
html_close();
}
function page_end_of_internet(): void {
header('Content-Type: text/html; charset=utf-8');
echo <<<'HTML'
END OF THE INTERNET
🔥 🔥 🔥
CONGRATULATIONS!
YOU HAVE REACHED
THE END OF THE INTERNET
There is nothing more to see.
Please turn off your computer and go outside.
Visitor #: 1,337,042
⭐ This site best viewed in Netscape Navigator 2.0 at 640x480 ⭐ Under Construction ⭐ Please sign my guestbook ⭐ AOL Keyword: END ⭐
🌀
← Go back where it is safe
This page was last updated: January 1, 1997
Made with ❤️ and Microsoft FrontPage 97
HTML;
exit;
}
function page_login(): void {
if (session_user()) { header('Location: ' . site_url()); exit; }
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$users = load_users();
$u = trim($_POST['username'] ?? '');
$p = $_POST['password'] ?? '';
if (isset($users[$u]) && password_verify($p, $users[$u]['password_hash'])) {
session_regenerate_id(true);
$_SESSION['gitgram_user'] = $u;
header('Location: ' . site_url($_GET['next'] ?? ''));
exit;
}
$error = 'Invalid username or password.';
}
html_open('Login — ' . SITE_TITLE);
echo 'Login
';
echo '';
if ($error) echo '
' . h($error) . '
';
echo '
';
html_close();
}
function page_logout(): void {
$_SESSION = [];
session_destroy();
header('Location: ' . site_url());
exit;
}
function page_register(): void {
if (session_user()) { header('Location: ' . site_url()); exit; }
$s = load_settings();
if (!$s['registration_open']) {
html_open('Registration Closed — ' . SITE_TITLE);
echo 'Registration
';
echo 'Registration is currently closed.
';
html_close(); return;
}
$errors = [];
// Generate captcha on GET or if session captcha missing
if ($_SERVER['REQUEST_METHOD'] === 'GET' || empty($_SESSION['captcha_answer'])) {
$a = rand(2, 12); $b = rand(1, 10);
$ops = ['+' => $a + $b, '−' => $a - $b, '×' => $a * $b];
$op = array_rand($ops);
$_SESSION['captcha_q'] = "$a $op $b";
$_SESSION['captcha_answer'] = $ops[$op];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$users = load_users();
$username = trim($_POST['username'] ?? '');
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$confirm = $_POST['confirm'] ?? '';
$captcha = (int)($_POST['captcha'] ?? -9999);
$invite = trim($_POST['invite_code'] ?? '');
if (!preg_match('/^[a-z0-9_\-]{2,32}$/', $username))
$errors[] = 'Username: 2–32 lowercase letters, numbers, hyphens, or underscores.';
elseif (isset($users[$username]))
$errors[] = 'That username is already taken.';
if (!$name) $errors[] = 'Display name is required.';
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL))
$errors[] = 'Enter a valid email address.';
if (strlen($password) < 8)
$errors[] = 'Password must be at least 8 characters.';
if ($password !== $confirm)
$errors[] = 'Passwords do not match.';
if ($captcha !== (int)$_SESSION['captcha_answer'])
$errors[] = 'Math answer is incorrect.';
if ($s['invite_only']) {
$invites = json_decode(@file_get_contents(DATA_PATH . '/invites.json'), true) ?? [];
if (!isset($invites[$invite]) || $invites[$invite]['used_by'] !== null)
$errors[] = 'Invalid or already-used invite code.';
}
if (!$errors) {
$users[$username] = [
'name' => $name,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'role' => 'user',
'groups' => [],
];
file_put_contents(DATA_PATH . '/users.json',
json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
if ($s['invite_only'] && $invite) {
$invites[$invite]['used_by'] = $username;
$invites[$invite]['used_at'] = date('Y-m-d H:i:s');
file_put_contents(DATA_PATH . '/invites.json',
json_encode($invites, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
session_regenerate_id(true);
$_SESSION['gitgram_user'] = $username;
unset($_SESSION['captcha_q'], $_SESSION['captcha_answer']);
header('Location: ' . site_url());
exit;
}
// Regenerate captcha after failed attempt
$a = rand(2, 12); $b = rand(1, 10);
$ops = ['+' => $a + $b, '−' => $a - $b, '×' => $a * $b];
$op = array_rand($ops);
$_SESSION['captcha_q'] = "$a $op $b";
$_SESSION['captcha_answer'] = $ops[$op];
}
html_open('Register — ' . SITE_TITLE);
echo 'Create Account
';
echo '';
foreach ($errors as $e) echo '
' . h($e) . '
';
echo '
';
html_close();
}
// ── Dashboard ─────────────────────────────────────────────────────────────────
function page_dashboard(): void {
require_login();
$u = session_user();
$users = load_users();
$cfg = load_repos_config();
$profile = $users[$u] ?? [];
$all = repo_list();
// Repos the user owns or can write
$mine = array_filter($all, fn($r) => ($cfg[$r['name']]['owner'] ?? null) === $u);
$shared = array_filter($all, fn($r) =>
($cfg[$r['name']]['owner'] ?? null) !== $u
&& check_repo_access($r['name'], 'W', $u)
);
html_open('Dashboard — ' . SITE_TITLE);
echo '';
echo '';
// Profile card
echo '
';
$av_path = avatar_path($u);
if (!file_exists($av_path)) generate_initials_avatar($u, $profile['name'] ?? $u, $av_path);
$av_url = avatar_url($u);
echo '
';
echo '
';
echo '
';
echo '
' . h($profile['name'] ?? $u) . '
';
echo '
' . h($u) . '
';
if (!empty($profile['email'])) echo '
' . h($profile['email']) . '
';
echo '
' . h($profile['role'] ?? 'user') . '
';
echo '
';
echo '
';
echo '
';
// My repos
echo '
My Repositories
';
if (empty($mine)) {
echo '
No repositories yet. Create one .
';
} else {
echo '
';
echo 'Name Description Visibility ';
foreach ($mine as $r) {
$access = $cfg[$r['name']]['access'] ?? [];
$public = isset($access['@all']) && $access['@all'] !== '-';
$forked_from = $cfg[$r['name']]['forked_from'] ?? '';
echo '';
echo '' . h($r['name']) . ' '
. ($forked_from
? ' ⑂ '
. '' . h($forked_from) . ' '
: '')
. ' ';
echo '' . h($r['desc']) . ' ';
echo ''
. ($public ? 'public' : 'private') . ' ';
echo ''
. '⬆ Upload '
. 'Settings '
. ' ';
echo ' ';
}
echo '
';
}
if (!empty($shared)) {
echo '
Shared With Me
';
echo '
';
echo 'Name Owner Description ';
foreach ($shared as $r) {
$owner = $cfg[$r['name']]['owner'] ?? '';
echo '';
echo '' . h($r['name']) . ' ';
echo '' . ($owner ? owner_link($owner) : '— ') . ' ';
echo '' . h($r['desc']) . ' ';
echo '⬆ Upload ';
echo ' ';
}
echo '
';
}
echo '
';
html_close();
}
// ── New repository ────────────────────────────────────────────────────────────
function page_new_repo(): void {
require_login();
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['repo_name'] ?? '');
$desc = trim($_POST['repo_desc'] ?? '');
$visibility = $_POST['visibility'] ?? 'private';
$init = !empty($_POST['init_readme']);
if (!preg_match('/^[a-zA-Z0-9_\-\.]{1,64}$/', $name))
$errors[] = 'Name: letters, numbers, hyphens, dots, underscores only (max 64).';
if (!$errors) {
// Ensure repos/ directory exists
if (!is_dir(REPO_PATH)) {
@mkdir(REPO_PATH, 0755, true);
if (!is_dir(REPO_PATH)) {
$errors[] = 'Cannot create repos/ directory. Check server write permissions.';
}
}
}
if (!$errors) {
$dir = REPO_PATH . '/' . $name . '.git';
if (is_dir($dir)) {
$errors[] = "Repository \"$name\" already exists.";
} else {
if (!shell_available()) {
$rc = gl_init_bare($dir) ? 0 : 1;
$err_out = $rc ? 'gl_init_bare() failed — check directory permissions.' : '';
} else {
$r = run_cmd(escapeshellarg(GIT_BIN) . ' init --bare ' . escapeshellarg($dir) . ' 2>&1');
$rc = $r['rc'];
$err_out = $r['out'];
}
if ($rc !== 0) {
$errors[] = 'git init failed: ' . $err_out;
} else {
if ($desc) file_put_contents($dir . '/description', $desc . "\n");
$cfg = load_repos_config();
$user = session_user();
$access = [$user => 'RW+'];
if ($visibility === 'public') $access = array_merge(['@all' => 'R'], $access);
$cfg[$name] = ['description' => $desc, 'owner' => $user, 'access' => $access];
save_repos_config($cfg);
if ($init) {
$users = load_users();
$profile = $users[$user] ?? [];
$readme = "# $name\n\n" . ($desc ?: 'A new repository.') . "\n";
$result = git_commit_files($dir, 'main',
['README.md' => $readme],
'Initial commit',
$profile['name'] ?? $user,
$profile['email'] ?? "$user@localhost"
);
if (!$result['ok']) {
// Repo created but README commit failed — not fatal, just warn
$errors[] = 'Repository created, but initial commit failed: ' . $result['error']
. ' — you can push manually.';
// Still redirect, repo exists and is usable
}
}
if (!$errors) {
header('Location: ' . site_url($name));
exit;
}
}
}
}
}
html_open('New Repository — ' . SITE_TITLE);
echo 'New Repository
';
echo '';
foreach ($errors as $e) echo '
' . h($e) . '
';
echo '
';
html_close();
}
// ── Upload files ──────────────────────────────────────────────────────────────
function page_upload(string $name): void {
require_login();
if (!user_can_write($name)) {
http_response_code(403);
html_open('Forbidden'); echo 'You do not have write access to this repository.
'; html_close(); return;
}
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$branches = repo_branches($dir);
$default = repo_default_branch($dir);
$users = load_users();
$u = session_user();
$profile = $users[$u] ?? [];
$errors = [];
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$branch = trim($_POST['branch'] ?? $default);
$new_branch = trim($_POST['new_branch'] ?? '');
$msg = trim($_POST['message'] ?? 'Upload via GitGram web UI');
$mode = $_POST['upload_mode'] ?? 'single';
$base_path = sanitize_git_path(trim($_POST['base_path'] ?? ''));
$author_n = $profile['name'] ?? $u;
$author_e = $profile['email'] ?? "$u@localhost";
if ($new_branch !== '') $branch = $new_branch;
if (!preg_match('/^[a-zA-Z0-9_\-\.\/]{1,100}$/', $branch))
$errors[] = 'Invalid branch name.';
if (!$msg) $errors[] = 'Commit message is required.';
if (!$errors && isset($_FILES['upload']) && $_FILES['upload']['error'] === UPLOAD_ERR_OK) {
$tmp = $_FILES['upload']['tmp_name'];
if ($mode === 'zip') {
if (!class_exists('ZipArchive')) {
$errors[] = 'ZipArchive PHP extension is not available on this server.';
} else {
$zip = new ZipArchive();
$files = [];
if ($zip->open($tmp) === true) {
for ($i = 0; $i < $zip->numFiles; $i++) {
$zname = $zip->getNameIndex($i);
if (str_ends_with($zname, '/')) continue; // directory entry
$clean = sanitize_git_path(($base_path ? $base_path . '/' : '') . $zname);
if ($clean) $files[$clean] = $zip->getFromIndex($i);
}
$zip->close();
if (empty($files)) $errors[] = 'ZIP contains no files.';
} else {
$errors[] = 'Could not open ZIP archive.';
}
if (!$errors) {
$result = git_commit_files($dir, $branch, $files, $msg, $author_n, $author_e);
if ($result['ok']) { $success = count($files) . ' file(s) committed to ' . $branch; }
else { $errors[] = $result['error']; }
}
}
} else {
// Single file
$filename = sanitize_git_path(
($base_path ? $base_path . '/' : '') .
(trim($_POST['file_path'] ?? '') ?: basename($_FILES['upload']['name']))
);
if (!$filename) { $errors[] = 'Invalid file path.'; }
else {
$content = file_get_contents($tmp);
$result = git_commit_files($dir, $branch, [$filename => $content], $msg, $author_n, $author_e);
if ($result['ok']) { $success = "File committed: $filename → $branch"; }
else { $errors[] = $result['error']; }
}
}
} elseif (!$errors) {
$errors[] = 'No file uploaded or upload error (' . ($_FILES['upload']['error'] ?? 'unknown') . ').';
}
}
html_open('Upload — ' . h($name) . ' — ' . SITE_TITLE);
echo '';
echo '';
foreach ($errors as $e) echo '
' . h($e) . '
';
if ($success) echo '
' . h($success) . '
';
echo '
';
echo '
Max upload size is controlled by your server\'s PHP upload_max_filesize setting.
';
echo '
';
echo '';
html_close();
}
// ── Repo settings (owner) ─────────────────────────────────────────────────────
function page_repo_settings(string $name): void {
require_login();
if (!user_is_owner($name)) {
http_response_code(403);
html_open('Forbidden'); echo 'Only the repository owner can access settings.
'; html_close(); return;
}
$dir = repo_dir($name);
if (!is_dir($dir)) { page_404(); return; }
$cfg = load_repos_config();
$entry = $cfg[$name] ?? [];
$errors = [];
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'save') {
$desc = trim($_POST['description'] ?? '');
$visibility = $_POST['visibility'] ?? 'private';
$u = session_user();
file_put_contents($dir . '/description', $desc . "\n");
$cfg[$name]['description'] = $desc;
// Rebuild access: keep non-@all, non-owner rules, then set visibility
$old_access = $entry['access'] ?? [];
$new_access = [];
foreach ($old_access as $k => $v) {
if ($k !== '@all' && $k !== $u) $new_access[$k] = $v;
}
if ($visibility === 'public') $new_access = array_merge(['@all' => 'R'], $new_access);
$new_access[$u] = 'RW+';
$cfg[$name]['access'] = $new_access;
save_repos_config($cfg);
$entry = $cfg[$name];
$success = 'Settings saved.';
} elseif ($action === 'delete') {
$confirm = trim($_POST['confirm_name'] ?? '');
if ($confirm !== $name) {
$errors[] = 'Confirmation name did not match.';
} else {
recursive_rmdir($dir);
unset($cfg[$name]);
save_repos_config($cfg);
header('Location: ' . site_url('dashboard'));
exit;
}
}
}
$is_public = isset($entry['access']['@all']) && $entry['access']['@all'] !== '-';
$desc = trim(@file_get_contents($dir . '/description') ?: '');
if (str_starts_with($desc, 'Unnamed repository')) $desc = '';
html_open('Settings — ' . h($name) . ' — ' . SITE_TITLE);
echo '';
echo '';
foreach ($errors as $e) echo '
' . h($e) . '
';
if ($success) echo '
' . h($success) . '
';
// Main settings form
echo '
';
// Clone URL
$clone = site_url($name . '.git');
echo '
Clone URL
';
echo '
HTTP
';
// Danger zone
echo '
';
echo '
⚠ Delete Repository
';
echo '
Permanently destroys all commits. This cannot be undone.
';
echo '
';
echo '
';
html_close();
}
// ── Profile page ──────────────────────────────────────────────────────────────
// ── Public user profile ───────────────────────────────────────────────────────
function page_user_profile(string $username): void {
$users = load_users();
if (!isset($users[$username])) { page_404(); return; }
$profile = $users[$username];
$cfg = load_repos_config();
$all = repo_list();
$viewer = session_user();
// Repos this user owns that the viewer can read
$their_repos = array_filter($all, fn($r) =>
($cfg[$r['name']]['owner'] ?? null) === $username
&& check_repo_access($r['name'], 'R', $viewer)
);
$display = $profile['name'] ?? $username;
$av_path = avatar_path($username);
if (!file_exists($av_path)) generate_initials_avatar($username, $display, $av_path);
$av_url = avatar_url($username);
html_open(h($display) . ' — ' . SITE_TITLE);
echo '' . h($display) . '
';
echo '';
// Profile card
echo '
';
echo '
';
echo '
';
echo '
' . h($display) . '
';
echo '
@' . h($username) . '
';
if (!empty($profile['email'])) {
echo '
' . h($profile['email']) . '
';
}
if (!empty($profile['bio'])) {
echo '
' . h($profile['bio']) . '
';
}
echo '
'
. h($profile['role'] ?? 'user') . '
';
// If this is the logged-in user, show edit link
if ($viewer === $username) {
echo '
';
}
echo '
';
echo '
';
// Their public repositories
echo '
Repositories
';
if (empty($their_repos)) {
echo '
No public repositories.
';
} else {
echo '
';
echo 'Name Description Last commit When ';
foreach ($their_repos as $r) {
$last = repo_last_commit($r['dir']);
$access = $cfg[$r['name']]['access'] ?? [];
$public = isset($access['@all']) && $access['@all'] !== '-';
$forked_from = $cfg[$r['name']]['forked_from'] ?? '';
echo '';
echo '' . h($r['name']) . ' '
. ' '
. ($public ? 'public' : 'private') . ' '
. ($forked_from
? ' ⑂ '
. h($forked_from) . ' '
: '')
. ' ';
echo '' . h($r['desc']) . ' ';
echo '' . h($last['subject'] ?? '—') . ' ';
echo '' . h($last['when'] ?? '') . ' ';
echo ' ';
}
echo '
';
}
echo '
';
html_close();
}
// ── Own profile edit ──────────────────────────────────────────────────────────
function page_profile(): void {
require_login();
$u = session_user();
$users = load_users();
$profile = $users[$u] ?? [];
$errors = [];
$success = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$section = $_POST['section'] ?? '';
// ── Save profile details ──
if ($section === 'details') {
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
if (!$name) {
$errors[] = 'Display name cannot be empty.';
} elseif ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Enter a valid email address.';
} else {
$users[$u]['name'] = $name;
$users[$u]['email'] = $email;
file_put_contents(DATA_PATH . '/users.json',
json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
$profile = $users[$u];
$success[] = 'Profile details saved.';
}
}
// ── Change password ──
if ($section === 'password') {
$current = $_POST['current_password'] ?? '';
$new = $_POST['new_password'] ?? '';
$confirm = $_POST['confirm_password'] ?? '';
if (!password_verify($current, $profile['password_hash'] ?? '')) {
$errors[] = 'Current password is incorrect.';
} elseif (strlen($new) < 8) {
$errors[] = 'New password must be at least 8 characters.';
} elseif ($new !== $confirm) {
$errors[] = 'New passwords do not match.';
} else {
$users[$u]['password_hash'] = password_hash($new, PASSWORD_DEFAULT);
file_put_contents(DATA_PATH . '/users.json',
json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
$success[] = 'Password changed successfully.';
}
}
// ── Upload avatar ──
if ($section === 'avatar') {
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === UPLOAD_ERR_OK) {
$result = process_avatar($_FILES['avatar']['tmp_name'], avatar_path($u));
if ($result === true) {
$success[] = 'Avatar updated.';
} else {
$errors[] = $result;
}
} elseif (isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) {
$errors[] = 'Upload error code: ' . $_FILES['avatar']['error'];
}
}
// ── Remove avatar ──
if ($section === 'remove_avatar') {
$path = avatar_path($u);
if (file_exists($path)) unlink($path);
// Regenerate initials avatar
generate_initials_avatar($u, $users[$u]['name'] ?? $u, $path);
$success[] = 'Avatar reset to initials.';
}
$profile = $users[$u] ?? [];
}
// Ensure an initials avatar exists for display
$av_path = avatar_path($u);
if (!file_exists($av_path)) {
generate_initials_avatar($u, $profile['name'] ?? $u, $av_path);
}
$av_url = avatar_url($u);
html_open('Profile — ' . SITE_TITLE);
echo 'My Profile
';
echo '';
foreach ($errors as $e) echo '
' . h($e) . '
';
foreach ($success as $s) echo '
' . h($s) . '
';
echo '
';
// ── Left: Avatar column ──────────────────────────────────────────────────
echo '
';
// 128×128 avatar display
echo '
';
echo '
';
echo '
';
// Upload form
echo '
';
// Remove / reset avatar
echo '
';
echo '
JPEG/PNG/GIF/WebP Cropped to 128×128
';
echo '
';
// ── Right: Details columns ───────────────────────────────────────────────
echo '
';
// Profile details form
echo '
Profile Details
';
echo '
';
// Git config hint box
$git_name = $profile['name'] ?? $u;
$git_email = $profile['email'] ?? "$u@localhost";
echo '
';
echo '
Your git config commands
';
echo '
git config --global user.name "' . h($git_name) . '"'
. "\ngit config --global user.email \"" . h($git_email) . '" ';
echo '
';
// Password change form
echo '
Change Password
';
echo '
';
echo '
'; // right col
echo '
'; // flex row
echo '
'; // content-pad
echo '';
html_close();
}
function page_404(): void {
http_response_code(404);
html_open('Not Found — ' . SITE_TITLE);
echo '404 Not Found
';
echo 'The page or repository you requested does not exist.
';
echo '
← Back to repositories
';
html_close();
}
// ── Breadcrumb ────────────────────────────────────────────────────────────────
function breadcrumb_tree(string $name, string $ref, string $path, bool $is_blob = false): string {
$out = '' . h($name) . ' ';
$out .= ' / ' . h($ref) . ' ';
if (!$path) return $out;
$parts = explode('/', $path);
$built = '';
foreach ($parts as $i => $p) {
$built .= ($built ? '/' : '') . $p;
$is_last = $i === count($parts) - 1;
if ($is_last && $is_blob) {
$out .= ' / ' . h($p);
} else {
$url = site_url($name . '/tree/' . $ref . '/' . $built);
$out .= ' / ' . h($p) . ' ';
}
}
return $out;
}
// ── HTML layout (OS/2 Warp 3.0 theme) ────────────────────────────────────────
function html_open(string $title, array $opts = []): void {
$s = load_settings();
$nav_home = site_url();
$nav_readme = site_url('readme');
$end_url = site_url('end-of-internet');
$site_title = h($s['site_title'] ?? SITE_TITLE);
$prism = !empty($opts['prism']);
$cur_user = session_user();
$is_admin = session_is_admin();
$nav_dashboard = $cur_user
? 'My Repos '
: '';
$nav_avatar = '';
if ($cur_user) {
$av_url = avatar_url($cur_user);
if (!$av_url) {
// Generate initials avatar on-the-fly if missing
$u_data = (load_users())[$cur_user] ?? [];
generate_initials_avatar($cur_user, $u_data['name'] ?? $cur_user, avatar_path($cur_user));
$av_url = avatar_url($cur_user);
}
$nav_avatar = $av_url
? ' '
: '';
}
$nav_right = $cur_user
? ''
. ($is_admin ? 'Admin ' : '')
. ''
. $nav_avatar . h($cur_user) . ' '
. 'Logout '
: ''
. 'Login '
. ($s['registration_open'] ? 'Register ' : '')
. ' ';
echo <<
{$title}
HTML;
if ($prism) {
echo ' ';
echo '';
echo '';
}
echo <<
/* ── OS/2 Warp 3.0 Design System ── */
:root {
--desktop: #008080;
--win-bg: #c0c0c0;
--title-bg: #000080;
--title-text: #ffffff;
--border-hi: #ffffff;
--border-sh: #808080;
--border-dk: #404040;
--text: #000000;
--muted: #555;
--link: #000080;
--accent: #000080;
--code-bg: #1a1a2e;
--code-text: #c8c8ff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--desktop);
font-family: "Helv", "Arial", "Helvetica", sans-serif;
font-size: 13px;
color: var(--text);
padding: 12px;
min-height: 100vh;
}
a { color: var(--link); text-decoration: underline; }
a:hover { color: #0000cc; }
/* ── Window chrome ── */
.window {
background: var(--win-bg);
/* Raised 3D bevel */
border-top: 2px solid var(--border-hi);
border-left: 2px solid var(--border-hi);
border-right: 2px solid var(--border-dk);
border-bottom: 2px solid var(--border-dk);
box-shadow: 1px 1px 0 var(--border-sh) inset,
-1px -1px 0 var(--border-sh) inset;
margin: 0 auto;
max-width: 980px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 24px);
}
/* ── Title bar ── */
.titlebar {
background: var(--title-bg);
color: var(--title-text);
display: flex;
align-items: center;
height: 22px;
padding: 0 2px;
gap: 2px;
flex-shrink: 0;
user-select: none;
}
.titlebar-sysmenu {
width: 18px; height: 14px;
background: var(--win-bg);
border-top: 1px solid var(--border-hi);
border-left: 1px solid var(--border-hi);
border-right: 1px solid var(--border-dk);
border-bottom:1px solid var(--border-dk);
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0; font-size: 9px;
}
.titlebar-title {
flex: 1; text-align: center; font-size: 12px; font-weight: bold;
letter-spacing: 0.02em; padding: 0 4px;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.titlebar-btn {
width: 16px; height: 14px;
background: var(--win-bg);
border-top: 1px solid var(--border-hi);
border-left: 1px solid var(--border-hi);
border-right: 1px solid var(--border-dk);
border-bottom:1px solid var(--border-dk);
display: flex; align-items: center; justify-content: center;
cursor: pointer; flex-shrink: 0; color: #000;
font-size: 10px; font-weight: bold; text-decoration: none;
}
.titlebar-btn:hover { background: #d4d4d4; color: #000; }
.titlebar-btn:active {
border-top: 1px solid var(--border-dk);
border-left: 1px solid var(--border-dk);
border-right: 1px solid var(--border-hi);
border-bottom:1px solid var(--border-hi);
}
/* ── Menu bar ── */
.menubar {
background: var(--win-bg);
border-bottom: 1px solid var(--border-sh);
padding: 2px 6px;
display: flex; gap: 16px; flex-shrink: 0;
}
.menubar a {
color: var(--text); text-decoration: none; font-size: 12px; padding: 1px 4px;
}
.menubar a:hover { background: var(--accent); color: #fff; }
/* ── Toolbar (page-level heading bar) ── */
.toolbar {
background: #b0b0b0;
border-bottom: 1px solid var(--border-sh);
border-top: 1px solid var(--border-hi);
padding: 4px 8px;
font-size: 12px; font-weight: bold;
display: flex; justify-content: space-between; align-items: center;
flex-shrink: 0;
}
.toolbar-right { display:flex; align-items:center; }
.toolbar-right a { margin-left: 10px; font-weight: normal; }
/* ── Window body ── */
.win-body {
flex: 1; overflow: auto;
/* Sunken inner area */
border-top: 1px solid var(--border-sh);
border-left: 1px solid var(--border-sh);
border-right: 1px solid var(--border-hi);
border-bottom:1px solid var(--border-hi);
margin: 4px;
background: #fff;
}
.win-body.minimized { display: none; }
/* ── Status bar ── */
.statusbar {
background: var(--win-bg);
border-top: 1px solid var(--border-sh);
padding: 2px 8px;
font-size: 11px; color: #444;
display: flex; gap: 1px; flex-shrink: 0;
}
.statusbar-cell {
border-top: 1px solid var(--border-sh);
border-left: 1px solid var(--border-sh);
border-right: 1px solid var(--border-hi);
border-bottom:1px solid var(--border-hi);
padding: 1px 8px;
}
/* ── Content padding ── */
.content-pad { padding: 12px 16px; }
/* ── Tables ── */
table { border-collapse: collapse; width: 100%; }
th { text-align: left; padding: 4px 8px; background: #e0e0e0;
border-bottom: 2px solid var(--border-sh); font-size: 12px; }
td { padding: 3px 8px; border-bottom: 1px solid #ddd; vertical-align: top; }
tr:hover td { background: #e8e8f8; }
.repo-table td:first-child a { font-weight: bold; }
.commit-table { font-size: 12px; }
.file-table td { padding: 2px 6px; }
.file-table tr:hover td { background: #d8d8f8; }
.file-icon { width: 24px; }
.file-size { text-align: right; color: #666; font-size: 11px; width: 70px; }
.dl-link { font-size: 11px; color: #000080; text-decoration: none; padding: 0 4px;
opacity: 0.5; }
.file-table tr:hover .dl-link { opacity: 1; }
.dl-link:hover { text-decoration: underline; }
.btn-download { text-decoration: none; font-size: 11px; padding: 2px 10px;
margin-left: 8px; display: inline-block; }
.meta-table th { width: 90px; }
.hash { font-family: monospace; font-size: 11px; width: 80px; }
.mono { font-family: monospace; font-size: 11px; }
.muted { color: var(--muted); }
/* ── Clone box ── */
.clone-box { display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
background: #f0f0f8; border: 1px solid #ccc; padding: 6px 10px; flex-wrap: wrap; }
.clone-box label { font-weight: bold; white-space: nowrap; font-size: 11px; }
.clone-box input { flex: 1; min-width: 180px; font-family: monospace; font-size: 12px;
border: 1px inset #aaa; padding: 2px 6px; background: #fff; }
/* ── Code / diff views ── */
.file-view { font-family: monospace; font-size: 12px; line-height: 1.5;
overflow: auto; padding: 12px; background: var(--code-bg);
color: var(--code-text); }
.diff-view { font-family: monospace; font-size: 12px; line-height: 1.5;
overflow: auto; padding: 12px; background: #1a1a1a; color: #ccc;
white-space: pre-wrap; }
.readme-box { border: 1px solid #ccc; margin: 12px 0; }
.readme-title { background: #e8e8e8; border-bottom: 1px solid #ccc;
padding: 4px 10px; font-weight: bold; font-size: 12px; }
.readme-pre { padding: 10px; white-space: pre-wrap; font-family: monospace;
font-size: 12px; line-height: 1.6; }
.section-title { font-weight: bold; margin: 16px 0 6px; border-bottom: 1px solid #ccc; padding-bottom: 3px; }
.commit-body { font-size: 11px; margin-top: 4px; color: #444; }
/* ── Owner links ── */
.owner-link { color: #000080; text-decoration: none; }
.owner-link:hover { text-decoration: underline; }
.owner-cell { white-space: nowrap; font-size: 12px; }
.clone-owner { font-size: 11px; white-space: nowrap; color: #444; }
.clone-owner a { color: #000080; text-decoration: none; }
.clone-owner a:hover { text-decoration: underline; }
/* ── Fork UI ── */
.fork-btn { font-size: 11px; padding: 2px 10px; text-decoration: none;
display: inline-block; background: #ddeeff; }
.fork-btn:hover { background: #bbddff; }
.fork-banner { font-size: 12px; background: #f0f4ff; border: 1px solid #b0c4e8;
border-left: 4px solid #000080; padding: 5px 10px; margin-bottom: 10px;
display: flex; align-items: center; gap: 6px; }
.fork-banner a { color: #000080; }
.fork-indicator { font-size: 10px; color: #000080; margin-left: 6px; opacity: 0.7; }
.fork-source-card { display: flex; gap: 14px; align-items: center;
background: #e8eef8; border: 1px solid #b0c4e8; padding: 10px 14px;
margin-bottom: 12px; }
.fork-source-icon { font-size: 24px; color: #000080; flex-shrink: 0; }
/* ── Public user profile card ── */
.user-profile-card { display: flex; gap: 18px; align-items: flex-start;
background: #e8e8e8; border: 1px solid #ccc;
padding: 14px 18px; margin-bottom: 4px; }
.user-avatar { border: 2px solid #aaa; flex-shrink: 0; image-rendering: pixelated; }
.user-profile-info { display: flex; flex-direction: column; gap: 3px; }
.user-display-name { font-size: 16px; font-weight: bold; }
.user-username { font-size: 12px; color: #555; }
.user-email { font-size: 12px; color: #555; }
.user-bio { font-size: 12px; color: #333; margin-top: 4px; max-width: 400px; white-space: pre-wrap; }
/* ── Readme doc page ── */
.readme-doc h2 { margin: 20px 0 8px; font-size: 14px; border-bottom: 1px solid #ccc; padding-bottom: 3px; }
.readme-doc p { margin: 6px 0; line-height: 1.6; }
.readme-doc code { background: #e8e8f0; padding: 1px 4px; font-family: monospace; border: 1px solid #ccc; }
.code-block { background: var(--code-bg); color: var(--code-text); padding: 10px 14px;
font-family: monospace; font-size: 12px; margin: 8px 0; line-height: 1.6;
border-left: 3px solid #000080; overflow-x: auto; white-space: pre; display: block; }
/* ── Forms ── */
label { display:block; font-weight:bold; font-size:12px; margin-bottom:3px; margin-top:8px; }
label small { font-weight:normal; color:#666; }
input[type=text], input[type=password], input[type=email], input[type=number], select, textarea {
width:100%; padding:4px 6px; font-size:13px; font-family:inherit;
border-top:2px solid var(--border-sh); border-left:2px solid var(--border-sh);
border-right:2px solid var(--border-hi); border-bottom:2px solid var(--border-hi);
background:#fff; box-sizing:border-box;
}
input:focus, select:focus, textarea:focus { outline:2px solid #000080; }
.form-row { display:flex; gap:12px; }
.form-row > * { flex:1; }
.os2-btn {
padding:4px 18px; font-size:12px; font-family:inherit; cursor:pointer;
background:var(--win-bg);
border-top:2px solid var(--border-hi); border-left:2px solid var(--border-hi);
border-right:2px solid var(--border-dk); border-bottom:2px solid var(--border-dk);
}
.os2-btn:active {
border-top:2px solid var(--border-dk); border-left:2px solid var(--border-dk);
border-right:2px solid var(--border-hi); border-bottom:2px solid var(--border-hi);
}
.os2-btn.primary { background:#ccccff; font-weight:bold; }
.os2-btn.danger { background:#ffcccc; }
.alert-box { padding:7px 12px; margin:6px 0; font-size:12px; border-left:4px solid; }
.alert-box.err { background:#fff0f0; border-color:#cc0000; color:#800; }
.alert-box.ok { background:#f0fff0; border-color:#008000; color:#040; }
.alert-box.warn { background:#fffbe6; border-color:#c0a000; color:#640; }
/* ── Maximize state ── */
body.maximized { padding: 0; }
body.maximized .window {
max-width: 100%; margin: 0;
min-height: 100vh;
border: none; box-shadow: none;
}
{$site_title} — {$title}
▼
▲
✕
HTML;
}
function html_close(): void {
echo <<<'HTML'
HTML;
}