GitGram — index.php — GitGram
GitGram / main / v3.00 / index.php131,408 B↓ Raw
<?php
ob_start(); // buffer all output — prevents headers-already-sent 500s
// ─────────────────────────────────────────────────────────────────────────────
//  GitGram — PHP git hosting for shared servers
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/gitlib.php';

if (session_status() === PHP_SESSION_NONE) {
    session_name('gitgram');
    session_start();
}

// ── Helpers ──────────────────────────────────────────────────────────────────

function load_settings(): array {
    $defaults = ['site_title' => SITE_TITLE, 'registration_open' => true, 'invite_only' => false, 'rss_enabled' => 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'); }

function record_download(string $repo, string $type): void {
    $path = DATA_PATH . '/downloads.json';
    $data = json_decode(@file_get_contents($path), true) ?? [];
    $data[$repo][$type] = ($data[$repo][$type] ?? 0) + 1;
    file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}

/**
 * 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 '<a href="' . h($url) . '" class="owner-link">' . h($display) . '</a>';
}

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') {
            record_download($repo_name, 'git');
            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;
    }

    if ($path_info === 'git-upload-pack' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
        record_download($repo_name, 'git');
    }
    $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'                                              => page_404(),   // handled directly by admin.php via .htaccess
    $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] === 'rss'    && count($parts) === 2  => page_repo_rss($parts[0]),
    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] === 'rss'                                                => page_site_rss(),
    $parts[0] === 'user' && isset($parts[2]) && $parts[2] === 'rss'   => page_user_rss($parts[1]),
    $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));
    $s = load_settings();
    html_open('Repositories — ' . SITE_TITLE);
    echo '<div class="toolbar"><span>Repositories</span>';
    if (!empty($s['rss_enabled'])) {
        echo '<span class="toolbar-right"><a href="' . site_url('rss') . '" title="RSS feed" style="text-decoration:none">&#x2605; RSS</a></span>';
    }
    echo '</div>';
    echo '<div class="content-pad">';
    if (empty($repos)) {
        echo '<p class="muted">No repositories yet. See the <a href="' . site_url('readme') . '">setup guide</a>.</p>';
    } else {
        $cfg = load_repos_config();
        echo '<table class="repo-table" id="repo-table">';
        echo '<tr>'
           . '<th class="sortable" data-col="0" style="cursor:pointer;user-select:none">Name <span class="sort-ind"></span></th>'
           . '<th class="sortable" data-col="1" style="cursor:pointer;user-select:none">Owner <span class="sort-ind"></span></th>'
           . '<th class="sortable" data-col="2" style="cursor:pointer;user-select:none">Description <span class="sort-ind"></span></th>'
           . '<th class="sortable" data-col="3" style="cursor:pointer;user-select:none">Last commit <span class="sort-ind"></span></th>'
           . '<th class="sortable" data-col="4" style="cursor:pointer;user-select:none">When <span class="sort-ind"></span></th>'
           . '</tr>';
        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 '<tr>';
            echo '<td><a href="' . h($url) . '">' . h($r['name']) . '</a>'
               . ($forked_from ? ' <span class="fork-indicator" title="Forked from ' . h($forked_from) . '">&#x2442;</span>' : '')
               . '</td>';
            echo '<td class="owner-cell">' . ($owner ? owner_link($owner) : '<span class="muted">—</span>') . '</td>';
            echo '<td class="muted">' . h($r['desc']) . '</td>';
            echo '<td class="muted mono">' . h($last['subject'] ?? '—') . '</td>';
            echo '<td class="muted">' . h($last['when'] ?? '') . '</td>';
            echo '</tr>';
        }
        echo '</table>';
    }
    echo '</div>';
    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 '<div class="toolbar">';
    echo '<span>' . h($name) . '</span>';
    echo '<span class="toolbar-right">';
    echo '<a href="' . site_url($name . '/tree/' . $branch) . '">Files</a> ';
    echo '<a href="' . site_url($name . '/commits/' . $branch) . '">Commits</a> ';
    echo '<a href="' . site_url($name . '/archive/' . $branch) . '" title="Download ZIP" class="os2-btn btn-download">&#8595; ZIP</a>';
    if ($can_fork) {
        if ($viewer_fork) {
            echo ' <a href="' . site_url($viewer_fork) . '" class="os2-btn fork-btn" title="You already forked this — go to your fork">&#x2442; Forked</a>';
        } else {
            echo ' <a href="' . site_url($name . '/fork') . '" class="os2-btn fork-btn">&#x2442; Fork</a>';
        }
    }
    if (user_can_write($name))
        echo ' <a href="' . site_url($name . '/upload') . '">⬆ Upload</a>';
    if (user_is_owner($name))
        echo ' <a href="' . site_url($name . '/settings') . '">Settings</a>';
    $s_rss = load_settings();
    if (!empty($s_rss['rss_enabled']))
        echo ' <a href="' . site_url($name . '/rss') . '" title="RSS feed" style="text-decoration:none">&#x2605; RSS</a>';
    echo '</span></div>';

    echo '<div class="content-pad">';

    // Forked-from banner
    if ($forked_from) {
        $from_owner = $cfg[$forked_from]['owner'] ?? '';
        echo '<div class="fork-banner">&#x2442; Forked from ';
        if (is_dir(repo_dir($forked_from))) {
            echo '<a href="' . site_url($forked_from) . '">' . h($forked_from) . '</a>';
        } else {
            echo '<span class="muted">' . h($forked_from) . ' (deleted)</span>';
        }
        if ($from_owner) echo ' by ' . owner_link($from_owner);
        echo '</div>';
    }

    // Clone URL + owner + fork count
    $clone = site_url($name . '.git');
    echo '<div class="clone-box"><label>Clone</label>';
    echo '<input type="text" readonly value="' . h($clone) . '" onclick="this.select()">';
    if ($owner) echo '<span class="clone-owner">by ' . owner_link($owner) . '</span>';
    if ($fork_count) echo '<span class="clone-owner" style="margin-left:8px">&#x2442; ' . $fork_count . ' fork' . ($fork_count !== 1 ? 's' : '') . '</span>';
    echo '</div>';

    // README
    $readme = repo_blob($dir, $branch, 'README.md')
           ?: repo_blob($dir, $branch, 'README');
    if ($readme) {
        echo '<div class="readme-box"><div class="readme-title">README</div>';
        echo '<pre class="readme-pre">' . h($readme) . '</pre></div>';
    }

    // Recent commits
    if ($commits) {
        echo '<div class="section-title">Recent commits</div>';
        echo '<table class="commit-table">';
        foreach ($commits as $c) {
            $url = site_url($name . '/commit/' . $c['hash']);
            echo '<tr>';
            echo '<td class="mono hash"><a href="' . h($url) . '">' . substr($c['hash'], 0, 8) . '</a></td>';
            echo '<td>' . h($c['subject']) . '</td>';
            echo '<td class="muted">' . h($c['author']) . '</td>';
            echo '<td class="muted">' . h($c['when']) . '</td>';
            echo '</tr>';
        }
        echo '</table>';
        echo '<p><a href="' . site_url($name . '/commits/' . $branch) . '">View all commits →</a></p>';
    }
    echo '</div>';
    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 '<div class="content-pad"><p class="muted">You do not have read access to this repository.</p></div>';
        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 '<div class="toolbar"><span>Fork Repository</span></div>';
    echo '<div class="content-pad">';

    // Source info card
    echo '<div class="fork-source-card">';
    echo '<div class="fork-source-icon">&#x2442;</div>';
    echo '<div>';
    echo '<div style="font-weight:bold">' . h($name) . '</div>';
    echo '<div class="muted" style="font-size:11px">by ' . h($src_owner_display) . '</div>';
    if ($cfg[$name]['description'] ?? '') echo '<div style="font-size:12px;margin-top:3px">' . h($cfg[$name]['description']) . '</div>';
    echo '</div>';
    echo '</div>';
    echo '<p style="font-size:12px;margin:10px 0">Creating a fork copies the entire repository into your account. '
       . 'You can commit to it independently and later propose changes back to the original.</p>';

    foreach ($errors as $e) echo '<div class="alert-box err">' . h($e) . '</div>';

    echo '<form method="post">';
    echo '<label>Fork Name</label>';
    echo '<input type="text" name="fork_name" value="' . h($fork_name) . '" required pattern="[a-zA-Z0-9_\-\.]{1,64}" autofocus>';
    echo '<label>Description <small>(optional)</small></label>';
    echo '<input type="text" name="fork_desc" value="' . h($fork_desc) . '">';
    echo '<div style="margin-top:14px;display:flex;gap:10px;align-items:center">';
    echo '<button type="submit" class="os2-btn primary">&#x2442; Create Fork</button>';
    echo '<a href="' . site_url($name) . '" class="os2-btn">Cancel</a>';
    echo '</div>';
    echo '</form>';
    echo '</div>';
    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 '<div class="toolbar">';
    echo breadcrumb_tree($name, $ref, $path);
    echo '<span class="toolbar-right">';
    $zip_path = $path ? $path : '';
    echo '<a href="' . site_url($name . '/archive/' . $ref . ($zip_path ? '/' . $zip_path : '')) . '" class="os2-btn btn-download" title="Download directory as ZIP">&#8595; ZIP</a>';
    echo '</span>';
    echo '</div>';
    echo '<div class="content-pad">';
    echo '<table class="file-table">';
    echo '<colgroup><col style="width:20px"><col><col style="width:70px"><col style="width:60px"></colgroup>';

    // Parent dir link
    if ($path) {
        $parent = dirname($path);
        $url    = site_url($name . '/tree/' . $ref . ($parent !== '.' ? '/' . $parent : ''));
        echo '<tr><td></td><td colspan="3"><a href="' . h($url) . '">..</a></td></tr>';
    }

    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     = '&#128193;';
            $size_col = '<a href="' . site_url($name . '/archive/' . $ref . '/' . $item_path) . '" class="dl-link" title="Download ZIP">ZIP</a>';
        } else {
            $url      = site_url($name . '/blob/' . $ref . '/' . $item_path);
            $icon     = '&#128196;';
            $sz       = is_numeric($item['size']) ? number_format((int)$item['size']) . ' B' : '';
            $size_col = '<span class="muted file-size">' . $sz . '</span>';
        }
        $dl_url = $item['type'] === 'blob'
            ? site_url($name . '/raw/' . $ref . '/' . $item_path)
            : site_url($name . '/archive/' . $ref . '/' . $item_path);
        echo '<tr>';
        echo '<td class="file-icon">' . $icon . '</td>';
        echo '<td><a href="' . h($url) . '">' . h($item['name']) . '</a></td>';
        echo '<td class="muted file-size">' . $size_col . '</td>';
        echo '<td style="text-align:right"><a href="' . h($dl_url) . '" class="dl-link" title="Download">&#8595;</a></td>';
        echo '</tr>';
    }
    echo '</table></div>';
    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 '<div class="toolbar">';
    echo breadcrumb_tree($name, $ref, $path, true);
    echo '<span class="toolbar-right">';
    echo '<span class="muted" style="font-size:11px;margin-right:8px">' . $size_str . '</span>';
    echo '<a href="' . h($raw_url) . '" class="os2-btn btn-download">&#8595; Raw</a>';
    echo '</span>';
    echo '</div>';
    echo '<div class="content-pad">';
    if ($is_binary) {
        echo '<p class="muted" style="padding:12px">Binary file — <a href="' . h($raw_url) . '">download raw</a></p>';
    } else {
        echo '<pre class="file-view language-' . $lang . '"><code class="language-' . $lang . '">' . h($content) . '</code></pre>';
    }
    echo '</div>';
    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();

    record_download($name, 'zip');
    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 '<div class="toolbar"><span>Commits: ' . h($name) . ' [' . h($ref) . ']</span></div>';
    echo '<div class="content-pad">';
    echo '<table class="commit-table">';
    foreach ($commits as $c) {
        $url = site_url($name . '/commit/' . $c['hash']);
        echo '<tr>';
        echo '<td class="mono hash"><a href="' . h($url) . '">' . substr($c['hash'], 0, 8) . '</a></td>';
        echo '<td>' . h($c['subject']) . '</td>';
        echo '<td class="muted">' . h($c['author']) . '</td>';
        echo '<td class="muted">' . h($c['date']) . '</td>';
        echo '</tr>';
    }
    echo '</table>';
    if (count($commits) === 30) {
        echo '<a href="?p=' . ($page + 1) . '">Older →</a>';
    }
    echo '</div>';
    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 '<div class="toolbar"><span>Commit: ' . h(substr($c['hash'], 0, 12)) . '</span></div>';
    echo '<div class="content-pad">';
    echo '<table class="meta-table">';
    echo '<tr><th>Author</th><td>' . h($c['author']) . ' &lt;' . h($c['email']) . '&gt;</td></tr>';
    echo '<tr><th>Date</th><td>' . h($c['date']) . '</td></tr>';
    echo '<tr><th>Message</th><td>' . h($c['subject']) . ($c['body'] ? '<br><pre class="commit-body">' . h(trim($c['body'])) . '</pre>' : '') . '</td></tr>';
    echo '</table>';
    echo '<pre class="diff-view">' . h($c['diff']) . '</pre>';
    echo '</div>';
    html_close();
}

function page_site_readme(): void {
    html_open('How to Use Git — ' . SITE_TITLE);
    echo '<div class="toolbar"><span>How to Use GitGram</span></div>';
    echo '<div class="content-pad readme-doc">';
    $clone_example = site_url('yourrepo.git');
    echo <<<HTML
<h2>Getting Started</h2>
<p>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.</p>

<h2>Clone a Repository</h2>
<pre class="code-block">git clone {$clone_example}</pre>

<h2>Push to a Repository</h2>
<p>Pushing requires your username and password (set in <code>data/users.json</code>).</p>
<pre class="code-block">git remote add origin {$clone_example}
git push origin main</pre>
<p>Git will prompt for credentials. To cache them:</p>
<pre class="code-block">git config credential.helper store</pre>

<h2>Creating a New Repository (Server Side)</h2>
<p>SSH into your server and run:</p>
<pre class="code-block">cd /path/to/gitgram/repos
git init --bare yourrepo.git
echo "My new repository" &gt; yourrepo.git/description</pre>
<p>The repo will appear on the home page immediately.</p>

<h2>First Push of a Local Repo</h2>
<pre class="code-block">cd my-project
git init
git add .
git commit -m "Initial commit"
git remote add origin {$clone_example}
git push -u origin main</pre>

<h2>Setting Your Password</h2>
<p>Generate a bcrypt hash:</p>
<pre class="code-block">php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);"</pre>
<p>Paste the output into <code>data/users.json</code>:</p>
<pre class="code-block">{
    "yourusername": {
        "name": "Your Name",
        "password_hash": "\$2y\$12\$...",
        "role": "admin"
    }
}</pre>

<h2>Checking git-http-backend</h2>
<p>If push/pull over HTTP fails, verify the backend is available:</p>
<pre class="code-block">which git-http-backend
ls /usr/lib/git-core/git-http-backend</pre>
<p>Update <code>GIT_HTTP_BACKEND_PATHS</code> in <code>config.php</code> if yours is in a different location.</p>

<h2>SSH (Alternative)</h2>
<p>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.</p>
<pre class="code-block">git remote add ssh-origin ssh://user@host/path/to/repos/yourrepo.git
git push ssh-origin main</pre>

<h2>Access Control (Gitolite-inspired)</h2>
<p>GitGram uses a permission model modelled on
<a href="https://gitolite.com/gitolite/" target="_blank">Gitolite</a>.
Access rules live in <code>data/repos.json</code> — no database needed.</p>

<h3>Permission levels</h3>
<table>
<tr><th>Symbol</th><th>Meaning</th></tr>
<tr><td><code>R</code></td><td>Read-only (clone, fetch)</td></tr>
<tr><td><code>RW</code></td><td>Read + write (push)</td></tr>
<tr><td><code>RW+</code></td><td>Read + write + force-push / tag deletion</td></tr>
<tr><td><code>-</code></td><td>Explicit deny (overrides any group grant)</td></tr>
</table>

<h3>Rule subjects</h3>
<table>
<tr><th>Subject</th><th>Meaning</th></tr>
<tr><td><code>@all</code></td><td>Everyone, including anonymous visitors</td></tr>
<tr><td><code>@groupname</code></td><td>All users whose <code>groups</code> array includes <em>groupname</em></td></tr>
<tr><td><code>username</code></td><td>A specific user</td></tr>
</table>
<p>Priority (highest wins): explicit deny <code>-</code> &gt; username &gt; @group &gt; @all.<br>
Admin-role users always have full access regardless of rules.</p>

<h3>Example <code>data/repos.json</code></h3>
<pre class="code-block">{
    "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":     "-"
        }
    }
}</pre>

<h3>Example <code>data/users.json</code> with groups</h3>
<pre class="code-block">{
    "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": []
    }
}</pre>
<p>Repos with no entry in <code>repos.json</code> are private (admin-only) by default —
the same safe default as Gitolite.</p>

<h3>Differences from Gitolite</h3>
<ul style="margin:6px 0 6px 20px; line-height:1.8">
  <li>Gitolite uses SSH keys exclusively; GitGram uses HTTP Basic auth (passwords in users.json)</li>
  <li>Gitolite manages repos via a special <em>gitolite-admin</em> push; GitGram uses JSON files edited directly</li>
  <li>Gitolite supports branch-level rules (<code>refs/heads/main</code>); GitGram access is repo-level only</li>
  <li>Gitolite runs as its own Unix user; GitGram runs under your web server's PHP process</li>
</ul>
HTML;
    echo '</div>';
    html_close();
}

function page_end_of_internet(): void {
    header('Content-Type: text/html; charset=utf-8');
    echo <<<'HTML'
<!DOCTYPE html>
<html>
<head>
<title>END OF THE INTERNET</title>
<style>
  body { background: #000080; color: #ffff00; font-family: "Comic Sans MS", cursive; text-align: center; padding: 20px; }
  h1   { font-size: 3em; text-shadow: 2px 2px #ff0000; animation: blink 0.8s step-start infinite; }
  @keyframes blink { 50% { visibility: hidden; } }
  .counter { border: 4px ridge #ff0000; display: inline-block; padding: 8px 20px; margin: 20px; background: #000; color: #00ff00; font-family: monospace; font-size: 1.4em; }
  .fire { font-size: 3em; }
  .sign { border: 8px ridge #ffff00; padding: 20px; margin: 30px auto; max-width: 600px; background: #000033; }
  a { color: #ff66ff; }
  .scroll { overflow: hidden; white-space: nowrap; width: 100%; }
  .scroll span { display: inline-block; animation: scroll 12s linear infinite; }
  @keyframes scroll { from { transform: translateX(100vw); } to { transform: translateX(-100%); } }
  img.spin { animation: spin 3s linear infinite; display: inline-block; font-size: 4em; }
  @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="fire">🔥 🔥 🔥</div>
<h1>CONGRATULATIONS!</h1>
<div class="sign">
  <p style="font-size:1.6em; color:#ffffff">YOU HAVE REACHED</p>
  <p style="font-size:2.5em; color:#ff4444; animation:blink 0.5s step-start infinite">THE END OF THE INTERNET</p>
  <p style="color:#aaffaa">There is nothing more to see.</p>
  <p style="color:#aaffaa">Please turn off your computer and go outside.</p>
</div>

<div class="counter">Visitor #: 1,337,042</div>

<div class="scroll"><span>⭐ This site best viewed in Netscape Navigator 2.0 at 640x480 ⭐ Under Construction ⭐ Please sign my guestbook ⭐ AOL Keyword: END ⭐</span></div>

<br>
<div class="spin">🌀</div>
<br>

<p style="color: #aaaaff; font-size: 0.8em">
  <a href="javascript:history.back()">← Go back where it is safe</a><br><br>
  <em>This page was last updated: January 1, 1997</em><br>
  <em>Made with ❤️ and Microsoft FrontPage 97</em>
</p>

<p><img src="https://web.archive.org/web/1996*/http://www.geocities.com/cgi-bin/counter.cgi" alt="[counter]" onerror="this.replaceWith('[ 00042 ]')"></p>
</body>
</html>
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 '<div class="toolbar"><span>Login</span></div>';
    echo '<div class="content-pad" style="max-width:380px">';
    if ($error) echo '<div class="alert-box err">' . h($error) . '</div>';
    echo '<form method="post" style="margin-top:10px">';
    echo '<label>Username</label><input type="text" name="username" autofocus autocomplete="username">';
    echo '<label>Password</label><input type="password" name="password" autocomplete="current-password">';
    echo '<button type="submit" class="os2-btn">Login</button>';
    $s = load_settings();
    if ($s['registration_open']) {
        echo '<p style="margin-top:10px;font-size:12px">No account? <a href="' . site_url('register') . '">Register</a></p>';
    }
    echo '</form></div>';
    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 '<div class="toolbar"><span>Registration</span></div>';
        echo '<div class="content-pad"><p>Registration is currently closed.</p></div>';
        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 '<div class="toolbar"><span>Create Account</span></div>';
    echo '<div class="content-pad" style="max-width:440px">';
    foreach ($errors as $e) echo '<div class="alert-box err">' . h($e) . '</div>';
    echo '<form method="post">';
    echo '<div class="form-row"><div><label>Username</label><input type="text" name="username" value="' . h($_POST['username'] ?? '') . '" autofocus autocomplete="off" pattern="[a-z0-9_\\-]{2,32}"></div>';
    echo '<div><label>Display Name</label><input type="text" name="name" value="' . h($_POST['name'] ?? '') . '"></div></div>';
    echo '<label>Email <small>(optional)</small></label><input type="email" name="email" value="' . h($_POST['email'] ?? '') . '">';
    echo '<div class="form-row"><div><label>Password</label><input type="password" name="password" autocomplete="new-password"></div>';
    echo '<div><label>Confirm Password</label><input type="password" name="confirm" autocomplete="new-password"></div></div>';
    if ($s['invite_only']) {
        echo '<label>Invite Code</label><input type="text" name="invite_code" value="' . h($_POST['invite_code'] ?? '') . '" autocomplete="off">';
    }
    echo '<label>What is <strong>' . h($_SESSION['captcha_q']) . '</strong> = ?</label>';
    echo '<input type="number" name="captcha" style="max-width:120px" autocomplete="off">';
    echo '<br><button type="submit" class="os2-btn" style="margin-top:10px">Create Account</button>';
    echo '</form></div>';
    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 '<div class="toolbar"><span>Dashboard — ' . h($u) . '</span>'
       . '<span class="toolbar-right"><a href="' . site_url('new') . '">+ New Repository</a></span></div>';
    echo '<div class="content-pad">';

    // Profile card
    echo '<div style="display:flex;gap:16px;align-items:flex-start;margin-bottom:18px">';
    $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 '<div style="background:#e8e8e8;border:1px solid #ccc;padding:12px 18px;display:flex;gap:14px;align-items:center">';
    echo '<img src="' . h($av_url) . '" width="64" height="64" style="border:1px solid #aaa;flex-shrink:0" alt="">';
    echo '<div>';
    echo '<div style="font-weight:bold;font-size:14px">' . h($profile['name'] ?? $u) . '</div>';
    echo '<div style="font-size:11px;color:#555">' . h($u) . '</div>';
    if (!empty($profile['email'])) echo '<div style="font-size:11px;color:#555">' . h($profile['email']) . '</div>';
    echo '<div style="margin-top:6px"><span class="badge badge-' . h($profile['role'] ?? 'user') . '">' . h($profile['role'] ?? 'user') . '</span></div>';
    echo '</div>';
    echo '<div style="margin-left:auto"><a href="' . site_url('profile') . '" class="os2-btn" style="font-size:11px">Edit Profile</a></div>';
    echo '</div></div>';

    // My repos
    echo '<div class="section-title">My Repositories</div>';
    if (empty($mine)) {
        echo '<p class="muted" style="margin-bottom:14px">No repositories yet. <a href="' . site_url('new') . '">Create one</a>.</p>';
    } else {
        echo '<table class="repo-table">';
        echo '<tr><th>Name</th><th>Description</th><th>Visibility</th><th></th></tr>';
        foreach ($mine as $r) {
            $access      = $cfg[$r['name']]['access'] ?? [];
            $public      = isset($access['@all']) && $access['@all'] !== '-';
            $forked_from = $cfg[$r['name']]['forked_from'] ?? '';
            echo '<tr>';
            echo '<td><a href="' . site_url($r['name']) . '">' . h($r['name']) . '</a>'
               . ($forked_from
                   ? ' <span class="fork-indicator" title="Forked from ' . h($forked_from) . '">&#x2442; '
                     . '<a href="' . site_url($forked_from) . '" style="color:inherit">' . h($forked_from) . '</a></span>'
                   : '')
               . '</td>';
            echo '<td class="muted">' . h($r['desc']) . '</td>';
            echo '<td><span class="badge ' . ($public ? 'badge-open' : 'badge-admin') . '">'
               . ($public ? 'public' : 'private') . '</span></td>';
            echo '<td style="white-space:nowrap">'
               . '<a href="' . site_url($r['name'] . '/upload') . '">⬆ Upload</a> '
               . '<a href="' . site_url($r['name'] . '/settings') . '">Settings</a>'
               . '</td>';
            echo '</tr>';
        }
        echo '</table>';
    }

    if (!empty($shared)) {
        echo '<div class="section-title" style="margin-top:18px">Shared With Me</div>';
        echo '<table class="repo-table">';
        echo '<tr><th>Name</th><th>Owner</th><th>Description</th><th></th></tr>';
        foreach ($shared as $r) {
            $owner = $cfg[$r['name']]['owner'] ?? '';
            echo '<tr>';
            echo '<td><a href="' . site_url($r['name']) . '">' . h($r['name']) . '</a></td>';
            echo '<td class="owner-cell">' . ($owner ? owner_link($owner) : '<span class="muted">—</span>') . '</td>';
            echo '<td class="muted">' . h($r['desc']) . '</td>';
            echo '<td><a href="' . site_url($r['name'] . '/upload') . '">⬆ Upload</a></td>';
            echo '</tr>';
        }
        echo '</table>';
    }

    echo '</div>';
    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 '<div class="toolbar"><span>New Repository</span></div>';
    echo '<div class="content-pad" style="max-width:520px">';
    foreach ($errors as $e) echo '<div class="alert-box err">' . h($e) . '</div>';
    echo '<form method="post">';

    echo '<label>Repository Name</label>';
    echo '<input type="text" name="repo_name" value="' . h($_POST['repo_name'] ?? '') . '" '
       . 'autofocus pattern="[a-zA-Z0-9_\\-\\.]{1,64}" required placeholder="my-project">';

    echo '<label>Description <small>(optional)</small></label>';
    echo '<input type="text" name="repo_desc" value="' . h($_POST['repo_desc'] ?? '') . '" placeholder="Short description">';

    echo '<label>Visibility</label>';
    echo '<select name="visibility">';
    echo '<option value="private"' . (($_POST['visibility'] ?? 'private') === 'private' ? ' selected' : '') . '>Private — only you (and admins)</option>';
    echo '<option value="public"'  . (($_POST['visibility'] ?? '') === 'public'  ? ' selected' : '') . '>Public — anyone can read, only you can push</option>';
    echo '</select>';

    echo '<div style="margin:10px 0;display:flex;align-items:center;gap:8px">';
    echo '<input type="checkbox" name="init_readme" id="init_readme" value="1" checked style="width:auto">';
    echo '<label for="init_readme" style="margin:0;font-weight:normal">Initialize with a README.md</label>';
    echo '</div>';

    echo '<button type="submit" class="os2-btn primary" style="margin-top:10px">Create Repository</button>';
    echo '</form></div>';
    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 '<div class="content-pad"><p>You do not have write access to this repository.</p></div>'; 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 '<div class="toolbar"><span>⬆ Upload to ' . h($name) . '</span>'
       . '<span class="toolbar-right"><a href="' . site_url($name) . '">← Back</a></span></div>';
    echo '<div class="content-pad" style="max-width:560px">';

    foreach ($errors  as $e) echo '<div class="alert-box err">'  . h($e)       . '</div>';
    if ($success)             echo '<div class="alert-box ok">'   . h($success) . '</div>';

    echo '<form method="post" enctype="multipart/form-data">';

    // Upload type toggle
    echo '<label>Upload Type</label>';
    echo '<div style="display:flex;gap:16px;margin-bottom:10px">';
    echo '<label style="font-weight:normal;display:flex;gap:6px;align-items:center;margin:0">'
       . '<input type="radio" name="upload_mode" value="single" id="mode-single" checked style="width:auto"> Single File</label>';
    echo '<label style="font-weight:normal;display:flex;gap:6px;align-items:center;margin:0">'
       . '<input type="radio" name="upload_mode" value="zip" id="mode-zip" style="width:auto"> ZIP Archive</label>';
    echo '</div>';

    // Branch
    echo '<div class="form-row">';
    echo '<div><label>Branch</label><select name="branch" id="branch-select">';
    if (empty($branches)) echo '<option value="main">main (new)</option>';
    foreach ($branches as $b) echo '<option value="' . h($b) . '"' . ($b === $default ? ' selected' : '') . '>' . h($b) . '</option>';
    echo '<option value="__new__">— Create new branch —</option>';
    echo '</select></div>';
    echo '<div id="new-branch-wrap" style="display:none"><label>New Branch Name</label>'
       . '<input type="text" name="new_branch" id="new-branch" placeholder="feature/my-branch"></div>';
    echo '</div>';

    // File input
    echo '<label>File <span id="file-label-hint">(any file)</span></label>';
    echo '<input type="file" name="upload" id="file-input" required style="width:100%;padding:4px 0">';

    // Single file path
    echo '<div id="path-wrap"><label>Target Path in Repo <small>(leave blank to use filename)</small></label>'
       . '<input type="text" name="file_path" placeholder="e.g. src/app.js or just leave blank"></div>';

    // ZIP base path
    echo '<div id="zip-base-wrap" style="display:none"><label>Extract Into <small>(optional subdirectory)</small></label>'
       . '<input type="text" name="base_path" placeholder="e.g. src/  (leave blank for root)"></div>';

    echo '<label>Commit Message</label>';
    echo '<input type="text" name="message" value="' . h($_POST['message'] ?? '') . '" placeholder="Upload via GitGram web UI">';

    echo '<button type="submit" class="os2-btn primary" style="margin-top:12px">⬆ Commit Upload</button>';
    echo '</form>';
    echo '<p style="margin-top:12px;font-size:11px;color:#666">Max upload size is controlled by your server\'s PHP <code>upload_max_filesize</code> setting.</p>';
    echo '</div>';
    echo '<script>
var mSingle = document.getElementById("mode-single");
var mZip    = document.getElementById("mode-zip");
var brSel   = document.getElementById("branch-select");
var newBrW  = document.getElementById("new-branch-wrap");
var pathW   = document.getElementById("path-wrap");
var zipW    = document.getElementById("zip-base-wrap");
var hint    = document.getElementById("file-label-hint");
function updateMode(){
  var isZip = mZip.checked;
  pathW.style.display = isZip ? "none" : "";
  zipW.style.display  = isZip ? "" : "none";
  hint.textContent    = isZip ? "(ZIP archive)" : "(any file)";
}
mSingle.addEventListener("change", updateMode);
mZip.addEventListener("change", updateMode);
brSel.addEventListener("change", function(){
  newBrW.style.display = this.value === "__new__" ? "" : "none";
  if(this.value === "__new__") document.getElementById("new-branch").focus();
});
</script>';
    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 '<div class="content-pad"><p>Only the repository owner can access settings.</p></div>'; 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 '<div class="toolbar"><span>Settings — ' . h($name) . '</span>'
       . '<span class="toolbar-right"><a href="' . site_url($name) . '">← Repository</a></span></div>';
    echo '<div class="content-pad" style="max-width:520px">';

    foreach ($errors as $e) echo '<div class="alert-box err">' . h($e) . '</div>';
    if ($success)            echo '<div class="alert-box ok">'  . h($success) . '</div>';

    // Main settings form
    echo '<form method="post" style="margin-bottom:24px">';
    echo '<input type="hidden" name="action" value="save">';
    echo '<label>Description</label>';
    echo '<input type="text" name="description" value="' . h($desc) . '">';
    echo '<label>Visibility</label>';
    echo '<select name="visibility">';
    echo '<option value="private"' . (!$is_public ? ' selected' : '') . '>Private — only you and collaborators</option>';
    echo '<option value="public"'  . ($is_public  ? ' selected' : '') . '>Public — anyone can read</option>';
    echo '</select>';
    echo '<button type="submit" class="os2-btn primary" style="margin-top:12px">Save Settings</button>';
    echo '</form>';

    // Clone URL
    $clone = site_url($name . '.git');
    echo '<div class="section-title">Clone URL</div>';
    echo '<div class="clone-box"><label>HTTP</label><input type="text" readonly value="' . h($clone) . '" onclick="this.select()"></div>';

    // Danger zone
    echo '<div style="border:2px solid #cc0000;padding:14px;margin-top:24px;background:#fff8f8">';
    echo '<div style="font-weight:bold;color:#cc0000;margin-bottom:8px">⚠ Delete Repository</div>';
    echo '<p style="font-size:12px;margin-bottom:10px">Permanently destroys all commits. This cannot be undone.</p>';
    echo '<form method="post" onsubmit="return confirm(\'Permanently delete ' . h($name) . '?\')">';
    echo '<input type="hidden" name="action" value="delete">';
    echo '<label>Type <strong>' . h($name) . '</strong> to confirm</label>';
    echo '<div style="display:flex;gap:8px;align-items:flex-end;margin-top:4px">';
    echo '<input type="text" name="confirm_name" style="max-width:260px">';
    echo '<button type="submit" class="os2-btn danger">Delete</button>';
    echo '</div></form></div>';

    echo '</div>';
    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 '<div class="toolbar"><span>' . h($display) . '</span></div>';
    echo '<div class="content-pad">';

    // Profile card
    echo '<div class="user-profile-card">';
    echo '<img src="' . h($av_url) . '" width="128" height="128" class="user-avatar" alt="">';
    echo '<div class="user-profile-info">';
    echo '<div class="user-display-name">' . h($display) . '</div>';
    echo '<div class="user-username">@' . h($username) . '</div>';
    if (!empty($profile['email'])) {
        echo '<div class="user-email">' . h($profile['email']) . '</div>';
    }
    if (!empty($profile['bio'])) {
        echo '<div class="user-bio">' . h($profile['bio']) . '</div>';
    }
    echo '<div style="margin-top:8px"><span class="badge badge-' . h($profile['role'] ?? 'user') . '">'
       . h($profile['role'] ?? 'user') . '</span></div>';
    // If this is the logged-in user, show edit link
    if ($viewer === $username) {
        echo '<div style="margin-top:10px"><a href="' . site_url('profile') . '" class="os2-btn" style="font-size:11px">Edit Profile</a></div>';
    }
    if (!empty($profile['rss_enabled'])) {
        echo '<div style="margin-top:6px"><a href="' . site_url('user/' . rawurlencode($username) . '/rss') . '" title="RSS feed" style="font-size:11px;text-decoration:none">&#x2605; RSS Feed</a></div>';
    }
    echo '</div>';
    echo '</div>';

    // Their public repositories
    echo '<div class="section-title" style="margin-top:18px">Repositories</div>';
    if (empty($their_repos)) {
        echo '<p class="muted">No public repositories.</p>';
    } else {
        echo '<table class="repo-table">';
        echo '<tr><th>Name</th><th>Description</th><th>Last commit</th><th>When</th></tr>';
        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 '<tr>';
            echo '<td><a href="' . site_url($r['name']) . '">' . h($r['name']) . '</a>'
               . ' <span class="badge ' . ($public ? 'badge-open' : 'badge-admin') . '" style="font-size:10px">'
               . ($public ? 'public' : 'private') . '</span>'
               . ($forked_from
                   ? ' <span class="fork-indicator" title="Forked from ' . h($forked_from) . '">&#x2442; '
                     . h($forked_from) . '</span>'
                   : '')
               . '</td>';
            echo '<td class="muted">' . h($r['desc']) . '</td>';
            echo '<td class="muted mono">' . h($last['subject'] ?? '—') . '</td>';
            echo '<td class="muted">' . h($last['when'] ?? '') . '</td>';
            echo '</tr>';
        }
        echo '</table>';
    }
    echo '</div>';
    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'];
            }
        }

        // ── RSS preference ──
        if ($section === 'rss') {
            $users[$u]['rss_enabled'] = !empty($_POST['rss_enabled']);
            file_put_contents(DATA_PATH . '/users.json',
                json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
            $profile = $users[$u];
            $success[] = 'RSS preference saved.';
        }

        // ── 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 '<div class="toolbar"><span>My Profile</span></div>';
    echo '<div class="content-pad">';

    foreach ($errors  as $e) echo '<div class="alert-box err">' . h($e) . '</div>';
    foreach ($success as $s) echo '<div class="alert-box ok">'  . h($s) . '</div>';

    echo '<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">';

    // ── Left: Avatar column ──────────────────────────────────────────────────
    echo '<div style="flex:0 0 auto;text-align:center">';

    // 128×128 avatar display
    echo '<div style="width:128px;height:128px;border:2px solid #aaa;overflow:hidden;'
       . 'background:#e0e0e0;margin:0 auto 10px">';
    echo '<img src="' . h($av_url) . '?' . time() . '" width="128" height="128" '
       . 'style="display:block;object-fit:cover" alt="Avatar" id="avatar-preview">';
    echo '</div>';

    // Upload form
    echo '<form method="post" enctype="multipart/form-data">';
    echo '<input type="hidden" name="section" value="avatar">';
    echo '<input type="file" name="avatar" id="avatar-input" accept="image/*" '
       . 'style="display:none" onchange="previewAvatar(this)">';
    echo '<button type="button" class="os2-btn" style="width:128px;margin-bottom:4px" '
       . 'onclick="document.getElementById(\'avatar-input\').click()">Choose Image</button><br>';
    echo '<button type="submit" class="os2-btn primary" style="width:128px;margin-bottom:4px" '
       . 'id="upload-btn" disabled>Upload</button><br>';
    echo '</form>';

    // Remove / reset avatar
    echo '<form method="post">';
    echo '<input type="hidden" name="section" value="remove_avatar">';
    echo '<button type="submit" class="os2-btn" style="width:128px;font-size:11px" '
       . 'onclick="return confirm(\'Reset avatar to initials?\')">Reset to Initials</button>';
    echo '</form>';

    echo '<p style="font-size:10px;color:#777;margin-top:8px;width:128px">JPEG/PNG/GIF/WebP<br>Cropped to 128×128</p>';
    echo '</div>';

    // ── Right: Details columns ───────────────────────────────────────────────
    echo '<div style="flex:1;min-width:260px">';

    // Profile details form
    echo '<div style="font-weight:bold;font-size:12px;border-bottom:1px solid #ccc;'
       . 'padding-bottom:3px;margin-bottom:10px">Profile Details</div>';
    echo '<form method="post">';
    echo '<input type="hidden" name="section" value="details">';

    echo '<label>Username <small>(cannot be changed)</small></label>';
    echo '<input type="text" value="' . h($u) . '" disabled style="background:#f0f0f0;color:#666">';

    echo '<label>Display Name</label>';
    echo '<input type="text" name="name" value="' . h($profile['name'] ?? '') . '" required autofocus>';

    echo '<label>Email <small>(optional — used as git commit author email)</small></label>';
    echo '<input type="email" name="email" value="' . h($profile['email'] ?? '') . '">';

    echo '<button type="submit" class="os2-btn primary" style="margin-top:10px">Save Details</button>';
    echo '</form>';

    // Git config hint box
    $git_name  = $profile['name']  ?? $u;
    $git_email = $profile['email'] ?? "$u@localhost";
    echo '<div style="margin-top:18px;background:#f0f0f8;border:1px solid #ccc;padding:10px 12px">';
    echo '<div style="font-weight:bold;font-size:11px;margin-bottom:6px">Your git config commands</div>';
    echo '<pre style="font-size:11px;line-height:1.8;margin:0">git config --global user.name  "' . h($git_name) . '"'
       . "\ngit config --global user.email \"" . h($git_email) . '"</pre>';
    echo '</div>';

    // Password change form
    echo '<div style="font-weight:bold;font-size:12px;border-bottom:1px solid #ccc;'
       . 'padding-bottom:3px;margin:20px 0 10px">Change Password</div>';
    echo '<form method="post">';
    echo '<input type="hidden" name="section" value="password">';
    echo '<label>Current Password</label>';
    echo '<input type="password" name="current_password" autocomplete="current-password">';
    echo '<div class="form-row">';
    echo '<div><label>New Password</label>'
       . '<input type="password" name="new_password" autocomplete="new-password" placeholder="Min 8 characters"></div>';
    echo '<div><label>Confirm New Password</label>'
       . '<input type="password" name="confirm_password" autocomplete="new-password"></div>';
    echo '</div>';
    echo '<button type="submit" class="os2-btn" style="margin-top:10px">Change Password</button>';
    echo '</form>';

    // RSS preference form
    $site_rss = load_settings()['rss_enabled'] ?? false;
    echo '<div style="font-weight:bold;font-size:12px;border-bottom:1px solid #ccc;'
       . 'padding-bottom:3px;margin:20px 0 10px">RSS Feed</div>';
    if (!$site_rss) {
        echo '<p style="font-size:11px;color:#777">RSS feeds are currently disabled site-wide by the administrator.</p>';
    } else {
        echo '<form method="post">';
        echo '<input type="hidden" name="section" value="rss">';
        echo '<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:normal">';
        echo '<input type="checkbox" name="rss_enabled" value="1"' . (!empty($profile['rss_enabled']) ? ' checked' : '') . '>';
        echo '<span>Publish my RSS feed at <code>' . h(site_url('user/' . rawurlencode($u) . '/rss')) . '</code></span>';
        echo '</label>';
        echo '<button type="submit" class="os2-btn" style="margin-top:10px">Save RSS Setting</button>';
        echo '</form>';
    }

    echo '</div>'; // right col
    echo '</div>'; // flex row
    echo '</div>'; // content-pad

    echo '<script>
function previewAvatar(input) {
    if (!input.files || !input.files[0]) return;
    var reader = new FileReader();
    reader.onload = function(e) {
        document.getElementById("avatar-preview").src = e.target.result;
        document.getElementById("upload-btn").disabled = false;
    };
    reader.readAsDataURL(input.files[0]);
}
</script>';
    html_close();
}

