121 lines
5.7 KiB
PHP
Executable file
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) : '');
|
|
}
|