LAN_Portal/admin.php
2026-10-16 16:22:59 -04:00

121 lines
5.7 KiB
PHP
Executable file

<?php
// Optional editing support, included by index.php. Delete this file for a read-only portal.
if (!defined('PORTAL')) { http_response_code(403); exit; }
// Password hash. Create one with:
// php -r 'echo password_hash("your-password", PASSWORD_DEFAULT), "\n";'
// While this is empty, editing is switched off.
const ADMIN_PASSWORD_HASH = '$2y$12$98qfYdiklqjpY/2S4CL.e./XYEnO22GUjPCsIKxMWTLt4SWerRLkm';
// Only start a session for visitors who already have one or are logging in.
if (isset($_COOKIE[session_name()]) || isset($_GET['adminpswd'])) {
session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax']);
}
$here = '/' . ltrim((string)strtok($_SERVER['REQUEST_URI'], '?'), '/');
function go($query = '') { global $here; header('Location: ' . $here . $query); exit; }
// ?adminpswd=... : check it, then redirect to the same page without it.
if (isset($_GET['adminpswd'])) {
$pw = $_GET['adminpswd'];
if (is_string($pw) && ADMIN_PASSWORD_HASH !== '' && password_verify($pw, ADMIN_PASSWORD_HASH)) {
session_regenerate_id(true);
$_SESSION['admin'] = true;
$_SESSION['csrf'] = bin2hex(random_bytes(16));
} else {
$_SESSION['flash'] = ['danger', 'Incorrect password.'];
}
$rest = $_GET;
unset($rest['adminpswd']);
go($rest ? '?' . http_build_query($rest) : '');
}
if (session_status() === PHP_SESSION_ACTIVE) {
$isAdmin = !empty($_SESSION['admin']);
$csrf = (string)($_SESSION['csrf'] ?? '');
if (isset($_SESSION['flash'])) { $flash = $_SESSION['flash']; unset($_SESSION['flash']); }
}
// Read a trimmed single-line text field. Returns null if invalid (empty when required, too long, control characters).
function field($name, $max, $required = true) {
$v = trim((string)($_POST[$name] ?? ''));
if ($v === '') return $required ? null : '';
return (strlen($v) > $max || !preg_match('/^[^\x00-\x1F\x7F]*$/u', $v)) ? null : $v;
}
// Lock, re-read the current JSON, apply one change, write atomically. Returns an error message or null.
function portal_update($change) {
$lock = @fopen(CONFIG_FILE . '.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX)) return 'Could not lock the configuration.';
$cfg = json_decode((string)@file_get_contents(CONFIG_FILE), true);
if (!is_array($cfg)) return 'config.json is missing or malformed. Nothing was saved; fix the file first.';
$err = $change($cfg);
if ($err) return $err;
$json = json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
$tmp = tempnam(dirname(CONFIG_FILE), 'cfg');
if (!$tmp || file_put_contents($tmp, $json) !== strlen($json) || !@rename($tmp, CONFIG_FILE)) {
if ($tmp) @unlink($tmp);
return 'Could not write config.json. Check the directory permissions.';
}
return null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if (!$isAdmin || !hash_equals($csrf, (string)($_POST['csrf'] ?? ''))) {
http_response_code(403);
exit('Not allowed.');
}
$act = (string)($_POST['action'] ?? '');
if ($act === 'exit') { session_destroy(); go(); }
$id = (string)($_POST['id'] ?? '');
$err = portal_update(function (array &$cfg) use ($act, $id) {
$cfg['links'] = $cfg['links'] ?? [];
$i = null;
foreach ($cfg['links'] as $k => $l) { if (($l['id'] ?? '') === $id) $i = $k; }
if ($act === 'save_site') {
$t = field('title', 80);
$w = field('welcome', 300, false);
if ($t === null || $w === null) return 'Title is required (max 80) and welcome is max 300 characters, one line.';
$cfg['title'] = $t;
$cfg['welcome'] = $w;
} elseif ($act === 'save_appearance') {
$m = $_POST['mode'] ?? '';
$a = $_POST['accent'] ?? '';
if (!in_array($m, MODES, true) || !in_array($a, ACCENTS, true)) return 'Invalid appearance choice.';
$cfg['appearance'] = ['mode' => $m, 'accent' => $a] + ($cfg['appearance'] ?? []);
} elseif ($act === 'save_link') {
$t = field('title', 60);
$d = field('description', 200, false);
$u = field('url', 300);
$ic = field('icon', 16, false);
if ($t === null || $d === null || $u === null || $ic === null) return 'Title and URL are required; check the lengths.';
if (!preg_match('~^(https?://[^\s/]+|/(?!/))~i', $u)) return 'URL must start with http://, https:// or a single /.';
$row = ['title' => $t, 'description' => $d, 'url' => $u, 'icon' => $ic, 'enabled' => isset($_POST['enabled'])];
if ($id === 'new') $cfg['links'][] = ['id' => 'l' . bin2hex(random_bytes(4))] + $row;
elseif ($i === null) return 'That link no longer exists.';
else $cfg['links'][$i] = $row + $cfg['links'][$i];
} elseif ($i === null) {
return 'That link no longer exists.';
} elseif ($act === 'toggle') {
$cfg['links'][$i]['enabled'] = empty($cfg['links'][$i]['enabled']);
} elseif ($act === 'delete_link') {
array_splice($cfg['links'], $i, 1);
} elseif ($act === 'up' || $act === 'down') {
$j = $act === 'up' ? $i - 1 : $i + 1;
if (isset($cfg['links'][$j])) {
$tmp = $cfg['links'][$i];
$cfg['links'][$i] = $cfg['links'][$j];
$cfg['links'][$j] = $tmp;
}
} else {
return 'Unknown action.';
}
return null;
});
$_SESSION['flash'] = $err ? ['danger', $err] : ['success', 'Saved.'];
$back = ['save_site' => 'site', 'save_appearance' => 'appearance', 'save_link' => $id][$act] ?? '';
go($err && $back !== '' ? '?edit=' . urlencode($back) : '');
}