function page_404(): void {
    http_response_code(404);
    html_open('Not Found — ' . SITE_TITLE);
    echo '<div class="toolbar"><span>404 Not Found</span></div>';
    echo '<div class="content-pad"><p>The page or repository you requested does not exist.</p>';
    echo '<p><a href="' . site_url() . '">← Back to repositories</a></p></div>';
    html_close();
}

// ── RSS feeds ─────────────────────────────────────────────────────────────────

/**
 * Get commits from a repo with Unix timestamps suitable for RSS.
 * Returns array of ['hash', 'subject', 'author', 'ts'].
 */
function repo_log_for_rss(string $dir, string $ref, int $n = 20): array {
    if (!is_dir($dir)) return [];
    if (!shell_available()) {
        $gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref";
        $all    = gl_log($dir, $gl_ref, $n);
        $result = [];
        foreach ($all as $c) {
            preg_match('/^(.+?) </', $c['author'], $am);
            $result[] = ['hash' => $c['sha'], 'subject' => $c['message'],
                         'author' => $am[1] ?? $c['author'], 'ts' => (int)$c['time']];
        }
        return $result;
    }
    $fmt = '%H|%s|%an|%at';
    $out = git_run($dir, ['log', '-' . $n, '--format=' . $fmt, $ref]);
    if (!$out) return [];
    $result = [];
    foreach (explode("\n", $out) as $line) {
        if (!$line) continue;
        [$hash, $subject, $author, $ts] = explode('|', $line, 4);
        $result[] = ['hash' => $hash, 'subject' => $subject, 'author' => $author, 'ts' => (int)$ts];
    }
    return $result;
}

