<?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];
$saved = json_decode(@file_get_contents(DATA_PATH . '/settings.json'), true) ?? [];
return array_merge($defaults, $saved);
}
function session_user(): ?string {
return $_SESSION['gitgram_user'] ?? null;
}
function session_is_admin(): bool {
if (!session_user()) return false;
$users = load_users();
return ($users[session_user()]['role'] ?? '') === 'admin';
}
function site_url(string $path = ''): string {
if (SITE_URL !== '') return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
return $scheme . '://' . $host . $base . '/' . ltrim($path, '/');
}
/**
* Run a shell command using the best available method:
* proc_open (preferred) → exec → shell_exec.
* Returns ['out' => string, 'rc' => int].
*/
function run_cmd(string $cmd): array {
if (function_exists('proc_open')) {
$proc = proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w']], $pipes);
if (is_resource($proc)) {
fclose($pipes[0]);
$out = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$rc = proc_close($proc);
return ['out' => trim($out), 'rc' => $rc];
}
}
if (function_exists('exec')) {
exec($cmd, $lines, $rc);
return ['out' => trim(implode("\n", $lines)), 'rc' => $rc];
}
if (function_exists('shell_exec')) {
$out = shell_exec($cmd) ?? '';
return ['out' => trim($out), 'rc' => 0];
}
return ['out' => '', 'rc' => -1];
}
function shell_available(): bool {
return function_exists('proc_open')
|| function_exists('exec')
|| function_exists('shell_exec');
}
function git_run(string $repo_dir, array $args): string {
$cmd = array_map('escapeshellarg', array_merge([GIT_BIN, '--git-dir=' . $repo_dir], $args));
$r = run_cmd(implode(' ', $cmd) . ' 2>/dev/null');
return $r['rc'] === 0 ? $r['out'] : '';
}
function find_git_backend(): string|false {
foreach (GIT_HTTP_BACKEND_PATHS as $p) {
if (is_executable($p)) return $p;
}
$found = trim(shell_exec('which git-http-backend 2>/dev/null') ?? '');
return $found ?: false;
}
function repo_list(): array {
$repos = [];
if (!is_dir(REPO_PATH)) return $repos;
foreach (glob(REPO_PATH . '/*.git', GLOB_ONLYDIR) as $dir) {
$name = basename($dir, '.git');
$desc = @file_get_contents($dir . '/description') ?: '';
if (str_starts_with($desc, 'Unnamed repository')) $desc = '';
$repos[] = ['name' => $name, 'dir' => $dir, 'desc' => trim($desc)];
}
return $repos;
}
function repo_dir(string $name): string {
return REPO_PATH . '/' . $name . '.git';
}
// ── Upload / commit helpers ───────────────────────────────────────────────────
function sanitize_git_path(string $path): string {
$parts = explode('/', str_replace('\\', '/', $path));
$clean = [];
foreach ($parts as $p) {
if ($p === '' || $p === '.' || $p === '..') continue;
$clean[] = $p;
}
return implode('/', $clean);
}
function recursive_rmdir(string $dir): void {
if (!is_dir($dir)) return;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $f) {
$f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
}
rmdir($dir);
}
/**
* Commit $files ([relative_path => content_string]) to $branch of $bare_dir.
* Returns ['ok' => bool, 'error' => string].
*/
function git_commit_files(string $bare_dir, string $branch, array $files,
string $message, string $author_name, string $author_email): array {
// Pure-PHP path: no shell required
if (!shell_available()) {
return gl_write_files($bare_dir, $branch, $files, $message, $author_name, $author_email);
}
$work = rtrim(sys_get_temp_dir() ?: '/tmp', '/') . '/gitgram_' . bin2hex(random_bytes(8));
@mkdir($work, 0755, true);
if (!is_dir($work)) {
return ['ok' => false, 'error' => 'Could not create temp working directory.'];
}
// Pass author identity via -c; avoids needing putenv() or a global git config
$g = escapeshellarg(GIT_BIN);
$cw = escapeshellarg($work);
$id = '-c ' . escapeshellarg('user.name=' . $author_name)
. ' -c ' . escapeshellarg('user.email=' . $author_email);
// Try clone — fails on a brand-new empty bare repo (exit 128), that's fine
$clone = run_cmd("$g clone " . escapeshellarg($bare_dir) . " $cw 2>&1");
$is_empty = ($clone['rc'] !== 0);
if ($is_empty) {
recursive_rmdir($work);
@mkdir($work, 0755, true);
run_cmd("$g -C $cw init 2>&1");
// symbolic-ref sets the default branch before any commits exist
run_cmd("$g -C $cw symbolic-ref HEAD " . escapeshellarg("refs/heads/$branch") . ' 2>&1');
run_cmd("$g -C $cw remote add origin " . escapeshellarg($bare_dir) . ' 2>&1');
} else {
$co = run_cmd("$g -C $cw checkout " . escapeshellarg($branch) . ' 2>&1');
if ($co['rc'] !== 0) {
run_cmd("$g -C $cw checkout -b " . escapeshellarg($branch) . ' 2>&1');
}
}
foreach ($files as $rel => $content) {
$clean = sanitize_git_path($rel);
if (!$clean) continue;
$full = $work . '/' . $clean;
@mkdir(dirname($full), 0755, true);
file_put_contents($full, $content);
}
run_cmd("$g $id -C $cw add -A 2>&1");
$commit = run_cmd("$g $id -C $cw commit -m " . escapeshellarg($message) . ' 2>&1');
if ($commit['rc'] !== 0) {
recursive_rmdir($work);
if (str_contains($commit['out'], 'nothing to commit')) {
return ['ok' => false, 'error' => 'No changes to commit.'];
}
return ['ok' => false, 'error' => $commit['out']];
}
$up = $is_empty ? '--set-upstream ' : '';
$push = run_cmd("$g -C $cw push {$up}origin " . escapeshellarg($branch) . ' 2>&1');
recursive_rmdir($work);
return $push['rc'] === 0
? ['ok' => true, 'error' => '']
: ['ok' => false, 'error' => $push['out']];
}
function user_can_write(string $repo): bool {
return session_is_admin() || check_repo_access($repo, 'W', session_user());
}
function user_is_owner(string $repo): bool {
if (session_is_admin()) return true;
$cfg = load_repos_config();
return ($cfg[$repo]['owner'] ?? null) === session_user();
}
function load_repos_config(): array {
return json_decode(@file_get_contents(DATA_PATH . '/repos.json'), true) ?? [];
}
function save_repos_config(array $data): void {
file_put_contents(DATA_PATH . '/repos.json',
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function require_login(): void {
if (!session_user()) {
$uri = $_SERVER['REQUEST_URI'] ?? '/';
header('Location: ' . site_url('login') . '?next=' . urlencode($uri));
exit;
}
}
// ── Avatar helpers ────────────────────────────────────────────────────────────
function avatar_path(string $username): string {
return AVATAR_PATH . '/' . preg_replace('/[^a-z0-9_\-]/i', '_', $username) . '.jpg';
}
function avatar_url(string $username): string {
$path = avatar_path($username);
$bust = file_exists($path) ? '?v=' . filemtime($path) : '';
return $bust ? site_url('avatars/' . basename($path) . $bust) : '';
}
/**
* Resize & center-crop an uploaded image to 128×128 JPEG.
* Returns true on success or an error string.
*/
function process_avatar(string $tmp_path, string $dest_path): bool|string {
if (!function_exists('imagecreatefromjpeg')) {
return 'GD extension is not available on this server.';
}
$info = @getimagesize($tmp_path);
if (!$info) return 'Could not read image file.';
$src = match ($info['mime']) {
'image/jpeg' => @imagecreatefromjpeg($tmp_path),
'image/png' => @imagecreatefrompng($tmp_path),
'image/gif' => @imagecreatefromgif($tmp_path),
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($tmp_path) : false,
default => false,
};
if (!$src) return 'Unsupported image format. Use JPEG, PNG, GIF, or WebP.';
$sw = imagesx($src);
$sh = imagesy($src);
$dim = min($sw, $sh); // largest square that fits
$sx = (int)(($sw - $dim) / 2); // center-crop X offset
$sy = (int)(($sh - $dim) / 2); // center-crop Y offset
$dst = imagecreatetruecolor(128, 128);
// Preserve transparency for PNG sources before resampling
imagealphablending($dst, false);
imagesavealpha($dst, true);
$white = imagecolorallocate($dst, 255, 255, 255);
imagefill($dst, 0, 0, $white);
imagecopyresampled($dst, $src, 0, 0, $sx, $sy, 128, 128, $dim, $dim);
imagedestroy($src);
@mkdir(dirname($dest_path), 0755, true);
$ok = imagejpeg($dst, $dest_path, 90);
imagedestroy($dst);
return $ok ? true : 'Failed to write avatar file.';
}
/**
* Generate a simple initials avatar when no photo is uploaded.
* Produces a 128×128 JPEG with a coloured background.
*/
function generate_initials_avatar(string $username, string $display_name, string $dest_path): void {
if (!function_exists('imagecreatetruecolor')) return;
// Pick a deterministic background colour from the username
$hue = crc32($username) % 360;
[$r, $g, $b] = hsl_to_rgb($hue / 360, 0.55, 0.45);
$img = imagecreatetruecolor(128, 128);
$bg = imagecolorallocate($img, $r, $g, $b);
$fg = imagecolorallocate($img, 255, 255, 255);
imagefill($img, 0, 0, $bg);
// Draw initials using built-in font (size 5 = 9×15px per char)
$initials = strtoupper(substr($display_name ?: $username, 0, 1));
if (preg_match('/\s+(\S)/', $display_name, $m)) $initials .= strtoupper($m[1]);
$fw = 9 * strlen($initials);
$fh = 15;
imagestring($img, 5, (int)((128 - $fw) / 2), (int)((128 - $fh) / 2), $initials, $fg);
@mkdir(dirname($dest_path), 0755, true);
imagejpeg($img, $dest_path, 90);
imagedestroy($img);
}
function hsl_to_rgb(float $h, float $s, float $l): array {
$c = (1 - abs(2 * $l - 1)) * $s;
$x = $c * (1 - abs(fmod($h * 6, 2) - 1));
$m = $l - $c / 2;
$hi = (int)($h * 6) % 6;
[$r, $g, $b] = match($hi) {
0 => [$c, $x, 0], 1 => [$x, $c, 0], 2 => [0, $c, $x],
3 => [0, $x, $c], 4 => [$x, 0, $c], default => [$c, 0, $x],
};
return [(int)(($r + $m) * 255), (int)(($g + $m) * 255), (int)(($b + $m) * 255)];
}
function repo_default_branch(string $dir): string {
$head = @file_get_contents($dir . '/HEAD');
if ($head && preg_match('#ref: refs/heads/(.+)#', $head, $m)) return trim($m[1]);
return 'main';
}
function repo_branches(string $dir): array {
if (!shell_available()) {
return array_keys(gl_list_branches($dir));
}
$out = git_run($dir, ['branch', '--format=%(refname:short)']);
return $out ? array_filter(explode("\n", $out)) : [];
}
function _time_ago(int $ts): string {
$diff = max(0, time() - $ts);
if ($diff < 60) return $diff . ' seconds ago';
if ($diff < 3600) return (int)($diff/60) . ' minutes ago';
if ($diff < 86400) return (int)($diff/3600) . ' hours ago';
if ($diff < 2592000) return (int)($diff/86400) . ' days ago';
return date('Y-m-d', $ts);
}
function repo_last_commit(string $dir, string $ref = 'HEAD'): array {
if (!shell_available()) {
$r = gl_last_commit($dir, $ref === 'HEAD' ? 'HEAD' : "refs/heads/$ref");
if (!$r) return [];
preg_match('/^(.+?) </', $r['author'], $am);
return [
'hash' => $r['sha'],
'subject' => $r['message'],
'author' => $am[1] ?? $r['author'],
'when' => _time_ago($r['time']),
];
}
$fmt = '%H|%s|%an|%ar';
$line = git_run($dir, ['log', '-1', '--format=' . $fmt, $ref]);
if (!$line) return [];
[$hash, $subject, $author, $when] = explode('|', $line, 4);
return compact('hash', 'subject', 'author', 'when');
}
function repo_log(string $dir, string $ref, int $n = 30, int $skip = 0): array {
if (!shell_available()) {
$gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref";
$all = gl_log($dir, $gl_ref, $n + $skip);
$slice = array_slice($all, $skip, $n);
$result = [];
foreach ($slice as $c) {
preg_match('/^(.+?) </', $c['author'], $am);
$result[] = [
'hash' => $c['sha'],
'subject' => $c['message'],
'author' => $am[1] ?? $c['author'],
'when' => _time_ago($c['time']),
'date' => date('Y-m-d', $c['time']),
];
}
return $result;
}
$fmt = '%H|%s|%an|%ar|%ad';
$out = git_run($dir, ['log', '-' . $n, '--skip=' . $skip, '--format=' . $fmt, '--date=short', $ref]);
if (!$out) return [];
$commits = [];
foreach (explode("\n", $out) as $line) {
if (!$line) continue;
[$hash, $subject, $author, $when, $date] = explode('|', $line, 5);
$commits[] = compact('hash', 'subject', 'author', 'when', 'date');
}
return $commits;
}
function repo_tree(string $dir, string $ref, string $path = ''): array {
if (!shell_available()) {
$gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref";
$entries = gl_ls_tree($dir, $gl_ref, $path);
if (!$entries) return [];
$items = [];
foreach ($entries as $e) {
$items[] = [
'mode' => $e['mode'], 'type' => $e['type'],
'hash' => $e['sha'], 'size' => '-', 'name' => $e['name'],
];
}
usort($items, fn($a, $b) => ($a['type'] === 'tree' ? -1 : 1) <=> ($b['type'] === 'tree' ? -1 : 1));
return $items;
}
$target = $path ? $ref . ':' . $path : $ref . ':';
$out = git_run($dir, ['ls-tree', '--long', $target]);
if (!$out) return [];
$items = [];
foreach (explode("\n", $out) as $line) {
if (!$line) continue;
preg_match('/^(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/', $line, $m);
if (!$m) continue;
$items[] = [
'mode' => $m[1], 'type' => $m[2], 'hash' => $m[3],
'size' => $m[4], 'name' => $m[5],
];
}
usort($items, fn($a, $b) => ($a['type'] === 'tree' ? -1 : 1) <=> ($b['type'] === 'tree' ? -1 : 1));
return $items;
}
function repo_blob(string $dir, string $ref, string $path): string {
if (!shell_available()) {
$gl_ref = ($ref === 'HEAD') ? 'HEAD' : "refs/heads/$ref";
return gl_cat_file($dir, $gl_ref, $path) ?? '';
}
return git_run($dir, ['show', $ref . ':' . $path]);
}
function repo_commit(string $dir, string $hash): array {
if (!shell_available()) {
$obj = gl_read_object($dir, $hash);
if (!$obj || $obj['type'] !== 'commit') return [];
$c = gl_parse_commit($obj['data']);
preg_match('/^(.+?) <(.+?)> (\d+)/', $c['author'], $am);
preg_match('/^(.+?) <(.+?)> (\d+)/', $c['committer'], $cm);
return [
'hash' => $hash,
'subject' => trim(explode("\n", $c['message'])[0]),
'body' => implode("\n", array_slice(explode("\n", $c['message']), 1)),
'author' => $am[1] ?? '',
'email' => $am[2] ?? '',
'date' => isset($am[3]) ? date('Y-m-d H:i:s', (int)$am[3]) : '',
'parents' => implode(' ', $c['parents']),
'diff' => '',
];
}
$fmt = '%H|%s|%b|%an|%ae|%ad|%P';
$line = git_run($dir, ['show', '-s', '--format=' . $fmt, '--date=format:%Y-%m-%d %H:%M:%S', $hash]);
if (!$line) return [];
$parts = explode('|', $line, 7);
$diff = git_run($dir, ['show', '--stat', '--format=', $hash]);
return [
'hash' => $parts[0], 'subject' => $parts[1], 'body' => $parts[2],
'author' => $parts[3], 'email' => $parts[4], 'date' => $parts[5],
'parents' => $parts[6] ?? '', 'diff' => $diff,
];
}
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
/**
* Return an HTML link to a user's public profile, with their display name.
* Falls back to the username if no display name is set.
*/
function owner_link(string $username): string {
if (!$username) return '—';
$users = load_users();
$display = $users[$username]['name'] ?? $username;
$url = site_url('user/' . rawurlencode($username));
return '<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') {
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 '<div class="toolbar"><span>Repositories</span></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">';
echo '<tr><th>Name</th><th>Owner</th><th>Description</th><th>Last commit</th><th>When</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) . '">⑂</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">↓ 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">⑂ Forked</a>';
} else {
echo ' <a href="' . site_url($name . '/fork') . '" class="os2-btn fork-btn">⑂ 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>';
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">⑂ 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">⑂ ' . $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">⑂</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">⑂ 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">↓ 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 = '📁';
$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 = '📄';
$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">↓</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">↓ 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();
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']) . ' <' . h($c['email']) . '></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" > 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> > username > @group > @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) . '">⑂ '
. '<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>';
}
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) . '">⑂ '
. 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'];
}
}
// ── 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>';
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();
}
// ── 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']);
$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>';
}
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;
}
* { 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;
}
</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');
}
});
// 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;
}