SITE_TITLE, 'registration_open' => true, 'invite_only' => false]; $saved = json_decode(@file_get_contents(DATA_PATH . '/settings.json'), true) ?? []; return array_merge($defaults, $saved); } function session_user(): ?string { return $_SESSION['gitgram_user'] ?? null; } function session_is_admin(): bool { if (!session_user()) return false; $users = load_users(); return ($users[session_user()]['role'] ?? '') === 'admin'; } function site_url(string $path = ''): string { if (SITE_URL !== '') return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/'); $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; $base = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/'); return $scheme . '://' . $host . $base . '/' . ltrim($path, '/'); } /** * Run a shell command using the best available method: * proc_open (preferred) → exec → shell_exec. * Returns ['out' => string, 'rc' => int]. */ function run_cmd(string $cmd): array { if (function_exists('proc_open')) { $proc = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w']], $pipes); if (is_resource($proc)) { fclose($pipes[0]); $out = stream_get_contents($pipes[1]); fclose($pipes[1]); $rc = proc_close($proc); return ['out' => trim($out), 'rc' => $rc]; } } if (function_exists('exec')) { exec($cmd, $lines, $rc); return ['out' => trim(implode("\n", $lines)), 'rc' => $rc]; } if (function_exists('shell_exec')) { $out = shell_exec($cmd) ?? ''; return ['out' => trim($out), 'rc' => 0]; } return ['out' => '', 'rc' => -1]; } function shell_available(): bool { return function_exists('proc_open') || function_exists('exec') || function_exists('shell_exec'); } function git_run(string $repo_dir, array $args): string { $cmd = array_map('escapeshellarg', array_merge([GIT_BIN, '--git-dir=' . $repo_dir], $args)); $r = run_cmd(implode(' ', $cmd) . ' 2>/dev/null'); return $r['rc'] === 0 ? $r['out'] : ''; } function find_git_backend(): string|false { foreach (GIT_HTTP_BACKEND_PATHS as $p) { if (is_executable($p)) return $p; } $found = trim(shell_exec('which git-http-backend 2>/dev/null') ?? ''); return $found ?: false; } function repo_list(): array { $repos = []; if (!is_dir(REPO_PATH)) return $repos; foreach (glob(REPO_PATH . '/*.git', GLOB_ONLYDIR) as $dir) { $name = basename($dir, '.git'); $desc = @file_get_contents($dir . '/description') ?: ''; if (str_starts_with($desc, 'Unnamed repository')) $desc = ''; $repos[] = ['name' => $name, 'dir' => $dir, 'desc' => trim($desc)]; } return $repos; } function repo_dir(string $name): string { return REPO_PATH . '/' . $name . '.git'; } // ── Upload / commit helpers ─────────────────────────────────────────────────── function sanitize_git_path(string $path): string { $parts = explode('/', str_replace('\\', '/', $path)); $clean = []; foreach ($parts as $p) { if ($p === '' || $p === '.' || $p === '..') continue; $clean[] = $p; } return implode('/', $clean); } function recursive_rmdir(string $dir): void { if (!is_dir($dir)) return; $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($it as $f) { $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname()); } rmdir($dir); } /** * Commit $files ([relative_path => content_string]) to $branch of $bare_dir. * Returns ['ok' => bool, 'error' => string]. */ function git_commit_files(string $bare_dir, string $branch, array $files, string $message, string $author_name, string $author_email): array { // Pure-PHP path: no shell required if (!shell_available()) { return gl_write_files($bare_dir, $branch, $files, $message, $author_name, $author_email); } $work = rtrim(sys_get_temp_dir() ?: '/tmp', '/') . '/gitgram_' . bin2hex(random_bytes(8)); @mkdir($work, 0755, true); if (!is_dir($work)) { return ['ok' => false, 'error' => 'Could not create temp working directory.']; } // Pass author identity via -c; avoids needing putenv() or a global git config $g = escapeshellarg(GIT_BIN); $cw = escapeshellarg($work); $id = '-c ' . escapeshellarg('user.name=' . $author_name) . ' -c ' . escapeshellarg('user.email=' . $author_email); // Try clone — fails on a brand-new empty bare repo (exit 128), that's fine $clone = run_cmd("$g clone " . escapeshellarg($bare_dir) . " $cw 2>&1"); $is_empty = ($clone['rc'] !== 0); if ($is_empty) { recursive_rmdir($work); @mkdir($work, 0755, true); run_cmd("$g -C $cw init 2>&1"); // symbolic-ref sets the default branch before any commits exist run_cmd("$g -C $cw symbolic-ref HEAD " . escapeshellarg("refs/heads/$branch") . ' 2>&1'); run_cmd("$g -C $cw remote add origin " . escapeshellarg($bare_dir) . ' 2>&1'); } else { $co = run_cmd("$g -C $cw checkout " . escapeshellarg($branch) . ' 2>&1'); if ($co['rc'] !== 0) { run_cmd("$g -C $cw checkout -b " . escapeshellarg($branch) . ' 2>&1'); } } foreach ($files as $rel => $content) { $clean = sanitize_git_path($rel); if (!$clean) continue; $full = $work . '/' . $clean; @mkdir(dirname($full), 0755, true); file_put_contents($full, $content); } run_cmd("$g $id -C $cw add -A 2>&1"); $commit = run_cmd("$g $id -C $cw commit -m " . escapeshellarg($message) . ' 2>&1'); if ($commit['rc'] !== 0) { recursive_rmdir($work); if (str_contains($commit['out'], 'nothing to commit')) { return ['ok' => false, 'error' => 'No changes to commit.']; } return ['ok' => false, 'error' => $commit['out']]; } $up = $is_empty ? '--set-upstream ' : ''; $push = run_cmd("$g -C $cw push {$up}origin " . escapeshellarg($branch) . ' 2>&1'); recursive_rmdir($work); return $push['rc'] === 0 ? ['ok' => true, 'error' => ''] : ['ok' => false, 'error' => $push['out']]; } function user_can_write(string $repo): bool { return session_is_admin() || check_repo_access($repo, 'W', session_user()); } function user_is_owner(string $repo): bool { if (session_is_admin()) return true; $cfg = load_repos_config(); return ($cfg[$repo]['owner'] ?? null) === session_user(); } function load_repos_config(): array { return json_decode(@file_get_contents(DATA_PATH . '/repos.json'), true) ?? []; } function save_repos_config(array $data): void { file_put_contents(DATA_PATH . '/repos.json', json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); } function require_login(): void { if (!session_user()) { $uri = $_SERVER['REQUEST_URI'] ?? '/'; header('Location: ' . site_url('login') . '?next=' . urlencode($uri)); exit; } } // ── Avatar helpers ──────────────────────────────────────────────────────────── function avatar_path(string $username): string { return AVATAR_PATH . '/' . preg_replace('/[^a-z0-9_\-]/i', '_', $username) . '.jpg'; } function avatar_url(string $username): string { $path = avatar_path($username); $bust = file_exists($path) ? '?v=' . filemtime($path) : ''; return $bust ? site_url('avatars/' . basename($path) . $bust) : ''; } /** * Resize & center-crop an uploaded image to 128×128 JPEG. * Returns true on success or an error string. */ function process_avatar(string $tmp_path, string $dest_path): bool|string { if (!function_exists('imagecreatefromjpeg')) { return 'GD extension is not available on this server.'; } $info = @getimagesize($tmp_path); if (!$info) return 'Could not read image file.'; $src = match ($info['mime']) { 'image/jpeg' => @imagecreatefromjpeg($tmp_path), 'image/png' => @imagecreatefrompng($tmp_path), 'image/gif' => @imagecreatefromgif($tmp_path), 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($tmp_path) : false, default => false, }; if (!$src) return 'Unsupported image format. Use JPEG, PNG, GIF, or WebP.'; $sw = imagesx($src); $sh = imagesy($src); $dim = min($sw, $sh); // largest square that fits $sx = (int)(($sw - $dim) / 2); // center-crop X offset $sy = (int)(($sh - $dim) / 2); // center-crop Y offset $dst = imagecreatetruecolor(128, 128); // Preserve transparency for PNG sources before resampling imagealphablending($dst, false); imagesavealpha($dst, true); $white = imagecolorallocate($dst, 255, 255, 255); imagefill($dst, 0, 0, $white); imagecopyresampled($dst, $src, 0, 0, $sx, $sy, 128, 128, $dim, $dim); imagedestroy($src); @mkdir(dirname($dest_path), 0755, true); $ok = imagejpeg($dst, $dest_path, 90); imagedestroy($dst); return $ok ? true : 'Failed to write avatar file.'; } /** * Generate a simple initials avatar when no photo is uploaded. * Produces a 128×128 JPEG with a coloured background. */ function generate_initials_avatar(string $username, string $display_name, string $dest_path): void { if (!function_exists('imagecreatetruecolor')) return; // Pick a deterministic background colour from the username $hue = crc32($username) % 360; [$r, $g, $b] = hsl_to_rgb($hue / 360, 0.55, 0.45); $img = imagecreatetruecolor(128, 128); $bg = imagecolorallocate($img, $r, $g, $b); $fg = imagecolorallocate($img, 255, 255, 255); imagefill($img, 0, 0, $bg); // Draw initials using built-in font (size 5 = 9×15px per char) $initials = strtoupper(substr($display_name ?: $username, 0, 1)); if (preg_match('/\s+(\S)/', $display_name, $m)) $initials .= strtoupper($m[1]); $fw = 9 * strlen($initials); $fh = 15; imagestring($img, 5, (int)((128 - $fw) / 2), (int)((128 - $fh) / 2), $initials, $fg); @mkdir(dirname($dest_path), 0755, true); imagejpeg($img, $dest_path, 90); imagedestroy($img); } function hsl_to_rgb(float $h, float $s, float $l): array { $c = (1 - abs(2 * $l - 1)) * $s; $x = $c * (1 - abs(fmod($h * 6, 2) - 1)); $m = $l - $c / 2; $hi = (int)($h * 6) % 6; [$r, $g, $b] = match($hi) { 0 => [$c, $x, 0], 1 => [$x, $c, 0], 2 => [0, $c, $x], 3 => [0, $x, $c], 4 => [$x, 0, $c], default => [$c, 0, $x], }; return [(int)(($r + $m) * 255), (int)(($g + $m) * 255), (int)(($b + $m) * 255)]; } function repo_default_branch(string $dir): string { $head = @file_get_contents($dir . '/HEAD'); if ($head && preg_match('#ref: refs/heads/(.+)#', $head, $m)) return trim($m[1]); return 'main'; } function repo_branches(string $dir): array { if (!shell_available()) { return array_keys(gl_list_branches($dir)); } $out = git_run($dir, ['branch', '--format=%(refname:short)']); return $out ? array_filter(explode("\n", $out)) : []; } function _time_ago(int $ts): string { $diff = max(0, time() - $ts); if ($diff < 60) return $diff . ' seconds ago'; if ($diff < 3600) return (int)($diff/60) . ' minutes ago'; if ($diff < 86400) return (int)($diff/3600) . ' hours ago'; if ($diff < 2592000) return (int)($diff/86400) . ' days ago'; return date('Y-m-d', $ts); } function repo_last_commit(string $dir, string $ref = 'HEAD'): array { if (!shell_available()) { $r = gl_last_commit($dir, $ref === 'HEAD' ? 'HEAD' : "refs/heads/$ref"); if (!$r) return []; preg_match('/^(.+?) $r['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['sha'], 'subject' => $c['message'], 'author' => $am[1] ?? $c['author'], 'when' => _time_ago($c['time']), 'date' => date('Y-m-d', $c['time']), ]; } return $result; } $fmt = '%H|%s|%an|%ar|%ad'; $out = git_run($dir, ['log', '-' . $n, '--skip=' . $skip, '--format=' . $fmt, '--date=short', $ref]); if (!$out) return []; $commits = []; foreach (explode("\n", $out) as $line) { if (!$line) continue; [$hash, $subject, $author, $when, $date] = explode('|', $line, 5); $commits[] = compact('hash', 'subject', 'author', 'when', 'date'); } return $commits; } function repo_tree(string $dir, string $ref, string $path = ''): array { if (!shell_available()) { $gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref"; $entries = gl_ls_tree($dir, $gl_ref, $path); if (!$entries) return []; $items = []; foreach ($entries as $e) { $items[] = [ 'mode' => $e['mode'], 'type' => $e['type'], 'hash' => $e['sha'], 'size' => '-', 'name' => $e['name'], ]; } usort($items, fn($a, $b) => ($a['type'] === 'tree' ? -1 : 1) <=> ($b['type'] === 'tree' ? -1 : 1)); return $items; } $target = $path ? $ref . ':' . $path : $ref . ':'; $out = git_run($dir, ['ls-tree', '--long', $target]); if (!$out) return []; $items = []; foreach (explode("\n", $out) as $line) { if (!$line) continue; preg_match('/^(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/', $line, $m); if (!$m) continue; $items[] = [ 'mode' => $m[1], 'type' => $m[2], 'hash' => $m[3], 'size' => $m[4], 'name' => $m[5], ]; } usort($items, fn($a, $b) => ($a['type'] === 'tree' ? -1 : 1) <=> ($b['type'] === 'tree' ? -1 : 1)); return $items; } function repo_blob(string $dir, string $ref, string $path): string { if (!shell_available()) { $gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref"; return gl_cat_file($dir, $gl_ref, $path) ?? ''; } return git_run($dir, ['show', $ref . ':' . $path]); } function repo_commit(string $dir, string $hash): array { if (!shell_available()) { $obj = gl_read_object($dir, $hash); if (!$obj || $obj['type'] !== 'commit') return []; $c = gl_parse_commit($obj['data']); preg_match('/^(.+?) <(.+?)> (\d+)/', $c['author'], $am); preg_match('/^(.+?) <(.+?)> (\d+)/', $c['committer'], $cm); return [ 'hash' => $hash, 'subject' => trim(explode("\n", $c['message'])[0]), 'body' => implode("\n", array_slice(explode("\n", $c['message']), 1)), 'author' => $am[1] ?? '', 'email' => $am[2] ?? '', 'date' => isset($am[3]) ? date('Y-m-d H:i:s', (int)$am[3]) : '', 'parents' => implode(' ', $c['parents']), 'diff' => '', ]; } $fmt = '%H|%s|%b|%an|%ae|%ad|%P'; $line = git_run($dir, ['show', '-s', '--format=' . $fmt, '--date=format:%Y-%m-%d %H:%M:%S', $hash]); if (!$line) return []; $parts = explode('|', $line, 7); $diff = git_run($dir, ['show', '--stat', '--format=', $hash]); return [ 'hash' => $parts[0], 'subject' => $parts[1], 'body' => $parts[2], 'author' => $parts[3], 'email' => $parts[4], 'date' => $parts[5], 'parents' => $parts[6] ?? '', 'diff' => $diff, ]; } function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } /** * Return an HTML link to a user's public profile, with their display name. * Falls back to the username if no display name is set. */ function owner_link(string $username): string { if (!$username) return '—'; $users = load_users(); $display = $users[$username]['name'] ?? $username; $url = site_url('user/' . rawurlencode($username)); return '' . h($display) . ''; } function ext_lang(string $filename): string { $map = [ 'php' => 'php', 'js' => 'javascript', 'ts' => 'typescript', 'py' => 'python', 'rb' => 'ruby', 'go' => 'go', 'rs' => 'rust', 'c' => 'c', 'cpp' => 'cpp', 'h' => 'c', 'java' => 'java', 'sh' => 'bash', 'css' => 'css', 'html' => 'html', 'xml' => 'xml', 'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml', 'md' => 'markdown', 'sql' => 'sql', 'ini' => 'ini', 'toml' => 'toml', ]; $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); return $map[$ext] ?? 'plaintext'; } // ── Git HTTP backend ────────────────────────────────────────────────────────── function handle_git_http(string $repo_name, string $path_info): void { $dir = repo_dir($repo_name); if (!is_dir($dir)) { http_response_code(404); echo "Repository not found"; return; } $is_push = str_contains($path_info, 'git-receive-pack') || (isset($_GET['service']) && $_GET['service'] === 'git-receive-pack'); $action = $is_push ? 'W' : 'R'; // Try credentials if supplied (doesn't force a 401 yet) [$authed_user, $ok] = try_basic_auth(); // Anonymous read — check @all R access first if (!$ok && !check_repo_access($repo_name, $action, null)) { // Need credentials [$authed_user, $ok] = [null, false]; demand_auth(); return; } // Authenticated — verify permission if ($ok && !check_repo_access($repo_name, $action, $authed_user)) { http_response_code(403); echo 'Forbidden'; return; } // Pure-PHP path: no shell / git-http-backend needed if (!shell_available()) { ob_end_clean(); $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $service = $_GET['service'] ?? ''; if ($path_info === 'info/refs' && $method === 'GET') { $svc = in_array($service, ['git-upload-pack','git-receive-pack'], true) ? $service : 'git-upload-pack'; gl_http_info_refs($dir, $svc); } elseif ($path_info === 'git-upload-pack' && $method === 'POST') { gl_http_upload_pack($dir); } elseif ($path_info === 'git-receive-pack' && $method === 'POST') { gl_http_receive_pack($dir); } else { http_response_code(404); echo "Not found"; } exit; } $backend = find_git_backend(); if (!$backend) { http_response_code(500); echo "git-http-backend not found on this server."; return; } $env = array_merge(getenv() ?: [], [ 'GIT_HTTP_EXPORT_ALL' => '1', 'GIT_PROJECT_ROOT' => REPO_PATH, 'PATH_INFO' => '/' . $repo_name . '.git/' . $path_info, 'QUERY_STRING' => $_SERVER['QUERY_STRING'] ?? '', 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], 'CONTENT_TYPE' => $_SERVER['CONTENT_TYPE'] ?? '', 'CONTENT_LENGTH' => $_SERVER['CONTENT_LENGTH'] ?? '', 'HTTP_GIT_PROTOCOL' => $_SERVER['HTTP_GIT_PROTOCOL'] ?? '', 'SERVER_PROTOCOL' => $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1', 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'] ?? '', ]); $proc = proc_open($backend, [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['file', '/dev/null', 'w'], ], $pipes, null, $env); if (!is_resource($proc)) { http_response_code(500); return; } stream_copy_to_stream(fopen('php://input', 'r'), $pipes[0]); fclose($pipes[0]); $response = stream_get_contents($pipes[1]); fclose($pipes[1]); proc_close($proc); // Parse CGI response headers then body if (preg_match('/\A(.*?)\r?\n\r?\n(.*)/s', $response, $m)) { foreach (explode("\n", $m[1]) as $hdr) { $hdr = trim($hdr); if (!$hdr) continue; if (preg_match('/^Status:\s*(\d+)/i', $hdr, $s)) { http_response_code((int)$s[1]); } else { header($hdr); } } echo $m[2]; } else { echo $response; } } // ── Access control (Gitolite-inspired) ─────────────────────────────────────── // // repos.json access rules mirror Gitolite's permission model: // "R" = read only // "RW" = read + write (push) // "RW+" = read + write + force-push / tag deletion // "-" = explicit deny (overrides group grants) // // Rule keys: "@all" (everyone incl. anonymous), "@groupname", or "username". // Evaluation order: explicit deny > username > group > @all. // If a repo has no entry in repos.json it is private (admin-only). function load_users(): array { return json_decode(@file_get_contents(DATA_PATH . '/users.json'), true) ?? []; } function user_groups(string $username, array $users): array { $groups = []; if (isset($users[$username]['groups'])) { $groups = (array)$users[$username]['groups']; } if (isset($users[$username]['role']) && $users[$username]['role'] === 'admin') { $groups[] = 'admins'; } return $groups; } /** * Check whether $username (null = anonymous) has $action ('R' or 'W') on $repo. * Returns true/false. */ function check_repo_access(string $repo, string $action, ?string $username): bool { $config = load_repos_config(); $users = load_users(); // Admins always have full access if ($username && isset($users[$username]['role']) && $users[$username]['role'] === 'admin') { return true; } // No repo config = private, only admins if (!isset($config[$repo]['access'])) return false; $rules = $config[$repo]['access']; $groups = $username ? user_groups($username, $users) : []; // Collect grants in priority order: @all < @group < username // Explicit deny ("-") at any level blocks access immediately. $grant = null; // null = no rule matched yet // @all if (isset($rules['@all'])) { if ($rules['@all'] === '-') return false; $grant = $rules['@all']; } // @group foreach ($groups as $g) { $key = '@' . $g; if (!isset($rules[$key])) continue; if ($rules[$key] === '-') return false; $grant = $rules[$key]; } // specific username if ($username && isset($rules[$username])) { if ($rules[$username] === '-') return false; $grant = $rules[$username]; } if ($grant === null) return false; return match($action) { 'R' => in_array($grant, ['R', 'RW', 'RW+'], true), 'W' => in_array($grant, ['RW', 'RW+'], true), 'W+' => $grant === 'RW+', default => false, }; } /** * Attempt HTTP Basic auth. Returns [username, true] on success or [null, false]. * Does NOT send a 401 — caller decides whether auth is required. */ function try_basic_auth(): array { $users = load_users(); $user = $_SERVER['PHP_AUTH_USER'] ?? ''; $pass = $_SERVER['PHP_AUTH_PW'] ?? ''; if ($user && isset($users[$user]) && password_verify($pass, $users[$user]['password_hash'])) { return [$user, true]; } return [null, false]; } function demand_auth(string $realm = 'GitGram'): void { header('WWW-Authenticate: Basic realm="' . $realm . '"'); http_response_code(401); echo 'Authentication required'; } // ── Router ──────────────────────────────────────────────────────────────────── $_request_uri = $_SERVER['REQUEST_URI'] ?? $_SERVER['REDIRECT_URL'] ?? '/'; $request_path = trim(parse_url($_request_uri, PHP_URL_PATH), '/'); $parts = $request_path ? explode('/', $request_path) : []; // Remove script-name prefix if running in a subdirectory $script_dir = trim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/'); if ($script_dir && str_starts_with($request_path, $script_dir)) { $request_path = ltrim(substr($request_path, strlen($script_dir)), '/'); $parts = $request_path ? explode('/', $request_path) : []; } // Git smart HTTP: /reponame.git/path if (!empty($parts[0]) && str_ends_with($parts[0], '.git')) { $repo_name = basename($parts[0], '.git'); array_shift($parts); handle_git_http($repo_name, implode('/', $parts)); exit; } match (true) { empty($parts[0]) => page_home(), $parts[0] === 'admin' => (function(){ require __DIR__ . '/admin.php'; exit; })(), $parts[0] === 'login' => page_login(), $parts[0] === 'logout' => page_logout(), $parts[0] === 'register' => page_register(), $parts[0] === 'readme' => page_site_readme(), $parts[0] === 'end-of-internet' => page_end_of_internet(), isset($parts[1]) && $parts[1] === 'tree' => page_tree($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))), isset($parts[1]) && $parts[1] === 'blob' => page_blob($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))), isset($parts[1]) && $parts[1] === 'raw' => page_raw($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))), isset($parts[1]) && $parts[1] === 'archive' => page_archive($parts[0], $parts[2] ?? 'HEAD', implode('/', array_slice($parts, 3))), isset($parts[1]) && $parts[1] === 'commits' => page_log($parts[0], $parts[2] ?? 'HEAD'), isset($parts[1]) && $parts[1] === 'commit' => page_commit($parts[0], $parts[2] ?? ''), isset($parts[1]) && $parts[1] === 'fork' => page_fork($parts[0]), isset($parts[1]) && $parts[1] === 'upload' => page_upload($parts[0]), isset($parts[1]) && $parts[1] === 'settings' && count($parts) === 2 => page_repo_settings($parts[0]), $parts[0] === 'dashboard' => page_dashboard(), $parts[0] === 'new' => page_new_repo(), $parts[0] === 'profile' => page_profile(), $parts[0] === 'user' && isset($parts[1]) => page_user_profile($parts[1]), count($parts) === 1 => page_repo($parts[0]), default => page_404(), }; // ── Page handlers ───────────────────────────────────────────────────────────── function page_home(): void { [$authed_user] = try_basic_auth(); $all_repos = repo_list(); // Only show repos the current visitor can read $repos = array_filter($all_repos, fn($r) => check_repo_access($r['name'], 'R', $authed_user)); html_open('Repositories — ' . SITE_TITLE); echo '
Repositories
'; echo '
'; if (empty($repos)) { echo '

No repositories yet. See the setup guide.

'; } else { $cfg = load_repos_config(); echo ''; echo ''; foreach ($repos as $r) { $last = repo_last_commit($r['dir']); $owner = $cfg[$r['name']]['owner'] ?? ''; $forked_from = $cfg[$r['name']]['forked_from'] ?? ''; $url = site_url($r['name']); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
NameOwnerDescriptionLast commitWhen
' . h($r['name']) . '' . ($forked_from ? ' ' : '') . '' . ($owner ? owner_link($owner) : '') . '' . h($r['desc']) . '' . h($last['subject'] ?? '—') . '' . h($last['when'] ?? '') . '
'; } echo '
'; html_close(); } function page_repo(string $name): void { $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $branch = repo_default_branch($dir); $commits = repo_log($dir, $branch, COMMIT_PREVIEW); $branches = repo_branches($dir); $cfg = load_repos_config(); $owner = $cfg[$name]['owner'] ?? ''; $forked_from = $cfg[$name]['forked_from'] ?? ''; $viewer = session_user(); // Count forks of this repo $fork_count = 0; foreach ($cfg as $rname => $rcfg) { if (($rcfg['forked_from'] ?? '') === $name) $fork_count++; } // Can the viewer fork? Must be logged in and not the owner $can_fork = $viewer && $viewer !== $owner && check_repo_access($name, 'R', $viewer); // Has the viewer already forked this repo? $viewer_fork = null; if ($viewer) { foreach ($cfg as $rname => $rcfg) { if (($rcfg['forked_from'] ?? '') === $name && ($rcfg['owner'] ?? '') === $viewer) { $viewer_fork = $rname; break; } } } html_open(h($name) . ' — ' . SITE_TITLE); echo '
'; echo '' . h($name) . ''; echo ''; echo 'Files '; echo 'Commits '; echo '↓ ZIP'; if ($can_fork) { if ($viewer_fork) { echo ' ⑂ Forked'; } else { echo ' ⑂ Fork'; } } if (user_can_write($name)) echo ' ⬆ Upload'; if (user_is_owner($name)) echo ' Settings'; echo '
'; echo '
'; // Forked-from banner if ($forked_from) { $from_owner = $cfg[$forked_from]['owner'] ?? ''; echo '
⑂ Forked from '; if (is_dir(repo_dir($forked_from))) { echo '' . h($forked_from) . ''; } else { echo '' . h($forked_from) . ' (deleted)'; } if ($from_owner) echo ' by ' . owner_link($from_owner); echo '
'; } // Clone URL + owner + fork count $clone = site_url($name . '.git'); echo '
'; echo ''; if ($owner) echo 'by ' . owner_link($owner) . ''; if ($fork_count) echo '⑂ ' . $fork_count . ' fork' . ($fork_count !== 1 ? 's' : '') . ''; echo '
'; // README $readme = repo_blob($dir, $branch, 'README.md') ?: repo_blob($dir, $branch, 'README'); if ($readme) { echo '
README
'; echo '
' . h($readme) . '
'; } // Recent commits if ($commits) { echo '
Recent commits
'; echo ''; foreach ($commits as $c) { $url = site_url($name . '/commit/' . $c['hash']); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . substr($c['hash'], 0, 8) . '' . h($c['subject']) . '' . h($c['author']) . '' . h($c['when']) . '
'; echo '

View all commits →

'; } echo '
'; html_close(); } // ── Fork ────────────────────────────────────────────────────────────────────── function page_fork(string $name): void { require_login(); $viewer = session_user(); $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $cfg = load_repos_config(); $owner = $cfg[$name]['owner'] ?? ''; // Must be readable by the viewer if (!check_repo_access($name, 'R', $viewer)) { http_response_code(403); html_open('Forbidden — ' . SITE_TITLE); echo '

You do not have read access to this repository.

'; html_close(); return; } // Cannot fork your own repo if ($viewer === $owner) { header('Location: ' . site_url($name)); exit; } // Already forked? foreach ($cfg as $rname => $rcfg) { if (($rcfg['forked_from'] ?? '') === $name && ($rcfg['owner'] ?? '') === $viewer) { header('Location: ' . site_url($rname)); exit; } } $errors = []; $fork_name = ''; $fork_desc = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $fork_name = trim($_POST['fork_name'] ?? ''); $fork_desc = trim($_POST['fork_desc'] ?? ($cfg[$name]['description'] ?? '')); if (!preg_match('/^[a-zA-Z0-9_\-\.]{1,64}$/', $fork_name)) $errors[] = 'Repository name must be 1–64 characters: letters, numbers, hyphens, dots, underscores.'; elseif (is_dir(repo_dir($fork_name))) $errors[] = "A repository named \"$fork_name\" already exists. Choose a different name."; if (!$errors) { $dst = repo_dir($fork_name); if (!gl_fork_repo($dir, $dst)) { $errors[] = 'Fork failed — could not copy repository data. Check directory permissions.'; } else { // Write description if ($fork_desc) file_put_contents($dst . '/description', $fork_desc . "\n"); // Register in repos.json — inherit access model but set new owner $src_access = $cfg[$name]['access'] ?? []; $new_access = [$viewer => 'RW+']; // owner always has full access // Keep @all R if source was public if (isset($src_access['@all']) && $src_access['@all'] !== '-') $new_access = array_merge(['@all' => 'R'], $new_access); $cfg[$fork_name] = [ 'description' => $fork_desc, 'owner' => $viewer, 'forked_from' => $name, 'access' => $new_access, ]; save_repos_config($cfg); header('Location: ' . site_url($fork_name)); exit; } } } else { // Pre-fill name: use original name if free, else append username $fork_name = !is_dir(repo_dir($name)) ? $name : (is_dir(repo_dir($name . '-' . $viewer)) ? $name . '-fork' : $name . '-' . $viewer); $fork_desc = $cfg[$name]['description'] ?? ''; } $users = load_users(); $src_owner_display = isset($owner) && $owner ? ($users[$owner]['name'] ?? $owner) : 'unknown'; html_open('Fork ' . h($name) . ' — ' . SITE_TITLE); echo '
Fork Repository
'; echo '
'; // Source info card echo '
'; echo '
'; echo '
'; echo '
' . h($name) . '
'; echo '
by ' . h($src_owner_display) . '
'; if ($cfg[$name]['description'] ?? '') echo '
' . h($cfg[$name]['description']) . '
'; echo '
'; echo '
'; echo '

Creating a fork copies the entire repository into your account. ' . 'You can commit to it independently and later propose changes back to the original.

'; foreach ($errors as $e) echo '
' . h($e) . '
'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo '
'; echo ''; echo 'Cancel'; echo '
'; echo '
'; echo '
'; html_close(); } function page_tree(string $name, string $ref, string $path): void { $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $items = repo_tree($dir, $ref, $path); html_open(h($name) . '/' . h($path) . ' — ' . SITE_TITLE); echo '
'; echo breadcrumb_tree($name, $ref, $path); echo ''; $zip_path = $path ? $path : ''; echo '↓ ZIP'; echo ''; echo '
'; echo '
'; echo ''; echo ''; // Parent dir link if ($path) { $parent = dirname($path); $url = site_url($name . '/tree/' . $ref . ($parent !== '.' ? '/' . $parent : '')); echo ''; } foreach ($items as $item) { $item_path = $path ? $path . '/' . $item['name'] : $item['name']; if ($item['type'] === 'tree') { $url = site_url($name . '/tree/' . $ref . '/' . $item_path); $icon = '📁'; $size_col = 'ZIP'; } else { $url = site_url($name . '/blob/' . $ref . '/' . $item_path); $icon = '📄'; $sz = is_numeric($item['size']) ? number_format((int)$item['size']) . ' B' : ''; $size_col = '' . $sz . ''; } $dl_url = $item['type'] === 'blob' ? site_url($name . '/raw/' . $ref . '/' . $item_path) : site_url($name . '/archive/' . $ref . '/' . $item_path); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
..
' . $icon . '' . h($item['name']) . '' . $size_col . '
'; html_close(); } function page_blob(string $name, string $ref, string $path): void { $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $content = repo_blob($dir, $ref, $path); $lang = ext_lang($path); // Detect binary files (null bytes in first 8 KB) $is_binary = str_contains(substr($content, 0, 8192), "\0"); $raw_url = site_url($name . '/raw/' . $ref . '/' . $path); $size_str = number_format(strlen($content)) . ' B'; html_open(h(basename($path)) . ' — ' . SITE_TITLE, ['prism' => !$is_binary]); echo '
'; echo breadcrumb_tree($name, $ref, $path, true); echo ''; echo '' . $size_str . ''; echo '↓ Raw'; echo ''; echo '
'; echo '
'; if ($is_binary) { echo '

Binary file — download raw

'; } else { echo '
' . h($content) . '
'; } echo '
'; html_close(); } // ── Raw file download ───────────────────────────────────────────────────────── function page_raw(string $name, string $ref, string $path): void { if (!check_repo_access($name, 'R', session_user())) { [$u, $ok] = try_basic_auth(); if (!$ok || !check_repo_access($name, 'R', $u)) { demand_auth(); return; } } $dir = repo_dir($name); if (!is_dir($dir)) { http_response_code(404); echo 'Not found'; return; } $content = repo_blob($dir, $ref, $path); if ($content === '' && !is_string($content)) { http_response_code(404); echo 'File not found'; return; } // Detect MIME from extension; fall back to octet-stream for unknown/binary $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $mime_map = [ 'txt'=>'text/plain','md'=>'text/plain','html'=>'text/html','htm'=>'text/html', 'css'=>'text/css','js'=>'text/javascript','ts'=>'text/plain','json'=>'application/json', 'xml'=>'text/xml','svg'=>'image/svg+xml','php'=>'text/plain','py'=>'text/plain', 'rb'=>'text/plain','sh'=>'text/plain','go'=>'text/plain','rs'=>'text/plain', 'c'=>'text/plain','cpp'=>'text/plain','h'=>'text/plain','java'=>'text/plain', 'jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif', 'webp'=>'image/webp','ico'=>'image/x-icon','pdf'=>'application/pdf', 'zip'=>'application/zip','tar'=>'application/x-tar','gz'=>'application/gzip', ]; $is_binary = str_contains(substr($content, 0, 8192), "\0"); $mime = $mime_map[$ext] ?? ($is_binary ? 'application/octet-stream' : 'text/plain'); ob_end_clean(); header('Content-Type: ' . $mime); header('Content-Length: ' . strlen($content)); if ($is_binary || !in_array($mime, ['text/plain','text/html','text/css','text/javascript','application/json','image/svg+xml'], true)) { header('Content-Disposition: attachment; filename="' . addslashes(basename($path)) . '"'); } header('Cache-Control: private, max-age=3600'); echo $content; exit; } // ── ZIP archive download ────────────────────────────────────────────────────── /** * Recursively collect all blob paths+SHAs under a tree SHA. * $prefix: path prefix to prepend (for subdirectory downloads). */ function _collect_tree_files(string $repo, string $tree_sha, string $prefix, array &$out): void { $obj = gl_read_object($repo, $tree_sha); if (!$obj || $obj['type'] !== 'tree') return; foreach (gl_parse_tree($obj['data']) as $e) { $full = $prefix ? $prefix . '/' . $e['name'] : $e['name']; if ($e['mode'] === '40000' || $e['mode'] === '040000') { _collect_tree_files($repo, $e['sha'], $full, $out); } else { $out[$full] = $e['sha']; } } } function page_archive(string $name, string $ref, string $path): void { if (!check_repo_access($name, 'R', session_user())) { [$u, $ok] = try_basic_auth(); if (!$ok || !check_repo_access($name, 'R', $u)) { demand_auth(); return; } } $dir = repo_dir($name); if (!is_dir($dir)) { http_response_code(404); echo 'Not found'; return; } if (!class_exists('ZipArchive')) { http_response_code(500); echo 'ZipArchive extension is not available on this server.'; return; } // Resolve tree SHA for the requested ref + path $head_sha = gl_read_ref($dir, ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref"); if (!$head_sha) { http_response_code(404); echo 'Ref not found'; return; } $commit_obj = gl_read_object($dir, $head_sha); if (!$commit_obj || $commit_obj['type'] !== 'commit') { http_response_code(404); echo 'Commit not found'; return; } $commit = gl_parse_commit($commit_obj['data']); $tree_sha = $commit['tree']; if ($path !== '') { $res = gl_resolve_path($dir, $tree_sha, $path); if (!$res || $res['type'] !== 'tree') { http_response_code(404); echo 'Path not found or not a directory'; return; } $tree_sha = $res['sha']; } // Collect all blobs $files = []; _collect_tree_files($dir, $tree_sha, '', $files); // Build ZIP in a temp file $slug = preg_replace('/[^a-z0-9_\-]/i', '-', $name); $ref_slug = preg_replace('/[^a-z0-9_\-]/i', '-', $ref); $path_slug = $path ? '-' . preg_replace('/[^a-z0-9_\/\-]/i', '-', str_replace('/', '-', $path)) : ''; $zip_name = $slug . '-' . $ref_slug . $path_slug . '.zip'; $prefix = $slug . '-' . $ref_slug . ($path ? '/' . $path : '') . '/'; $tmp = tempnam(sys_get_temp_dir(), 'gitgram_zip_'); $zip = new ZipArchive(); if ($zip->open($tmp, ZipArchive::OVERWRITE) !== true) { http_response_code(500); echo 'Could not create archive.'; return; } foreach ($files as $file_path => $sha) { $obj = gl_read_object($dir, $sha); if (!$obj) continue; $zip->addFromString($prefix . $file_path, $obj['data']); } $zip->close(); ob_end_clean(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . addslashes($zip_name) . '"'); header('Content-Length: ' . filesize($tmp)); header('Cache-Control: private, max-age=60'); readfile($tmp); unlink($tmp); exit; } function page_log(string $name, string $ref): void { $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $page = max(0, (int)($_GET['p'] ?? 0)); $commits = repo_log($dir, $ref, 30, $page * 30); html_open('Commits — ' . h($name) . ' — ' . SITE_TITLE); echo '
Commits: ' . h($name) . ' [' . h($ref) . ']
'; echo '
'; echo ''; foreach ($commits as $c) { $url = site_url($name . '/commit/' . $c['hash']); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . substr($c['hash'], 0, 8) . '' . h($c['subject']) . '' . h($c['author']) . '' . h($c['date']) . '
'; if (count($commits) === 30) { echo 'Older →'; } echo '
'; html_close(); } function page_commit(string $name, string $hash): void { $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $c = repo_commit($dir, $hash); if (!$c) { page_404(); return; } html_open('Commit ' . substr($hash, 0, 8) . ' — ' . SITE_TITLE); echo '
Commit: ' . h(substr($c['hash'], 0, 12)) . '
'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo '
Author' . h($c['author']) . ' <' . h($c['email']) . '>
Date' . h($c['date']) . '
Message' . h($c['subject']) . ($c['body'] ? '
' . h(trim($c['body'])) . '
' : '') . '
'; echo '
' . h($c['diff']) . '
'; echo '
'; html_close(); } function page_site_readme(): void { html_open('How to Use Git — ' . SITE_TITLE); echo '
How to Use GitGram
'; echo '
'; $clone_example = site_url('yourrepo.git'); echo <<Getting Started

GitGram hosts bare git repositories over HTTP. You can clone, push, and pull using standard git commands. No special software is needed beyond git itself.

Clone a Repository

git clone {$clone_example}

Push to a Repository

Pushing requires your username and password (set in data/users.json).

git remote add origin {$clone_example}
git push origin main

Git will prompt for credentials. To cache them:

git config credential.helper store

Creating a New Repository (Server Side)

SSH into your server and run:

cd /path/to/gitgram/repos
git init --bare yourrepo.git
echo "My new repository" > yourrepo.git/description

The repo will appear on the home page immediately.

First Push of a Local Repo

cd my-project
git init
git add .
git commit -m "Initial commit"
git remote add origin {$clone_example}
git push -u origin main

Setting Your Password

Generate a bcrypt hash:

php -r "echo password_hash('yourpassword', PASSWORD_DEFAULT);"

Paste the output into data/users.json:

{
    "yourusername": {
        "name": "Your Name",
        "password_hash": "\$2y\$12\$...",
        "role": "admin"
    }
}

Checking git-http-backend

If push/pull over HTTP fails, verify the backend is available:

which git-http-backend
ls /usr/lib/git-core/git-http-backend

Update GIT_HTTP_BACKEND_PATHS in config.php if yours is in a different location.

SSH (Alternative)

If your host provides SSH access, you can also push over SSH directly to the bare repo path. No special GitGram configuration needed — just standard git+ssh.

git remote add ssh-origin ssh://user@host/path/to/repos/yourrepo.git
git push ssh-origin main

Access Control (Gitolite-inspired)

GitGram uses a permission model modelled on Gitolite. Access rules live in data/repos.json — no database needed.

Permission levels

SymbolMeaning
RRead-only (clone, fetch)
RWRead + write (push)
RW+Read + write + force-push / tag deletion
-Explicit deny (overrides any group grant)

Rule subjects

SubjectMeaning
@allEveryone, including anonymous visitors
@groupnameAll users whose groups array includes groupname
usernameA specific user

Priority (highest wins): explicit deny - > username > @group > @all.
Admin-role users always have full access regardless of rules.

Example data/repos.json

{
    "public-project": {
        "description": "Anyone can read, devs can write",
        "access": {
            "@all":      "R",
            "@devs":     "RW",
            "alice":     "RW+"
        }
    },
    "private-project": {
        "description": "Invite-only",
        "access": {
            "bob":       "RW",
            "charlie":   "R"
        }
    },
    "locked-repo": {
        "description": "Archived — no pushes",
        "access": {
            "@all":      "R",
            "@devs":     "R",
            "alice":     "-"
        }
    }
}

Example data/users.json with groups

{
    "alice": {
        "name": "Alice",
        "password_hash": "\$2y\$12\$...",
        "role": "admin",
        "groups": ["devs", "leads"]
    },
    "bob": {
        "name": "Bob",
        "password_hash": "\$2y\$12\$...",
        "role": "user",
        "groups": ["devs"]
    },
    "charlie": {
        "name": "Charlie",
        "password_hash": "\$2y\$12\$...",
        "role": "user",
        "groups": []
    }
}

Repos with no entry in repos.json are private (admin-only) by default — the same safe default as Gitolite.

Differences from Gitolite

HTML; echo '
'; html_close(); } function page_end_of_internet(): void { header('Content-Type: text/html; charset=utf-8'); echo <<<'HTML' END OF THE INTERNET
🔥 🔥 🔥

CONGRATULATIONS!

YOU HAVE REACHED

THE END OF THE INTERNET

There is nothing more to see.

Please turn off your computer and go outside.

Visitor #: 1,337,042
⭐ This site best viewed in Netscape Navigator 2.0 at 640x480 ⭐ Under Construction ⭐ Please sign my guestbook ⭐ AOL Keyword: END ⭐

🌀

← Go back where it is safe

This page was last updated: January 1, 1997
Made with ❤️ and Microsoft FrontPage 97

[counter]

HTML; exit; } function page_login(): void { if (session_user()) { header('Location: ' . site_url()); exit; } $error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $users = load_users(); $u = trim($_POST['username'] ?? ''); $p = $_POST['password'] ?? ''; if (isset($users[$u]) && password_verify($p, $users[$u]['password_hash'])) { session_regenerate_id(true); $_SESSION['gitgram_user'] = $u; header('Location: ' . site_url($_GET['next'] ?? '')); exit; } $error = 'Invalid username or password.'; } html_open('Login — ' . SITE_TITLE); echo '
Login
'; echo '
'; if ($error) echo '
' . h($error) . '
'; echo '
'; echo ''; echo ''; echo ''; $s = load_settings(); if ($s['registration_open']) { echo '

No account? Register

'; } echo '
'; html_close(); } function page_logout(): void { $_SESSION = []; session_destroy(); header('Location: ' . site_url()); exit; } function page_register(): void { if (session_user()) { header('Location: ' . site_url()); exit; } $s = load_settings(); if (!$s['registration_open']) { html_open('Registration Closed — ' . SITE_TITLE); echo '
Registration
'; echo '

Registration is currently closed.

'; html_close(); return; } $errors = []; // Generate captcha on GET or if session captcha missing if ($_SERVER['REQUEST_METHOD'] === 'GET' || empty($_SESSION['captcha_answer'])) { $a = rand(2, 12); $b = rand(1, 10); $ops = ['+' => $a + $b, '−' => $a - $b, '×' => $a * $b]; $op = array_rand($ops); $_SESSION['captcha_q'] = "$a $op $b"; $_SESSION['captcha_answer'] = $ops[$op]; } if ($_SERVER['REQUEST_METHOD'] === 'POST') { $users = load_users(); $username = trim($_POST['username'] ?? ''); $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; $confirm = $_POST['confirm'] ?? ''; $captcha = (int)($_POST['captcha'] ?? -9999); $invite = trim($_POST['invite_code'] ?? ''); if (!preg_match('/^[a-z0-9_\-]{2,32}$/', $username)) $errors[] = 'Username: 2–32 lowercase letters, numbers, hyphens, or underscores.'; elseif (isset($users[$username])) $errors[] = 'That username is already taken.'; if (!$name) $errors[] = 'Display name is required.'; if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Enter a valid email address.'; if (strlen($password) < 8) $errors[] = 'Password must be at least 8 characters.'; if ($password !== $confirm) $errors[] = 'Passwords do not match.'; if ($captcha !== (int)$_SESSION['captcha_answer']) $errors[] = 'Math answer is incorrect.'; if ($s['invite_only']) { $invites = json_decode(@file_get_contents(DATA_PATH . '/invites.json'), true) ?? []; if (!isset($invites[$invite]) || $invites[$invite]['used_by'] !== null) $errors[] = 'Invalid or already-used invite code.'; } if (!$errors) { $users[$username] = [ 'name' => $name, 'email' => $email, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'role' => 'user', 'groups' => [], ]; file_put_contents(DATA_PATH . '/users.json', json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); if ($s['invite_only'] && $invite) { $invites[$invite]['used_by'] = $username; $invites[$invite]['used_at'] = date('Y-m-d H:i:s'); file_put_contents(DATA_PATH . '/invites.json', json_encode($invites, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); } session_regenerate_id(true); $_SESSION['gitgram_user'] = $username; unset($_SESSION['captcha_q'], $_SESSION['captcha_answer']); header('Location: ' . site_url()); exit; } // Regenerate captcha after failed attempt $a = rand(2, 12); $b = rand(1, 10); $ops = ['+' => $a + $b, '−' => $a - $b, '×' => $a * $b]; $op = array_rand($ops); $_SESSION['captcha_q'] = "$a $op $b"; $_SESSION['captcha_answer'] = $ops[$op]; } html_open('Register — ' . SITE_TITLE); echo '
Create Account
'; echo '
'; foreach ($errors as $e) echo '
' . h($e) . '
'; echo '
'; echo '
'; echo '
'; echo ''; echo '
'; echo '
'; if ($s['invite_only']) { echo ''; } echo ''; echo ''; echo '
'; echo '
'; html_close(); } // ── Dashboard ───────────────────────────────────────────────────────────────── function page_dashboard(): void { require_login(); $u = session_user(); $users = load_users(); $cfg = load_repos_config(); $profile = $users[$u] ?? []; $all = repo_list(); // Repos the user owns or can write $mine = array_filter($all, fn($r) => ($cfg[$r['name']]['owner'] ?? null) === $u); $shared = array_filter($all, fn($r) => ($cfg[$r['name']]['owner'] ?? null) !== $u && check_repo_access($r['name'], 'W', $u) ); html_open('Dashboard — ' . SITE_TITLE); echo '
Dashboard — ' . h($u) . '' . '+ New Repository
'; echo '
'; // Profile card echo '
'; $av_path = avatar_path($u); if (!file_exists($av_path)) generate_initials_avatar($u, $profile['name'] ?? $u, $av_path); $av_url = avatar_url($u); echo '
'; echo ''; echo '
'; echo '
' . h($profile['name'] ?? $u) . '
'; echo '
' . h($u) . '
'; if (!empty($profile['email'])) echo '
' . h($profile['email']) . '
'; echo '
' . h($profile['role'] ?? 'user') . '
'; echo '
'; echo '
Edit Profile
'; echo '
'; // My repos echo '
My Repositories
'; if (empty($mine)) { echo '

No repositories yet. Create one.

'; } else { echo ''; echo ''; foreach ($mine as $r) { $access = $cfg[$r['name']]['access'] ?? []; $public = isset($access['@all']) && $access['@all'] !== '-'; $forked_from = $cfg[$r['name']]['forked_from'] ?? ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
NameDescriptionVisibility
' . h($r['name']) . '' . ($forked_from ? ' ⑂ ' . '' . h($forked_from) . '' : '') . '' . h($r['desc']) . '' . ($public ? 'public' : 'private') . '' . '⬆ Upload ' . 'Settings' . '
'; } if (!empty($shared)) { echo '
Shared With Me
'; echo ''; echo ''; foreach ($shared as $r) { $owner = $cfg[$r['name']]['owner'] ?? ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
NameOwnerDescription
' . h($r['name']) . '' . ($owner ? owner_link($owner) : '') . '' . h($r['desc']) . '⬆ Upload
'; } echo '
'; html_close(); } // ── New repository ──────────────────────────────────────────────────────────── function page_new_repo(): void { require_login(); $errors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $name = trim($_POST['repo_name'] ?? ''); $desc = trim($_POST['repo_desc'] ?? ''); $visibility = $_POST['visibility'] ?? 'private'; $init = !empty($_POST['init_readme']); if (!preg_match('/^[a-zA-Z0-9_\-\.]{1,64}$/', $name)) $errors[] = 'Name: letters, numbers, hyphens, dots, underscores only (max 64).'; if (!$errors) { // Ensure repos/ directory exists if (!is_dir(REPO_PATH)) { @mkdir(REPO_PATH, 0755, true); if (!is_dir(REPO_PATH)) { $errors[] = 'Cannot create repos/ directory. Check server write permissions.'; } } } if (!$errors) { $dir = REPO_PATH . '/' . $name . '.git'; if (is_dir($dir)) { $errors[] = "Repository \"$name\" already exists."; } else { if (!shell_available()) { $rc = gl_init_bare($dir) ? 0 : 1; $err_out = $rc ? 'gl_init_bare() failed — check directory permissions.' : ''; } else { $r = run_cmd(escapeshellarg(GIT_BIN) . ' init --bare ' . escapeshellarg($dir) . ' 2>&1'); $rc = $r['rc']; $err_out = $r['out']; } if ($rc !== 0) { $errors[] = 'git init failed: ' . $err_out; } else { if ($desc) file_put_contents($dir . '/description', $desc . "\n"); $cfg = load_repos_config(); $user = session_user(); $access = [$user => 'RW+']; if ($visibility === 'public') $access = array_merge(['@all' => 'R'], $access); $cfg[$name] = ['description' => $desc, 'owner' => $user, 'access' => $access]; save_repos_config($cfg); if ($init) { $users = load_users(); $profile = $users[$user] ?? []; $readme = "# $name\n\n" . ($desc ?: 'A new repository.') . "\n"; $result = git_commit_files($dir, 'main', ['README.md' => $readme], 'Initial commit', $profile['name'] ?? $user, $profile['email'] ?? "$user@localhost" ); if (!$result['ok']) { // Repo created but README commit failed — not fatal, just warn $errors[] = 'Repository created, but initial commit failed: ' . $result['error'] . ' — you can push manually.'; // Still redirect, repo exists and is usable } } if (!$errors) { header('Location: ' . site_url($name)); exit; } } } } } html_open('New Repository — ' . SITE_TITLE); echo '
New Repository
'; echo '
'; foreach ($errors as $e) echo '
' . h($e) . '
'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
'; echo ''; echo ''; echo '
'; echo ''; echo '
'; html_close(); } // ── Upload files ────────────────────────────────────────────────────────────── function page_upload(string $name): void { require_login(); if (!user_can_write($name)) { http_response_code(403); html_open('Forbidden'); echo '

You do not have write access to this repository.

'; html_close(); return; } $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $branches = repo_branches($dir); $default = repo_default_branch($dir); $users = load_users(); $u = session_user(); $profile = $users[$u] ?? []; $errors = []; $success = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $branch = trim($_POST['branch'] ?? $default); $new_branch = trim($_POST['new_branch'] ?? ''); $msg = trim($_POST['message'] ?? 'Upload via GitGram web UI'); $mode = $_POST['upload_mode'] ?? 'single'; $base_path = sanitize_git_path(trim($_POST['base_path'] ?? '')); $author_n = $profile['name'] ?? $u; $author_e = $profile['email'] ?? "$u@localhost"; if ($new_branch !== '') $branch = $new_branch; if (!preg_match('/^[a-zA-Z0-9_\-\.\/]{1,100}$/', $branch)) $errors[] = 'Invalid branch name.'; if (!$msg) $errors[] = 'Commit message is required.'; if (!$errors && isset($_FILES['upload']) && $_FILES['upload']['error'] === UPLOAD_ERR_OK) { $tmp = $_FILES['upload']['tmp_name']; if ($mode === 'zip') { if (!class_exists('ZipArchive')) { $errors[] = 'ZipArchive PHP extension is not available on this server.'; } else { $zip = new ZipArchive(); $files = []; if ($zip->open($tmp) === true) { for ($i = 0; $i < $zip->numFiles; $i++) { $zname = $zip->getNameIndex($i); if (str_ends_with($zname, '/')) continue; // directory entry $clean = sanitize_git_path(($base_path ? $base_path . '/' : '') . $zname); if ($clean) $files[$clean] = $zip->getFromIndex($i); } $zip->close(); if (empty($files)) $errors[] = 'ZIP contains no files.'; } else { $errors[] = 'Could not open ZIP archive.'; } if (!$errors) { $result = git_commit_files($dir, $branch, $files, $msg, $author_n, $author_e); if ($result['ok']) { $success = count($files) . ' file(s) committed to ' . $branch; } else { $errors[] = $result['error']; } } } } else { // Single file $filename = sanitize_git_path( ($base_path ? $base_path . '/' : '') . (trim($_POST['file_path'] ?? '') ?: basename($_FILES['upload']['name'])) ); if (!$filename) { $errors[] = 'Invalid file path.'; } else { $content = file_get_contents($tmp); $result = git_commit_files($dir, $branch, [$filename => $content], $msg, $author_n, $author_e); if ($result['ok']) { $success = "File committed: $filename → $branch"; } else { $errors[] = $result['error']; } } } } elseif (!$errors) { $errors[] = 'No file uploaded or upload error (' . ($_FILES['upload']['error'] ?? 'unknown') . ').'; } } html_open('Upload — ' . h($name) . ' — ' . SITE_TITLE); echo '
⬆ Upload to ' . h($name) . '' . '← Back
'; echo '
'; foreach ($errors as $e) echo '
' . h($e) . '
'; if ($success) echo '
' . h($success) . '
'; echo '
'; // Upload type toggle echo ''; echo '
'; echo ''; echo ''; echo '
'; // Branch echo '
'; echo '
'; echo ''; echo '
'; // File input echo ''; echo ''; // Single file path echo '
' . '
'; // ZIP base path echo ''; echo ''; echo ''; echo ''; echo '
'; echo '

Max upload size is controlled by your server\'s PHP upload_max_filesize setting.

'; echo '
'; echo ''; html_close(); } // ── Repo settings (owner) ───────────────────────────────────────────────────── function page_repo_settings(string $name): void { require_login(); if (!user_is_owner($name)) { http_response_code(403); html_open('Forbidden'); echo '

Only the repository owner can access settings.

'; html_close(); return; } $dir = repo_dir($name); if (!is_dir($dir)) { page_404(); return; } $cfg = load_repos_config(); $entry = $cfg[$name] ?? []; $errors = []; $success = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; if ($action === 'save') { $desc = trim($_POST['description'] ?? ''); $visibility = $_POST['visibility'] ?? 'private'; $u = session_user(); file_put_contents($dir . '/description', $desc . "\n"); $cfg[$name]['description'] = $desc; // Rebuild access: keep non-@all, non-owner rules, then set visibility $old_access = $entry['access'] ?? []; $new_access = []; foreach ($old_access as $k => $v) { if ($k !== '@all' && $k !== $u) $new_access[$k] = $v; } if ($visibility === 'public') $new_access = array_merge(['@all' => 'R'], $new_access); $new_access[$u] = 'RW+'; $cfg[$name]['access'] = $new_access; save_repos_config($cfg); $entry = $cfg[$name]; $success = 'Settings saved.'; } elseif ($action === 'delete') { $confirm = trim($_POST['confirm_name'] ?? ''); if ($confirm !== $name) { $errors[] = 'Confirmation name did not match.'; } else { recursive_rmdir($dir); unset($cfg[$name]); save_repos_config($cfg); header('Location: ' . site_url('dashboard')); exit; } } } $is_public = isset($entry['access']['@all']) && $entry['access']['@all'] !== '-'; $desc = trim(@file_get_contents($dir . '/description') ?: ''); if (str_starts_with($desc, 'Unnamed repository')) $desc = ''; html_open('Settings — ' . h($name) . ' — ' . SITE_TITLE); echo '
Settings — ' . h($name) . '' . '← Repository
'; echo '
'; foreach ($errors as $e) echo '
' . h($e) . '
'; if ($success) echo '
' . h($success) . '
'; // Main settings form echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
'; // Clone URL $clone = site_url($name . '.git'); echo '
Clone URL
'; echo '
'; // Danger zone echo '
'; echo '
⚠ Delete Repository
'; echo '

Permanently destroys all commits. This cannot be undone.

'; echo '
'; echo ''; echo ''; echo '
'; echo ''; echo ''; echo '
'; echo '
'; html_close(); } // ── Profile page ────────────────────────────────────────────────────────────── // ── Public user profile ─────────────────────────────────────────────────────── function page_user_profile(string $username): void { $users = load_users(); if (!isset($users[$username])) { page_404(); return; } $profile = $users[$username]; $cfg = load_repos_config(); $all = repo_list(); $viewer = session_user(); // Repos this user owns that the viewer can read $their_repos = array_filter($all, fn($r) => ($cfg[$r['name']]['owner'] ?? null) === $username && check_repo_access($r['name'], 'R', $viewer) ); $display = $profile['name'] ?? $username; $av_path = avatar_path($username); if (!file_exists($av_path)) generate_initials_avatar($username, $display, $av_path); $av_url = avatar_url($username); html_open(h($display) . ' — ' . SITE_TITLE); echo '
' . h($display) . '
'; echo '
'; // Profile card echo '
'; echo ''; echo ''; echo '
'; // Their public repositories echo '
Repositories
'; if (empty($their_repos)) { echo '

No public repositories.

'; } else { echo ''; echo ''; foreach ($their_repos as $r) { $last = repo_last_commit($r['dir']); $access = $cfg[$r['name']]['access'] ?? []; $public = isset($access['@all']) && $access['@all'] !== '-'; $forked_from = $cfg[$r['name']]['forked_from'] ?? ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
NameDescriptionLast commitWhen
' . h($r['name']) . '' . ' ' . ($public ? 'public' : 'private') . '' . ($forked_from ? ' ⑂ ' . h($forked_from) . '' : '') . '' . h($r['desc']) . '' . h($last['subject'] ?? '—') . '' . h($last['when'] ?? '') . '
'; } echo '
'; html_close(); } // ── Own profile edit ────────────────────────────────────────────────────────── function page_profile(): void { require_login(); $u = session_user(); $users = load_users(); $profile = $users[$u] ?? []; $errors = []; $success = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $section = $_POST['section'] ?? ''; // ── Save profile details ── if ($section === 'details') { $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); if (!$name) { $errors[] = 'Display name cannot be empty.'; } elseif ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Enter a valid email address.'; } else { $users[$u]['name'] = $name; $users[$u]['email'] = $email; file_put_contents(DATA_PATH . '/users.json', json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); $profile = $users[$u]; $success[] = 'Profile details saved.'; } } // ── Change password ── if ($section === 'password') { $current = $_POST['current_password'] ?? ''; $new = $_POST['new_password'] ?? ''; $confirm = $_POST['confirm_password'] ?? ''; if (!password_verify($current, $profile['password_hash'] ?? '')) { $errors[] = 'Current password is incorrect.'; } elseif (strlen($new) < 8) { $errors[] = 'New password must be at least 8 characters.'; } elseif ($new !== $confirm) { $errors[] = 'New passwords do not match.'; } else { $users[$u]['password_hash'] = password_hash($new, PASSWORD_DEFAULT); file_put_contents(DATA_PATH . '/users.json', json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); $success[] = 'Password changed successfully.'; } } // ── Upload avatar ── if ($section === 'avatar') { if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === UPLOAD_ERR_OK) { $result = process_avatar($_FILES['avatar']['tmp_name'], avatar_path($u)); if ($result === true) { $success[] = 'Avatar updated.'; } else { $errors[] = $result; } } elseif (isset($_FILES['avatar']) && $_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) { $errors[] = 'Upload error code: ' . $_FILES['avatar']['error']; } } // ── Remove avatar ── if ($section === 'remove_avatar') { $path = avatar_path($u); if (file_exists($path)) unlink($path); // Regenerate initials avatar generate_initials_avatar($u, $users[$u]['name'] ?? $u, $path); $success[] = 'Avatar reset to initials.'; } $profile = $users[$u] ?? []; } // Ensure an initials avatar exists for display $av_path = avatar_path($u); if (!file_exists($av_path)) { generate_initials_avatar($u, $profile['name'] ?? $u, $av_path); } $av_url = avatar_url($u); html_open('Profile — ' . SITE_TITLE); echo '
My Profile
'; echo '
'; foreach ($errors as $e) echo '
' . h($e) . '
'; foreach ($success as $s) echo '
' . h($s) . '
'; echo '
'; // ── Left: Avatar column ────────────────────────────────────────────────── echo '
'; // 128×128 avatar display echo '
'; echo 'Avatar'; echo '
'; // Upload form echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo '
'; // Remove / reset avatar echo '
'; echo ''; echo ''; echo '
'; echo '

JPEG/PNG/GIF/WebP
Cropped to 128×128

'; echo '
'; // ── Right: Details columns ─────────────────────────────────────────────── echo '
'; // Profile details form echo '
Profile Details
'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
'; // Git config hint box $git_name = $profile['name'] ?? $u; $git_email = $profile['email'] ?? "$u@localhost"; echo '
'; echo '
Your git config commands
'; echo '
git config --global user.name  "' . h($git_name) . '"'
       . "\ngit config --global user.email \"" . h($git_email) . '"
'; echo '
'; // Password change form echo '
Change Password
'; echo '
'; echo ''; echo ''; echo ''; echo '
'; echo '
' . '
'; echo '
' . '
'; echo '
'; echo ''; echo '
'; echo '
'; // right col echo '
'; // flex row echo '
'; // content-pad echo ''; html_close(); } function page_404(): void { http_response_code(404); html_open('Not Found — ' . SITE_TITLE); echo '
404 Not Found
'; echo '

The page or repository you requested does not exist.

'; echo '

← Back to repositories

'; html_close(); } // ── Breadcrumb ──────────────────────────────────────────────────────────────── function breadcrumb_tree(string $name, string $ref, string $path, bool $is_blob = false): string { $out = '' . h($name) . ''; $out .= ' / ' . h($ref) . ''; if (!$path) return $out; $parts = explode('/', $path); $built = ''; foreach ($parts as $i => $p) { $built .= ($built ? '/' : '') . $p; $is_last = $i === count($parts) - 1; if ($is_last && $is_blob) { $out .= ' / ' . h($p); } else { $url = site_url($name . '/tree/' . $ref . '/' . $built); $out .= ' / ' . h($p) . ''; } } return $out; } // ── HTML layout (OS/2 Warp 3.0 theme) ──────────────────────────────────────── function html_open(string $title, array $opts = []): void { $s = load_settings(); $nav_home = site_url(); $nav_readme = site_url('readme'); $end_url = site_url('end-of-internet'); $site_title = h($s['site_title'] ?? SITE_TITLE); $prism = !empty($opts['prism']); $cur_user = session_user(); $is_admin = session_is_admin(); $nav_dashboard = $cur_user ? 'My Repos' : ''; $nav_avatar = ''; if ($cur_user) { $av_url = avatar_url($cur_user); if (!$av_url) { // Generate initials avatar on-the-fly if missing $u_data = (load_users())[$cur_user] ?? []; generate_initials_avatar($cur_user, $u_data['name'] ?? $cur_user, avatar_path($cur_user)); $av_url = avatar_url($cur_user); } $nav_avatar = $av_url ? '' : ''; } $nav_right = $cur_user ? '' . ($is_admin ? 'Admin' : '') . '' . $nav_avatar . h($cur_user) . '' . 'Logout' : '' . 'Login' . ($s['registration_open'] ? 'Register' : '') . ''; echo << {$title} HTML; if ($prism) { echo ''; echo ''; echo ''; } echo << /* ── OS/2 Warp 3.0 Design System ── */ :root { --desktop: #008080; --win-bg: #c0c0c0; --title-bg: #000080; --title-text: #ffffff; --border-hi: #ffffff; --border-sh: #808080; --border-dk: #404040; --text: #000000; --muted: #555; --link: #000080; --accent: #000080; --code-bg: #1a1a2e; --code-text: #c8c8ff; } * { box-sizing: border-box; margin: 0; padding: 0; } html, body { height: 100%; } body { background: var(--desktop); font-family: "Helv", "Arial", "Helvetica", sans-serif; font-size: 13px; color: var(--text); padding: 12px; min-height: 100vh; } a { color: var(--link); text-decoration: underline; } a:hover { color: #0000cc; } /* ── Window chrome ── */ .window { background: var(--win-bg); /* Raised 3D bevel */ border-top: 2px solid var(--border-hi); border-left: 2px solid var(--border-hi); border-right: 2px solid var(--border-dk); border-bottom: 2px solid var(--border-dk); box-shadow: 1px 1px 0 var(--border-sh) inset, -1px -1px 0 var(--border-sh) inset; margin: 0 auto; max-width: 980px; display: flex; flex-direction: column; min-height: calc(100vh - 24px); } /* ── Title bar ── */ .titlebar { background: var(--title-bg); color: var(--title-text); display: flex; align-items: center; height: 22px; padding: 0 2px; gap: 2px; flex-shrink: 0; user-select: none; } .titlebar-sysmenu { width: 18px; height: 14px; background: var(--win-bg); border-top: 1px solid var(--border-hi); border-left: 1px solid var(--border-hi); border-right: 1px solid var(--border-dk); border-bottom:1px solid var(--border-dk); display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; font-size: 9px; } .titlebar-title { flex: 1; text-align: center; font-size: 12px; font-weight: bold; letter-spacing: 0.02em; padding: 0 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .titlebar-btn { width: 16px; height: 14px; background: var(--win-bg); border-top: 1px solid var(--border-hi); border-left: 1px solid var(--border-hi); border-right: 1px solid var(--border-dk); border-bottom:1px solid var(--border-dk); display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; color: #000; font-size: 10px; font-weight: bold; text-decoration: none; } .titlebar-btn:hover { background: #d4d4d4; color: #000; } .titlebar-btn:active { border-top: 1px solid var(--border-dk); border-left: 1px solid var(--border-dk); border-right: 1px solid var(--border-hi); border-bottom:1px solid var(--border-hi); } /* ── Menu bar ── */ .menubar { background: var(--win-bg); border-bottom: 1px solid var(--border-sh); padding: 2px 6px; display: flex; gap: 16px; flex-shrink: 0; } .menubar a { color: var(--text); text-decoration: none; font-size: 12px; padding: 1px 4px; } .menubar a:hover { background: var(--accent); color: #fff; } /* ── Toolbar (page-level heading bar) ── */ .toolbar { background: #b0b0b0; border-bottom: 1px solid var(--border-sh); border-top: 1px solid var(--border-hi); padding: 4px 8px; font-size: 12px; font-weight: bold; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } .toolbar-right { display:flex; align-items:center; } .toolbar-right a { margin-left: 10px; font-weight: normal; } /* ── Window body ── */ .win-body { flex: 1; overflow: auto; /* Sunken inner area */ border-top: 1px solid var(--border-sh); border-left: 1px solid var(--border-sh); border-right: 1px solid var(--border-hi); border-bottom:1px solid var(--border-hi); margin: 4px; background: #fff; } .win-body.minimized { display: none; } /* ── Status bar ── */ .statusbar { background: var(--win-bg); border-top: 1px solid var(--border-sh); padding: 2px 8px; font-size: 11px; color: #444; display: flex; gap: 1px; flex-shrink: 0; } .statusbar-cell { border-top: 1px solid var(--border-sh); border-left: 1px solid var(--border-sh); border-right: 1px solid var(--border-hi); border-bottom:1px solid var(--border-hi); padding: 1px 8px; } /* ── Content padding ── */ .content-pad { padding: 12px 16px; } /* ── Tables ── */ table { border-collapse: collapse; width: 100%; } th { text-align: left; padding: 4px 8px; background: #e0e0e0; border-bottom: 2px solid var(--border-sh); font-size: 12px; } td { padding: 3px 8px; border-bottom: 1px solid #ddd; vertical-align: top; } tr:hover td { background: #e8e8f8; } .repo-table td:first-child a { font-weight: bold; } .commit-table { font-size: 12px; } .file-table td { padding: 2px 6px; } .file-table tr:hover td { background: #d8d8f8; } .file-icon { width: 24px; } .file-size { text-align: right; color: #666; font-size: 11px; width: 70px; } .dl-link { font-size: 11px; color: #000080; text-decoration: none; padding: 0 4px; opacity: 0.5; } .file-table tr:hover .dl-link { opacity: 1; } .dl-link:hover { text-decoration: underline; } .btn-download { text-decoration: none; font-size: 11px; padding: 2px 10px; margin-left: 8px; display: inline-block; } .meta-table th { width: 90px; } .hash { font-family: monospace; font-size: 11px; width: 80px; } .mono { font-family: monospace; font-size: 11px; } .muted { color: var(--muted); } /* ── Clone box ── */ .clone-box { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; background: #f0f0f8; border: 1px solid #ccc; padding: 6px 10px; flex-wrap: wrap; } .clone-box label { font-weight: bold; white-space: nowrap; font-size: 11px; } .clone-box input { flex: 1; min-width: 180px; font-family: monospace; font-size: 12px; border: 1px inset #aaa; padding: 2px 6px; background: #fff; } /* ── Code / diff views ── */ .file-view { font-family: monospace; font-size: 12px; line-height: 1.5; overflow: auto; padding: 12px; background: var(--code-bg); color: var(--code-text); } .diff-view { font-family: monospace; font-size: 12px; line-height: 1.5; overflow: auto; padding: 12px; background: #1a1a1a; color: #ccc; white-space: pre-wrap; } .readme-box { border: 1px solid #ccc; margin: 12px 0; } .readme-title { background: #e8e8e8; border-bottom: 1px solid #ccc; padding: 4px 10px; font-weight: bold; font-size: 12px; } .readme-pre { padding: 10px; white-space: pre-wrap; font-family: monospace; font-size: 12px; line-height: 1.6; } .section-title { font-weight: bold; margin: 16px 0 6px; border-bottom: 1px solid #ccc; padding-bottom: 3px; } .commit-body { font-size: 11px; margin-top: 4px; color: #444; } /* ── Owner links ── */ .owner-link { color: #000080; text-decoration: none; } .owner-link:hover { text-decoration: underline; } .owner-cell { white-space: nowrap; font-size: 12px; } .clone-owner { font-size: 11px; white-space: nowrap; color: #444; } .clone-owner a { color: #000080; text-decoration: none; } .clone-owner a:hover { text-decoration: underline; } /* ── Fork UI ── */ .fork-btn { font-size: 11px; padding: 2px 10px; text-decoration: none; display: inline-block; background: #ddeeff; } .fork-btn:hover { background: #bbddff; } .fork-banner { font-size: 12px; background: #f0f4ff; border: 1px solid #b0c4e8; border-left: 4px solid #000080; padding: 5px 10px; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; } .fork-banner a { color: #000080; } .fork-indicator { font-size: 10px; color: #000080; margin-left: 6px; opacity: 0.7; } .fork-source-card { display: flex; gap: 14px; align-items: center; background: #e8eef8; border: 1px solid #b0c4e8; padding: 10px 14px; margin-bottom: 12px; } .fork-source-icon { font-size: 24px; color: #000080; flex-shrink: 0; } /* ── Public user profile card ── */ .user-profile-card { display: flex; gap: 18px; align-items: flex-start; background: #e8e8e8; border: 1px solid #ccc; padding: 14px 18px; margin-bottom: 4px; } .user-avatar { border: 2px solid #aaa; flex-shrink: 0; image-rendering: pixelated; } .user-profile-info { display: flex; flex-direction: column; gap: 3px; } .user-display-name { font-size: 16px; font-weight: bold; } .user-username { font-size: 12px; color: #555; } .user-email { font-size: 12px; color: #555; } .user-bio { font-size: 12px; color: #333; margin-top: 4px; max-width: 400px; white-space: pre-wrap; } /* ── Readme doc page ── */ .readme-doc h2 { margin: 20px 0 8px; font-size: 14px; border-bottom: 1px solid #ccc; padding-bottom: 3px; } .readme-doc p { margin: 6px 0; line-height: 1.6; } .readme-doc code { background: #e8e8f0; padding: 1px 4px; font-family: monospace; border: 1px solid #ccc; } .code-block { background: var(--code-bg); color: var(--code-text); padding: 10px 14px; font-family: monospace; font-size: 12px; margin: 8px 0; line-height: 1.6; border-left: 3px solid #000080; overflow-x: auto; white-space: pre; display: block; } /* ── Forms ── */ label { display:block; font-weight:bold; font-size:12px; margin-bottom:3px; margin-top:8px; } label small { font-weight:normal; color:#666; } input[type=text], input[type=password], input[type=email], input[type=number], select, textarea { width:100%; padding:4px 6px; font-size:13px; font-family:inherit; border-top:2px solid var(--border-sh); border-left:2px solid var(--border-sh); border-right:2px solid var(--border-hi); border-bottom:2px solid var(--border-hi); background:#fff; box-sizing:border-box; } input:focus, select:focus, textarea:focus { outline:2px solid #000080; } .form-row { display:flex; gap:12px; } .form-row > * { flex:1; } .os2-btn { padding:4px 18px; font-size:12px; font-family:inherit; cursor:pointer; background:var(--win-bg); border-top:2px solid var(--border-hi); border-left:2px solid var(--border-hi); border-right:2px solid var(--border-dk); border-bottom:2px solid var(--border-dk); } .os2-btn:active { border-top:2px solid var(--border-dk); border-left:2px solid var(--border-dk); border-right:2px solid var(--border-hi); border-bottom:2px solid var(--border-hi); } .os2-btn.primary { background:#ccccff; font-weight:bold; } .os2-btn.danger { background:#ffcccc; } .alert-box { padding:7px 12px; margin:6px 0; font-size:12px; border-left:4px solid; } .alert-box.err { background:#fff0f0; border-color:#cc0000; color:#800; } .alert-box.ok { background:#f0fff0; border-color:#008000; color:#040; } .alert-box.warn { background:#fffbe6; border-color:#c0a000; color:#640; } /* ── Maximize state ── */ body.maximized { padding: 0; } body.maximized .window { max-width: 100%; margin: 0; min-height: 100vh; border: none; box-shadow: none; }
{$site_title} — {$title}
HTML; } function html_close(): void { echo <<<'HTML'
Ready
GitGram
HTML; }