/**
 * Flush output buffer, set RSS headers, emit RSS XML, and exit.
 * $items: array of ['title', 'link', 'desc', 'ts'] (ts = Unix timestamp).
 */
function rss_output(string $channel_title, string $channel_link, string $channel_desc, array $items): void {
    ob_end_clean();
    header('Content-Type: application/rss+xml; charset=UTF-8');
    $xe = fn(string $s): string => htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
    echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    echo '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' . "\n";
    echo '<channel>' . "\n";
    echo '<title>'       . $xe($channel_title) . '</title>' . "\n";
    echo '<link>'        . $xe($channel_link)  . '</link>'  . "\n";
    echo '<description>' . $xe($channel_desc)  . '</description>' . "\n";
    echo '<atom:link href="' . $xe($channel_link) . '" rel="self" type="application/rss+xml"/>' . "\n";
    foreach ($items as $item) {
        echo '<item>' . "\n";
        echo '<title>'       . $xe($item['title'])            . '</title>'   . "\n";
        echo '<link>'        . $xe($item['link'])             . '</link>'    . "\n";
        echo '<description>' . $xe($item['desc'])             . '</description>' . "\n";
        echo '<pubDate>'     . date(DATE_RSS, (int)$item['ts']) . '</pubDate>' . "\n";
        echo '<guid isPermaLink="true">' . $xe($item['link']) . '</guid>'    . "\n";
        echo '</item>' . "\n";
    }
    echo '</channel>' . "\n";
    echo '</rss>' . "\n";
    exit;
}

