From 6f4488c1b8a9f8f5c3446bfa96e1efc913163a56 Mon Sep 17 00:00:00 2001 From: Andrie Date: Thu, 11 Jun 2026 23:15:42 +0700 Subject: [PATCH] Harden Project 01 public endpoints --- .env.example | 2 + 01/.htaccess | 15 ++ 01/README.md | 12 + 01/ambil_data.php | 8 +- 01/api/choropleth/ambil.php | 6 +- 01/api/choropleth/geojson.php | 11 +- 01/api/choropleth/hapus.php | 34 +-- 01/api/choropleth/toggle.php | 34 ++- 01/api/choropleth/upload.php | 63 +++-- 01/api/jalan/ambil.php | 6 +- 01/api/jalan/hapus.php | 39 ++-- 01/api/jalan/simpan.php | 77 +++---- 01/api/parsil/ambil.php | 6 +- 01/api/parsil/hapus.php | 39 ++-- 01/api/parsil/simpan.php | 76 +++--- 01/api/point/ambil.php | 6 +- 01/api/point/hapus.php | 39 ++-- 01/api/point/simpan.php | 68 +++--- 01/api/point/update_posisi.php | 46 ++-- 01/auth/helper.php | 154 ++++++++++++- 01/hapus.php | 45 +--- 01/includes/validation.php | 128 +++++++++++ 01/index.html | 14 +- 01/index.php | 267 ++++++++++++++++++++-- 01/koneksi.php | 7 +- 01/login.php | 44 ++-- 01/logout.php | 4 +- 01/modules/choropleth.js | 53 ++++- 01/modules/jalan.js | 54 +++-- 01/modules/parsil.js | 55 +++-- 01/modules/point.js | 84 ++++--- 01/setup_database.sql | 3 +- 01/simpan.php | 88 +------ 01/tests/run_all.php | 15 ++ 01/tests/test_layer_panes.php | 13 +- 01/tests/test_public_hardening_static.php | 112 +++++++++ 01/update_posisi.php | 51 +---- docker-compose.yml | 2 + docker/apache-webgis.conf | 15 ++ 39 files changed, 1183 insertions(+), 612 deletions(-) create mode 100644 01/.htaccess create mode 100644 01/includes/validation.php create mode 100644 01/tests/run_all.php create mode 100644 01/tests/test_public_hardening_static.php diff --git a/.env.example b/.env.example index f6bc1a2..6d0ad84 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ MARIADB_ROOT_PASSWORD=change-this-password +APP_DB_USER=webgis_app +APP_DB_PASSWORD=change-this-app-password diff --git a/01/.htaccess b/01/.htaccess new file mode 100644 index 0000000..bbfa2a3 --- /dev/null +++ b/01/.htaccess @@ -0,0 +1,15 @@ +Options -Indexes + + + Header always set X-Frame-Options "SAMEORIGIN" + Header always set X-Content-Type-Options "nosniff" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" + + + + Require all denied + + +RedirectMatch 404 ^/01/(tests|docs)(/|$) +RedirectMatch 404 ^/01/\. diff --git a/01/README.md b/01/README.md index 0fc54bc..8d122ea 100644 --- a/01/README.md +++ b/01/README.md @@ -82,3 +82,15 @@ http://localhost/webgis/01/ - Google Fonts Dependensi frontend dimuat dari CDN, sehingga demo membutuhkan koneksi internet. + +## Public Deployment Hardening + +Before exposing `01/` publicly: + +- Rotate the seeded admin password. +- Keep public users read-only. +- Confirm anonymous POST requests to `api/point`, `api/jalan`, `api/parsil`, and `api/choropleth` return `403`. +- Confirm `/01/setup_database.sql`, `/01/tests/`, `/01/docs/`, and dotfiles are not reachable by browser. +- Use a database user with limited app privileges in production. + +Production database note: create a MariaDB user with privileges only on `db_webgis_01` and configure `APP_DB_USER` / `APP_DB_PASSWORD`. Do not run the public app with the root database user after initial setup. diff --git a/01/ambil_data.php b/01/ambil_data.php index 2ff1c26..1e8a0cb 100644 --- a/01/ambil_data.php +++ b/01/ambil_data.php @@ -6,7 +6,6 @@ */ header('Content-Type: application/json'); -header('Access-Control-Allow-Origin: *'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); header('Expires: 0'); @@ -20,9 +19,8 @@ $result = $conn->query( ); if (!$result) { - http_response_code(500); - echo json_encode(['status' => 'error', 'message' => 'Query gagal: ' . $conn->error]); - exit; + error_log('Project 01 legacy point read failed: ' . $conn->error); + json_error('Gagal memuat data.', 500); } $data = []; @@ -43,4 +41,4 @@ echo json_encode([ 'data' => $data ]); -$conn->close(); \ No newline at end of file +$conn->close(); diff --git a/01/api/choropleth/ambil.php b/01/api/choropleth/ambil.php index 540179c..0ad0fa8 100644 --- a/01/api/choropleth/ambil.php +++ b/01/api/choropleth/ambil.php @@ -8,8 +8,8 @@ $result = $conn->query( ); if ($result === false) { - echo json_encode(['status' => 'error', 'message' => 'Tabel belum ada. Jalankan setup_database.sql terlebih dahulu. (' . $conn->error . ')']); - $conn->close(); exit; + error_log('Project 01 choropleth metadata read failed: ' . $conn->error); + json_error('Gagal memuat layer.', 500); } $rows = []; @@ -26,4 +26,4 @@ while ($r = $result->fetch_assoc()) { } $conn->close(); -echo json_encode(['status' => 'success', 'data' => $rows, 'total' => count($rows)]); +json_success(['data' => $rows, 'total' => count($rows)]); diff --git a/01/api/choropleth/geojson.php b/01/api/choropleth/geojson.php index 4c3119e..6074ae2 100644 --- a/01/api/choropleth/geojson.php +++ b/01/api/choropleth/geojson.php @@ -4,10 +4,14 @@ header('Content-Type: application/json'); $id = (int)($_GET['id'] ?? 0); if ($id <= 0) { - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit; + json_error('ID tidak valid.', 400); } -$stmt = $conn->prepare("SELECT geojson FROM choropleth_layers WHERE id = ?"); +$stmt = $conn->prepare("SELECT geojson FROM choropleth_layers WHERE id = ? AND is_visible = 1"); +if (!$stmt) { + error_log('Project 01 choropleth geojson prepare failed: ' . $conn->error); + json_error('Gagal memuat GeoJSON.', 500); +} $stmt->bind_param('i', $id); $stmt->execute(); $row = $stmt->get_result()->fetch_assoc(); @@ -15,8 +19,7 @@ $stmt->close(); $conn->close(); if (!$row) { - http_response_code(404); - echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit; + json_error('Layer tidak ditemukan.', 404); } echo $row['geojson']; diff --git a/01/api/choropleth/hapus.php b/01/api/choropleth/hapus.php index 89e8efd..11ef7c8 100644 --- a/01/api/choropleth/hapus.php +++ b/01/api/choropleth/hapus.php @@ -1,26 +1,28 @@ 'error', 'message' => 'Method tidak valid.']); exit; -} +require_admin_post(); +require_once '../../koneksi.php'; $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit; + json_error('ID tidak valid.', 400); } $stmt = $conn->prepare("DELETE FROM choropleth_layers WHERE id = ?"); -$stmt->bind_param('i', $id); -$stmt->execute(); -$affected = $stmt->affected_rows; -$stmt->close(); -$conn->close(); - -if ($affected === 0) { - echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit; +if (!$stmt) { + error_log('Project 01 choropleth delete prepare failed: ' . $conn->error); + json_error('Gagal menghapus layer.', 500); } -echo json_encode(['status' => 'success']); +$stmt->bind_param('i', $id); +if (!$stmt->execute()) { + error_log('Project 01 choropleth delete failed: ' . $stmt->error); + json_error('Gagal menghapus layer.', 500); +} + +if ($stmt->affected_rows <= 0) { + json_error('Layer tidak ditemukan.', 404); +} + +json_success(); diff --git a/01/api/choropleth/toggle.php b/01/api/choropleth/toggle.php index a55d76a..53eb6b5 100644 --- a/01/api/choropleth/toggle.php +++ b/01/api/choropleth/toggle.php @@ -1,27 +1,39 @@ 'error', 'message' => 'Method tidak valid.']); exit; -} +require_admin_post(); +require_once '../../koneksi.php'; $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit; + json_error('ID tidak valid.', 400); } $stmt = $conn->prepare("UPDATE choropleth_layers SET is_visible = 1 - is_visible WHERE id = ?"); +if (!$stmt) { + error_log('Project 01 choropleth toggle prepare failed: ' . $conn->error); + json_error('Gagal mengubah visibilitas layer.', 500); +} + $stmt->bind_param('i', $id); -$stmt->execute(); +if (!$stmt->execute()) { + error_log('Project 01 choropleth toggle failed: ' . $stmt->error); + json_error('Gagal mengubah visibilitas layer.', 500); +} $stmt->close(); $stmt2 = $conn->prepare("SELECT is_visible FROM choropleth_layers WHERE id = ?"); +if (!$stmt2) { + error_log('Project 01 choropleth visibility prepare failed: ' . $conn->error); + json_error('Gagal memuat status layer.', 500); +} + $stmt2->bind_param('i', $id); $stmt2->execute(); $row = $stmt2->get_result()->fetch_assoc(); -$stmt2->close(); -$conn->close(); -echo json_encode(['status' => 'success', 'is_visible' => (bool)$row['is_visible']]); +if (!$row) { + json_error('Layer tidak ditemukan.', 404); +} + +json_success(['is_visible' => (bool)$row['is_visible']]); diff --git a/01/api/choropleth/upload.php b/01/api/choropleth/upload.php index 1984a09..99f59b3 100644 --- a/01/api/choropleth/upload.php +++ b/01/api/choropleth/upload.php @@ -1,54 +1,47 @@ 'error', 'message' => 'Method tidak valid.']); exit; -} +$nama = clean_string($_POST['nama'] ?? '', 150); +$deskripsi = clean_string($_POST['deskripsi'] ?? '', 500); +$attrKey = clean_string($_POST['attribute_key'] ?? '', 100); +$palette = validate_enum($_POST['palette'] ?? '', ['blue', 'orange', 'green', 'purple']) ?? 'blue'; -$nama = trim($_POST['nama'] ?? ''); -$deskripsi = trim($_POST['deskripsi'] ?? ''); -$attr_key = trim($_POST['attribute_key'] ?? ''); -$palette = in_array($_POST['palette'] ?? '', ['blue', 'orange', 'green', 'purple']) - ? $_POST['palette'] : 'blue'; - -if (!$nama || !$attr_key) { - echo json_encode(['status' => 'error', 'message' => 'Nama layer dan atribut wajib diisi.']); exit; +if ($nama === '' || $attrKey === '') { + json_error('Nama layer dan atribut wajib diisi.', 400); } if (empty($_FILES['geojson']['tmp_name']) || $_FILES['geojson']['error'] !== UPLOAD_ERR_OK) { - echo json_encode(['status' => 'error', 'message' => 'File GeoJSON tidak ditemukan atau gagal diupload.']); exit; + json_error('File GeoJSON tidak ditemukan atau gagal diupload.', 400); } -if ($_FILES['geojson']['size'] > 10 * 1024 * 1024) { - echo json_encode(['status' => 'error', 'message' => 'File terlalu besar (maksimum 10 MB).']); exit; +if ($_FILES['geojson']['size'] > MAX_GEOJSON_BYTES) { + json_error('File terlalu besar.', 400); } $raw = file_get_contents($_FILES['geojson']['tmp_name']); if ($raw === false) { - echo json_encode(['status' => 'error', 'message' => 'Gagal membaca file.']); exit; + json_error('Gagal membaca file.', 400); } -$gj = json_decode($raw, true); -if (!$gj || !isset($gj['type']) || $gj['type'] !== 'FeatureCollection') { - echo json_encode(['status' => 'error', 'message' => 'File bukan GeoJSON FeatureCollection yang valid.']); exit; -} -if (empty($gj['features'])) { - echo json_encode(['status' => 'error', 'message' => 'GeoJSON tidak memiliki fitur/data.']); exit; -} - -$first_props = $gj['features'][0]['properties'] ?? []; -if (!array_key_exists($attr_key, $first_props)) { - $available = implode(', ', array_keys($first_props)); - echo json_encode(['status' => 'error', 'message' => "Atribut '{$attr_key}' tidak ditemukan. Tersedia: {$available}"]); exit; +$geojson = decode_geojson($raw); +if ($geojson === null || !validate_feature_collection($geojson, $attrKey)) { + json_error('GeoJSON tidak valid atau atribut choropleth tidak lengkap.', 400); } $stmt = $conn->prepare("INSERT INTO choropleth_layers (nama, deskripsi, geojson, attribute_key, palette) VALUES (?, ?, ?, ?, ?)"); -$stmt->bind_param('sssss', $nama, $deskripsi, $raw, $attr_key, $palette); -$stmt->execute(); -$id = $conn->insert_id; -$stmt->close(); -$conn->close(); +if (!$stmt) { + error_log('Project 01 choropleth upload prepare failed: ' . $conn->error); + json_error('Gagal menyimpan layer.', 500); +} -echo json_encode(['status' => 'success', 'id' => $id, 'nama' => $nama]); +$stmt->bind_param('sssss', $nama, $deskripsi, $raw, $attrKey, $palette); +if (!$stmt->execute()) { + error_log('Project 01 choropleth upload failed: ' . $stmt->error); + json_error('Gagal menyimpan layer.', 500); +} + +json_success(['id' => $conn->insert_id, 'nama' => $nama]); diff --git a/01/api/jalan/ambil.php b/01/api/jalan/ambil.php index e01ae96..3bebeb1 100644 --- a/01/api/jalan/ambil.php +++ b/01/api/jalan/ambil.php @@ -6,8 +6,8 @@ require_once '../../koneksi.php'; $result = $conn->query("SELECT id, nama_jalan, status_jalan, geojson, panjang_meter FROM data_jalan ORDER BY id DESC"); if (!$result) { - echo json_encode(['status' => 'error', 'message' => $conn->error]); - exit; + error_log('Project 01 road read failed: ' . $conn->error); + json_error('Gagal memuat data jalan.', 500); } $data = []; @@ -21,4 +21,4 @@ echo json_encode([ 'data' => $data ]); -$conn->close(); \ No newline at end of file +$conn->close(); diff --git a/01/api/jalan/hapus.php b/01/api/jalan/hapus.php index da2f2cf..bcdfdf8 100644 --- a/01/api/jalan/hapus.php +++ b/01/api/jalan/hapus.php @@ -1,27 +1,28 @@ 'error', 'message' => 'Method not allowed']); - exit; -} - -$id = (int) ($_POST['id'] ?? 0); -if (!$id) { - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); - exit; +$id = (int)($_POST['id'] ?? 0); +if ($id <= 0) { + json_error('ID tidak valid.', 400); } $stmt = $conn->prepare("DELETE FROM data_jalan WHERE id = ?"); -$stmt->bind_param('i', $id); - -if ($stmt->execute() && $stmt->affected_rows > 0) { - echo json_encode(['status' => 'success', 'message' => 'Data jalan berhasil dihapus']); -} else { - echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']); +if (!$stmt) { + error_log('Project 01 road delete prepare failed: ' . $conn->error); + json_error('Gagal menghapus data jalan.', 500); } -$stmt->close(); -$conn->close(); \ No newline at end of file +$stmt->bind_param('i', $id); +if (!$stmt->execute()) { + error_log('Project 01 road delete failed: ' . $stmt->error); + json_error('Gagal menghapus data jalan.', 500); +} + +if ($stmt->affected_rows <= 0) { + json_error('Data jalan tidak ditemukan.', 404); +} + +json_success(['message' => 'Data jalan berhasil dihapus']); diff --git a/01/api/jalan/simpan.php b/01/api/jalan/simpan.php index d5d2083..f4232b7 100644 --- a/01/api/jalan/simpan.php +++ b/01/api/jalan/simpan.php @@ -1,56 +1,39 @@ 'error', 'message' => 'Method not allowed']); - exit; -} +$nama = clean_string($_POST['nama_jalan'] ?? '', 150); +$statusJalan = validate_enum($_POST['status_jalan'] ?? '', ['Nasional', 'Provinsi', 'Kabupaten']); +$geojsonRaw = trim($_POST['geojson'] ?? ''); +$decoded = decode_geojson($geojsonRaw); +$panjang = max(0, (float)($_POST['panjang_meter'] ?? 0)); -$nama = trim($_POST['nama_jalan'] ?? ''); -$status_jalan = trim($_POST['status_jalan'] ?? ''); -$geojson = trim($_POST['geojson'] ?? ''); -$panjang = (float) ($_POST['panjang_meter'] ?? 0); - -$allowed_status = ['Nasional', 'Provinsi', 'Kabupaten']; - -if (!$nama) { - echo json_encode(['status' => 'error', 'message' => 'Nama jalan tidak boleh kosong']); - exit; -} - -if (!in_array($status_jalan, $allowed_status)) { - echo json_encode(['status' => 'error', 'message' => 'Status jalan tidak valid']); - exit; -} - -// Validasi JSON -json_decode($geojson); -if (json_last_error() !== JSON_ERROR_NONE) { - echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']); - exit; +if ($nama === '' || $statusJalan === null || $decoded === null || !validate_geojson_geometry($decoded, ['LineString'])) { + json_error('Data jalan tidak valid.', 400); } $stmt = $conn->prepare("INSERT INTO data_jalan (nama_jalan, status_jalan, geojson, panjang_meter) VALUES (?, ?, ?, ?)"); -$stmt->bind_param('sssd', $nama, $status_jalan, $geojson, $panjang); - -if ($stmt->execute()) { - $id = $conn->insert_id; - echo json_encode([ - 'status' => 'success', - 'message' => 'Data jalan berhasil disimpan', - 'data' => [ - 'id' => $id, - 'nama_jalan' => $nama, - 'status_jalan' => $status_jalan, - 'geojson' => $geojson, - 'panjang_meter' => $panjang - ] - ]); -} else { - echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]); +if (!$stmt) { + error_log('Project 01 road insert prepare failed: ' . $conn->error); + json_error('Gagal menyimpan data jalan.', 500); } -$stmt->close(); -$conn->close(); \ No newline at end of file +$stmt->bind_param('sssd', $nama, $statusJalan, $geojsonRaw, $panjang); +if (!$stmt->execute()) { + error_log('Project 01 road insert failed: ' . $stmt->error); + json_error('Gagal menyimpan data jalan.', 500); +} + +json_success([ + 'message' => 'Data jalan berhasil disimpan', + 'data' => [ + 'id' => $conn->insert_id, + 'nama_jalan' => $nama, + 'status_jalan' => $statusJalan, + 'geojson' => $geojsonRaw, + 'panjang_meter' => $panjang, + ], +]); diff --git a/01/api/parsil/ambil.php b/01/api/parsil/ambil.php index 0fcef5d..3aeb860 100644 --- a/01/api/parsil/ambil.php +++ b/01/api/parsil/ambil.php @@ -6,8 +6,8 @@ require_once '../../koneksi.php'; $result = $conn->query("SELECT id, nama_parsil, status_kepemilikan, geojson, luas_m2 FROM data_parsil ORDER BY id DESC"); if (!$result) { - echo json_encode(['status' => 'error', 'message' => $conn->error]); - exit; + error_log('Project 01 parcel read failed: ' . $conn->error); + json_error('Gagal memuat data parsil.', 500); } $data = []; @@ -21,4 +21,4 @@ echo json_encode([ 'data' => $data ]); -$conn->close(); \ No newline at end of file +$conn->close(); diff --git a/01/api/parsil/hapus.php b/01/api/parsil/hapus.php index 2a6a540..1676794 100644 --- a/01/api/parsil/hapus.php +++ b/01/api/parsil/hapus.php @@ -1,27 +1,28 @@ 'error', 'message' => 'Method not allowed']); - exit; -} - -$id = (int) ($_POST['id'] ?? 0); -if (!$id) { - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); - exit; +$id = (int)($_POST['id'] ?? 0); +if ($id <= 0) { + json_error('ID tidak valid.', 400); } $stmt = $conn->prepare("DELETE FROM data_parsil WHERE id = ?"); -$stmt->bind_param('i', $id); - -if ($stmt->execute() && $stmt->affected_rows > 0) { - echo json_encode(['status' => 'success', 'message' => 'Data parsil berhasil dihapus']); -} else { - echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']); +if (!$stmt) { + error_log('Project 01 parcel delete prepare failed: ' . $conn->error); + json_error('Gagal menghapus data parsil.', 500); } -$stmt->close(); -$conn->close(); \ No newline at end of file +$stmt->bind_param('i', $id); +if (!$stmt->execute()) { + error_log('Project 01 parcel delete failed: ' . $stmt->error); + json_error('Gagal menghapus data parsil.', 500); +} + +if ($stmt->affected_rows <= 0) { + json_error('Data parsil tidak ditemukan.', 404); +} + +json_success(['message' => 'Data parsil berhasil dihapus']); diff --git a/01/api/parsil/simpan.php b/01/api/parsil/simpan.php index a9341e8..20ba04c 100644 --- a/01/api/parsil/simpan.php +++ b/01/api/parsil/simpan.php @@ -1,55 +1,39 @@ 'error', 'message' => 'Method not allowed']); - exit; -} +$nama = clean_string($_POST['nama_parsil'] ?? '', 150); +$status = validate_enum($_POST['status_kepemilikan'] ?? '', ['SHM', 'HGB', 'HGU', 'HP']); +$geojsonRaw = trim($_POST['geojson'] ?? ''); +$decoded = decode_geojson($geojsonRaw); +$luas = max(0, (float)($_POST['luas_m2'] ?? 0)); -$nama = trim($_POST['nama_parsil'] ?? ''); -$status = trim($_POST['status_kepemilikan'] ?? ''); -$geojson = trim($_POST['geojson'] ?? ''); -$luas = (float) ($_POST['luas_m2'] ?? 0); - -$allowed_status = ['SHM', 'HGB', 'HGU', 'HP']; - -if (!$nama) { - echo json_encode(['status' => 'error', 'message' => 'Nama parsil tidak boleh kosong']); - exit; -} - -if (!in_array($status, $allowed_status)) { - echo json_encode(['status' => 'error', 'message' => 'Status kepemilikan tidak valid']); - exit; -} - -json_decode($geojson); -if (json_last_error() !== JSON_ERROR_NONE) { - echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']); - exit; +if ($nama === '' || $status === null || $decoded === null || !validate_geojson_geometry($decoded, ['Polygon'])) { + json_error('Data parsil tidak valid.', 400); } $stmt = $conn->prepare("INSERT INTO data_parsil (nama_parsil, status_kepemilikan, geojson, luas_m2) VALUES (?, ?, ?, ?)"); -$stmt->bind_param('sssd', $nama, $status, $geojson, $luas); - -if ($stmt->execute()) { - $id = $conn->insert_id; - echo json_encode([ - 'status' => 'success', - 'message' => 'Data parsil berhasil disimpan', - 'data' => [ - 'id' => $id, - 'nama_parsil' => $nama, - 'status_kepemilikan' => $status, - 'geojson' => $geojson, - 'luas_m2' => $luas - ] - ]); -} else { - echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]); +if (!$stmt) { + error_log('Project 01 parcel insert prepare failed: ' . $conn->error); + json_error('Gagal menyimpan data parsil.', 500); } -$stmt->close(); -$conn->close(); \ No newline at end of file +$stmt->bind_param('sssd', $nama, $status, $geojsonRaw, $luas); +if (!$stmt->execute()) { + error_log('Project 01 parcel insert failed: ' . $stmt->error); + json_error('Gagal menyimpan data parsil.', 500); +} + +json_success([ + 'message' => 'Data parsil berhasil disimpan', + 'data' => [ + 'id' => $conn->insert_id, + 'nama_parsil' => $nama, + 'status_kepemilikan' => $status, + 'geojson' => $geojsonRaw, + 'luas_m2' => $luas, + ], +]); diff --git a/01/api/point/ambil.php b/01/api/point/ambil.php index 4bdabc6..f3beef5 100644 --- a/01/api/point/ambil.php +++ b/01/api/point/ambil.php @@ -6,8 +6,8 @@ require_once '../../koneksi.php'; $result = $conn->query("SELECT id, nama_tempat, no_wa, buka_24jam, latitude, longitude FROM lokasi_usaha ORDER BY id DESC"); if (!$result) { - echo json_encode(['status' => 'error', 'message' => $conn->error]); - exit; + error_log('Project 01 point read failed: ' . $conn->error); + json_error('Gagal memuat data lokasi.', 500); } $data = []; @@ -21,4 +21,4 @@ echo json_encode([ 'data' => $data ]); -$conn->close(); \ No newline at end of file +$conn->close(); diff --git a/01/api/point/hapus.php b/01/api/point/hapus.php index 014ecd0..31150a6 100644 --- a/01/api/point/hapus.php +++ b/01/api/point/hapus.php @@ -1,27 +1,28 @@ 'error', 'message' => 'Method not allowed']); - exit; -} - -$id = (int) ($_POST['id'] ?? 0); -if (!$id) { - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); - exit; +$id = (int)($_POST['id'] ?? 0); +if ($id <= 0) { + json_error('ID tidak valid.', 400); } $stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?"); -$stmt->bind_param('i', $id); - -if ($stmt->execute() && $stmt->affected_rows > 0) { - echo json_encode(['status' => 'success', 'message' => 'Data berhasil dihapus']); -} else { - echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']); +if (!$stmt) { + error_log('Project 01 point delete prepare failed: ' . $conn->error); + json_error('Gagal menghapus data.', 500); } -$stmt->close(); -$conn->close(); \ No newline at end of file +$stmt->bind_param('i', $id); +if (!$stmt->execute()) { + error_log('Project 01 point delete failed: ' . $stmt->error); + json_error('Gagal menghapus data.', 500); +} + +if ($stmt->affected_rows <= 0) { + json_error('Data tidak ditemukan.', 404); +} + +json_success(['message' => 'Data berhasil dihapus']); diff --git a/01/api/point/simpan.php b/01/api/point/simpan.php index 6e4b787..6000cec 100644 --- a/01/api/point/simpan.php +++ b/01/api/point/simpan.php @@ -1,44 +1,40 @@ 'error', 'message' => 'Method not allowed']); - exit; -} - -$nama = trim($_POST['nama_tempat'] ?? ''); -$wa = trim($_POST['no_wa'] ?? ''); -$buka24 = isset($_POST['buka_24jam']) ? (int) $_POST['buka_24jam'] : 0; -$lat = (float) ($_POST['latitude'] ?? 0); -$lng = (float) ($_POST['longitude'] ?? 0); - -if (!$nama) { - echo json_encode(['status' => 'error', 'message' => 'Nama tempat tidak boleh kosong']); - exit; +$nama = clean_string($_POST['nama_tempat'] ?? '', 150); +$wa = clean_string($_POST['no_wa'] ?? '', 30); +$buka24 = (int)($_POST['buka_24jam'] ?? 0) === 1 ? 1 : 0; +$coords = validate_lat_lng($_POST['latitude'] ?? null, $_POST['longitude'] ?? null); + +if ($nama === '' || $coords === null) { + json_error('Nama tempat dan koordinat valid wajib diisi.', 400); } +[$lat, $lng] = $coords; $stmt = $conn->prepare("INSERT INTO lokasi_usaha (nama_tempat, no_wa, buka_24jam, latitude, longitude) VALUES (?, ?, ?, ?, ?)"); -$stmt->bind_param('ssidd', $nama, $wa, $buka24, $lat, $lng); - -if ($stmt->execute()) { - $id = $conn->insert_id; - echo json_encode([ - 'status' => 'success', - 'message' => 'Data berhasil disimpan', - 'data' => [ - 'id' => $id, - 'nama_tempat' => $nama, - 'no_wa' => $wa, - 'buka_24jam' => $buka24, - 'latitude' => $lat, - 'longitude' => $lng - ] - ]); -} else { - echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]); +if (!$stmt) { + error_log('Project 01 point insert prepare failed: ' . $conn->error); + json_error('Gagal menyimpan data.', 500); } -$stmt->close(); -$conn->close(); \ No newline at end of file +$stmt->bind_param('ssidd', $nama, $wa, $buka24, $lat, $lng); +if (!$stmt->execute()) { + error_log('Project 01 point insert failed: ' . $stmt->error); + json_error('Gagal menyimpan data.', 500); +} + +json_success([ + 'message' => 'Data berhasil disimpan', + 'data' => [ + 'id' => $conn->insert_id, + 'nama_tempat' => $nama, + 'no_wa' => $wa, + 'buka_24jam' => $buka24, + 'latitude' => $lat, + 'longitude' => $lng, + ], +]); diff --git a/01/api/point/update_posisi.php b/01/api/point/update_posisi.php index 2e52b44..5b81490 100644 --- a/01/api/point/update_posisi.php +++ b/01/api/point/update_posisi.php @@ -1,30 +1,32 @@ 'error', 'message' => 'Method not allowed']); - exit; -} - -$id = (int) ($_POST['id'] ?? 0); -$lat = (float) ($_POST['latitude'] ?? 0); -$lng = (float) ($_POST['longitude'] ?? 0); - -if (!$id) { - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']); - exit; +$id = (int)($_POST['id'] ?? 0); +$coords = validate_lat_lng($_POST['latitude'] ?? null, $_POST['longitude'] ?? null); + +if ($id <= 0 || $coords === null) { + json_error('ID atau koordinat tidak valid.', 400); } +[$lat, $lng] = $coords; $stmt = $conn->prepare("UPDATE lokasi_usaha SET latitude = ?, longitude = ? WHERE id = ?"); -$stmt->bind_param('ddi', $lat, $lng, $id); - -if ($stmt->execute() && $stmt->affected_rows > 0) { - echo json_encode(['status' => 'success', 'message' => 'Posisi diperbarui']); -} else { - echo json_encode(['status' => 'error', 'message' => 'Gagal memperbarui posisi']); +if (!$stmt) { + error_log('Project 01 point position prepare failed: ' . $conn->error); + json_error('Gagal memperbarui posisi.', 500); } -$stmt->close(); -$conn->close(); \ No newline at end of file +$stmt->bind_param('ddi', $lat, $lng, $id); +if (!$stmt->execute()) { + error_log('Project 01 point position update failed: ' . $stmt->error); + json_error('Gagal memperbarui posisi.', 500); +} + +if ($stmt->affected_rows <= 0) { + json_error('Data tidak ditemukan atau posisi tidak berubah.', 404); +} + +json_success(['message' => 'Posisi diperbarui']); diff --git a/01/auth/helper.php b/01/auth/helper.php index adcb987..167d2c2 100644 --- a/01/auth/helper.php +++ b/01/auth/helper.php @@ -1,20 +1,156 @@ 0, 'httponly' => true, 'samesite' => 'Strict']); - session_start(); + +const SESSION_IDLE_TIMEOUT = 1800; +const LOGIN_MAX_ATTEMPTS = 5; +const LOGIN_LOCK_SECONDS = 300; + +function is_https(): bool +{ + if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { + return true; } + return ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'; } -function is_admin(): bool { +function clear_session_cookie(): void +{ + if (session_status() === PHP_SESSION_NONE) { + return; + } + + $params = session_get_cookie_params(); + setcookie(session_name(), '', [ + 'expires' => time() - 42000, + 'path' => $params['path'] ?? '/', + 'domain' => $params['domain'] ?? '', + 'secure' => (bool)($params['secure'] ?? false), + 'httponly' => true, + 'samesite' => $params['samesite'] ?? 'Strict', + ]); +} + +function start_session(): void +{ + if (session_status() === PHP_SESSION_NONE) { + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => is_https(), + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_start(); + } + + $now = time(); + $lastSeen = (int)($_SESSION['last_seen_at'] ?? $now); + if (($now - $lastSeen) > SESSION_IDLE_TIMEOUT) { + $_SESSION = []; + clear_session_cookie(); + session_destroy(); + session_start(); + } + $_SESSION['last_seen_at'] = $now; +} + +function harden_successful_login(): void +{ + start_session(); + session_regenerate_id(true); + $_SESSION['last_seen_at'] = time(); +} + +function is_admin(): bool +{ start_session(); return !empty($_SESSION['admin_id']); } -function require_admin_json(): void { +function login_throttle_key(string $username): string +{ + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + return hash('sha256', strtolower(trim($username)) . '|' . $ip); +} + +function login_is_throttled(string $username): bool +{ + start_session(); + $entry = $_SESSION['login_throttle'][login_throttle_key($username)] ?? null; + if (!is_array($entry)) { + return false; + } + return (int)($entry['locked_until'] ?? 0) > time(); +} + +function login_record_failure(string $username): void +{ + start_session(); + $key = login_throttle_key($username); + $entry = $_SESSION['login_throttle'][$key] ?? ['count' => 0, 'locked_until' => 0]; + $entry['count'] = (int)$entry['count'] + 1; + if ($entry['count'] >= LOGIN_MAX_ATTEMPTS) { + $entry['locked_until'] = time() + LOGIN_LOCK_SECONDS; + $entry['count'] = 0; + } + $_SESSION['login_throttle'][$key] = $entry; +} + +function login_record_success(string $username): void +{ + start_session(); + unset($_SESSION['login_throttle'][login_throttle_key($username)]); +} + +function csrf_token(): string +{ + start_session(); + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; +} + +function verify_csrf_token(?string $token): bool +{ + start_session(); + return is_string($token) + && isset($_SESSION['csrf_token']) + && hash_equals($_SESSION['csrf_token'], $token); +} + +function json_response(array $payload, int $statusCode = 200): void +{ + http_response_code($statusCode); + header('Content-Type: application/json'); + echo json_encode($payload); + exit; +} + +function json_error(string $message, int $statusCode = 400): void +{ + json_response(['status' => 'error', 'message' => $message], $statusCode); +} + +function json_success(array $payload = []): void +{ + json_response(['status' => 'success'] + $payload); +} + +function require_admin_json(): void +{ if (!is_admin()) { - http_response_code(403); - echo json_encode(['status' => 'error', 'message' => 'Akses ditolak. Silahkan login terlebih dahulu.']); - exit; + json_error('Akses ditolak. Silahkan login terlebih dahulu.', 403); + } +} + +function require_admin_post(): void +{ + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_error('Method tidak valid.', 405); + } + require_admin_json(); + $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + if (!verify_csrf_token($token)) { + json_error('Token keamanan tidak valid.', 403); } } diff --git a/01/hapus.php b/01/hapus.php index e4c07d6..96c3d1e 100644 --- a/01/hapus.php +++ b/01/hapus.php @@ -1,44 +1,5 @@ 'error', 'message' => 'Metode tidak diizinkan.']); - exit; -} - -$idRaw = $_POST['id'] ?? null; - -if ($idRaw === null || !is_numeric($idRaw) || (int)$idRaw <= 0) { - http_response_code(400); - echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); - exit; -} - -$id = (int)$idRaw; - -$stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?"); - -if (!$stmt) { - http_response_code(500); - echo json_encode(['status' => 'error', 'message' => 'Persiapan query gagal: ' . $conn->error]); - exit; -} - -$stmt->bind_param('i', $id); - -if ($stmt->execute()) { - if ($stmt->affected_rows > 0) { - echo json_encode(['status' => 'success', 'message' => 'Lokasi berhasil dihapus.']); - } else { - http_response_code(404); - echo json_encode(['status' => 'error', 'message' => 'Lokasi tidak ditemukan.']); - } -} else { - http_response_code(500); - echo json_encode(['status' => 'error', 'message' => $stmt->error]); -} - -$stmt->close(); -$conn->close(); +http_response_code(410); +json_error('Endpoint legacy sudah dinonaktifkan. Gunakan API di folder api/.', 410); diff --git a/01/includes/validation.php b/01/includes/validation.php new file mode 100644 index 0000000..dd8f86d --- /dev/null +++ b/01/includes/validation.php @@ -0,0 +1,128 @@ + $maxLength) { + return mb_substr($value, 0, $maxLength, 'UTF-8'); + } + return $value; +} + +function validate_enum(string $value, array $allowed): ?string +{ + return in_array($value, $allowed, true) ? $value : null; +} + +function validate_lat_lng($lat, $lng): ?array +{ + if (!is_numeric($lat) || !is_numeric($lng)) { + return null; + } + + $lat = (float)$lat; + $lng = (float)$lng; + if ($lat < MAP_MIN_LAT || $lat > MAP_MAX_LAT || $lng < MAP_MIN_LNG || $lng > MAP_MAX_LNG) { + return null; + } + + return [$lat, $lng]; +} + +function decode_geojson(string $geojson): ?array +{ + if ($geojson === '' || strlen($geojson) > MAX_GEOJSON_BYTES) { + return null; + } + + $decoded = json_decode($geojson, true); + return json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : null; +} + +function count_coordinates($coordinates): int +{ + if (!is_array($coordinates)) { + return 0; + } + + if (count($coordinates) >= 2 && is_numeric($coordinates[0]) && is_numeric($coordinates[1])) { + return 1; + } + + $total = 0; + foreach ($coordinates as $child) { + $total += count_coordinates($child); + if ($total > MAX_COORDINATE_POINTS) { + return $total; + } + } + + return $total; +} + +function coordinates_in_bounds($coordinates): bool +{ + if (!is_array($coordinates)) { + return false; + } + + if (count($coordinates) >= 2 && is_numeric($coordinates[0]) && is_numeric($coordinates[1])) { + return validate_lat_lng($coordinates[1], $coordinates[0]) !== null; + } + + foreach ($coordinates as $child) { + if (!coordinates_in_bounds($child)) { + return false; + } + } + + return true; +} + +function validate_geojson_geometry(array $geometry, array $allowedTypes): bool +{ + $type = $geometry['type'] ?? ''; + if (!in_array($type, $allowedTypes, true)) { + return false; + } + if (!array_key_exists('coordinates', $geometry)) { + return false; + } + if (count_coordinates($geometry['coordinates']) > MAX_COORDINATE_POINTS) { + return false; + } + return coordinates_in_bounds($geometry['coordinates']); +} + +function validate_feature_collection(array $geojson, string $attributeKey): bool +{ + if (($geojson['type'] ?? '') !== 'FeatureCollection') { + return false; + } + + $features = $geojson['features'] ?? null; + if (!is_array($features) || count($features) === 0 || count($features) > 1000) { + return false; + } + + foreach ($features as $feature) { + if (($feature['type'] ?? '') !== 'Feature') { + return false; + } + if (!array_key_exists($attributeKey, $feature['properties'] ?? [])) { + return false; + } + if (!validate_geojson_geometry($feature['geometry'] ?? [], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString', 'Point', 'MultiPoint'])) { + return false; + } + } + + return true; +} diff --git a/01/index.html b/01/index.html index 4e8a349..21ce045 100644 --- a/01/index.html +++ b/01/index.html @@ -725,7 +725,7 @@ `; @@ -1114,7 +1114,7 @@ } btn.disabled = true; - btn.textContent = '⏳ Menyimpan...'; + btn.textContent = 'Menyimpan...'; status.textContent = ''; const formData = new FormData(); @@ -1148,7 +1148,7 @@ status.textContent = '✕ ' + err.message; status.className = 'form-status error'; btn.disabled = false; - btn.textContent = '💾 Simpan Lokasi'; + btn.textContent = 'Simpan Lokasi'; }); } @@ -1158,7 +1158,7 @@ if (!confirmed) return; btnEl.disabled = true; - btnEl.textContent = '⏳ Menghapus...'; + btnEl.textContent = 'Menghapus...'; const formData = new FormData(); formData.append('id', id); @@ -1196,7 +1196,7 @@ .catch((err) => { showToast(err.message || 'Gagal terhubung ke server.', 'error'); btnEl.disabled = false; - btnEl.textContent = '🗑 Hapus Lokasi'; + btnEl.textContent = 'Hapus Lokasi'; }); } diff --git a/01/index.php b/01/index.php index 0099773..3265b06 100644 --- a/01/index.php +++ b/01/index.php @@ -22,28 +22,28 @@ $admin_init = $is_admin ? mb_strtoupper(mb_substr($admin_username, 0, 1, 'UT *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { --sb-w: 260px; - --sb-bg: #1c1612; - --sb-text: #a89f96; - --sb-text-h: #ffffff; - --sb-sect: #5c5148; - --hd-bg: #fafaf9; - --hd-border: #ddd8d2; - --body-bg: #f5f0eb; - --card-bg: #fafaf9; - --card-border: #ddd8d2; - --text-1: #201515; - --text-2: #3d3530; - --text-m: #7a7067; - --accent: #0d7490; - --accent-h: #0a5f7a; - --accent-dim: rgba(13,116,144,.08); + --sb-bg: #201515; /* ink */ + --sb-text: #939084; /* body-mid */ + --sb-text-h: #fffefb; /* canvas */ + --sb-sect: #c5c0b1; /* mute */ + --hd-bg: #fffefb; /* canvas */ + --hd-border: #e6e1d5; /* warm border */ + --body-bg: #f8f4f0; /* canvas-soft */ + --card-bg: #fffefb; /* canvas */ + --card-border: #e6e1d5; /* warm border */ + --text-1: #201515; /* ink */ + --text-2: #2f2a26; /* ink-soft */ + --text-m: #605d52; /* body */ + --accent: #ff4f00; /* primary (Zapier Orange) */ + --accent-h: #e04600; /* accent hover */ + --accent-dim: rgba(255, 79, 0, 0.08); --danger: #ef4444; - --danger-dim: rgba(239,68,68,.08); - --danger-bdr: rgba(239,68,68,.25); - --radius: 12px; - --radius-sm: 8px; + --danger-dim: rgba(239, 68, 68, 0.08); + --danger-bdr: rgba(239, 68, 68, 0.25); + --radius: 12px; /* rounded.md */ + --radius-sm: 6px; /* rounded.sm */ --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --shadow: 0 4px 16px rgba(32,21,21,.1); + --shadow: 0 6px 20px rgba(32, 21, 21, 0.08); } html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text-1); overflow: hidden; } @@ -293,12 +293,12 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg); .form-group select, .form-group textarea { width: 100%; padding: 10px 13px; - background: #ede8e2; border: 1.5px solid var(--card-border); border-radius: 8px; + background: var(--body-bg); border: 1.5px solid var(--card-border); border-radius: var(--radius-sm); color: var(--text-1); font-family: var(--font); font-size: 13px; outline: none; transition: border-color .2s, background .2s, box-shadow .2s; } .form-group input:focus, .form-group select:focus, .form-group textarea:focus { - border-color: var(--accent); background: #fafaf9; box-shadow: 0 0 0 3px rgba(13,116,144,.08); + border-color: var(--text-1); background: var(--card-bg); box-shadow: 0 0 0 3px var(--accent-dim); } .form-group textarea { resize: vertical; min-height: 64px; } .form-group input[type="file"] { padding: 8px 13px; cursor: pointer; } @@ -426,6 +426,201 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg); } .classic-row:hover { background: rgba(255,255,255,.05); } .classic-count { font-size: 10px; color: var(--sb-text); margin-top: 1px; } + +/* ── Leaflet Popup Customizations ───────────────────────────── */ +.leaflet-popup-content-wrapper { + background: var(--card-bg) !important; + color: var(--text-1) !important; + border: 1px solid var(--card-border); + border-radius: var(--radius) !important; + box-shadow: var(--shadow) !important; + overflow: hidden; +} +.leaflet-popup-content { + margin: 0 !important; + font-family: var(--font) !important; +} +.leaflet-popup-tip { + background: var(--card-bg) !important; + border: 1px solid var(--card-border); + box-shadow: none !important; +} + +/* ── Form Popup ─────────────────────────────────────────────── */ +.form-popup { + padding: 16px 20px; + background: var(--card-bg); +} +.form-popup-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--card-border); +} +.form-popup-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} +.form-popup-icon.blue { + background: rgba(56, 139, 253, 0.12); + color: #388bfd; +} +.form-popup-icon.amber { + background: rgba(234, 179, 8, 0.15); + color: #a87c00; +} +.form-popup-title { + font-size: 14px; + font-weight: 700; + color: var(--text-1); + letter-spacing: -0.3px; +} +.form-popup-coords { + font-size: 10px; + color: var(--text-m); + font-family: var(--font); + margin-top: 2px; +} +.input-readonly { + width: 100%; + padding: 10px 13px; + background: var(--body-bg); + border: 1.5px solid var(--card-border); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-m); + font-weight: 500; +} +.btn-save { + width: 100%; + padding: 11px; + background: var(--accent); + color: var(--sb-text-h); + border: none; + border-radius: var(--radius); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + margin-top: 6px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.btn-save:hover:not(:disabled) { + background: var(--accent-h); +} +.btn-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Info Popup ─────────────────────────────────────────────── */ +.info-popup { + padding: 16px 20px; +} +.info-popup-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} +.info-popup-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} +.info-popup-name { + font-size: 14px; + font-weight: 700; + color: var(--text-1); + letter-spacing: -0.3px; + line-height: 1.3; +} +.info-popup-id { + font-size: 10px; + color: var(--text-m); + font-family: var(--font); + margin-top: 2px; +} +.info-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-top: 1px solid var(--card-border); + font-size: 13px; +} +.info-row-icon { + width: 28px; + height: 28px; + border-radius: 6px; + background: var(--body-bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} +.info-row-label { + font-size: 10px; + color: var(--text-m); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} +.info-row-value { + font-weight: 500; + color: var(--text-1); +} +.status-badge-inline { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; +} +.btn-hapus { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + margin-top: 12px; + padding: 9px; + background: transparent; + color: var(--danger); + border: 1.5px solid var(--danger-bdr); + border-radius: var(--radius); + font-family: var(--font); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} +.btn-hapus:hover { + background: var(--danger-dim); + border-color: var(--danger); +} @@ -680,6 +875,7 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg);