asd
This commit is contained in:
commit
3e4a056b14
67 files changed
+13926
No files matched your search
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
/*
|
||||
* POST handlers for the admin page. Each returns null on success (after a
|
||||
* flash + redirect) or an array ['errors' => [...], 'draft' => [...]] so the
|
||||
* form can be re-rendered with what the admin typed.
|
||||
*/
|
||||
|
||||
const MAX_TIER_ROWS = 30;
|
||||
const MAX_NOMINAL = 1e15; // Rp1.000 triliun; also keeps values inside DECIMAL(20,0)
|
||||
|
||||
/** Rejects oversized or malformed array input before any processing. */
|
||||
function guard_rows(array $rows, array &$errors, string $key, string $label): array
|
||||
{
|
||||
if (count($rows) > MAX_TIER_ROWS) {
|
||||
$errors[$key] = "$label: maksimal " . MAX_TIER_ROWS . ' baris.';
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter($rows, 'is_array'));
|
||||
}
|
||||
|
||||
function nominal_error(?float $v, string $label): ?string
|
||||
{
|
||||
return $v !== null && $v > MAX_NOMINAL ? "$label terlalu besar (maksimal Rp1.000 triliun)." : null;
|
||||
}
|
||||
|
||||
function pct_bounds_error($v, string $label, float $max = 1.0): ?string
|
||||
{
|
||||
if ($v === false || $v === null) {
|
||||
return "$label wajib diisi dengan angka persen, contoh 0,75.";
|
||||
}
|
||||
if ($v < 0 || $v > $max) {
|
||||
return "$label harus antara 0 dan " . fmt_pct($max) . '%.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parses tier rows [['max' => '1.000.000', 'rate' => '0,75'], ...]; last row is "di atas". */
|
||||
function parse_tier_rows(array $rows, string $label, array &$errors, string $errKey): array
|
||||
{
|
||||
$rows = guard_rows($rows, $errors, $errKey, $label);
|
||||
if (isset($errors[$errKey])) {
|
||||
return [];
|
||||
}
|
||||
$rows = array_values(array_filter($rows, fn($r) => trim(input_str($r['max'] ?? '')) !== '' || trim(input_str($r['rate'] ?? '')) !== ''));
|
||||
if (!$rows) {
|
||||
$errors[$errKey] = "$label: minimal satu baris.";
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
$prev = -1;
|
||||
$last = count($rows) - 1;
|
||||
foreach ($rows as $i => $r) {
|
||||
$max = $i === $last ? null : parse_idr($r['max'] ?? '');
|
||||
$rate = parse_pct($r['rate'] ?? '');
|
||||
$n = $i + 1;
|
||||
if ($e = nominal_error($max, "$label baris $n")) {
|
||||
$errors[$errKey] = $e;
|
||||
} elseif ($i !== $last && $max === null) {
|
||||
$errors[$errKey] = "$label baris $n: isi batas nominal (hanya baris terakhir yang tanpa batas).";
|
||||
} elseif ($max !== null && $max <= $prev) {
|
||||
$errors[$errKey] = "$label baris $n: batas nominal harus lebih besar dari baris sebelumnya.";
|
||||
} elseif ($err = pct_bounds_error($rate, "$label baris $n")) {
|
||||
$errors[$errKey] = $err;
|
||||
}
|
||||
if ($max !== null) {
|
||||
$prev = $max;
|
||||
}
|
||||
$out[] = ['max' => $max, 'rate' => $rate === false ? null : $rate];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function changed_settings(array $old, array $new): array
|
||||
{
|
||||
$diff = [];
|
||||
foreach ($new as $k => $v) {
|
||||
if (abs((float)$old[$k]['value'] - $v) > 1e-9) {
|
||||
$diff[$k] = ['label' => $old[$k]['label'], 'unit' => $old[$k]['unit'], 'old' => (float)$old[$k]['value'], 'new' => $v];
|
||||
}
|
||||
}
|
||||
return $diff;
|
||||
}
|
||||
|
||||
function action_save_settings(): ?array
|
||||
{
|
||||
$rows = settings_rows();
|
||||
$input = (array)($_POST['s'] ?? []);
|
||||
$errors = [];
|
||||
$values = [];
|
||||
foreach ($rows as $key => $row) {
|
||||
$raw = input_str($input[$key] ?? '');
|
||||
if ($row['unit'] === 'pct') {
|
||||
$v = parse_pct($raw);
|
||||
if ($e = pct_bounds_error($v, $row['label'])) {
|
||||
$errors["s.$key"] = $e;
|
||||
continue;
|
||||
}
|
||||
} elseif ($row['unit'] === 'idr') {
|
||||
$v = parse_idr($raw);
|
||||
if ($v === null) {
|
||||
$errors["s.$key"] = $row['label'] . ' wajib diisi.';
|
||||
continue;
|
||||
}
|
||||
if ($e = nominal_error($v, $row['label'])) {
|
||||
$errors["s.$key"] = $e;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$v = is_numeric(str_replace(',', '.', $raw)) ? (float)str_replace(',', '.', $raw) : null;
|
||||
if ($v === null || $v <= 0 || $v > 1000) {
|
||||
$errors["s.$key"] = $row['label'] . ' harus angka lebih dari 0.';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$values[$key] = $v;
|
||||
}
|
||||
if (isset($values['hari_setahun']) && ($values['hari_setahun'] < 360 || $values['hari_setahun'] > 366)) {
|
||||
$errors['s.hari_setahun'] = 'Hari bunga setahun antara 360 dan 366.';
|
||||
}
|
||||
if (isset($values['hari_sebulan']) && ($values['hari_sebulan'] < 28 || $values['hari_sebulan'] > 31)) {
|
||||
$errors['s.hari_sebulan'] = 'Hari bunga sebulan antara 28 dan 31.';
|
||||
}
|
||||
if (isset($values['porsi_puab'], $values['porsi_kredit']) && abs($values['porsi_puab'] + $values['porsi_kredit'] - 1) > 1e-9) {
|
||||
$errors['s.porsi_kredit'] = 'Porsi PUAB + porsi kredit harus 100%.';
|
||||
}
|
||||
if (isset($values['gwm'], $values['kas']) && $values['gwm'] + $values['kas'] >= 1) {
|
||||
$errors['s.kas'] = 'GWM + kas harus kurang dari 100%.';
|
||||
}
|
||||
if ($errors) {
|
||||
return ['errors' => $errors, 'draft' => $input];
|
||||
}
|
||||
|
||||
$diff = changed_settings($rows, $values);
|
||||
if (!$diff) {
|
||||
flash('info', 'Tidak ada perubahan parameter.');
|
||||
redirect(base_url('admin/?tab=parameter'));
|
||||
}
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
$st = $pdo->prepare('UPDATE settings SET value = ? WHERE skey = ?');
|
||||
foreach ($diff as $k => $d) {
|
||||
$st->execute([$d['new'], $k]);
|
||||
}
|
||||
audit('param.settings', 'Mengubah ' . count($diff) . ' parameter: ' . implode(', ', array_column($diff, 'label')), $diff);
|
||||
$pdo->commit();
|
||||
flash('ok', count($diff) . ' parameter disimpan. Kalkulator langsung memakai nilai baru.');
|
||||
redirect(base_url('admin/?tab=parameter'));
|
||||
}
|
||||
|
||||
function action_save_products(): ?array
|
||||
{
|
||||
$input = (array)($_POST['tiers'] ?? []);
|
||||
$errors = [];
|
||||
$parsed = [];
|
||||
foreach (SERUBI_PRODUCTS as $key => $label) {
|
||||
$parsed[$key] = parse_tier_rows((array)($input[$key] ?? []), $label, $errors, "tiers.$key");
|
||||
}
|
||||
if ($errors) {
|
||||
return ['errors' => $errors, 'draft' => $input];
|
||||
}
|
||||
$before = load_params()['productRates'];
|
||||
$after = [];
|
||||
foreach ($parsed as $k => $rows) {
|
||||
$after[$k] = array_map(fn($r) => ['max' => $r['max'], 'rate' => $r['rate']], $rows);
|
||||
}
|
||||
$changed = array_keys(array_filter($after, fn($rows, $k) => json_encode($rows) !== json_encode($before[$k]), ARRAY_FILTER_USE_BOTH));
|
||||
if (!$changed) {
|
||||
flash('info', 'Tidak ada perubahan rate produk.');
|
||||
redirect(base_url('admin/?tab=produk'));
|
||||
}
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
$del = $pdo->prepare('DELETE FROM product_rates WHERE product = ?');
|
||||
$ins = $pdo->prepare('INSERT INTO product_rates (product, max_nominal, rate, sort) VALUES (?,?,?,?)');
|
||||
foreach ($changed as $k) {
|
||||
$del->execute([$k]);
|
||||
foreach ($after[$k] as $i => $r) {
|
||||
$ins->execute([$k, $r['max'], $r['rate'], $i]);
|
||||
}
|
||||
}
|
||||
$names = array_map(fn($k) => SERUBI_PRODUCTS[$k], $changed);
|
||||
audit('param.produk', 'Mengubah rate ' . implode(', ', $names),
|
||||
array_combine($changed, array_map(fn($k) => ['old' => $before[$k], 'new' => $after[$k]], $changed)));
|
||||
$pdo->commit();
|
||||
flash('ok', 'Rate ' . implode(', ', $names) . ' disimpan.');
|
||||
redirect(base_url('admin/?tab=produk'));
|
||||
}
|
||||
|
||||
function action_save_deposito(): ?array
|
||||
{
|
||||
$params = load_params();
|
||||
$tenors = $params['tenors'];
|
||||
$bandsIn = array_values((array)($_POST['bands'] ?? []));
|
||||
$ratesIn = array_values((array)($_POST['rates'] ?? []));
|
||||
$errors = [];
|
||||
if (count($bandsIn) > MAX_TIER_ROWS || count($ratesIn) > MAX_TIER_ROWS) {
|
||||
return ['errors' => ['deposito' => 'Maksimal ' . MAX_TIER_ROWS . ' kelompok nominal.'], 'draft' => null];
|
||||
}
|
||||
$bandsIn = array_map(fn($b) => is_array($b) ? '' : (string)$b, $bandsIn);
|
||||
$ratesIn = array_map(fn($r) => is_array($r) ? $r : [], $ratesIn);
|
||||
$count = count($bandsIn);
|
||||
if ($count === 0) {
|
||||
$errors['deposito'] = 'Minimal satu kelompok nominal.';
|
||||
}
|
||||
$bands = [];
|
||||
$matrix = [];
|
||||
$prev = -1;
|
||||
foreach ($bandsIn as $i => $raw) {
|
||||
$max = $i === $count - 1 ? null : parse_idr($raw);
|
||||
if ($e = nominal_error($max, 'Baris ' . ($i + 1))) {
|
||||
$errors['deposito'] = $e;
|
||||
} elseif ($i !== $count - 1 && $max === null) {
|
||||
$errors['deposito'] = 'Baris ' . ($i + 1) . ': isi batas nominal (hanya baris terakhir yang tanpa batas).';
|
||||
} elseif ($max !== null && $max <= $prev) {
|
||||
$errors['deposito'] = 'Baris ' . ($i + 1) . ': batas nominal harus lebih besar dari baris sebelumnya.';
|
||||
}
|
||||
if ($max !== null) {
|
||||
$prev = $max;
|
||||
}
|
||||
$bands[] = $max;
|
||||
foreach ($tenors as $t) {
|
||||
$v = parse_pct($ratesIn[$i][$t] ?? '');
|
||||
if ($e = pct_bounds_error($v, 'Rate ' . $t . ' bulan baris ' . ($i + 1))) {
|
||||
$errors['deposito'] = $e;
|
||||
}
|
||||
$matrix[$i][$t] = $v === false ? null : $v;
|
||||
}
|
||||
}
|
||||
if ($errors) {
|
||||
return ['errors' => $errors, 'draft' => ['bands' => $bandsIn, 'rates' => $ratesIn]];
|
||||
}
|
||||
$before = ['bands' => $params['depositoBands'], 'rates' => $params['depositoRates']];
|
||||
$afterRates = [];
|
||||
foreach ($tenors as $t) {
|
||||
foreach ($bands as $i => $max) {
|
||||
$afterRates[$t][] = ['max' => $max, 'rate' => $matrix[$i][$t]];
|
||||
}
|
||||
}
|
||||
if (json_encode($before['rates']) === json_encode($afterRates)) {
|
||||
flash('info', 'Tidak ada perubahan rate deposito.');
|
||||
redirect(base_url('admin/?tab=deposito'));
|
||||
}
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec('DELETE FROM deposito_bands');
|
||||
$insB = $pdo->prepare('INSERT INTO deposito_bands (max_nominal, sort) VALUES (?,?)');
|
||||
$insR = $pdo->prepare('INSERT INTO deposito_rates (band_id, tenor, rate) VALUES (?,?,?)');
|
||||
foreach ($bands as $i => $max) {
|
||||
$insB->execute([$max, $i]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
foreach ($tenors as $t) {
|
||||
$insR->execute([$id, $t, $matrix[$i][$t]]);
|
||||
}
|
||||
}
|
||||
audit('param.deposito', 'Mengubah tabel rate deposito', ['old' => $before['rates'], 'new' => $afterRates]);
|
||||
$pdo->commit();
|
||||
flash('ok', 'Rate deposito disimpan.');
|
||||
redirect(base_url('admin/?tab=deposito'));
|
||||
}
|
||||
|
||||
function action_save_cashback(): ?array
|
||||
{
|
||||
$params = load_params();
|
||||
$errors = [];
|
||||
$rowsIn = guard_rows((array)($_POST['cb'] ?? []), $errors, 'cb', 'Porsi cashback');
|
||||
if ($errors) {
|
||||
return ['errors' => $errors, 'draft' => null];
|
||||
}
|
||||
$tenorIn = array_map('intval', array_keys((array)($_POST['cbtenor'] ?? [])));
|
||||
$errors = [];
|
||||
$tiers = [];
|
||||
$seen = [];
|
||||
foreach ($rowsIn as $i => $r) {
|
||||
if (trim(input_str($r['min'] ?? '')) === '' && trim(input_str($r['share'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$min = parse_idr($r['min'] ?? '');
|
||||
$share = parse_pct($r['share'] ?? '');
|
||||
$n = $i + 1;
|
||||
if ($min === null || $min <= 0) {
|
||||
$errors['cb'] = "Baris $n: nominal minimal wajib diisi.";
|
||||
} elseif ($e = nominal_error($min, "Baris $n")) {
|
||||
$errors['cb'] = $e;
|
||||
} elseif (isset($seen[(string)$min])) {
|
||||
$errors['cb'] = "Baris $n: nominal minimal sama dengan baris lain.";
|
||||
} elseif ($e = pct_bounds_error($share, "Porsi nasabah baris $n")) {
|
||||
$errors['cb'] = $e;
|
||||
}
|
||||
$seen[(string)$min] = true;
|
||||
$tiers[] = ['min' => $min, 'nasabah' => $share === false ? null : $share];
|
||||
}
|
||||
if (!$tiers && !$errors) {
|
||||
$errors['cb'] = 'Minimal satu tingkat porsi cashback.';
|
||||
}
|
||||
$tenorIn = array_values(array_intersect($params['tenors'], $tenorIn));
|
||||
if ($errors) {
|
||||
return ['errors' => $errors, 'draft' => ['cb' => $rowsIn, 'cbtenor' => $tenorIn]];
|
||||
}
|
||||
usort($tiers, fn($a, $b) => $a['min'] <=> $b['min']);
|
||||
$before = ['tiers' => $params['cashbackTiers'], 'tenors' => $params['cashbackTenors']];
|
||||
$after = ['tiers' => $tiers, 'tenors' => $tenorIn];
|
||||
if (json_encode($before) === json_encode($after)) {
|
||||
flash('info', 'Tidak ada perubahan porsi cashback.');
|
||||
redirect(base_url('admin/?tab=cashback'));
|
||||
}
|
||||
$pdo = db();
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec('DELETE FROM cashback_tiers');
|
||||
$ins = $pdo->prepare('INSERT INTO cashback_tiers (min_nominal, share_nasabah) VALUES (?,?)');
|
||||
foreach ($tiers as $t) {
|
||||
$ins->execute([$t['min'], $t['nasabah']]);
|
||||
}
|
||||
$up = $pdo->prepare('UPDATE tenors SET cashback = ? WHERE months = ?');
|
||||
foreach ($params['tenors'] as $m) {
|
||||
$up->execute([in_array($m, $tenorIn, true) ? 1 : 0, $m]);
|
||||
}
|
||||
audit('param.cashback', 'Mengubah porsi cashback / tenor program', ['old' => $before, 'new' => $after]);
|
||||
$pdo->commit();
|
||||
flash('ok', 'Porsi cashback disimpan.');
|
||||
redirect(base_url('admin/?tab=cashback'));
|
||||
}
|
||||
|
||||
function action_reset_defaults(): ?array
|
||||
{
|
||||
if (($_POST['confirm'] ?? '') !== 'RESET') {
|
||||
return ['errors' => ['reset' => 'Ketik RESET untuk konfirmasi.'], 'draft' => []];
|
||||
}
|
||||
$before = load_params();
|
||||
db_seed(db(), true);
|
||||
audit('param.reset', 'Mengembalikan semua parameter ke nilai file Excel', ['old' => $before]);
|
||||
flash('ok', 'Semua parameter dikembalikan ke nilai file Excel 22092026.');
|
||||
redirect(base_url('admin/?tab=parameter'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Managing other admins is reserved for super admins and needs the acting
|
||||
* admin's own password (re-authentication). Returns an error response or null.
|
||||
*/
|
||||
function user_admin_guard(array $me, string $errKey, int $targetId = 0): ?array
|
||||
{
|
||||
if (!is_super_admin($me)) {
|
||||
http_response_code(403);
|
||||
return ['errors' => [$errKey => 'Hanya super admin yang dapat mengelola akun admin lain.', 'user_id' => $targetId], 'draft' => []];
|
||||
}
|
||||
if (!reauth_ok($me, input_str($_POST['confirm_password'] ?? ''))) {
|
||||
return ['errors' => [$errKey => 'Password Anda (konfirmasi) salah.', 'user_id' => $targetId], 'draft' => []];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function admin_target(int $id): ?array
|
||||
{
|
||||
$st = db()->prepare('SELECT * FROM admins WHERE id = ?');
|
||||
$st->execute([$id]);
|
||||
return $st->fetch() ?: null;
|
||||
}
|
||||
|
||||
function active_super_admins(): int
|
||||
{
|
||||
return (int)db()->query("SELECT COUNT(*) FROM admins WHERE role = 'super_admin' AND is_active = 1")->fetchColumn();
|
||||
}
|
||||
|
||||
function action_user_create(array $me): ?array
|
||||
{
|
||||
$u = strtolower(trim(input_str($_POST['username'] ?? '')));
|
||||
$n = trim(input_str($_POST['full_name'] ?? ''));
|
||||
if ($denied = user_admin_guard($me, 'user_new')) {
|
||||
$denied['draft'] = ['username' => $u, 'full_name' => $n];
|
||||
return $denied;
|
||||
}
|
||||
$p = input_str($_POST['password'] ?? '');
|
||||
$role = input_str($_POST['role'] ?? '') === 'super_admin' ? 'super_admin' : 'admin';
|
||||
$err = validate_new_admin($u, $n, $p, input_str($_POST['password_confirm'] ?? ''));
|
||||
if (!$err) {
|
||||
$st = db()->prepare('SELECT COUNT(*) FROM admins WHERE username = ?');
|
||||
$st->execute([$u]);
|
||||
if ((int)$st->fetchColumn() > 0) {
|
||||
$err = 'Username sudah dipakai.';
|
||||
}
|
||||
}
|
||||
if ($err) {
|
||||
return ['errors' => ['user_new' => $err], 'draft' => ['username' => $u, 'full_name' => $n]];
|
||||
}
|
||||
// The new admin replaces this initial password and sets up OTP at the first login.
|
||||
db()->prepare('INSERT INTO admins (username, full_name, role, password_hash, must_change_password) VALUES (?,?,?,?,1)')
|
||||
->execute([$u, $n, $role, password_hash($p, PASSWORD_DEFAULT)]);
|
||||
audit('user.create', "Menambah admin $u (" . ($role === 'super_admin' ? 'super admin' : 'admin') . ')');
|
||||
flash('ok', "Admin $u ditambahkan. Password awal wajib diganti dan OTP diaktifkan saat login pertama.");
|
||||
redirect(base_url('admin/?tab=pengguna'));
|
||||
}
|
||||
|
||||
function action_user_toggle(array $me): ?array
|
||||
{
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($denied = user_admin_guard($me, 'user', $id)) {
|
||||
return $denied;
|
||||
}
|
||||
$row = admin_target($id);
|
||||
if (!$row) {
|
||||
return ['errors' => ['user' => 'Admin tidak ditemukan.'], 'draft' => []];
|
||||
}
|
||||
if ($id === $me['id']) {
|
||||
return ['errors' => ['user' => 'Anda tidak dapat menonaktifkan akun sendiri.'], 'draft' => []];
|
||||
}
|
||||
$active = (int)$row['is_active'] ? 0 : 1;
|
||||
if (!$active && $row['role'] === 'super_admin' && active_super_admins() <= 1) {
|
||||
return ['errors' => ['user' => 'Super admin aktif terakhir tidak dapat dinonaktifkan.', 'user_id' => $id], 'draft' => []];
|
||||
}
|
||||
db()->prepare('UPDATE admins SET is_active = ? WHERE id = ?')->execute([$active, $id]);
|
||||
audit('user.toggle', ($active ? 'Mengaktifkan' : 'Menonaktifkan') . ' admin ' . $row['username']);
|
||||
flash('ok', 'Admin ' . $row['username'] . ($active ? ' diaktifkan.' : ' dinonaktifkan.'));
|
||||
redirect(base_url('admin/?tab=pengguna'));
|
||||
}
|
||||
|
||||
function action_user_password(array $me): ?array
|
||||
{
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($denied = user_admin_guard($me, 'user', $id)) {
|
||||
return $denied;
|
||||
}
|
||||
$p = input_str($_POST['password'] ?? '');
|
||||
$row = admin_target($id);
|
||||
$err = $row ? validate_password($p, input_str($_POST['password_confirm'] ?? ''), (string)$row['username']) : 'Admin tidak ditemukan.';
|
||||
if ($err) {
|
||||
return ['errors' => ['user' => $err, 'user_id' => $id], 'draft' => []];
|
||||
}
|
||||
// Temporary password: the owner must replace it at the next login (the new hash also ends their sessions).
|
||||
db()->prepare('UPDATE admins SET password_hash = ?, must_change_password = 1 WHERE id = ?')->execute([password_hash($p, PASSWORD_DEFAULT), $id]);
|
||||
audit('user.password', 'Me-reset password admin ' . $row['username']);
|
||||
flash('ok', 'Password ' . $row['username'] . ' di-reset. Pemilik akun wajib menggantinya saat login berikutnya.');
|
||||
redirect(base_url('admin/?tab=pengguna'));
|
||||
}
|
||||
|
||||
function action_user_role(array $me): ?array
|
||||
{
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($denied = user_admin_guard($me, 'user', $id)) {
|
||||
return $denied;
|
||||
}
|
||||
$row = admin_target($id);
|
||||
if (!$row || $id === $me['id']) {
|
||||
return ['errors' => ['user' => $row ? 'Anda tidak dapat mengubah peran akun sendiri.' : 'Admin tidak ditemukan.', 'user_id' => $id], 'draft' => []];
|
||||
}
|
||||
$role = $row['role'] === 'super_admin' ? 'admin' : 'super_admin';
|
||||
if ($role === 'admin' && (int)$row['is_active'] && active_super_admins() <= 1) {
|
||||
return ['errors' => ['user' => 'Harus ada minimal satu super admin aktif.', 'user_id' => $id], 'draft' => []];
|
||||
}
|
||||
db()->prepare('UPDATE admins SET role = ? WHERE id = ?')->execute([$role, $id]);
|
||||
$label = $role === 'super_admin' ? 'super admin' : 'admin';
|
||||
audit('user.role', 'Mengubah peran ' . $row['username'] . ' menjadi ' . $label);
|
||||
flash('ok', 'Peran ' . $row['username'] . ' diubah menjadi ' . $label . '.');
|
||||
redirect(base_url('admin/?tab=pengguna'));
|
||||
}
|
||||
|
||||
function action_user_reset_otp(array $me): ?array
|
||||
{
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($denied = user_admin_guard($me, 'user', $id)) {
|
||||
return $denied;
|
||||
}
|
||||
$row = admin_target($id);
|
||||
if (!$row || $id === $me['id']) {
|
||||
return ['errors' => ['user' => $row ? 'OTP akun sendiri tidak dapat di-reset dari sini.' : 'Admin tidak ditemukan.', 'user_id' => $id], 'draft' => []];
|
||||
}
|
||||
// Lost phone: the owner sets up a new authenticator at the next login.
|
||||
db()->prepare('UPDATE admins SET otp_secret = NULL, otp_enabled = 0, otp_last_step = NULL, recovery_codes = NULL WHERE id = ?')->execute([$id]);
|
||||
audit('user.otp', 'Me-reset OTP admin ' . $row['username']);
|
||||
flash('ok', 'OTP ' . $row['username'] . ' di-reset. Authenticator baru dipasang saat login berikutnya.');
|
||||
redirect(base_url('admin/?tab=pengguna'));
|
||||
}
|
||||
|
||||
function action_own_password(array $me): ?array
|
||||
{
|
||||
$st = db()->prepare('SELECT password_hash FROM admins WHERE id = ?');
|
||||
$st->execute([$me['id']]);
|
||||
$hash = (string)$st->fetchColumn();
|
||||
$err = null;
|
||||
if (!password_verify(input_str($_POST['current'] ?? ''), $hash)) {
|
||||
$err = 'Password saat ini salah.';
|
||||
} else {
|
||||
$err = validate_password(input_str($_POST['password'] ?? ''), input_str($_POST['password_confirm'] ?? ''), $me['username']);
|
||||
}
|
||||
if ($err) {
|
||||
return ['errors' => ['own' => $err], 'draft' => []];
|
||||
}
|
||||
$newHash = password_hash(input_str($_POST['password']), PASSWORD_DEFAULT);
|
||||
db()->prepare('UPDATE admins SET password_hash = ?, must_change_password = 0 WHERE id = ?')->execute([$newHash, $me['id']]);
|
||||
// keep this session, end every other session of this account
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['pwv'] = password_fingerprint($newHash);
|
||||
audit('user.password', 'Mengganti password sendiri');
|
||||
flash('ok', 'Password Anda diganti.');
|
||||
redirect(base_url('admin/?tab=pengguna'));
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
require_once __DIR__ . '/totp.php';
|
||||
|
||||
const LOGIN_MAX_FAILS = 5; // per IP within the window: the IP is locked out
|
||||
const LOGIN_MAX_FAILS_USER = 10; // per username within the window: audited + shown to the owner (no lockout)
|
||||
const LOGIN_WINDOW_MIN = 15;
|
||||
const PRE_AUTH_TTL = 300; // seconds between a correct password and the OTP step
|
||||
const SESSION_IDLE_MIN = 30;
|
||||
const SESSION_ABSOLUTE_HOURS = 8;
|
||||
const SESSION_ROTATE_MIN = 15;
|
||||
const PASSWORD_MIN = 10;
|
||||
|
||||
/** Short fingerprint of the password hash; a password change invalidates every other session. */
|
||||
function password_fingerprint(string $hash): string
|
||||
{
|
||||
return substr(hash('sha256', $hash), 0, 16);
|
||||
}
|
||||
|
||||
function current_admin(): ?array
|
||||
{
|
||||
start_session();
|
||||
$a = $_SESSION['admin'] ?? null;
|
||||
if (!$a) {
|
||||
return null;
|
||||
}
|
||||
$now = time();
|
||||
if ($now - ($_SESSION['seen'] ?? 0) > SESSION_IDLE_MIN * 60
|
||||
|| $now - ($_SESSION['started'] ?? 0) > SESSION_ABSOLUTE_HOURS * 3600) {
|
||||
logout_admin();
|
||||
return null;
|
||||
}
|
||||
if ($now - ($_SESSION['rotated'] ?? 0) > SESSION_ROTATE_MIN * 60) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['rotated'] = $now;
|
||||
}
|
||||
$_SESSION['seen'] = $now;
|
||||
return $a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a logged-in session is no longer valid, or null when it is: the account
|
||||
* may have been deactivated, its password changed, or it logged in elsewhere.
|
||||
*/
|
||||
/** The admin's current database row (read once per request). */
|
||||
function admin_db_row(int $id, bool $fresh = false): ?array
|
||||
{
|
||||
static $rows = [];
|
||||
if ($fresh || !array_key_exists($id, $rows)) {
|
||||
$st = db()->prepare('SELECT * FROM admins WHERE id = ?');
|
||||
$st->execute([$id]);
|
||||
$rows[$id] = $st->fetch() ?: null;
|
||||
}
|
||||
return $rows[$id];
|
||||
}
|
||||
|
||||
function admin_session_problem(array $a): ?string
|
||||
{
|
||||
$row = admin_db_row($a['id']);
|
||||
if (!$row || !(int)$row['is_active']) {
|
||||
return 'inactive';
|
||||
}
|
||||
if (!hash_equals((string)($_SESSION['pwv'] ?? ''), password_fingerprint($row['password_hash']))) {
|
||||
return 'password';
|
||||
}
|
||||
if (!hash_equals((string)($_SESSION['login_at'] ?? ''), (string)$row['last_login_at'])) {
|
||||
return 'elsewhere'; // one active session per account: the newest login wins
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function require_admin(): array
|
||||
{
|
||||
send_security_headers(true);
|
||||
$a = current_admin();
|
||||
if (!$a) {
|
||||
redirect(base_url('admin/login.php'));
|
||||
}
|
||||
if ($reason = admin_session_problem($a)) {
|
||||
logout_admin();
|
||||
redirect(base_url('admin/login.php?reason=' . $reason));
|
||||
}
|
||||
// Role and pending password change are read from the database on every
|
||||
// request, so a change by a super admin applies immediately.
|
||||
$row = admin_db_row($a['id']);
|
||||
$a['role'] = $row['role'] === 'super_admin' ? 'super_admin' : 'admin';
|
||||
$a['must_change'] = (int)$row['must_change_password'] === 1;
|
||||
return $a;
|
||||
}
|
||||
|
||||
function is_super_admin(array $me): bool
|
||||
{
|
||||
return ($me['role'] ?? '') === 'super_admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-authentication before managing other admins: the acting admin types
|
||||
* their own password. Wrong answers count like failed logins (IP lockout).
|
||||
*/
|
||||
function reauth_ok(array $me, string $password): bool
|
||||
{
|
||||
$row = admin_db_row($me['id'], true);
|
||||
$ok = $row && password_verify($password, $row['password_hash']);
|
||||
if (!$ok) {
|
||||
db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,0)')
|
||||
->execute([client_ip(), substr('reauth:' . $me['username'], 0, 50)]);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* The logged-in admin on a public page, or null. Never redirects, and never
|
||||
* starts a session for visitors who have no admin session cookie.
|
||||
*/
|
||||
function viewing_admin(): ?array
|
||||
{
|
||||
if (empty($_COOKIE[SESSION_COOKIE])) {
|
||||
return null;
|
||||
}
|
||||
$a = current_admin();
|
||||
if ($a && admin_session_problem($a)) {
|
||||
logout_admin();
|
||||
return null;
|
||||
}
|
||||
return $a;
|
||||
}
|
||||
|
||||
function admin_count(): int
|
||||
{
|
||||
return (int)db()->query('SELECT COUNT(*) FROM admins')->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this IP has too many failures. There is deliberately no lockout
|
||||
* per username (anyone could lock an admin out by guessing on purpose); the
|
||||
* CAPTCHA and the OTP step slow down distributed guessing instead, and the
|
||||
* owner is told about failed attempts at the next login.
|
||||
*/
|
||||
function login_locked(): bool
|
||||
{
|
||||
$st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND success = 0 AND created_at > (NOW() - INTERVAL ' . LOGIN_WINDOW_MIN . ' MINUTE)');
|
||||
$st->execute([client_ip()]);
|
||||
return (int)$st->fetchColumn() >= LOGIN_MAX_FAILS;
|
||||
}
|
||||
|
||||
/** Failed password/OTP attempts recorded for a username since a moment (null = last 30 days). */
|
||||
function failed_attempts_since(string $username, ?string $since): int
|
||||
{
|
||||
$st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE username IN (?, ?) AND success = 0 AND created_at > ?');
|
||||
$st->execute([substr($username, 0, 50), substr('otp:' . $username, 0, 50), $since ?: date('Y-m-d H:i:s', time() - 30 * 86400)]);
|
||||
return (int)$st->fetchColumn();
|
||||
}
|
||||
|
||||
/** Keeps the attempts table small; called on login. */
|
||||
function prune_login_attempts(): void
|
||||
{
|
||||
if (random_int(1, 50) === 1) {
|
||||
db()->exec('DELETE FROM login_attempts WHERE created_at < (NOW() - INTERVAL 30 DAY)');
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 1 of the login: the admin row when username and password are right, else null. */
|
||||
function verify_credentials(string $username, string $password): ?array
|
||||
{
|
||||
$st = db()->prepare('SELECT * FROM admins WHERE username = ? AND is_active = 1');
|
||||
$st->execute([$username]);
|
||||
$row = $st->fetch();
|
||||
// Verify against a dummy hash when the user does not exist to keep timing similar.
|
||||
static $dummy = null;
|
||||
if ($dummy === null) {
|
||||
$dummy = password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT);
|
||||
}
|
||||
prune_login_attempts();
|
||||
$ok = password_verify($password, $row['password_hash'] ?? $dummy) && $row;
|
||||
db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,?)')
|
||||
->execute([client_ip(), substr($username, 0, 50), $ok ? 1 : 0]);
|
||||
if (!$ok) {
|
||||
// A burst of failures on one username is recorded for the admins (no lockout, see login_locked()).
|
||||
if (failed_attempts_since($username, date('Y-m-d H:i:s', time() - LOGIN_WINDOW_MIN * 60)) === LOGIN_MAX_FAILS_USER) {
|
||||
db()->prepare("INSERT INTO audit_log (action, summary, ip) VALUES ('login.suspicious', ?, ?)")
|
||||
->execute(['Percobaan login gagal beruntun untuk username ' . substr($username, 0, 50), client_ip()]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) {
|
||||
$row['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
|
||||
db()->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')->execute([$row['password_hash'], $row['id']]);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a correct password the admin is NOT logged in yet: the session only
|
||||
* remembers who passed step 1. Mode 'verify' asks for the OTP code, mode
|
||||
* 'enroll' sets up the authenticator first (2FA is mandatory).
|
||||
*/
|
||||
function begin_second_factor(array $row): void
|
||||
{
|
||||
start_session();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['pre_auth'] = [
|
||||
'id' => (int)$row['id'],
|
||||
'mode' => (int)$row['otp_enabled'] === 1 ? 'verify' : 'enroll',
|
||||
'exp' => time() + PRE_AUTH_TTL,
|
||||
];
|
||||
}
|
||||
|
||||
/** [admin row, mode] of a login waiting for its second factor, or null. */
|
||||
function pending_login(): ?array
|
||||
{
|
||||
start_session();
|
||||
$p = $_SESSION['pre_auth'] ?? null;
|
||||
if (!$p || time() > $p['exp']) {
|
||||
unset($_SESSION['pre_auth']);
|
||||
return null;
|
||||
}
|
||||
$row = admin_db_row($p['id'], true);
|
||||
if (!$row || !(int)$row['is_active']) {
|
||||
unset($_SESSION['pre_auth']);
|
||||
return null;
|
||||
}
|
||||
return [$row, $p['mode']];
|
||||
}
|
||||
|
||||
/** Wrong OTP codes for this username within the window. */
|
||||
function otp_failures(string $username): int
|
||||
{
|
||||
$st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE username = ? AND success = 0 AND created_at > (NOW() - INTERVAL ' . LOGIN_WINDOW_MIN . ' MINUTE)');
|
||||
$st->execute([substr('otp:' . $username, 0, 50)]);
|
||||
return (int)$st->fetchColumn();
|
||||
}
|
||||
|
||||
function record_otp_attempt(string $username, bool $ok): void
|
||||
{
|
||||
db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,?)')
|
||||
->execute([client_ip(), substr('otp:' . $username, 0, 50), $ok ? 1 : 0]);
|
||||
}
|
||||
|
||||
/** Final step: creates the admin session after password + second factor. */
|
||||
function complete_login(array $row, string $method): void
|
||||
{
|
||||
unset($_SESSION['pre_auth']);
|
||||
$failed = failed_attempts_since($row['username'], $row['last_login_at']);
|
||||
// The login time doubles as the session marker: any later login on another
|
||||
// device changes it and ends this session (see require_admin()).
|
||||
$row['last_login_at'] = date('Y-m-d H:i:s');
|
||||
db()->prepare('UPDATE admins SET last_login_at = ? WHERE id = ?')->execute([$row['last_login_at'], $row['id']]);
|
||||
login_admin($row);
|
||||
audit('login', 'Masuk ke halaman admin (' . $method . ')');
|
||||
if ($failed > 0) {
|
||||
flash('info', "Ada $failed percobaan login gagal pada akun Anda sejak login terakhir. Bila itu bukan Anda, segera ganti password dan hubungi super admin.");
|
||||
}
|
||||
}
|
||||
|
||||
function login_admin(array $row): void
|
||||
{
|
||||
start_session();
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['admin'] = ['id' => (int)$row['id'], 'username' => $row['username'], 'name' => $row['full_name']];
|
||||
$_SESSION['pwv'] = password_fingerprint($row['password_hash']);
|
||||
$_SESSION['login_at'] = (string)$row['last_login_at'];
|
||||
$_SESSION['seen'] = $_SESSION['started'] = $_SESSION['rotated'] = time();
|
||||
unset($_SESSION['csrf']); // fresh CSRF token for the authenticated session
|
||||
}
|
||||
|
||||
function logout_admin(): void
|
||||
{
|
||||
start_session();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'] ?? '', (bool)$p['secure'], true);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
/** Returns an error message or null. */
|
||||
function validate_new_admin(string $username, string $name, string $password, string $confirm): ?string
|
||||
{
|
||||
if (!preg_match('/^[a-z0-9._-]{3,50}$/', $username)) {
|
||||
return 'Username 3–50 karakter: huruf kecil, angka, titik, minus, atau garis bawah.';
|
||||
}
|
||||
if (trim($name) === '' || mb_strlen($name) > 120 || preg_match('/[[:cntrl:]<>]/u', $name)) {
|
||||
return 'Nama lengkap wajib diisi, maksimal 120 karakter, tanpa karakter khusus < >.';
|
||||
}
|
||||
return validate_password($password, $confirm, $username);
|
||||
}
|
||||
|
||||
function validate_password(string $password, string $confirm, string $username = ''): ?string
|
||||
{
|
||||
if (mb_strlen($password) < PASSWORD_MIN || strlen($password) > 72) {
|
||||
return 'Password ' . PASSWORD_MIN . '–72 karakter.';
|
||||
}
|
||||
if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password) || !preg_match('/[^A-Za-z\d]/', $password)) {
|
||||
return 'Password harus berisi huruf, angka, dan simbol.';
|
||||
}
|
||||
if ($username !== '' && stripos($password, $username) !== false) {
|
||||
return 'Password tidak boleh memuat username.';
|
||||
}
|
||||
if ($password !== $confirm) {
|
||||
return 'Konfirmasi password tidak sama.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Self-hosted CAPTCHA for the admin login: the image is drawn by the server
|
||||
* (GD), so no third-party service is called and the Content-Security-Policy
|
||||
* stays strict. The answer lives only in the session, as a hash, and is valid
|
||||
* once, for CAPTCHA_TTL seconds.
|
||||
*/
|
||||
|
||||
const CAPTCHA_TTL = 300;
|
||||
const CAPTCHA_LENGTH = 5;
|
||||
const CAPTCHA_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I/L
|
||||
|
||||
function captcha_available(): bool
|
||||
{
|
||||
return function_exists('imagecreatetruecolor') && function_exists('imagepng');
|
||||
}
|
||||
|
||||
/** Starts a new challenge (called when the login form is rendered). */
|
||||
function captcha_new(): void
|
||||
{
|
||||
start_session();
|
||||
if (!captcha_available()) {
|
||||
// Without GD: a text question, weaker than the image, so login never becomes impossible.
|
||||
error_log('[serubi] Ekstensi GD tidak tersedia: CAPTCHA memakai soal teks.');
|
||||
$a = random_int(2, 9);
|
||||
$b = random_int(2, 9);
|
||||
$_SESSION['captcha'] = ['code' => (string)($a + $b), 'question' => "Berapa $a + $b?", 'exp' => time() + CAPTCHA_TTL];
|
||||
return;
|
||||
}
|
||||
$code = '';
|
||||
for ($i = 0; $i < CAPTCHA_LENGTH; $i++) {
|
||||
$code .= CAPTCHA_ALPHABET[random_int(0, strlen(CAPTCHA_ALPHABET) - 1)];
|
||||
}
|
||||
$_SESSION['captcha'] = ['code' => $code, 'exp' => time() + CAPTCHA_TTL];
|
||||
}
|
||||
|
||||
/** Text question when the image cannot be drawn, else null. */
|
||||
function captcha_question(): ?string
|
||||
{
|
||||
return $_SESSION['captcha']['question'] ?? null;
|
||||
}
|
||||
|
||||
/** Checks the answer and always consumes the challenge (one attempt per image). */
|
||||
function captcha_check(string $answer): bool
|
||||
{
|
||||
start_session();
|
||||
$c = $_SESSION['captcha'] ?? null;
|
||||
unset($_SESSION['captcha']);
|
||||
if (!$c || time() > $c['exp']) {
|
||||
return false;
|
||||
}
|
||||
$answer = strtoupper(preg_replace('/\s+/', '', $answer));
|
||||
return $answer !== '' && hash_equals($c['code'], $answer);
|
||||
}
|
||||
|
||||
/** Outputs the PNG for the current challenge. */
|
||||
function captcha_render(): void
|
||||
{
|
||||
start_session();
|
||||
$code = (string)($_SESSION['captcha']['code'] ?? '');
|
||||
$w = 200;
|
||||
$h = 64;
|
||||
$img = imagecreatetruecolor($w, $h);
|
||||
imagefilledrectangle($img, 0, 0, $w, $h, imagecolorallocate($img, 244, 246, 250));
|
||||
// background noise
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
$c = imagecolorallocate($img, random_int(140, 200), random_int(150, 205), random_int(170, 215));
|
||||
imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $c);
|
||||
}
|
||||
// each glyph is drawn small (built-in font), then scaled and jittered
|
||||
$x = 14;
|
||||
foreach (str_split($code) as $ch) {
|
||||
$g = imagecreatetruecolor(10, 16);
|
||||
$bg = imagecolorallocate($g, 244, 246, 250);
|
||||
imagefill($g, 0, 0, $bg);
|
||||
imagecolortransparent($g, $bg);
|
||||
imagestring($g, 5, 1, 0, $ch, imagecolorallocate($g, random_int(10, 60), random_int(30, 70), random_int(90, 140)));
|
||||
$size = random_int(30, 38);
|
||||
imagecopyresized($img, $g, $x, random_int(4, $h - $size - 10) + 6, 0, 0, (int)($size * 0.66), $size, 10, 16);
|
||||
$x += random_int(31, 36);
|
||||
}
|
||||
// foreground noise over the text
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
imagesetthickness($img, 2);
|
||||
imageline($img, 0, random_int(10, $h - 10), $w, random_int(10, $h - 10), imagecolorallocate($img, 60, 80, 130));
|
||||
}
|
||||
for ($i = 0; $i < 220; $i++) {
|
||||
imagesetpixel($img, random_int(0, $w - 1), random_int(0, $h - 1), imagecolorallocate($img, random_int(60, 160), random_int(60, 160), random_int(60, 160)));
|
||||
}
|
||||
header('Content-Type: image/png');
|
||||
header('Cache-Control: no-store, max-age=0');
|
||||
imagepng($img);
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
function app_config(): array
|
||||
{
|
||||
static $config = null;
|
||||
if ($config === null) {
|
||||
$config = require __DIR__ . '/../config.php';
|
||||
date_default_timezone_set($config['app']['timezone']);
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared PDO connection. Web requests only connect: the schema is created and
|
||||
* upgraded explicitly (database/migrate.php or the SQL files in database/),
|
||||
* never on each request.
|
||||
*/
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo instanceof PDO) {
|
||||
return $pdo;
|
||||
}
|
||||
$c = app_config()['db'];
|
||||
if ($c['user'] === '') {
|
||||
throw new RuntimeException('Kredensial database belum dikonfigurasi (lihat database/secrets.example.php).');
|
||||
}
|
||||
$pdo = db_connect($c, true);
|
||||
$pdo->exec("SET time_zone = '" . (new DateTime())->format('P') . "'");
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function db_connect(array $c, bool $withDatabase): PDO
|
||||
{
|
||||
$dsn = sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $c['host'], $c['port']);
|
||||
if ($withDatabase) {
|
||||
$dsn .= ';dbname=' . str_replace('`', '', $c['name']);
|
||||
}
|
||||
return new PDO($dsn, $c['user'], $c['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Runs a trusted SQL file; statements end with ";" at the end of a line (no splitting inside literals). */
|
||||
function db_run_sql_file(PDO $pdo, string $file): void
|
||||
{
|
||||
$sql = preg_replace('/^\s*--.*$/m', '', (string)file_get_contents($file));
|
||||
foreach (preg_split('/;\s*$/m', $sql) as $stmt) {
|
||||
if (trim($stmt) !== '') {
|
||||
$pdo->exec($stmt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CLI only (database/migrate.php): creates the tables and seed data on an empty database. */
|
||||
function db_migrate(PDO $pdo): void
|
||||
{
|
||||
$exists = $pdo->query("SHOW TABLES LIKE 'login_attempts'")->fetchColumn();
|
||||
if (!$exists) {
|
||||
db_run_sql_file($pdo, __DIR__ . '/../database/schema.sql');
|
||||
}
|
||||
if ((int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn() === 0) {
|
||||
db_seed($pdo);
|
||||
}
|
||||
db_clean_descriptions($pdo);
|
||||
}
|
||||
|
||||
/** Removes workbook cell references like " (COB!F28)" from older seeded descriptions (runs until none are left). */
|
||||
function db_clean_descriptions(PDO $pdo): void
|
||||
{
|
||||
$rows = $pdo->query("SELECT skey, description FROM settings WHERE description LIKE '%COB!%' OR description LIKE '%CALC!%'")->fetchAll();
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
$st = $pdo->prepare('UPDATE settings SET description = ? WHERE skey = ?');
|
||||
foreach ($rows as $r) {
|
||||
$st->execute([trim(preg_replace('/\s*\((?:COB|CALC)![A-Z0-9:]+\)/', '', (string)$r['description'])), $r['skey']]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Seeds parameters from database/defaults.php (the values of the reference workbook). */
|
||||
function db_seed(PDO $pdo, bool $wipe = false): void
|
||||
{
|
||||
$d = require __DIR__ . '/../database/defaults.php';
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($wipe) {
|
||||
foreach (['deposito_rates', 'deposito_bands', 'product_rates', 'cashback_tiers', 'tenors', 'settings'] as $t) {
|
||||
$pdo->exec("DELETE FROM $t");
|
||||
}
|
||||
}
|
||||
$st = $pdo->prepare('INSERT INTO settings (skey, value, label, unit, grp, description, sort) VALUES (?,?,?,?,?,?,?)');
|
||||
$i = 0;
|
||||
foreach ($d['settings'] as $key => [$value, $label, $unit, $grp, $desc]) {
|
||||
$st->execute([$key, $value, $label, $unit, $grp, $desc, ++$i]);
|
||||
}
|
||||
$st = $pdo->prepare('INSERT INTO tenors (months, cashback) VALUES (?,?)');
|
||||
foreach ($d['tenors'] as $m => $cb) {
|
||||
$st->execute([$m, $cb ? 1 : 0]);
|
||||
}
|
||||
$st = $pdo->prepare('INSERT INTO product_rates (product, max_nominal, rate, sort) VALUES (?,?,?,?)');
|
||||
foreach ($d['product_rates'] as $product => $tiers) {
|
||||
foreach ($tiers as $i => [$max, $rate]) {
|
||||
$st->execute([$product, $max, $rate, $i]);
|
||||
}
|
||||
}
|
||||
$band = $pdo->prepare('INSERT INTO deposito_bands (max_nominal, sort) VALUES (?,?)');
|
||||
$rate = $pdo->prepare('INSERT INTO deposito_rates (band_id, tenor, rate) VALUES (?,?,?)');
|
||||
foreach ($d['deposito_bands'] as $i => $max) {
|
||||
$band->execute([$max, $i]);
|
||||
$bandId = (int)$pdo->lastInsertId();
|
||||
foreach ($d['deposito_rates'] as $tenor => $rates) {
|
||||
$rate->execute([$bandId, $tenor, $rates[$i]]);
|
||||
}
|
||||
}
|
||||
$st = $pdo->prepare('INSERT INTO cashback_tiers (min_nominal, share_nasabah) VALUES (?,?)');
|
||||
foreach ($d['cashback_tiers'] as [$min, $share]) {
|
||||
$st->execute([$min, $share]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* SERUBI cashback engine, server side. Line-by-line port of assets/js/engine.js
|
||||
* (sheet "COB" and "CALCULATOR CASHBACK SERUBI" of the reference workbook), so
|
||||
* the public calculator gets results without receiving the bank's internal
|
||||
* parameters. Both engines are validated against tests/excel-fixtures.json.
|
||||
*/
|
||||
|
||||
const ENGINE_PRODUCTS = ['giro' => 'Giro Umum', 'simanja' => 'Tabungan SIMANJA', 'simpeda' => 'Tabungan SIMPEDA'];
|
||||
|
||||
/** Excel ROUNDDOWN(x, 0) on 15 significant digits (same as Number(x.toPrecision(15)) in JS). */
|
||||
function engine_rounddown(float $x): float
|
||||
{
|
||||
$v = (float)sprintf('%.14e', $x);
|
||||
return $v < 0 ? ceil($v) : floor($v);
|
||||
}
|
||||
|
||||
/** Tiers [{max: float|null, rate}], ascending; first tier with n <= max wins, null max = "di atas". */
|
||||
function engine_tier_rate(?array $tiers, float $n): float
|
||||
{
|
||||
foreach ($tiers ?? [] as $t) {
|
||||
if (!isset($t['max']) || $n <= $t['max']) {
|
||||
return (float)($t['rate'] ?? 0);
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/** Cashback sharing [{min, nasabah}]: highest min that n reaches wins (COB!I34:I35). */
|
||||
function engine_share(array $params, float $n, int $tenor): array
|
||||
{
|
||||
if (!in_array($tenor, $params['cashbackTenors'] ?? [], true)) {
|
||||
return ['share' => null, 'reason' => 'tenor'];
|
||||
}
|
||||
$tiers = $params['cashbackTiers'] ?? [];
|
||||
usort($tiers, fn($a, $b) => $b['min'] <=> $a['min']);
|
||||
foreach ($tiers as $t) {
|
||||
if ($n >= $t['min']) {
|
||||
return ['share' => (float)$t['nasabah'], 'reason' => null];
|
||||
}
|
||||
}
|
||||
return ['share' => null, 'reason' => 'nominal'];
|
||||
}
|
||||
|
||||
function engine_min_cashback_nominal(array $params): float
|
||||
{
|
||||
$mins = array_column($params['cashbackTiers'] ?? [], 'min');
|
||||
return $mins ? (float)min($mins) : 0.0;
|
||||
}
|
||||
|
||||
function engine_deposito_rate(array $params, float $n, int $tenor): float
|
||||
{
|
||||
if ($n < $params['settings']['deposito_min']) { // COB!K14: F4<1000000 -> 0
|
||||
return 0.0;
|
||||
}
|
||||
return engine_tier_rate($params['depositoRates'][$tenor] ?? null, $n);
|
||||
}
|
||||
|
||||
function engine_calculate(array $params, float $nominal, int $tenor, string $customerType): array
|
||||
{
|
||||
$p = $params['settings'];
|
||||
$n = max(0.0, $nominal);
|
||||
$t = $tenor;
|
||||
$customerType = $customerType === 'badan' ? 'badan' : 'perorangan';
|
||||
$year = $p['hari_setahun'];
|
||||
$month = $p['hari_sebulan'];
|
||||
|
||||
// Pengelolaan dana (COB!F23:F38)
|
||||
$gwm = $n * $p['gwm'];
|
||||
$kas = $n * $p['kas'];
|
||||
$managed = $n - ($gwm + $kas);
|
||||
$puabBase = $managed * $p['porsi_puab'];
|
||||
$puabIncome = $puabBase * $p['puab'] * ($month / $year) * $t;
|
||||
$creditBase = $managed * $p['porsi_kredit'];
|
||||
$creditIncome = $creditBase * $p['kredit'] * ($t / 12);
|
||||
$income = $puabIncome + $creditIncome;
|
||||
|
||||
$sh = engine_share($params, $n, $t);
|
||||
$eligible = $sh['share'] !== null;
|
||||
$share = $eligible ? $sh['share'] : 0.0;
|
||||
|
||||
// Biaya & spread per produk (COB!H22:R37)
|
||||
$lps = min($n, $p['lps_batas']) * $p['premi_lps'];
|
||||
$ohc = $puabIncome * $p['ohc'];
|
||||
$products = [];
|
||||
foreach (ENGINE_PRODUCTS as $key => $name) {
|
||||
$rate = engine_tier_rate($params['productRates'][$key] ?? null, $n);
|
||||
$bunga = engine_rounddown((($n * $rate * $month) / $year) * $t);
|
||||
$program = $key === 'simanja' ? $p['biaya_program'] : 0.0;
|
||||
$cost = $bunga + $program + $ohc + $lps;
|
||||
$spread = $income - $cost;
|
||||
$bungaTax = $bunga * $p['pajak_tabungan'];
|
||||
$products[$key] = [
|
||||
'key' => $key, 'name' => $name, 'rate' => $rate,
|
||||
'bungaGross' => $bunga, 'bungaTax' => $bungaTax, 'bungaNet' => $bunga - $bungaTax,
|
||||
'program' => $program, 'ohc' => $ohc, 'lps' => $lps, 'cost' => $cost, 'spread' => $spread,
|
||||
'cashbackGross' => $spread * $share,
|
||||
'costOfFund' => $managed > 0 ? ($cost + $spread * $share) / $managed : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
// Cashback = rata-rata 3 produk (COB!H7:H10)
|
||||
$gross = $eligible ? ($products['giro']['cashbackGross'] + $products['simanja']['cashbackGross'] + $products['simpeda']['cashbackGross']) / 3 : 0.0;
|
||||
$negative = $gross < 0;
|
||||
if ($negative) {
|
||||
$gross = 0.0;
|
||||
}
|
||||
$taxRate = $customerType === 'badan' ? $p['pajak_cashback_badan'] : $p['pajak_cashback_perorangan'];
|
||||
$tax = $gross * $taxRate;
|
||||
$net = $gross - $tax;
|
||||
$equiv = $n > 0 && $t > 0 ? ($net / $n) * ($year / ($month * $t)) : 0.0;
|
||||
|
||||
// Deposito pembanding (COB!K14:L17)
|
||||
$dRate = engine_deposito_rate($params, $n, $t);
|
||||
$dGross = engine_rounddown((($n * $dRate * $month) / $year) * $t);
|
||||
$dTax = $dGross * $p['pajak_deposito'];
|
||||
$dNet = $dGross - $dTax;
|
||||
$deposito = ['rate' => $dRate, 'gross' => $dGross, 'tax' => $dTax, 'net' => $dNet, 'monthly' => $t ? $dNet / $t : 0.0];
|
||||
|
||||
// Perbandingan (CALC!A10:F13)
|
||||
$comparison = [];
|
||||
foreach (ENGINE_PRODUCTS as $key => $name) {
|
||||
$pr = $products[$key];
|
||||
$total = $pr['bungaNet'] + $net;
|
||||
$comparison[] = [
|
||||
'key' => $key, 'name' => $name, 'rate' => $pr['rate'], 'bungaNet' => $pr['bungaNet'],
|
||||
'cashbackNet' => $net, 'total' => $total, 'totalRate' => $pr['rate'] + $equiv, 'diff' => $total - $dNet,
|
||||
];
|
||||
}
|
||||
$best = array_reduce($comparison, fn($a, $b) => $a === null || $b['total'] > $a['total'] ? $b : $a);
|
||||
|
||||
// Pinalti pencairan sebelum jatuh tempo (CALC!A17:B22)
|
||||
$penalty = ['refund' => $net, 'tax' => $gross * $taxRate, 'fee' => $net * $p['pinalti']];
|
||||
$penalty['total'] = $penalty['refund'] + $penalty['tax'] + $penalty['fee'];
|
||||
|
||||
return [
|
||||
'input' => ['nominal' => $n, 'tenor' => $t, 'customerType' => $customerType],
|
||||
'eligible' => $eligible && !$negative,
|
||||
'reason' => $negative ? 'spread' : $sh['reason'],
|
||||
'minNominal' => engine_min_cashback_nominal($params),
|
||||
'share' => $share, 'shareBank' => $eligible ? 1 - $share : 0.0,
|
||||
'fund' => [
|
||||
'gwm' => $gwm, 'kas' => $kas, 'managed' => $managed, 'puabBase' => $puabBase, 'puabIncome' => $puabIncome,
|
||||
'creditBase' => $creditBase, 'creditIncome' => $creditIncome, 'income' => $income,
|
||||
],
|
||||
'products' => $products,
|
||||
'cashback' => ['gross' => $gross, 'taxRate' => $taxRate, 'tax' => $tax, 'net' => $net, 'monthly' => $t ? $net / $t : 0.0, 'equiv' => $equiv],
|
||||
'deposito' => $deposito,
|
||||
'comparison' => $comparison,
|
||||
'best' => $best,
|
||||
'penalty' => $penalty,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a visitor without an admin session may see: the customer-facing result
|
||||
* only. Cost structure, spread, sharing tiers and the deposito comparison stay
|
||||
* on the server.
|
||||
*/
|
||||
function engine_public_result(array $r, array $params): array
|
||||
{
|
||||
return [
|
||||
'input' => $r['input'],
|
||||
'eligible' => $r['eligible'],
|
||||
'reason' => $r['reason'],
|
||||
'minNominal' => $r['minNominal'],
|
||||
'share' => $r['share'],
|
||||
'cashback' => $r['cashback'],
|
||||
'comparison' => array_map(fn($c) => array_diff_key($c, ['diff' => 1]), $r['comparison']),
|
||||
'penalty' => $r['penalty'] + ['rate' => $params['settings']['pinalti']],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model for the public in-browser engine (assets/js/engine-public.js), so the
|
||||
* calculator answers instantly and offline. The bank's cost structure is folded
|
||||
* into one margin per tenor:
|
||||
*
|
||||
* pendapatan PUAB − OHC + pendapatan kredit = nominal × margin[tenor]
|
||||
* margin = (1 − GWM − kas) × (porsi PUAB × rate PUAB × hari sebulan/hari setahun × tenor × (1 − OHC)
|
||||
* + porsi kredit × bunga kredit × tenor/12)
|
||||
*
|
||||
* GWM, kas, porsi PUAB/kredit, rate PUAB, bunga kredit, OHC and the deposito
|
||||
* rates are not sent. Everything else is customer-facing (product rates, taxes,
|
||||
* penalty, LPS rules) or, like the sharing tiers, can be read off the results.
|
||||
*/
|
||||
function engine_client_model(array $params): array
|
||||
{
|
||||
$p = $params['settings'];
|
||||
$year = $p['hari_setahun'];
|
||||
$month = $p['hari_sebulan'];
|
||||
$margin = [];
|
||||
foreach ($params['tenors'] as $t) {
|
||||
$margin[(string)$t] = (1 - ($p['gwm'] + $p['kas']))
|
||||
* ($p['porsi_puab'] * $p['puab'] * ($month / $year) * $t * (1 - $p['ohc'])
|
||||
+ $p['porsi_kredit'] * $p['kredit'] * ($t / 12));
|
||||
}
|
||||
return [
|
||||
'tenors' => $params['tenors'],
|
||||
'cashbackTenors' => $params['cashbackTenors'],
|
||||
'minNominal' => engine_min_cashback_nominal($params),
|
||||
'days' => ['month' => $month, 'year' => $year],
|
||||
'margin' => $margin,
|
||||
'programAvg' => $p['biaya_program'] / 3, // SIMANJA programme fee, averaged over the 3 products
|
||||
'lps' => ['rate' => $p['premi_lps'], 'cap' => $p['lps_batas']],
|
||||
'productRates' => $params['productRates'],
|
||||
'cashbackTiers' => $params['cashbackTiers'],
|
||||
'tax' => ['bunga' => $p['pajak_tabungan'], 'perorangan' => $p['pajak_cashback_perorangan'], 'badan' => $p['pajak_cashback_badan']],
|
||||
'pinalti' => $p['pinalti'],
|
||||
'paramsAt' => $params['updatedAt'],
|
||||
'generatedAt' => date('c'),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?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>";
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
const SERUBI_PRODUCTS = ['giro' => 'Giro Umum', 'simanja' => 'Tabungan SIMANJA', 'simpeda' => 'Tabungan SIMPEDA'];
|
||||
|
||||
const SERUBI_GROUPS = [
|
||||
'dana' => ['Struktur dana', 'Bagaimana dana nasabah dibagi sebelum menghasilkan pendapatan.'],
|
||||
'bunga' => ['Suku bunga pendapatan', 'Rate yang menghasilkan pendapatan bank dari dana kelolaan.'],
|
||||
'biaya' => ['Biaya', 'Biaya yang mengurangi spread sebelum dibagi ke nasabah.'],
|
||||
'pajak' => ['Pajak', 'Tarif pajak atas bunga dan cashback.'],
|
||||
'konvensi' => ['Konvensi perhitungan', 'Hari bunga, pinalti, dan batas minimal.'],
|
||||
];
|
||||
|
||||
/** Raw settings rows keyed by skey. */
|
||||
function settings_rows(): array
|
||||
{
|
||||
$rows = db()->query('SELECT * FROM settings ORDER BY sort')->fetchAll();
|
||||
return array_column($rows, null, 'skey');
|
||||
}
|
||||
|
||||
function to_num($v): ?float
|
||||
{
|
||||
return $v === null ? null : (float)$v;
|
||||
}
|
||||
|
||||
const PARAMS_CACHE_TTL = 300; // seconds; the cache is also cleared whenever an admin saves
|
||||
|
||||
function params_cache_file(): string
|
||||
{
|
||||
$dir = (string)(app_config()['app']['cache_dir'] ?? '');
|
||||
if ($dir === '') {
|
||||
$dir = sys_get_temp_dir() . '/serubi-' . substr(md5(__DIR__), 0, 12);
|
||||
}
|
||||
return rtrim($dir, '/\\') . '/params.json';
|
||||
}
|
||||
|
||||
/** Parameters in the shape assets/js/engine.js expects (cached to spare the database). */
|
||||
function load_params(): array
|
||||
{
|
||||
$file = params_cache_file();
|
||||
if (is_file($file) && time() - (int)@filemtime($file) < PARAMS_CACHE_TTL) {
|
||||
$cached = json_decode((string)@file_get_contents($file), true);
|
||||
if (is_array($cached) && isset($cached['settings'])) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
$params = load_params_db();
|
||||
$dir = dirname($file);
|
||||
if (is_dir($dir) || @mkdir($dir, 0700, true)) {
|
||||
$tmp = $file . '.' . bin2hex(random_bytes(4));
|
||||
if (@file_put_contents($tmp, json_encode($params, JSON_PRESERVE_ZERO_FRACTION)) !== false) {
|
||||
@rename($tmp, $file);
|
||||
}
|
||||
@unlink($tmp);
|
||||
}
|
||||
return $params;
|
||||
}
|
||||
|
||||
function params_cache_clear(): void
|
||||
{
|
||||
@unlink(params_cache_file());
|
||||
}
|
||||
|
||||
function load_params_db(): array
|
||||
{
|
||||
$pdo = db();
|
||||
$settings = [];
|
||||
$updated = null;
|
||||
foreach (settings_rows() as $key => $row) {
|
||||
$settings[$key] = (float)$row['value'];
|
||||
$updated = max($updated ?? '', $row['updated_at']);
|
||||
}
|
||||
|
||||
$tenors = $pdo->query('SELECT months, cashback FROM tenors ORDER BY months')->fetchAll();
|
||||
|
||||
$productRates = array_fill_keys(array_keys(SERUBI_PRODUCTS), []);
|
||||
foreach ($pdo->query('SELECT product, max_nominal, rate FROM product_rates ORDER BY product, sort, id') as $r) {
|
||||
$productRates[$r['product']][] = ['max' => to_num($r['max_nominal']), 'rate' => (float)$r['rate']];
|
||||
}
|
||||
|
||||
$bands = $pdo->query('SELECT id, max_nominal FROM deposito_bands ORDER BY sort, id')->fetchAll();
|
||||
$rates = [];
|
||||
foreach ($pdo->query('SELECT band_id, tenor, rate FROM deposito_rates') as $r) {
|
||||
$rates[$r['band_id']][$r['tenor']] = (float)$r['rate'];
|
||||
}
|
||||
$depositoRates = [];
|
||||
foreach ($tenors as $t) {
|
||||
$m = (int)$t['months'];
|
||||
$depositoRates[$m] = array_map(fn($b) => [
|
||||
'max' => to_num($b['max_nominal']),
|
||||
'rate' => $rates[$b['id']][$m] ?? 0.0,
|
||||
], $bands);
|
||||
}
|
||||
|
||||
$tiers = array_map(fn($r) => ['min' => (float)$r['min_nominal'], 'nasabah' => (float)$r['share_nasabah']],
|
||||
$pdo->query('SELECT min_nominal, share_nasabah FROM cashback_tiers ORDER BY min_nominal')->fetchAll());
|
||||
|
||||
$last = $pdo->query("SELECT username, created_at FROM audit_log WHERE action LIKE 'param%' ORDER BY id DESC LIMIT 1")->fetch();
|
||||
|
||||
return [
|
||||
'settings' => $settings,
|
||||
'tenors' => array_map(fn($t) => (int)$t['months'], $tenors),
|
||||
'cashbackTenors' => array_values(array_map(fn($t) => (int)$t['months'], array_filter($tenors, fn($t) => (int)$t['cashback'] === 1))),
|
||||
'productRates' => $productRates,
|
||||
'depositoBands' => array_map(fn($b) => to_num($b['max_nominal']), $bands),
|
||||
'depositoRates' => $depositoRates,
|
||||
'cashbackTiers' => $tiers,
|
||||
'updatedAt' => $last['created_at'] ?? $updated,
|
||||
'updatedBy' => $last['username'] ?? null,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Security baseline, loaded first by every web entry point (via helpers.php):
|
||||
* error handling, request-method guard, security headers, HTTPS detection,
|
||||
* and hardened session settings.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
function security_config(): array
|
||||
{
|
||||
return (app_config()['security'] ?? []) + [
|
||||
'force_https' => false,
|
||||
'trusted_proxies' => [],
|
||||
'setup_key' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/** True when the client reached us over HTTPS (directly or via a trusted reverse proxy). */
|
||||
function is_https(): bool
|
||||
{
|
||||
if (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') {
|
||||
return true;
|
||||
}
|
||||
if ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443) {
|
||||
return true;
|
||||
}
|
||||
$proxies = security_config()['trusted_proxies'];
|
||||
if ($proxies && in_array($_SERVER['REMOTE_ADDR'] ?? '', $proxies, true)) {
|
||||
return strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Client IP; X-Forwarded-For is honoured only when the request came from a trusted proxy. */
|
||||
function client_ip(): string
|
||||
{
|
||||
$remote = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
|
||||
$proxies = security_config()['trusted_proxies'];
|
||||
if ($proxies && in_array($remote, $proxies, true) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$chain = array_map('trim', explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']));
|
||||
// right-most address that is not one of our proxies
|
||||
foreach (array_reverse($chain) as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && !in_array($ip, $proxies, true)) {
|
||||
return substr($ip, 0, 45);
|
||||
}
|
||||
}
|
||||
}
|
||||
return substr($remote, 0, 45);
|
||||
}
|
||||
|
||||
/** Per-request nonce for the few inline scripts (Content-Security-Policy). */
|
||||
function csp_nonce(): string
|
||||
{
|
||||
static $nonce = null;
|
||||
if ($nonce === null) {
|
||||
$nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
|
||||
}
|
||||
return $nonce;
|
||||
}
|
||||
|
||||
function send_security_headers(bool $sensitive = false): void
|
||||
{
|
||||
if (headers_sent()) {
|
||||
return;
|
||||
}
|
||||
header_remove('X-Powered-By');
|
||||
$csp = [
|
||||
"default-src 'none'",
|
||||
"script-src 'self' 'nonce-" . csp_nonce() . "'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self'",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'none'",
|
||||
"object-src 'none'",
|
||||
];
|
||||
if (is_https()) {
|
||||
$csp[] = 'upgrade-insecure-requests';
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
header('Content-Security-Policy: ' . implode('; ', $csp));
|
||||
header('X-Frame-Options: DENY');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Referrer-Policy: same-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
|
||||
header('Cross-Origin-Opener-Policy: same-origin');
|
||||
header('Cross-Origin-Resource-Policy: same-origin');
|
||||
header('X-Permitted-Cross-Domain-Policies: none');
|
||||
// Pages always revalidate so they reference the newest versioned assets;
|
||||
// admin pages are never stored at all.
|
||||
header($sensitive ? 'Cache-Control: no-store, max-age=0' : 'Cache-Control: no-cache, must-revalidate');
|
||||
if ($sensitive) {
|
||||
header('Pragma: no-cache');
|
||||
}
|
||||
}
|
||||
|
||||
function security_bootstrap(): void
|
||||
{
|
||||
static $done = false;
|
||||
if ($done || PHP_SAPI === 'cli') {
|
||||
return;
|
||||
}
|
||||
$done = true;
|
||||
|
||||
// Never leak errors or stack traces to the client.
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('display_startup_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
ini_set('expose_php', '0');
|
||||
error_reporting(E_ALL);
|
||||
set_exception_handler(function (Throwable $e): void {
|
||||
error_log('[serubi] ' . get_class($e) . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
}
|
||||
echo '<!doctype html><meta charset="utf-8"><title>Terjadi kesalahan</title><p>Terjadi kesalahan pada server. Silakan coba lagi beberapa saat lagi.</p>';
|
||||
});
|
||||
|
||||
// Hardened session settings (before any session_start()).
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_trans_sid', '0');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
ini_set('session.sid_length', '48');
|
||||
ini_set('session.sid_bits_per_character', '5');
|
||||
ini_set('session.gc_maxlifetime', '28800');
|
||||
|
||||
// Only GET, HEAD and POST are meaningful here (blocks TRACE/PUT/DELETE/OPTIONS...).
|
||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
if (!in_array($method, ['GET', 'HEAD', 'POST'], true)) {
|
||||
header('Allow: GET, HEAD, POST');
|
||||
http_response_code(405);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Optional HTTP -> HTTPS redirect for production.
|
||||
if (security_config()['force_https'] && !is_https()) {
|
||||
$host = preg_replace('/[^A-Za-z0-9.\-:\[\]]/', '', (string)($_SERVER['HTTP_HOST'] ?? ''));
|
||||
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||
if ($host !== '' && $uri !== '' && $uri[0] === '/') {
|
||||
header('Location: https://' . $host . $uri, true, 301);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Two-factor authentication for admins: TOTP (RFC 6238, SHA-1, 6 digits, 30 s),
|
||||
* compatible with Google/Microsoft Authenticator. The shared secret is stored
|
||||
* encrypted (AES-256-GCM) with security.app_key, which lives outside the
|
||||
* document root. Recovery codes are stored as HMACs keyed with the same key.
|
||||
*/
|
||||
|
||||
const TOTP_PERIOD = 30;
|
||||
const TOTP_DIGITS = 6;
|
||||
const TOTP_WINDOW = 1; // accept one step before/after for clock drift
|
||||
const OTP_MAX_FAILS = 5; // wrong codes within LOGIN_WINDOW_MIN before the login is dropped
|
||||
const RECOVERY_CODE_COUNT = 10;
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
function app_key(): string
|
||||
{
|
||||
$key = base64_decode((string)(security_config()['app_key'] ?? ''), true);
|
||||
if ($key === false || strlen($key) !== 32) {
|
||||
throw new RuntimeException('security.app_key belum diisi (32 byte base64); dibutuhkan untuk OTP.');
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
function base32_encode(string $bin): string
|
||||
{
|
||||
$bits = '';
|
||||
foreach (str_split($bin) as $ch) {
|
||||
$bits .= str_pad(decbin(ord($ch)), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$out = '';
|
||||
foreach (str_split($bits, 5) as $chunk) {
|
||||
$out .= BASE32_ALPHABET[bindec(str_pad($chunk, 5, '0'))];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function base32_decode(string $b32): string
|
||||
{
|
||||
$bits = '';
|
||||
foreach (str_split(strtoupper(preg_replace('/[^A-Za-z2-7]/', '', $b32))) as $ch) {
|
||||
$bits .= str_pad(decbin(strpos(BASE32_ALPHABET, $ch)), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$out = '';
|
||||
foreach (str_split($bits, 8) as $byte) {
|
||||
if (strlen($byte) === 8) {
|
||||
$out .= chr(bindec($byte));
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function totp_new_secret(): string
|
||||
{
|
||||
return base32_encode(random_bytes(20)); // 160-bit, as recommended by RFC 4226
|
||||
}
|
||||
|
||||
function totp_code(string $secret, int $step): string
|
||||
{
|
||||
$hash = hash_hmac('sha1', pack('J', $step), base32_decode($secret), true);
|
||||
$offset = ord($hash[19]) & 0x0f;
|
||||
$bin = ((ord($hash[$offset]) & 0x7f) << 24) | (ord($hash[$offset + 1]) << 16)
|
||||
| (ord($hash[$offset + 2]) << 8) | ord($hash[$offset + 3]);
|
||||
return str_pad((string)($bin % (10 ** TOTP_DIGITS)), TOTP_DIGITS, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/** The matching time step, or null. Steps at or before $lastStep are refused (no replay). */
|
||||
function totp_verify(string $secret, string $code, ?int $lastStep): ?int
|
||||
{
|
||||
$code = preg_replace('/\D/', '', $code);
|
||||
if (strlen($code) !== TOTP_DIGITS) {
|
||||
return null;
|
||||
}
|
||||
$now = intdiv(time(), TOTP_PERIOD);
|
||||
for ($s = $now - TOTP_WINDOW; $s <= $now + TOTP_WINDOW; $s++) {
|
||||
if (($lastStep === null || $s > $lastStep) && hash_equals(totp_code($secret, $s), $code)) {
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function totp_uri(string $secret, string $username): string
|
||||
{
|
||||
$label = rawurlencode('SERUBI Bank Papua:' . $username);
|
||||
return "otpauth://totp/$label?secret=$secret&issuer=" . rawurlencode('SERUBI Bank Papua') . '&period=' . TOTP_PERIOD . '&digits=' . TOTP_DIGITS;
|
||||
}
|
||||
|
||||
function secret_encrypt(string $plain): string
|
||||
{
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$ct = openssl_encrypt($plain, 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
return base64_encode($iv . $tag . $ct);
|
||||
}
|
||||
|
||||
function secret_decrypt(string $stored): string
|
||||
{
|
||||
$raw = (string)base64_decode($stored, true);
|
||||
$plain = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16));
|
||||
if ($plain === false) {
|
||||
throw new RuntimeException('OTP secret tidak dapat dibuka (app_key berubah?).');
|
||||
}
|
||||
return $plain;
|
||||
}
|
||||
|
||||
function recovery_hash(string $code): string
|
||||
{
|
||||
return hash_hmac('sha256', strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code)), app_key());
|
||||
}
|
||||
|
||||
/** Fresh one-time codes like "K7QD-M2XP": [plain codes to show once, hashes to store]. */
|
||||
function recovery_codes_new(): array
|
||||
{
|
||||
$plain = [];
|
||||
for ($i = 0; $i < RECOVERY_CODE_COUNT; $i++) {
|
||||
$c = substr(base32_encode(random_bytes(5)), 0, 8);
|
||||
$plain[] = substr($c, 0, 4) . '-' . substr($c, 4);
|
||||
}
|
||||
return [$plain, array_map('recovery_hash', $plain)];
|
||||
}
|
||||
Reference in new issue
Block a user