function page_site_rss(): void {
    $s = load_settings();
    if (empty($s['rss_enabled'])) {
        http_response_code(403);
        ob_end_clean();
        header('Content-Type: text/plain; charset=UTF-8');
        echo 'RSS feeds are disabled on this site.';
        exit;
    }
    $all_repos = repo_list();
    $cfg       = load_repos_config();
    // Only public repos (readable by anonymous)
    $pub_repos = array_filter($all_repos, fn($r) => check_repo_access($r['name'], 'R', null));
    $items = [];
    foreach ($pub_repos as $r) {
        $branch  = repo_default_branch($r['dir']);
        $commits = repo_log_for_rss($r['dir'], $branch, 10);
        foreach ($commits as $c) {
            $items[] = [
                'title' => '[' . $r['name'] . '] ' . $c['subject'],
                'link'  => site_url($r['name'] . '/commit/' . $c['hash']),
                'desc'  => $c['author'] . ' committed to ' . $r['name'] . ': ' . $c['subject'],
                'ts'    => $c['ts'],
            ];
        }
    }
    usort($items, fn($a, $b) => $b['ts'] <=> $a['ts']);
    $items = array_slice($items, 0, 40);
    $title = ($s['site_title'] ?? SITE_TITLE) . ' — Recent Commits';
    rss_output($title, site_url('rss'), 'Recent commits across all public repositories.', $items);
}

