Files
2026-10-20 13:16:38 +09:00

245 lines
10 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/security.php';
require_once __DIR__ . '/params.php';
security_bootstrap();
function e($v): string
{
return htmlspecialchars(input_str($v), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** Request values may be arrays when tampered with; anything non-scalar becomes ''. */
function input_str($v): string
{
return is_scalar($v) ? (string)$v : '';
}
/** Root-relative URL of the app, so it works under /calcserubi/ or a vhost root. */
function base_url(string $path = ''): string
{
static $base = null;
if ($base === null) {
$script = str_replace('\\', '/', $_SERVER['SCRIPT_NAME'] ?? '/');
$appRoot = str_replace('\\', '/', realpath(__DIR__ . '/..'));
$file = str_replace('\\', '/', realpath($_SERVER['SCRIPT_FILENAME'] ?? '') ?: '');
$rel = $file && strpos($file, $appRoot) === 0 ? substr($file, strlen($appRoot)) : '';
$base = rtrim($rel ? substr($script, 0, strlen($script) - strlen($rel)) : dirname($script), '/') . '/';
}
return $base . ltrim($path, '/');
}
function app_version(): string
{
return (string)(app_config()['app']['version'] ?? '1');
}
/**
* Versioned asset URL: ?v=<app version>-<file mtime>. Any file updated on the
* server gets a new URL, so clients never run a stale cached JS/CSS file.
*/
function asset(string $path): string
{
$file = __DIR__ . '/../' . $path;
$v = app_version() . (is_file($file) ? '-' . base_convert((string)filemtime($file), 10, 36) : '');
return base_url($path) . '?v=' . rawurlencode($v);
}
/** Public pages: security headers + always revalidate (so the newest versioned assets load). */
function send_no_cache_headers(): void
{
send_security_headers(false);
}
const SESSION_COOKIE = 'serubi_admin';
function start_session(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_name(SESSION_COOKIE);
session_set_cookie_params([
'lifetime' => 0,
'path' => base_url(),
'httponly' => true,
'samesite' => 'Strict',
'secure' => is_https(),
]);
session_start();
}
function csrf_token(): string
{
start_session();
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf" value="' . e(csrf_token()) . '">';
}
function csrf_check(): void
{
start_session();
$sent = $_POST['csrf'] ?? '';
if (!is_string($sent) || empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $sent)) {
http_response_code(403);
header('Cache-Control: no-store');
exit('Sesi formulir kedaluwarsa. Muat ulang halaman lalu coba lagi.');
}
}
function flash(?string $type = null, ?string $message = null): ?array
{
start_session();
if ($type !== null) {
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
return null;
}
$f = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $f;
}
function redirect(string $url): void
{
header('Location: ' . $url, true, 303);
exit;
}
function audit(string $action, string $summary, ?array $changes = null): void
{
$admin = $_SESSION['admin'] ?? null;
db()->prepare('INSERT INTO audit_log (admin_id, username, action, summary, changes, ip) VALUES (?,?,?,?,?,?)')
->execute([$admin['id'] ?? null, $admin['username'] ?? null, $action, $summary,
$changes ? json_encode($changes, JSON_UNESCAPED_UNICODE) : null, client_ip()]);
// Every parameter change is audited after it is written: drop the cached parameters here.
if (strpos($action, 'param') === 0) {
params_cache_clear();
}
}
// ---- Number formatting / parsing (Indonesian conventions) -------------------
function fmt_idr(?float $v): string
{
return $v === null ? '' : number_format($v, 0, ',', '.');
}
/** 0.0075 -> "0,75" */
function fmt_pct(?float $v): string
{
if ($v === null) {
return '';
}
$s = rtrim(rtrim(number_format($v * 100, 4, ',', ''), '0'), ',');
return $s === '' || $s === '-0' ? '0' : $s;
}
/** "1.000.000" -> 1000000.0; "" -> null */
function parse_idr($s): ?float
{
$s = preg_replace('/[^0-9]/', '', input_str($s));
return $s === '' ? null : (float)$s;
}
/** "0,75" or "0.75" -> 0.0075; "" -> null; invalid -> false */
function parse_pct($s)
{
$s = trim(str_replace([' ', '%'], '', input_str($s)));
if ($s === '') {
return null;
}
$s = str_replace(',', '.', $s);
if (!preg_match('/^-?\d+(\.\d+)?$/', $s)) {
return false;
}
return round(((float)$s) / 100, 8);
}
function fmt_setting(array $row): string
{
$v = (float)$row['value'];
switch ($row['unit']) {
case 'pct':
return fmt_pct($v);
case 'idr':
return fmt_idr($v);
default:
return rtrim(rtrim(number_format($v, 2, ',', ''), '0'), ',');
}
}
function fmt_datetime(?string $s): string
{
if (!$s) {
return '—';
}
$months = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
$t = strtotime($s);
return date('j', $t) . ' ' . $months[(int)date('n', $t)] . ' ' . date('Y, H.i', $t);
}
// ---- Icons (Lucide, ISC licence) --------------------------------------------
function icon(string $name, string $class = 'icon'): string
{
static $paths = [
'printer' => '<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>',
'copy' => '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'reset' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'info' => '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'check' => '<path d="M20 6 9 17l-5-5"/>',
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'logout' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'trash' => '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'history' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
'sliders' => '<path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/>',
'wallet' => '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
'grid' => '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
'pie' => '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
'calc' => '<rect width="16" height="20" x="4" y="2" rx="2"/><path d="M8 6h8"/><path d="M16 14v4"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/>',
'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
'moon' => '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
'chevron' => '<path d="m6 9 6 6 6-6"/>',
'save' => '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
'key' => '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
'external' => '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3"/>',
'menu' => '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
];
return '<svg class="' . e($class) . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . ($paths[$name] ?? '') . '</svg>';
}
/**
* Official Bank Papua logo. 'auto' swaps the color logo for the white one in
* dark mode; 'white' is for navy surfaces; 'color' for paper/print.
* The images are decorative (alt=""); the surrounding link or heading carries the name.
*/
function bank_logo(string $variant = 'auto', string $class = ''): string
{
$img = function (string $file, string $cls): string {
return '<img class="logo ' . $cls . '" src="' . e(asset('assets/img/' . $file)) . '" alt="" width="589" height="120" decoding="async">';
};
$color = $img('logo-bank-papua.png', 'logo-color');
$white = $img('logo-bank-papua-white.png', 'logo-white');
$html = $variant === 'white' ? $white : ($variant === 'color' ? $color : $color . $white);
return '<span class="logo-wrap logo-' . e($variant) . ($class ? ' ' . e($class) : '') . '">' . $html . '</span>';
}
/** Inline script that applies the saved theme before first paint. */
function theme_boot_script(): string
{
return '<script nonce="' . csp_nonce() . '">' . "try{var t=localStorage.getItem('serubi-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t}catch(e){}</script>";
}