SITE_TITLE, 'registration_open' => true, 'invite_only' => false]; return array_merge($d, json_decode(@file_get_contents(DATA_PATH . '/settings.json'), true) ?? []); } function save_settings(array $s): void { file_put_contents(DATA_PATH . '/settings.json', json_encode($s, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); } function load_invites(): array { return json_decode(@file_get_contents(DATA_PATH . '/invites.json'), true) ?? []; } function save_invites(array $i): void { file_put_contents(DATA_PATH . '/invites.json', json_encode($i, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); } function load_repos_config(): array { return json_decode(@file_get_contents(DATA_PATH . '/repos.json'), true) ?? []; } function load_downloads(): array { return json_decode(@file_get_contents(DATA_PATH . '/downloads.json'), true) ?? []; } function save_repos_config(array $r): void { file_put_contents(DATA_PATH . '/repos.json', json_encode($r, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); } function repo_list_all(): array { $repos = []; foreach (glob(REPO_PATH . '/*.git', GLOB_ONLYDIR) as $dir) { $name = basename($dir, '.git'); $desc = trim(@file_get_contents($dir . '/description') ?: ''); if (str_starts_with($desc, 'Unnamed repository')) $desc = ''; $repos[$name] = ['dir' => $dir, 'desc' => $desc]; } return $repos; } function run_cmd_admin(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')) { return ['out' => trim(shell_exec($cmd) ?? ''), 'rc' => 0]; } return ['out' => '', 'rc' => -1]; } function git_run_admin(string $repo_dir, array $args): string { $cmd = array_map('escapeshellarg', array_merge([GIT_BIN, '--git-dir=' . $repo_dir], $args)); $r = run_cmd_admin(implode(' ', $cmd) . ' 2>/dev/null'); return $r['out']; } function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } 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, '/'); } function admin_url(string $tab = ''): string { return site_url('admin' . ($tab ? '?tab=' . $tab : '')); } function flash(string $type, string $msg): void { $_SESSION['flash'][] = [$type, $msg]; } function get_flashes(): array { $f = $_SESSION['flash'] ?? []; unset($_SESSION['flash']); return $f; } // Require admin session — redirect to login if not authenticated $cur_user = $_SESSION['gitgram_user'] ?? null; if (!$cur_user) { header('Location: ' . site_url('login') . '?next=admin'); exit; } $users = load_users(); if (($users[$cur_user]['role'] ?? '') !== 'admin') { http_response_code(403); echo '403 Forbidden — admin access required.'; exit; } // ── Handle POST actions ─────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? ''; // ── Users ── if ($action === 'user_save') { $u = load_users(); $username = trim($_POST['username'] ?? ''); $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); $role = in_array($_POST['role'] ?? '', ['admin','user']) ? $_POST['role'] : 'user'; $groups = array_values(array_filter(array_map('trim', explode(',', $_POST['groups'] ?? '')))); $password = $_POST['password'] ?? ''; $is_new = !isset($u[$username]); if (!preg_match('/^[a-z0-9_\-]{2,32}$/', $username)) { flash('err', 'Invalid username format.'); } else { $entry = $u[$username] ?? []; $entry['name'] = $name; $entry['email'] = $email; $entry['role'] = $role; $entry['groups'] = $groups; if ($password !== '') { if (strlen($password) < 8) { flash('err', 'Password must be ≥8 characters.'); goto redirect; } $entry['password_hash'] = password_hash($password, PASSWORD_DEFAULT); } elseif ($is_new) { flash('err', 'Password required for new users.'); goto redirect; } $u[$username] = $entry; save_users($u); flash('ok', ($is_new ? 'User created: ' : 'User updated: ') . $username); } } elseif ($action === 'user_delete') { $u = load_users(); $username = trim($_POST['username'] ?? ''); $admins = array_filter($u, fn($x) => $x['role'] === 'admin'); if ($username === $cur_user) { flash('err', 'Cannot delete your own account.'); } elseif (count($admins) <= 1 && ($u[$username]['role'] ?? '') === 'admin') { flash('err', 'Cannot delete the last admin.'); } elseif (!isset($u[$username])) { flash('err', 'User not found.'); } else { unset($u[$username]); save_users($u); flash('ok', "User deleted: $username"); } // ── Repos ── } elseif ($action === 'repo_create') { $name = trim($_POST['repo_name'] ?? ''); $desc = trim($_POST['repo_desc'] ?? ''); if (!preg_match('/^[a-zA-Z0-9_\-\.]{1,64}$/', $name)) { flash('err', 'Invalid repo name. Use letters, numbers, hyphens, dots, underscores.'); } else { $dir = REPO_PATH . '/' . $name . '.git'; if (is_dir($dir)) { flash('err', "Repository '$name' already exists."); } else { // Use pure-PHP init when shell is unavailable $shell_ok = function_exists('proc_open') || function_exists('exec') || function_exists('shell_exec'); if (!$shell_ok) { $ok = gl_init_bare($dir); $rc = $ok ? 0 : 1; $err_msg = $ok ? '' : 'gl_init_bare() failed — check directory permissions.'; } else { $r = run_cmd_admin(escapeshellarg(GIT_BIN) . ' init --bare ' . escapeshellarg($dir) . ' 2>&1'); $rc = $r['rc']; $err_msg = $r['out']; } if ($rc !== 0) { flash('err', 'git init failed: ' . $err_msg); } else { if ($desc) file_put_contents($dir . '/description', $desc . "\n"); // Default: private (no access entry) — admin still has full access flash('ok', "Repository created: $name"); } } } } elseif ($action === 'repo_save') { $name = trim($_POST['repo_name'] ?? ''); $desc = trim($_POST['repo_desc'] ?? ''); $dir = REPO_PATH . '/' . $name . '.git'; if (is_dir($dir)) { file_put_contents($dir . '/description', $desc . "\n"); flash('ok', "Description updated: $name"); } else { flash('err', "Repository not found: $name"); } } elseif ($action === 'repo_access_save') { $name = trim($_POST['repo_name'] ?? ''); $raw = trim($_POST['access_json'] ?? ''); $rc = load_repos_config(); $parsed = json_decode($raw, true); if ($parsed === null && $raw !== 'null' && $raw !== '') { flash('err', 'Invalid JSON in access rules.'); } else { if ($parsed === null || $parsed === []) { unset($rc[$name]); } else { $rc[$name]['access'] = $parsed; $rc[$name]['description'] = trim($_POST['repo_desc'] ?? $rc[$name]['description'] ?? ''); } save_repos_config($rc); flash('ok', "Access rules saved: $name"); } } elseif ($action === 'repo_delete') { $name = trim($_POST['repo_name'] ?? ''); $confirm = trim($_POST['confirm_name'] ?? ''); if ($name !== $confirm) { flash('err', 'Confirmation name did not match.'); } else { $dir = REPO_PATH . '/' . $name . '.git'; if (!is_dir($dir)) { flash('err', "Repository not found: $name"); } else { // Recursive delete $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); // Remove from repos.json $rc = load_repos_config(); unset($rc[$name]); save_repos_config($rc); flash('ok', "Repository deleted: $name"); } } // ── Invites ── } elseif ($action === 'invite_create') { $n = max(1, min(20, (int)($_POST['count'] ?? 1))); $exp = trim($_POST['expires'] ?? ''); $inv = load_invites(); for ($i = 0; $i < $n; $i++) { $code = strtoupper(bin2hex(random_bytes(4))); $inv[$code] = [ 'created_by' => $cur_user, 'created_at' => date('Y-m-d H:i:s'), 'expires' => $exp ?: null, 'used_by' => null, 'used_at' => null, ]; } save_invites($inv); flash('ok', "Generated $n invite code(s)."); } elseif ($action === 'invite_revoke') { $code = trim($_POST['code'] ?? ''); $inv = load_invites(); if (isset($inv[$code]) && $inv[$code]['used_by'] === null) { unset($inv[$code]); save_invites($inv); flash('ok', "Invite $code revoked."); } else { flash('err', 'Invite not found or already used.'); } // ── Settings ── } elseif ($action === 'settings_save') { $s = load_settings(); $s['site_title'] = trim($_POST['site_title'] ?? '') ?: SITE_TITLE; $s['registration_open'] = !empty($_POST['registration_open']); $s['invite_only'] = !empty($_POST['invite_only']); save_settings($s); flash('ok', 'Settings saved.'); } redirect: $tab = $_POST['tab'] ?? 'users'; header('Location: ' . admin_url($tab)); exit; } // ── Render ──────────────────────────────────────────────────────────────────── $tab = $_GET['tab'] ?? 'users'; $settings = load_settings(); $all_users = load_users(); $all_repos = repo_list_all(); $repos_cfg = load_repos_config(); $invites = load_invites(); $flashes = get_flashes(); $site_title = h($settings['site_title'] ?? SITE_TITLE); $end_url = site_url('end-of-internet'); $home_url = site_url(); $readme_url = site_url('readme'); ?> Admin — <?= $site_title ?>
— Admin Panel
Users
$u): ?>
UsernameNameEmailRoleGroups
Repositories

No repositories yet.

$r): $access = $repos_cfg[$rname]['access'] ?? null; $last = git_run_admin($r['dir'], ['log','-1','--format=%ar']); ?>
NameDescriptionAccess rules
$perm): ?> : private (admin only)
Invite Codes
Registration is currently closed. Invite codes will not work until you re-enable registration in Settings.
Invite-only mode is off. Anyone can register without a code. Enable it in Settings.
Generate Invite Codes
$i['used_by'] === null); $used = array_filter($invites, fn($i) => $i['used_by'] !== null); ?>
Unused Codes ()
$inv): $expired = $inv['expires'] && $inv['expires'] < date('Y-m-d'); ?>
CodeCreated byCreatedExpires
never'; ?>

No unused codes.

Used Codes ()
$inv): ?>
CodeUsed byUsed at

No used codes yet.

Site Settings
General
Registration

Invite codes are managed in the Invites tab.

Downloads
$r) { $zip = (int)($dl[$rname]['zip'] ?? 0); $git = (int)($dl[$rname]['git'] ?? 0); $rows[] = ['name' => $rname, 'zip' => $zip, 'git' => $git, 'total' => $zip + $git]; } ?>

No repositories yet.

ZIP = browser archive downloads  |  Git = git clone / fetch operations. Click a column heading to sort.

Repository ZIP Git Total
Admin Panel