<?php
// ─────────────────────────────────────────────────────────────────────────────
// GitGram admin.php — front-end management panel
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/gitlib.php';
if (session_status() === PHP_SESSION_NONE) {
session_name('gitgram');
session_start();
}
// ── Auth guard ────────────────────────────────────────────────────────────────
function load_users(): array {
return json_decode(@file_get_contents(DATA_PATH . '/users.json'), true) ?? [];
}
function save_users(array $u): void {
file_put_contents(DATA_PATH . '/users.json', json_encode($u, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
function load_settings(): array {
$d = ['site_title' => 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');
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin — <?= $site_title ?></title>
<style>
:root {
--desktop: #008080; --win-bg: #c0c0c0; --title-bg: #000080; --title-txt: #fff;
--hi: #fff; --sh: #808080; --dk: #404040;
--accent: #000080;
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{height:100%;}
body{background:var(--desktop);font-family:"Arial","Helvetica",sans-serif;font-size:13px;padding:10px;min-height:100vh;}
a{color:var(--accent);text-decoration:underline;}
a:hover{color:#0000cc;}
/* Window */
.window{max-width:1060px;margin:0 auto;background:var(--win-bg);
border-top:2px solid var(--hi);border-left:2px solid var(--hi);
border-right:2px solid var(--dk);border-bottom:2px solid var(--dk);
display:flex;flex-direction:column;min-height:calc(100vh - 20px);}
/* Titlebar */
.titlebar{background:var(--title-bg);color:var(--title-txt);display:flex;
align-items:center;height:22px;padding:0 3px;gap:2px;user-select:none;}
.tb-sysmenu{width:18px;height:14px;background:var(--win-bg);color:#000;
border-top:1px solid var(--hi);border-left:1px solid var(--hi);
border-right:1px solid var(--dk);border-bottom:1px solid var(--dk);
display:flex;align-items:center;justify-content:center;font-size:9px;}
.tb-title{flex:1;text-align:center;font-size:12px;font-weight:bold;}
.tb-btn{width:16px;height:14px;background:var(--win-bg);color:#000;
border-top:1px solid var(--hi);border-left:1px solid var(--hi);
border-right:1px solid var(--dk);border-bottom:1px solid var(--dk);
display:flex;align-items:center;justify-content:center;font-size:10px;
font-weight:bold;cursor:pointer;text-decoration:none;}
.tb-btn:hover{background:#d4d4d4;}
/* Menubar */
.menubar{background:var(--win-bg);border-bottom:1px solid var(--sh);
padding:2px 8px;display:flex;gap:16px;align-items:center;}
.menubar a{color:#000;text-decoration:none;font-size:12px;padding:1px 4px;}
.menubar a:hover{background:var(--accent);color:#fff;}
.menubar .right{margin-left:auto;display:flex;gap:14px;align-items:center;}
.menubar .right .who{font-size:11px;color:#555;}
/* Win body */
.win-body{flex:1;border-top:1px solid var(--sh);border-left:1px solid var(--sh);
border-right:1px solid var(--hi);border-bottom:1px solid var(--hi);
margin:4px;background:#fff;display:flex;flex-direction:column;overflow:hidden;}
/* Sidebar + main */
.layout{display:flex;flex:1;overflow:hidden;}
.sidebar{width:150px;background:#e8e8e8;border-right:1px solid #ccc;
flex-shrink:0;padding:8px 0;}
.sidebar a{display:block;padding:5px 14px;font-size:12px;color:#000;text-decoration:none;
border-left:3px solid transparent;}
.sidebar a:hover{background:#d8d8f8;}
.sidebar a.active{background:#fff;border-left:3px solid var(--accent);font-weight:bold;}
.sidebar .nav-section{padding:10px 14px 3px;font-size:10px;color:#888;text-transform:uppercase;letter-spacing:.05em;}
.main{flex:1;overflow:auto;padding:16px;}
/* Flashes */
.flash{padding:7px 12px;margin-bottom:10px;font-size:12px;border-left:4px solid;}
.flash.ok {background:#f0fff0;border-color:#008000;color:#040;}
.flash.err {background:#fff0f0;border-color:#cc0000;color:#800;}
.flash.warn{background:#fffbe6;border-color:#c0a000;color:#640;}
/* Section headings */
.section-hd{font-weight:bold;font-size:13px;border-bottom:1px solid #ccc;
padding-bottom:4px;margin-bottom:12px;display:flex;align-items:center;gap:10px;}
/* Tables */
table{border-collapse:collapse;width:100%;}
th{text-align:left;padding:4px 8px;background:#e8e8e8;border-bottom:2px solid #bbb;font-size:12px;}
td{padding:4px 8px;border-bottom:1px solid #eee;vertical-align:middle;font-size:12px;}
tr:hover td{background:#f0f0ff;}
.mono{font-family:monospace;font-size:11px;}
/* Badges */
.badge{display:inline-block;font-size:10px;padding:1px 6px;border-radius:2px;font-weight:bold;}
.badge-admin{background:#000080;color:#fff;}
.badge-user {background:#666;color:#fff;}
.badge-used {background:#888;color:#fff;}
.badge-open {background:#008000;color:#fff;}
.badge-exp {background:#cc0000;color:#fff;}
/* Forms */
label{display:block;font-weight:bold;font-size:12px;margin-top:8px;margin-bottom:3px;}
label small{font-weight:normal;color:#666;}
input[type=text],input[type=password],input[type=email],
input[type=date],input[type=number],select,textarea{
width:100%;padding:4px 6px;font-size:13px;font-family:inherit;
border-top:2px solid var(--sh);border-left:2px solid var(--sh);
border-right:2px solid var(--hi);border-bottom:2px solid var(--hi);
background:#fff;}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);}
textarea{font-family:monospace;font-size:12px;resize:vertical;}
.form-row{display:flex;gap:12px;}
.form-row>*{flex:1;}
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px;}
/* Buttons */
.btn{padding:4px 16px;font-size:12px;font-family:inherit;cursor:pointer;
background:var(--win-bg);
border-top:2px solid var(--hi);border-left:2px solid var(--hi);
border-right:2px solid var(--dk);border-bottom:2px solid var(--dk);}
.btn:active{border-top:2px solid var(--dk);border-left:2px solid var(--dk);
border-right:2px solid var(--hi);border-bottom:2px solid var(--hi);}
.btn.primary{background:#ccccff;font-weight:bold;}
.btn.danger {background:#ffcccc;}
.btn.sm{padding:2px 8px;font-size:11px;}
/* Cards */
.card{border:1px solid #ccc;padding:12px 14px;margin-bottom:14px;background:#fafafa;}
.card-title{font-weight:bold;font-size:12px;margin-bottom:8px;color:#000080;}
/* Access rules editor */
.access-help{font-size:11px;color:#555;margin-bottom:6px;line-height:1.6;}
.perm-chip{display:inline-block;background:#e0e8ff;border:1px solid #99b;
padding:1px 6px;font-size:11px;font-family:monospace;margin:1px;}
/* Invite table */
.code-cell{font-family:monospace;font-size:13px;font-weight:bold;letter-spacing:.05em;}
/* Status bar */
.statusbar{background:var(--win-bg);border-top:1px solid var(--sh);
padding:2px 8px;font-size:11px;color:#555;display:flex;gap:4px;flex-shrink:0;}
.sc{border-top:1px inset var(--sh);padding:1px 8px;}
/* Repo delete confirm */
.danger-zone{border:2px solid #cc0000;padding:12px;margin-top:16px;background:#fff8f8;}
.danger-zone .dz-title{font-weight:bold;color:#cc0000;margin-bottom:8px;}
/* Responsive collapse */
@media(max-width:600px){
.sidebar{width:100%;border-right:none;border-bottom:1px solid #ccc;}
.layout{flex-direction:column;}
.form-row,.form-grid{flex-direction:column;display:block;}
}
</style>
</head>
<body id="body">
<div class="window" id="window">
<div class="titlebar">
<div class="tb-sysmenu">▪</div>
<div class="tb-title"><?= $site_title ?> — Admin Panel</div>
<div class="tb-btn" id="btn-min" title="Minimize">▼</div>
<div class="tb-btn" id="btn-max" title="Maximize">▲</div>
<a class="tb-btn" href="<?= h($end_url) ?>" title="Close">✕</a>
</div>
<div class="menubar">
<a href="<?= h($home_url) ?>">← Site</a>
<a href="<?= h($readme_url) ?>">Help</a>
<div class="right">
<span class="who"><?= h($cur_user) ?> [admin]</span>
<a href="<?= h(site_url('logout')) ?>">Logout</a>
</div>
</div>
<div class="win-body" id="win-body">
<div class="layout">
<!-- Sidebar nav -->
<div class="sidebar">
<div class="nav-section">Manage</div>
<a href="?tab=users" class="<?= $tab==='users' ? 'active' : '' ?>">Users</a>
<a href="?tab=repos" class="<?= $tab==='repos' ? 'active' : '' ?>">Repositories</a>
<a href="?tab=downloads" class="<?= $tab==='downloads' ? 'active' : '' ?>">Downloads</a>
<a href="?tab=invites" class="<?= $tab==='invites' ? 'active' : '' ?>">Invites</a>
<div class="nav-section">System</div>
<a href="?tab=settings" class="<?= $tab==='settings' ? 'active' : '' ?>">Settings</a>
</div>
<!-- Main content -->
<div class="main">
<?php foreach ($flashes as [$type, $msg]): ?>
<div class="flash <?= h($type) ?>"><?= $msg ?></div>
<?php endforeach; ?>
<?php /* ════════════════════════════════════════════════════════ USERS TAB */ ?>
<?php if ($tab === 'users'): ?>
<div class="section-hd">
Users
<button class="btn sm primary" onclick="togglePanel('panel-add-user')">+ Add User</button>
</div>
<!-- Add/Edit User form -->
<div class="card" id="panel-add-user" style="display:none">
<div class="card-title">Add / Edit User</div>
<form method="post">
<input type="hidden" name="action" value="user_save">
<input type="hidden" name="tab" value="users">
<div class="form-grid">
<div>
<label>Username <small>(a–z, 0–9, - _ , 2–32 chars)</small></label>
<input type="text" name="username" autocomplete="off" pattern="[a-z0-9_\-]{2,32}" required
list="existing-users">
<datalist id="existing-users">
<?php foreach ($all_users as $un => $_): ?>
<option value="<?= h($un) ?>">
<?php endforeach; ?>
</datalist>
</div>
<div>
<label>Display Name</label>
<input type="text" name="name" required>
</div>
<div>
<label>Email <small>(optional)</small></label>
<input type="email" name="email">
</div>
<div>
<label>Role</label>
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div>
<label>Password <small>(leave blank to keep existing)</small></label>
<input type="password" name="password" autocomplete="new-password">
</div>
<div>
<label>Groups <small>(comma-separated)</small></label>
<input type="text" name="groups" placeholder="devs, leads">
</div>
</div>
<button type="submit" class="btn primary" style="margin-top:10px">Save User</button>
</form>
</div>
<!-- Users table -->
<table>
<tr><th>Username</th><th>Name</th><th>Email</th><th>Role</th><th>Groups</th><th></th></tr>
<?php foreach ($all_users as $un => $u): ?>
<tr>
<td class="mono"><strong><?= h($un) ?></strong></td>
<td><?= h($u['name'] ?? '') ?></td>
<td class="mono" style="font-size:11px;color:#555"><?= h($u['email'] ?? '') ?></td>
<td><span class="badge badge-<?= h($u['role'] ?? 'user') ?>"><?= h($u['role'] ?? 'user') ?></span></td>
<td style="font-size:11px;color:#666"><?= h(implode(', ', $u['groups'] ?? [])) ?></td>
<td style="white-space:nowrap">
<button class="btn sm" onclick="fillEditUser(<?= htmlspecialchars(json_encode([
'username' => $un, 'name' => $u['name'] ?? '', 'email' => $u['email'] ?? '',
'role' => $u['role'] ?? 'user', 'groups' => implode(', ', $u['groups'] ?? [])
])) ?>)">Edit</button>
<?php if ($un !== $cur_user): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Delete <?= h($un) ?>?')">
<input type="hidden" name="action" value="user_delete">
<input type="hidden" name="tab" value="users">
<input type="hidden" name="username" value="<?= h($un) ?>">
<button class="btn sm danger">Del</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php /* ════════════════════════════════════════════════════════ REPOS TAB */ ?>
<?php elseif ($tab === 'repos'): ?>
<div class="section-hd">
Repositories
<button class="btn sm primary" onclick="togglePanel('panel-new-repo')">+ New Repo</button>
</div>
<!-- Create repo -->
<div class="card" id="panel-new-repo" style="display:none">
<div class="card-title">Create Repository</div>
<form method="post">
<input type="hidden" name="action" value="repo_create">
<input type="hidden" name="tab" value="repos">
<div class="form-row">
<div>
<label>Repository name</label>
<input type="text" name="repo_name" pattern="[a-zA-Z0-9_\-\.]{1,64}"
placeholder="my-project" required autocomplete="off">
</div>
<div>
<label>Description <small>(optional)</small></label>
<input type="text" name="repo_desc" placeholder="Short description">
</div>
</div>
<button type="submit" class="btn primary" style="margin-top:10px">Create</button>
<small style="margin-left:10px;color:#666">Initialises a bare repo in <code>repos/</code></small>
</form>
</div>
<!-- Repos table -->
<?php if (empty($all_repos)): ?>
<p style="color:#888">No repositories yet.</p>
<?php else: ?>
<table>
<tr><th>Name</th><th>Description</th><th>Access rules</th><th></th></tr>
<?php foreach ($all_repos as $rname => $r):
$access = $repos_cfg[$rname]['access'] ?? null;
$last = git_run_admin($r['dir'], ['log','-1','--format=%ar']);
?>
<tr>
<td class="mono"><a href="<?= h(site_url($rname)) ?>"><?= h($rname) ?></a></td>
<td style="font-size:12px;color:#555"><?= h($r['desc']) ?></td>
<td>
<?php if ($access): ?>
<?php foreach ($access as $subj => $perm): ?>
<span class="perm-chip"><?= h($subj) ?>:<?= h($perm) ?></span>
<?php endforeach; ?>
<?php else: ?>
<span style="color:#999;font-size:11px">private (admin only)</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap">
<button class="btn sm" onclick="openRepoEdit(<?= htmlspecialchars(json_encode([
'name' => $rname,
'desc' => $r['desc'],
'access' => $access ? json_encode($access, JSON_PRETTY_PRINT) : "{\n \"@all\": \"R\"\n}",
])) ?>)">Edit</button>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<!-- Edit repo panel -->
<div class="card" id="panel-repo-edit" style="display:none;margin-top:16px">
<div class="card-title" id="repo-edit-title">Edit Repository</div>
<form method="post" id="form-repo-access">
<input type="hidden" name="action" value="repo_access_save">
<input type="hidden" name="tab" value="repos">
<input type="hidden" name="repo_name" id="edit-repo-name">
<label>Description</label>
<input type="text" name="repo_desc" id="edit-repo-desc">
<label>Access Rules <small>(JSON — see <a href="<?= h(site_url('readme')) ?>" target="_blank">docs</a>)</small></label>
<div class="access-help">
Keys: <span class="perm-chip">@all</span>
<span class="perm-chip">@groupname</span>
<span class="perm-chip">username</span>
Values: <span class="perm-chip">R</span>
<span class="perm-chip">RW</span>
<span class="perm-chip">RW+</span>
<span class="perm-chip">-</span>
</div>
<textarea name="access_json" id="edit-repo-access" rows="7"></textarea>
<div style="margin-top:10px;display:flex;gap:10px">
<button type="submit" class="btn primary">Save Access Rules</button>
<button type="button" class="btn" onclick="document.getElementById('panel-repo-edit').style.display='none'">Cancel</button>
</div>
</form>
<!-- Description-only save -->
<form method="post" style="display:none" id="form-repo-desc">
<input type="hidden" name="action" value="repo_save">
<input type="hidden" name="tab" value="repos">
<input type="hidden" name="repo_name" id="save-repo-name">
<input type="hidden" name="repo_desc" id="save-repo-desc">
</form>
<div class="danger-zone">
<div class="dz-title">⚠ Delete Repository</div>
<p style="font-size:12px;margin-bottom:8px">This permanently destroys all commits and data. Type the repo name to confirm.</p>
<form method="post" onsubmit="return confirm('PERMANENTLY delete this repo?')">
<input type="hidden" name="action" value="repo_delete">
<input type="hidden" name="tab" value="repos">
<input type="hidden" name="repo_name" id="del-repo-name">
<div class="form-row" style="align-items:flex-end">
<div>
<label>Type repo name to confirm</label>
<input type="text" name="confirm_name" autocomplete="off">
</div>
<div style="flex:0">
<button type="submit" class="btn danger" style="white-space:nowrap">Delete Repo</button>
</div>
</div>
</form>
</div>
</div>
<?php /* ═══════════════════════════════════════════════════════ INVITES TAB */ ?>
<?php elseif ($tab === 'invites'): ?>
<div class="section-hd">Invite Codes</div>
<?php $s = $settings; ?>
<?php if (!$s['registration_open']): ?>
<div class="flash warn">Registration is currently closed. Invite codes will not work until you re-enable registration in Settings.</div>
<?php elseif (!$s['invite_only']): ?>
<div class="flash warn">Invite-only mode is off. Anyone can register without a code. Enable it in Settings.</div>
<?php endif; ?>
<!-- Generate invites -->
<div class="card">
<div class="card-title">Generate Invite Codes</div>
<form method="post">
<input type="hidden" name="action" value="invite_create">
<input type="hidden" name="tab" value="invites">
<div class="form-row">
<div>
<label>How many codes</label>
<input type="number" name="count" value="1" min="1" max="20" style="max-width:80px">
</div>
<div>
<label>Expires <small>(optional — leave blank for no expiry)</small></label>
<input type="date" name="expires">
</div>
</div>
<button type="submit" class="btn primary" style="margin-top:10px">Generate</button>
</form>
</div>
<!-- Unused codes -->
<?php
$unused = array_filter($invites, fn($i) => $i['used_by'] === null);
$used = array_filter($invites, fn($i) => $i['used_by'] !== null);
?>
<div class="section-hd" style="margin-top:4px">Unused Codes (<?= count($unused) ?>)</div>
<?php if ($unused): ?>
<table>
<tr><th>Code</th><th>Created by</th><th>Created</th><th>Expires</th><th></th></tr>
<?php foreach ($unused as $code => $inv):
$expired = $inv['expires'] && $inv['expires'] < date('Y-m-d');
?>
<tr>
<td class="code-cell"><?= h($code) ?></td>
<td><?= h($inv['created_by']) ?></td>
<td style="font-size:11px;color:#555"><?= h($inv['created_at']) ?></td>
<td>
<?php if ($inv['expires']): ?>
<span class="badge <?= $expired ? 'badge-exp' : 'badge-open' ?>"><?= h($inv['expires']) ?></span>
<?php else: echo '<span style="color:#888;font-size:11px">never</span>'; ?>
<?php endif; ?>
</td>
<td>
<form method="post" style="display:inline" onsubmit="return confirm('Revoke <?= h($code) ?>?')">
<input type="hidden" name="action" value="invite_revoke">
<input type="hidden" name="tab" value="invites">
<input type="hidden" name="code" value="<?= h($code) ?>">
<button class="btn sm danger">Revoke</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php else: ?>
<p style="color:#888;font-size:12px">No unused codes.</p>
<?php endif; ?>
<div class="section-hd" style="margin-top:16px">Used Codes (<?= count($used) ?>)</div>
<?php if ($used): ?>
<table>
<tr><th>Code</th><th>Used by</th><th>Used at</th></tr>
<?php foreach ($used as $code => $inv): ?>
<tr>
<td class="code-cell" style="color:#999"><?= h($code) ?></td>
<td><?= h($inv['used_by']) ?></td>
<td style="font-size:11px;color:#555"><?= h($inv['used_at'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php else: ?>
<p style="color:#888;font-size:12px">No used codes yet.</p>
<?php endif; ?>
<?php /* ════════════════════════════════════════════════════ SETTINGS TAB */ ?>
<?php elseif ($tab === 'settings'): ?>
<div class="section-hd">Site Settings</div>
<form method="post" style="max-width:520px">
<input type="hidden" name="action" value="settings_save">
<input type="hidden" name="tab" value="settings">
<div class="card">
<div class="card-title">General</div>
<label>Site Title</label>
<input type="text" name="site_title" value="<?= h($settings['site_title'] ?? SITE_TITLE) ?>">
</div>
<div class="card">
<div class="card-title">Registration</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:normal;margin-top:4px">
<input type="checkbox" name="registration_open" value="1" <?= !empty($settings['registration_open']) ? 'checked' : '' ?>>
<span><strong>Registration open</strong> — allow new accounts to be created via <code>/register</code></span>
</label>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:normal;margin-top:10px">
<input type="checkbox" name="invite_only" value="1" <?= !empty($settings['invite_only']) ? 'checked' : '' ?>>
<span><strong>Invite-only</strong> — require a valid invite code to register</span>
</label>
<p style="font-size:11px;color:#666;margin-top:8px">
Invite codes are managed in the Invites tab.
</p>
</div>
<button type="submit" class="btn primary">Save Settings</button>
</form>
<?php /* ════════════════════════════════════════════════ DOWNLOADS TAB */ ?>
<?php elseif ($tab === 'downloads'): ?>
<div class="section-hd">Downloads</div>
<?php
$dl = load_downloads();
$rows = [];
foreach ($all_repos as $rname => $r) {
$zip = (int)($dl[$rname]['zip'] ?? 0);
$git = (int)($dl[$rname]['git'] ?? 0);
$rows[] = ['name' => $rname, 'zip' => $zip, 'git' => $git, 'total' => $zip + $git];
}
?>
<?php if (empty($rows)): ?>
<p style="color:#888">No repositories yet.</p>
<?php else: ?>
<p style="font-size:11px;color:#666;margin-bottom:10px">
<strong>ZIP</strong> = browser archive downloads |
<strong>Git</strong> = git clone / fetch operations.
Click a column heading to sort.
</p>
<table id="dl-table">
<tr>
<th class="dl-sortable" data-col="0" style="cursor:pointer;user-select:none">Repository <span class="sort-ind"></span></th>
<th class="dl-sortable" data-col="1" style="cursor:pointer;user-select:none">ZIP <span class="sort-ind"></span></th>
<th class="dl-sortable" data-col="2" style="cursor:pointer;user-select:none">Git <span class="sort-ind"></span></th>
<th class="dl-sortable" data-col="3" style="cursor:pointer;user-select:none">Total <span class="sort-ind"></span></th>
</tr>
<?php foreach ($rows as $row): ?>
<tr>
<td class="mono"><a href="<?= h(site_url($row['name'])) ?>"><?= h($row['name']) ?></a></td>
<td><?= $row['zip'] ?></td>
<td><?= $row['git'] ?></td>
<td><?= $row['total'] ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php endif; ?>
</div><!-- main -->
</div><!-- layout -->
</div><!-- win-body -->
<div class="statusbar">
<div class="sc" id="status-text">Admin Panel</div>
<div class="sc"><?= h($cur_user) ?></div>
<div class="sc"><?= h($settings['site_title'] ?? SITE_TITLE) ?></div>
</div>
</div><!-- window -->
<script>
(function(){
var body = document.getElementById('body');
var winBody = document.getElementById('win-body');
var status = document.getElementById('status-text');
var minimized = false, maximized = false;
document.getElementById('btn-min').addEventListener('click', function(){
minimized = !minimized;
winBody.style.display = minimized ? 'none' : '';
status.textContent = minimized ? 'Minimized' : 'Admin Panel';
});
document.getElementById('btn-max').addEventListener('click', function(){
maximized = !maximized;
if (maximized) {
body.style.padding = '0';
document.querySelector('.window').style.cssText = 'max-width:100%;min-height:100vh;border:none;';
} else {
body.style.padding = '10px';
document.querySelector('.window').style.cssText = '';
}
if (minimized){ minimized=false; winBody.style.display=''; }
status.textContent = maximized ? 'Maximized' : 'Admin Panel';
});
document.querySelectorAll('a[href]').forEach(function(a){
a.addEventListener('mouseenter',function(){ status.textContent = a.href; });
a.addEventListener('mouseleave',function(){ status.textContent = 'Admin Panel'; });
});
})();
function togglePanel(id){
var p = document.getElementById(id);
p.style.display = p.style.display === 'none' ? 'block' : 'none';
}
function fillEditUser(data){
document.getElementById('panel-add-user').style.display = 'block';
var f = document.getElementById('panel-add-user').querySelector('form');
f.querySelector('[name=username]').value = data.username;
f.querySelector('[name=name]').value = data.name;
f.querySelector('[name=email]').value = data.email;
f.querySelector('[name=role]').value = data.role;
f.querySelector('[name=groups]').value = data.groups;
f.querySelector('[name=password]').value = '';
f.querySelector('[name=username]').scrollIntoView({behavior:'smooth',block:'center'});
}
// ── Downloads table sorting ───────────────────────────────────────────────────
(function(){
var tbl = document.getElementById('dl-table');
if (!tbl) return;
var sortCol = -1, sortAsc = true;
tbl.querySelectorAll('th.dl-sortable').forEach(function(th){
th.addEventListener('click', function(){
var col = parseInt(th.getAttribute('data-col'));
if (sortCol === col) { sortAsc = !sortAsc; }
else { sortCol = col; sortAsc = true; }
var rows = Array.from(tbl.rows).slice(1);
rows.sort(function(a, b){
var av = a.cells[col].textContent.trim();
var bv = b.cells[col].textContent.trim();
var an = parseFloat(av), bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) { return sortAsc ? an - bn : bn - an; }
return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
});
rows.forEach(function(r){ tbl.appendChild(r); });
tbl.querySelectorAll('th.dl-sortable .sort-ind').forEach(function(s){ s.textContent = ''; });
th.querySelector('.sort-ind').textContent = sortAsc ? ' ▲' : ' ▼';
});
});
})();
function openRepoEdit(data){
var p = document.getElementById('panel-repo-edit');
p.style.display = 'block';
document.getElementById('repo-edit-title').textContent = 'Edit: ' + data.name;
document.getElementById('edit-repo-name').value = data.name;
document.getElementById('edit-repo-desc').value = data.desc;
document.getElementById('edit-repo-access').value = data.access;
document.getElementById('del-repo-name').value = data.name;
document.getElementById('save-repo-name').value = data.name;
p.scrollIntoView({behavior:'smooth', block:'start'});
}
</script>
</body>
</html>