function page_repo_rss(string $name): void {
    $s = load_settings();
    if (empty($s['rss_enabled'])) {
        http_response_code(403);
        ob_end_clean();
        header('Content-Type: text/plain; charset=UTF-8');
        echo 'RSS feeds are disabled on this site.';
        exit;
    }
    $dir = repo_dir($name);
    if (!is_dir($dir)) { page_404(); return; }
    // Check read access (session or anonymous)
    $viewer = session_user();
    if (!check_repo_access($name, 'R', $viewer)) {
        [$authed, $ok] = try_basic_auth();
        if (!$ok || !check_repo_access($name, 'R', $authed)) {
            http_response_code(403);
            ob_end_clean();
            header('Content-Type: text/plain; charset=UTF-8');
            echo 'Access denied.';
            exit;
        }
    }
    $branch  = repo_default_branch($dir);
    $commits = repo_log_for_rss($dir, $branch, 20);
    $items   = [];
    foreach ($commits as $c) {
        $items[] = [
            'title' => $c['subject'],
            'link'  => site_url($name . '/commit/' . $c['hash']),
            'desc'  => $c['author'] . ': ' . $c['subject'],
            'ts'    => $c['ts'],
        ];
    }
    $cfg   = load_repos_config();
    $desc  = $cfg[$name]['description'] ?? (@file_get_contents($dir . '/description') ?: '');
    if (str_starts_with($desc, 'Unnamed repository')) $desc = '';
    $title = $name . ' — Commits';
    rss_output($title, site_url($name . '/rss'), trim($desc) ?: "Commits to $name.", $items);
}

