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 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 ?>
= $site_title ?> — Admin Panel
▼
▲
✕
= $msg ?>
Users
| Username | Name | Email | Role | Groups | |
$u): ?>
| = h($un) ?> |
= h($u['name'] ?? '') ?> |
= h($u['email'] ?? '') ?> |
= h($u['role'] ?? 'user') ?> |
= h(implode(', ', $u['groups'] ?? [])) ?> |
|
Repositories
No repositories yet.
| Name | Description | Access rules | |
$r):
$access = $repos_cfg[$rname]['access'] ?? null;
$last = git_run_admin($r['dir'], ['log','-1','--format=%ar']);
?>
| = h($rname) ?> |
= h($r['desc']) ?> |
$perm): ?>
= h($subj) ?>:= h($perm) ?>
private (admin only)
|
|
Edit Repository
⚠ Delete Repository
This permanently destroys all commits and data. Type the repo name to confirm.
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 (= count($unused) ?>)
| Code | Created by | Created | Expires | |
$inv):
$expired = $inv['expires'] && $inv['expires'] < date('Y-m-d');
?>
| = h($code) ?> |
= h($inv['created_by']) ?> |
= h($inv['created_at']) ?> |
= h($inv['expires']) ?>
never'; ?>
|
|
No unused codes.
Used Codes (= count($used) ?>)
| Code | Used by | Used at |
$inv): ?>
| = h($code) ?> |
= h($inv['used_by']) ?> |
= h($inv['used_at'] ?? '') ?> |
No used codes yet.
Site Settings
Admin Panel
= h($cur_user) ?>
= h($settings['site_title'] ?? SITE_TITLE) ?>