function page_user_rss(string $username): void {
    $s = load_settings();
    if (empty($s['rss_enabled'])) {
        http_response_code(403);
        ob_end_clean();
        header('Content-Type: text/plain; charset=UTF-8');
        echo 'RSS feeds are disabled on this site.';
        exit;
    }
    $users = load_users();
    if (!isset($users[$username])) { page_404(); return; }
    if (empty($users[$username]['rss_enabled'])) {
        http_response_code(403);
        ob_end_clean();
        header('Content-Type: text/plain; charset=UTF-8');
        echo 'This user has not enabled their RSS feed.';
        exit;
    }
    $cfg     = load_repos_config();
    $all     = repo_list();
    $viewer  = session_user();
    $owned   = array_filter($all, fn($r) =>
        ($cfg[$r['name']]['owner'] ?? null) === $username
        && check_repo_access($r['name'], 'R', $viewer)
    );
    $items = [];
    foreach ($owned as $r) {
        $branch  = repo_default_branch($r['dir']);
        $commits = repo_log_for_rss($r['dir'], $branch, 10);
        foreach ($commits as $c) {
            $items[] = [
                'title' => '[' . $r['name'] . '] ' . $c['subject'],
                'link'  => site_url($r['name'] . '/commit/' . $c['hash']),
                'desc'  => $c['author'] . ' committed to ' . $r['name'] . ': ' . $c['subject'],
                'ts'    => $c['ts'],
            ];
        }
    }
    usort($items, fn($a, $b) => $b['ts'] <=> $a['ts']);
    $items   = array_slice($items, 0, 40);
    $display = $users[$username]['name'] ?? $username;
    $title   = $display . ' — Commits';
    rss_output($title, site_url('user/' . rawurlencode($username) . '/rss'),
               "Recent commits by $display.", $items);
}

// ── Breadcrumb ────────────────────────────────────────────────────────────────

function breadcrumb_tree(string $name, string $ref, string $path, bool $is_blob = false): string {
    $out  = '<a href="' . site_url($name) . '">' . h($name) . '</a>';
    $out .= ' / <a href="' . site_url($name . '/tree/' . $ref) . '">' . h($ref) . '</a>';
    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 .= ' / <a href="' . h($url) . '">' . h($p) . '</a>';
        }
    }
    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']);

    // Build theme CSS override block (appended inside :root{} to win over defaults)
    $theme = $s['theme'] ?? [];
    $theme_css = '';
    $theme_map = [
        'desktop'    => ['--desktop'],
        'win_bg'     => ['--win-bg'],
        'title_bg'   => ['--title-bg'],
        'title_text' => ['--title-text'],
        'accent'     => ['--accent', '--link'],
        'code_bg'    => ['--code-bg'],
        'code_text'  => ['--code-text'],
    ];
    foreach ($theme_map as $k => $vars) {
        $v = $theme[$k] ?? '';
        if ($v && preg_match('/^#[0-9a-fA-F]{3,6}$/', $v)) {
            foreach ($vars as $var) { $theme_css .= "  $var: $v;\n"; }
        }
    }
    $cur_user   = session_user();
    $is_admin   = session_is_admin();
    $nav_dashboard = $cur_user
        ? '<a href="' . site_url('dashboard') . '">My Repos</a>'
        : '';
    $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
            ? '<img src="' . h($av_url) . '" width="20" height="20" '
              . 'style="border-radius:2px;border:1px solid #446;vertical-align:middle;margin-right:3px" alt="">'
            : '';
    }
    $nav_right  = $cur_user
        ? '<span style="margin-left:auto;display:flex;gap:14px;align-items:center">'
            . ($is_admin ? '<a href="' . site_url('admin') . '">Admin</a>' : '')
            . '<a href="' . site_url('profile') . '" style="color:#aaddff;font-size:11px;text-decoration:none">'
              . $nav_avatar . h($cur_user) . '</a>'
            . '<a href="' . site_url('logout') . '">Logout</a></span>'
        : '<span style="margin-left:auto;display:flex;gap:14px">'
            . '<a href="' . site_url('login') . '">Login</a>'
            . ($s['registration_open'] ? '<a href="' . site_url('register') . '">Register</a>' : '')
            . '</span>';
    echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{$title}</title>
HTML;
    if ($prism) {
        echo '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-dark.min.css">';
        echo '<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js" defer></script>';
        echo '<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-autoloader.min.js" defer></script>';
    }
    if (!empty($s['rss_enabled'])) {
        echo '<link rel="alternate" type="application/rss+xml" title="' . h($s['site_title'] ?? SITE_TITLE) . ' RSS" href="' . h(site_url('rss')) . '">';
    }
    echo <<<HTML
<style>
/* ── 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;
  {$theme_css}
}
* { 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: none;
  display: inline-block;
  padding: 1px 6px;
  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);
}
a:hover {
  background: var(--accent);
  color: #fff;
  border-top-color:    var(--border-dk);
  border-left-color:   var(--border-dk);
  border-right-color:  var(--border-hi);
  border-bottom-color: var(--border-hi);
}
a img { display: block; }
a:has(img) { padding: 0; border: none; background: transparent; }
a:has(img):hover { background: transparent; border: none; }

/* ── 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; border-top-color: var(--border-hi); border-left-color: var(--border-hi); border-right-color: var(--border-dk); border-bottom-color: var(--border-dk); }
.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: 2px 6px;
  background: transparent; border: none;
}
.menubar a:hover { background: var(--accent); color: #fff; border: none; }

/* ── 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;
}
</style>
</head>
<body id="body">
<div class="window" id="window">

<!-- Title bar -->
<div class="titlebar" id="titlebar">
  <div class="titlebar-sysmenu" title="System Menu">▪</div>
  <div class="titlebar-title">{$site_title} — {$title}</div>
  <div class="titlebar-btn" id="btn-min"  title="Minimize">▼</div>
  <div class="titlebar-btn" id="btn-max"  title="Maximize">▲</div>
  <a   class="titlebar-btn" id="btn-close" href="{$end_url}" title="Close">✕</a>
</div>

<!-- Menu bar -->
<div class="menubar">
  <a href="{$nav_home}">Repositories</a>
  <a href="{$nav_readme}">Help</a>
  {$nav_dashboard}
  {$nav_right}
</div>

<!-- Window body -->
<div class="win-body" id="win-body">
HTML;
}

function html_close(): void {
    echo <<<'HTML'
</div><!-- win-body -->

<!-- Status bar -->
<div class="statusbar">
  <div class="statusbar-cell" id="status-text">Ready</div>
  <div class="statusbar-cell">GitGram</div>
</div>

</div><!-- window -->

<script>
(function() {
  var body    = document.getElementById('body');
  var winBody = document.getElementById('win-body');
  var btnMin  = document.getElementById('btn-min');
  var btnMax  = document.getElementById('btn-max');
  var status  = document.getElementById('status-text');
  var minimized = false;
  var maximized = false;

  btnMin.addEventListener('click', function() {
    minimized = !minimized;
    winBody.classList.toggle('minimized', minimized);
    status.textContent = minimized ? 'Window minimized' : 'Ready';
    btnMin.title = minimized ? 'Restore' : 'Minimize';
  });

  btnMax.addEventListener('click', function() {
    maximized = !maximized;
    body.classList.toggle('maximized', maximized);
    btnMax.title = maximized ? 'Restore' : 'Maximize';
    status.textContent = maximized ? 'Maximized' : 'Ready';
    if (minimized) {
      minimized = false;
      winBody.classList.remove('minimized');
    }
  });

  // Sortable tables
  document.querySelectorAll('table[id]').forEach(function(tbl) {
    var sortCol = -1, sortAsc = true;
    tbl.querySelectorAll('th.sortable').forEach(function(th) {
      th.addEventListener('click', function() {
        var col = parseInt(th.getAttribute('data-col'));
        if (sortCol === col) { sortAsc = !sortAsc; }
        else { sortCol = col; sortAsc = true; }
        var rows = Array.from(tbl.rows).slice(1);
        rows.sort(function(a, b) {
          var av = a.cells[col] ? a.cells[col].textContent.trim() : '';
          var bv = b.cells[col] ? b.cells[col].textContent.trim() : '';
          var an = parseFloat(av), bn = parseFloat(bv);
          if (!isNaN(an) && !isNaN(bn)) { return sortAsc ? an - bn : bn - an; }
          return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
        });
        rows.forEach(function(r) { tbl.appendChild(r); });
        tbl.querySelectorAll('th.sortable .sort-ind').forEach(function(s) { s.textContent = ''; });
        th.querySelector('.sort-ind').textContent = sortAsc ? ' ▲' : ' ▼';
      });
    });
  });

  // Update status bar on hover for links and buttons
  document.querySelectorAll('a[href]').forEach(function(a) {
    a.addEventListener('mouseenter', function() {
      status.textContent = a.href;
    });
    a.addEventListener('mouseleave', function() {
      status.textContent = 'Ready';
    });
  });
})();
</script>

</body>
</html>
HTML;
}
Ready
GitGram