Harden Project 01 public endpoints
This commit is contained in:
@@ -1 +1,3 @@
|
||||
MARIADB_ROOT_PASSWORD=change-this-password
|
||||
APP_DB_USER=webgis_app
|
||||
APP_DB_PASSWORD=change-this-app-password
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
Options -Indexes
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
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=()"
|
||||
</IfModule>
|
||||
|
||||
<FilesMatch "^(setup_database\.sql|README\.md|index\.html)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
RedirectMatch 404 ^/01/(tests|docs)(/|$)
|
||||
RedirectMatch 404 ^/01/\.
|
||||
@@ -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.
|
||||
|
||||
+2
-4
@@ -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 = [];
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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'];
|
||||
|
||||
+18
-16
@@ -1,26 +1,28 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => '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();
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
<?php
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => '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']]);
|
||||
|
||||
@@ -1,54 +1,47 @@
|
||||
<?php
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
require_admin_json();
|
||||
header('Content-Type: application/json');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => '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]);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
+19
-18
@@ -1,27 +1,28 @@
|
||||
<?php
|
||||
// api/jalan/hapus.php — Hapus data jalan
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
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();
|
||||
$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']);
|
||||
|
||||
+30
-47
@@ -1,56 +1,39 @@
|
||||
<?php
|
||||
// api/jalan/simpan.php — Simpan data jalan baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => '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();
|
||||
$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,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
+19
-18
@@ -1,27 +1,28 @@
|
||||
<?php
|
||||
// api/parsil/hapus.php — Hapus data parsil tanah
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
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();
|
||||
$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']);
|
||||
|
||||
+30
-46
@@ -1,55 +1,39 @@
|
||||
<?php
|
||||
// api/parsil/simpan.php — Simpan data parsil tanah baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => '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();
|
||||
$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,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
+19
-18
@@ -1,27 +1,28 @@
|
||||
<?php
|
||||
// api/point/hapus.php — Hapus POI
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
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();
|
||||
$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']);
|
||||
|
||||
+26
-30
@@ -1,44 +1,40 @@
|
||||
<?php
|
||||
// api/point/simpan.php — Simpan POI baru
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => '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) {
|
||||
error_log('Project 01 point insert prepare failed: ' . $conn->error);
|
||||
json_error('Gagal menyimpan data.', 500);
|
||||
}
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
$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' => $id,
|
||||
'id' => $conn->insert_id,
|
||||
'nama_tempat' => $nama,
|
||||
'no_wa' => $wa,
|
||||
'buka_24jam' => $buka24,
|
||||
'latitude' => $lat,
|
||||
'longitude' => $lng
|
||||
]
|
||||
'longitude' => $lng,
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
@@ -1,30 +1,32 @@
|
||||
<?php
|
||||
// api/point/update_posisi.php — Update koordinat POI (drag marker)
|
||||
header('Content-Type: application/json');
|
||||
require_once '../../auth/helper.php';
|
||||
require_once '../../includes/validation.php';
|
||||
|
||||
require_admin_post();
|
||||
require_once '../../koneksi.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$lat = (float) ($_POST['latitude'] ?? 0);
|
||||
$lng = (float) ($_POST['longitude'] ?? 0);
|
||||
$coords = validate_lat_lng($_POST['latitude'] ?? null, $_POST['longitude'] ?? null);
|
||||
|
||||
if (!$id) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
|
||||
exit;
|
||||
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();
|
||||
$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']);
|
||||
|
||||
+145
-9
@@ -1,20 +1,156 @@
|
||||
<?php
|
||||
function start_session(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params(['lifetime' => 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 {
|
||||
if (!is_admin()) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Akses ditolak. Silahkan login terlebih dahulu.']);
|
||||
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()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-42
@@ -1,44 +1,5 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once 'koneksi.php';
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => '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);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
const MAP_MIN_LAT = -0.25;
|
||||
const MAP_MAX_LAT = 0.15;
|
||||
const MAP_MIN_LNG = 109.15;
|
||||
const MAP_MAX_LNG = 109.55;
|
||||
const MAX_GEOJSON_BYTES = 1048576;
|
||||
const MAX_COORDINATE_POINTS = 5000;
|
||||
|
||||
function clean_string(string $value, int $maxLength): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if (mb_strlen($value, 'UTF-8') > $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;
|
||||
}
|
||||
+7
-7
@@ -725,7 +725,7 @@
|
||||
<div class="confirm-overlay" id="deleteConfirmOverlay" aria-hidden="true">
|
||||
<div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="deleteConfirmTitle">
|
||||
<div class="confirm-header">
|
||||
<div class="confirm-icon">🗑</div>
|
||||
<div class="confirm-icon">!</div>
|
||||
<div id="deleteConfirmTitle">Hapus Lokasi</div>
|
||||
</div>
|
||||
<div class="confirm-body" id="deleteConfirmMessage">
|
||||
@@ -1011,7 +1011,7 @@
|
||||
</div>
|
||||
${waLink}
|
||||
<button class="btn-hapus" onclick="hapusMarker(${poi.id}, this)">
|
||||
🗑 Hapus Lokasi
|
||||
Hapus Lokasi
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
<input type="hidden" id="f_lng" value="${lng}">
|
||||
|
||||
<button class="btn-save" id="btnSimpan" onclick="simpanData()">
|
||||
💾 Simpan Lokasi
|
||||
Simpan Lokasi
|
||||
</button>
|
||||
<div class="form-status" id="formStatus"></div>
|
||||
</div>`;
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+243
-24
@@ -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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -680,6 +875,7 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg);
|
||||
<script>
|
||||
// ── Globals ──────────────────────────────────────────────────
|
||||
window._IS_ADMIN = <?= $is_admin ? 'true' : 'false' ?>;
|
||||
window._CSRF_TOKEN = <?= json_encode($is_admin ? csrf_token() : '') ?>;
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s ?? '')
|
||||
@@ -941,6 +1137,7 @@ async function submitUpload() {
|
||||
fd.append('geojson', gjBlob, file ? file.name : 'layer.geojson');
|
||||
fd.append('attribute_key', attr_key);
|
||||
fd.append('palette', _selectedPal);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
try {
|
||||
const res = await fetch('api/choropleth/upload.php', { method: 'POST', body: fd });
|
||||
@@ -972,8 +1169,16 @@ var currentSubMode = '';
|
||||
var activeTool = null;
|
||||
window._dlFocusFns = {};
|
||||
|
||||
function syncModeGlobals() {
|
||||
window.currentMode = currentMode;
|
||||
window.currentSubMode = currentSubMode;
|
||||
window.activeTool = activeTool;
|
||||
}
|
||||
syncModeGlobals();
|
||||
|
||||
window._resetActiveTool = function() {
|
||||
activeTool = null; currentMode = 0; currentSubMode = '';
|
||||
syncModeGlobals();
|
||||
setDrawBlockingLayerHitTesting(true);
|
||||
setChoroplethHitTesting(true);
|
||||
['Point','Jalan','Parsil'].forEach(function(t) {
|
||||
@@ -1009,11 +1214,13 @@ function activateTool(tool) {
|
||||
|
||||
if (tool === 'point') {
|
||||
currentMode = 1; currentSubMode = 'point';
|
||||
syncModeGlobals();
|
||||
setDrawBlockingLayerHitTesting(false);
|
||||
setChoroplethHitTesting(false);
|
||||
if (dH) dH.classList.add('visible');
|
||||
} else if (tool === 'jalan') {
|
||||
currentMode = 2; currentSubMode = 'jalan';
|
||||
syncModeGlobals();
|
||||
setDrawBlockingLayerHitTesting(false);
|
||||
setChoroplethHitTesting(false);
|
||||
if (lJ) lJ.classList.add('visible');
|
||||
@@ -1021,6 +1228,7 @@ function activateTool(tool) {
|
||||
if (typeof window._jalanStartDraw === 'function') window._jalanStartDraw();
|
||||
} else if (tool === 'parsil') {
|
||||
currentMode = 2; currentSubMode = 'parsil';
|
||||
syncModeGlobals();
|
||||
setDrawBlockingLayerHitTesting(false);
|
||||
setChoroplethHitTesting(false);
|
||||
if (lP) lP.classList.add('visible');
|
||||
@@ -1028,6 +1236,7 @@ function activateTool(tool) {
|
||||
if (typeof window._parsilStartDraw === 'function') window._parsilStartDraw();
|
||||
} else {
|
||||
currentMode = 0; currentSubMode = '';
|
||||
syncModeGlobals();
|
||||
setDrawBlockingLayerHitTesting(true);
|
||||
setChoroplethHitTesting(true);
|
||||
if (dH) dH.classList.remove('visible');
|
||||
@@ -1056,7 +1265,11 @@ function cancelActiveDrawing() {
|
||||
}
|
||||
|
||||
function updateCount(n, icon) {
|
||||
const m = { '📍': 'countPoint', '🛣️': 'countJalan', '🏘️': 'countParsil' };
|
||||
const m = {
|
||||
'📍': 'countPoint', 'Point': 'countPoint',
|
||||
'🛣️': 'countJalan', 'Jalan': 'countJalan',
|
||||
'🏘️': 'countParsil', 'Parsil': 'countParsil'
|
||||
};
|
||||
const el = document.getElementById(m[icon]);
|
||||
if (el) el.textContent = n + ' data';
|
||||
}
|
||||
@@ -1076,7 +1289,7 @@ function setPaneHitTesting(paneName, enabled) {
|
||||
}
|
||||
|
||||
function setDrawBlockingLayerHitTesting(enabled) {
|
||||
['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane', 'drawPane'].forEach(function(paneName) {
|
||||
['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane'].forEach(function(paneName) {
|
||||
setPaneHitTesting(paneName, enabled);
|
||||
});
|
||||
}
|
||||
@@ -1106,6 +1319,12 @@ const map = L.map('map', {
|
||||
zoomControl: false,
|
||||
});
|
||||
|
||||
map.on('popupopen', function() {
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
|
||||
[
|
||||
['choroplethPane', 410],
|
||||
['parsilPane', 430],
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
define('DB_HOST', getenv('DB_HOST') ?: 'localhost');
|
||||
define('DB_USER', getenv('DB_USER') ?: 'root');
|
||||
define('DB_PASS', getenv('DB_PASS') ?: '');
|
||||
define('DB_USER', getenv('APP_DB_USER') ?: (getenv('DB_USER') ?: 'root'));
|
||||
define('DB_PASS', getenv('APP_DB_PASSWORD') ?: (getenv('DB_PASS') ?: ''));
|
||||
define('DB_NAME', getenv('DB_01_NAME') ?: (getenv('DB_NAME') ?: 'db_webgis_01'));
|
||||
define('DB_PORT', (int)(getenv('DB_PORT') ?: 3307));
|
||||
|
||||
@@ -13,7 +13,8 @@ $conn = @new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
die(json_encode(['status' => 'error', 'message' => 'Koneksi database gagal: ' . $conn->connect_error]));
|
||||
error_log('Project 01 database connection failed: ' . $conn->connect_error);
|
||||
json_error('Koneksi database gagal. Hubungi administrator.', 500);
|
||||
}
|
||||
|
||||
$conn->set_charset('utf8mb4');
|
||||
|
||||
+25
-19
@@ -7,17 +7,22 @@ $error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
if ($username && $password) {
|
||||
if (login_is_throttled($username)) {
|
||||
$error = 'Terlalu banyak percobaan gagal. Coba lagi beberapa menit.';
|
||||
} elseif ($username && $password) {
|
||||
$stmt = $conn->prepare("SELECT id, password FROM users WHERE username = ?");
|
||||
$stmt->bind_param('s', $username);
|
||||
$stmt->execute();
|
||||
$row = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
if ($row && password_verify($password, $row['password'])) {
|
||||
harden_successful_login();
|
||||
login_record_success($username);
|
||||
$_SESSION['admin_id'] = $row['id'];
|
||||
$_SESSION['admin_username'] = $username;
|
||||
header('Location: index.php'); exit;
|
||||
}
|
||||
login_record_failure($username);
|
||||
$error = 'Username atau password salah.';
|
||||
} else {
|
||||
$error = 'Username dan password wajib diisi.';
|
||||
@@ -37,22 +42,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--sb-bg: #1c1612;
|
||||
--body-bg: #f5f0eb;
|
||||
--card-bg: #fafaf9;
|
||||
--card-border:#ddd8d2;
|
||||
--accent: #0d7490;
|
||||
--accent-h: #0a5f7a;
|
||||
--accent-dim: rgba(13,116,144,.06);
|
||||
--text: #201515;
|
||||
--text-sec: #3d3530;
|
||||
--text-muted: #7a7067;
|
||||
--sb-bg: #201515; /* ink */
|
||||
--body-bg: #f8f4f0; /* canvas-soft */
|
||||
--card-bg: #fffefb; /* canvas */
|
||||
--card-border:#e6e1d5; /* warm border */
|
||||
--accent: #ff4f00; /* primary (Zapier Orange) */
|
||||
--accent-h: #e04600; /* accent hover */
|
||||
--accent-dim: rgba(255, 79, 0, 0.08);
|
||||
--text: #201515; /* ink */
|
||||
--text-sec: #2f2a26; /* ink-soft */
|
||||
--text-muted: #605d52; /* body */
|
||||
--danger: #ef4444;
|
||||
--danger-dim: rgba(239,68,68,.08);
|
||||
--danger-bdr: rgba(239,68,68,.25);
|
||||
--border: #ddd8d2;
|
||||
--danger-dim: rgba(239, 68, 68, 0.08);
|
||||
--danger-bdr: rgba(239, 68, 68, 0.25);
|
||||
--border: #e6e1d5;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 12px rgba(32,21,21,.08), 0 1px 2px rgba(32,21,21,.04);
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 6px 20px rgba(32, 21, 21, 0.08);
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text); }
|
||||
@@ -73,7 +79,7 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg);
|
||||
}
|
||||
.brand-logo { display: flex; align-items: center; gap: 12px; position: relative; }
|
||||
.brand-logo-icon {
|
||||
width: 44px; height: 44px; border-radius: 12px; background: #0d7490;
|
||||
width: 44px; height: 44px; border-radius: var(--radius); background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.brand-logo-icon .lucide { width: 22px; height: 22px; color: #fff; }
|
||||
@@ -104,12 +110,12 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg);
|
||||
.form-group label { display: block; font-size: 12px; font-weight: 600; color: var(--text-sec); margin-bottom: 7px; }
|
||||
.input-wrap { position: relative; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 11px 14px; background: #ede8e2;
|
||||
border: 1.5px solid var(--border); border-radius: 9px;
|
||||
width: 100%; padding: 11px 14px; background: var(--body-bg);
|
||||
border: 1.5px solid var(--border); border-radius: var(--radius-sm);
|
||||
color: var(--text); font-family: var(--font); font-size: 14px; outline: none;
|
||||
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||
}
|
||||
.form-group input:focus { border-color: var(--accent); background: #fafaf9; box-shadow: 0 0 0 3px rgba(13,116,144,.08); }
|
||||
.form-group input:focus { border-color: var(--text); background: var(--card-bg); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
.form-group input::placeholder { color: var(--text-muted); }
|
||||
.input-wrap input { padding-right: 44px; }
|
||||
.pw-toggle {
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
require_once __DIR__ . '/koneksi.php';
|
||||
start_session();
|
||||
$_SESSION = [];
|
||||
clear_session_cookie();
|
||||
session_destroy();
|
||||
header('Location: index.php');
|
||||
exit;
|
||||
|
||||
@@ -60,7 +60,33 @@
|
||||
}
|
||||
|
||||
function isClassicDrawingActive() {
|
||||
return typeof window.currentMode !== 'undefined' && Number(window.currentMode) !== 0;
|
||||
const modeActive = typeof window.currentMode !== 'undefined' && Number(window.currentMode) !== 0;
|
||||
const toolActive = window.activeTool === 'jalan' || window.activeTool === 'parsil' || window.activeTool === 'point';
|
||||
const drawHint = document.getElementById('drawHint');
|
||||
const hintVisible = !!drawHint && drawHint.classList.contains('visible');
|
||||
return modeActive || toolActive || hintVisible;
|
||||
}
|
||||
|
||||
function stopLayerEvent(e) {
|
||||
if (e.originalEvent) {
|
||||
L.DomEvent.stop(e.originalEvent);
|
||||
} else {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
}
|
||||
}
|
||||
|
||||
function forwardDrawingEventToMap(type, e) {
|
||||
const active = isClassicDrawingActive();
|
||||
console.log("choropleth.js forwardDrawingEventToMap: type =", type, ", isClassicDrawingActive =", active);
|
||||
if (!active) return false;
|
||||
stopLayerEvent(e);
|
||||
map.fire(type, {
|
||||
latlng: e.latlng,
|
||||
layerPoint: e.layerPoint || map.latLngToLayerPoint(e.latlng),
|
||||
containerPoint: e.containerPoint || map.latLngToContainerPoint(e.latlng),
|
||||
originalEvent: e.originalEvent,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function setLayerHitTesting(leafletLayer, enabled) {
|
||||
@@ -125,12 +151,15 @@
|
||||
lyr.resetStyle(layer);
|
||||
});
|
||||
layer.on('click', function (e) {
|
||||
if (isClassicDrawingActive()) return;
|
||||
if (forwardDrawingEventToMap('click', e)) return;
|
||||
L.popup({ closeButton: true, className: 'choro-popup-wrap' })
|
||||
.setLatLng(e.latlng)
|
||||
.setContent(buildPopup(true))
|
||||
.openOn(map);
|
||||
L.DomEvent.stopPropagation(e);
|
||||
stopLayerEvent(e);
|
||||
});
|
||||
layer.on('dblclick', function (e) {
|
||||
if (forwardDrawingEventToMap('dblclick', e)) return;
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -189,9 +218,8 @@
|
||||
const eyeIcon = isVisible ? 'eye' : 'eye-off';
|
||||
const palDots = (PALETTES[m.palette] || PALETTES.blue)
|
||||
.map(c => `<span class="pal-dot" style="background:${c}"></span>`).join('');
|
||||
const safeNama = m.nama.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
|
||||
const delBtn = window._IS_ADMIN
|
||||
? `<button class="layer-del" onclick="window._choro.deleteLayer(${m.id},'${safeNama}')" title="Hapus"><i data-lucide="trash-2"></i></button>`
|
||||
? `<button class="layer-del" data-layer-delete="${m.id}" title="Hapus"><i data-lucide="trash-2"></i></button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="layer-item ${isVisible ? '' : 'is-hidden'}" id="li${m.id}">
|
||||
@@ -209,6 +237,14 @@
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('[data-layer-delete]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = Number(btn.getAttribute('data-layer-delete'));
|
||||
const meta = allMeta.find(item => item.id === id);
|
||||
if (meta) window._choro.deleteLayer(id, meta.nama);
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
}
|
||||
|
||||
@@ -255,7 +291,10 @@
|
||||
meta.is_visible = newVisible;
|
||||
if (rendered) {
|
||||
rendered.meta.is_visible = newVisible;
|
||||
if (newVisible) rendered.leafletLayer.addTo(map);
|
||||
if (newVisible) {
|
||||
rendered.leafletLayer.addTo(map);
|
||||
setLayerHitTesting(rendered.leafletLayer, !isClassicDrawingActive());
|
||||
}
|
||||
else map.removeLayer(rendered.leafletLayer);
|
||||
}
|
||||
|
||||
@@ -266,6 +305,7 @@
|
||||
if (window._IS_ADMIN) {
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
fetch('api/choropleth/toggle.php', { method: 'POST', body: fd }).catch(() => {});
|
||||
}
|
||||
},
|
||||
@@ -275,6 +315,7 @@
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
fetch('api/choropleth/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
|
||||
+34
-18
@@ -55,14 +55,18 @@
|
||||
return `
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${color}22;">🛣️</div>
|
||||
<div class="info-popup-icon" style="background:${color}22;">
|
||||
<i data-lucide="route" style="color:${color};width:18px;height:18px;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(row.nama_jalan)}</div>
|
||||
<div class="info-popup-id">#${row.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🏷️</div>
|
||||
<div class="info-row-icon">
|
||||
<i data-lucide="tag" style="width:14px;height:14px;color:var(--text-m)"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Jalan</div>
|
||||
<div class="info-row-value">
|
||||
@@ -74,13 +78,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📏</div>
|
||||
<div class="info-row-icon">
|
||||
<i data-lucide="milestone" style="width:14px;height:14px;color:var(--text-m)"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="info-row-label">Panjang Jalan</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);">${panjang}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-hapus" onclick="window._jalanHapus(${row.id}, this)">🗑 Hapus Jalan</button>
|
||||
${window._IS_ADMIN ? `<button class="btn-hapus" onclick="window._jalanHapus(${row.id}, this)"><i data-lucide="trash-2" style="width:13px;height:13px;margin-right:4px;"></i> Hapus Jalan</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -94,7 +100,7 @@
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '🛣️');
|
||||
updateCount(j.total, 'Jalan');
|
||||
j.data.forEach(addPolylineToMap);
|
||||
refreshJalanList(j.data);
|
||||
})
|
||||
@@ -120,10 +126,11 @@
|
||||
showDeleteConfirm('Yakin ingin menghapus data jalan ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
btnEl.innerHTML = '<span class="btn-spinner" style="margin-right: 6px; display: inline-block; vertical-align: middle;"></span> Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
fetch('api/jalan/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
@@ -131,7 +138,7 @@
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
|
||||
updateCount(Object.keys(allLayers).length, '🛣️');
|
||||
updateCount(Object.keys(allLayers).length, 'Jalan');
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const remaining = Object.values(allLayers).map(l => ({
|
||||
id: l._jalanId, name: l._jalanNama,
|
||||
@@ -146,7 +153,8 @@
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Jalan';
|
||||
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:13px;height:13px;margin-right:4px;"></i> Hapus Jalan';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -180,13 +188,16 @@
|
||||
drawingMarkers.forEach(m => map.removeLayer(m));
|
||||
drawingMarkers = [];
|
||||
|
||||
if (save && drawingPoints.length >= 2) {
|
||||
const shouldSave = save && drawingPoints.length >= 2;
|
||||
if (shouldSave) {
|
||||
showFormJalan(drawingPoints.slice());
|
||||
}
|
||||
drawingPoints = [];
|
||||
|
||||
if (!shouldSave) {
|
||||
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
|
||||
}
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
if (!isDrawing) return;
|
||||
@@ -303,7 +314,7 @@
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon blue">🛣️</div>
|
||||
<div class="form-popup-icon blue"><i data-lucide="route" style="width:18px;height:18px;"></i></div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Data Jalan</div>
|
||||
<div class="form-popup-coords">${points.length} titik · ${totalMeter} m</div>
|
||||
@@ -327,7 +338,7 @@
|
||||
</div>
|
||||
<input type="hidden" id="fj_geojson" value='${escapeHTML(geojson)}'>
|
||||
<input type="hidden" id="fj_panjang" value="${totalMeter}">
|
||||
<button class="btn-save" id="btnSimpanJalan" onclick="window._jalanSimpan()">💾 Simpan Jalan</button>
|
||||
<button class="btn-save" id="btnSimpanJalan" onclick="window._jalanSimpan()"><i data-lucide="save" style="width:14px;height:14px;margin-right:4px;"></i> Simpan Jalan</button>
|
||||
<div class="form-status" id="jalanStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
@@ -335,9 +346,11 @@
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('fj_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Kalau popup ditutup tanpa simpan: hapus preview
|
||||
// Reset active tool when popup is closed (either by saving or clicking close/outside)
|
||||
map.once('popupclose', () => {
|
||||
// preview sudah di-clear di stopDrawing
|
||||
if (typeof window._resetActiveTool === 'function') {
|
||||
window._resetActiveTool();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,20 +364,22 @@
|
||||
const btn = document.getElementById('btnSimpanJalan');
|
||||
|
||||
if (!nama) {
|
||||
statusEl.textContent = '⚠ Nama jalan wajib diisi.';
|
||||
statusEl.innerHTML = '<i data-lucide="alert-triangle" style="width:14px;height:14px;margin-right:4px;display:inline-block;vertical-align:middle;"></i> Nama jalan wajib diisi.';
|
||||
statusEl.className = 'form-status error';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
document.getElementById('fj_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
btn.innerHTML = '<span class="btn-spinner" style="margin-right: 6px; display: inline-block; vertical-align: middle;"></span> Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_jalan', nama);
|
||||
fd.append('status_jalan', status);
|
||||
fd.append('geojson', geojson);
|
||||
fd.append('panjang_meter', panjang);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
fetch('api/jalan/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
@@ -372,7 +387,7 @@
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
addPolylineToMap(j.data);
|
||||
updateCount(Object.keys(allLayers).length, '🛣️');
|
||||
updateCount(Object.keys(allLayers).length, 'Jalan');
|
||||
const COLORS = { 'Nasional': '#ef4444', 'Provinsi': '#f97316', 'Kabupaten': '#eab308' };
|
||||
const allItems = Object.values(allLayers).map(l => ({
|
||||
id: l._jalanId, name: l._jalanNama,
|
||||
@@ -385,10 +400,11 @@
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.textContent = '✕ ' + err.message;
|
||||
statusEl.innerHTML = '<i data-lucide="x-circle" style="width:14px;height:14px;margin-right:4px;display:inline-block;vertical-align:middle;"></i> ' + escapeHTML(err.message);
|
||||
statusEl.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Jalan';
|
||||
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;margin-right:4px;"></i> Simpan Jalan';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+35
-18
@@ -74,14 +74,18 @@
|
||||
return `
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon" style="background:${color}22;">🏘️</div>
|
||||
<div class="info-popup-icon" style="background:${color}22;">
|
||||
<i data-lucide="pentagon" style="color:${color};width:18px;height:18px;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(row.nama_parsil)}</div>
|
||||
<div class="info-popup-id">#${row.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📜</div>
|
||||
<div class="info-row-icon">
|
||||
<i data-lucide="file-text" style="width:14px;height:14px;color:var(--text-m)"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="info-row-label">Status Kepemilikan</div>
|
||||
<div class="info-row-value">
|
||||
@@ -93,13 +97,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📐</div>
|
||||
<div class="info-row-icon">
|
||||
<i data-lucide="maximize-2" style="width:14px;height:14px;color:var(--text-m)"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="info-row-label">Luas Tanah</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);">${luas}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-hapus" onclick="window._parsilHapus(${row.id}, this)">🗑 Hapus Parsil</button>
|
||||
${window._IS_ADMIN ? `<button class="btn-hapus" onclick="window._parsilHapus(${row.id}, this)"><i data-lucide="trash-2" style="width:13px;height:13px;margin-right:4px;"></i> Hapus Parsil</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -113,7 +119,7 @@
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '🏘️');
|
||||
updateCount(j.total, 'Parsil');
|
||||
j.data.forEach(addPolygonToMap);
|
||||
refreshParsilList(j.data);
|
||||
})
|
||||
@@ -138,10 +144,11 @@
|
||||
showDeleteConfirm('Yakin ingin menghapus data parsil tanah ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
btnEl.innerHTML = '<span class="btn-spinner" style="margin-right: 6px; display: inline-block; vertical-align: middle;"></span> Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
fetch('api/parsil/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
@@ -149,7 +156,7 @@
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allLayers[id]) { layerGroup.removeLayer(allLayers[id]); delete allLayers[id]; }
|
||||
updateCount(Object.keys(allLayers).length, '🏘️');
|
||||
updateCount(Object.keys(allLayers).length, 'Parsil');
|
||||
const remaining = Object.values(allLayers).map(l => ({
|
||||
id: l._parsilId, name: l._parsilNama,
|
||||
badge: l._parsilStatus,
|
||||
@@ -163,7 +170,8 @@
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Parsil';
|
||||
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:13px;height:13px;margin-right:4px;"></i> Hapus Parsil';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -197,15 +205,18 @@
|
||||
drawingMarkers.forEach(m => map.removeLayer(m));
|
||||
drawingMarkers = [];
|
||||
|
||||
if (save && drawingPoints.length >= 3) {
|
||||
const shouldSave = save && drawingPoints.length >= 3;
|
||||
if (shouldSave) {
|
||||
showFormParsil(drawingPoints.slice());
|
||||
} else if (save && drawingPoints.length < 3) {
|
||||
showToast('Minimal 3 titik untuk membuat parsil (Polygon)', 'error');
|
||||
}
|
||||
drawingPoints = [];
|
||||
|
||||
if (!shouldSave) {
|
||||
if (typeof window._resetActiveTool === 'function') window._resetActiveTool();
|
||||
}
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
if (!isDrawing) return;
|
||||
@@ -217,6 +228,7 @@
|
||||
}
|
||||
|
||||
function onMapClick(e) {
|
||||
console.log("parsil.js onMapClick: currentMode =", (typeof currentMode !== "undefined" ? currentMode : "undefined"), ", currentSubMode =", (typeof currentSubMode !== "undefined" ? currentSubMode : "undefined"), ", isDrawing =", isDrawing);
|
||||
if (currentMode !== 2 || currentSubMode !== 'parsil') return;
|
||||
if (!isDrawing) return;
|
||||
|
||||
@@ -330,7 +342,7 @@
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon amber">🏘️</div>
|
||||
<div class="form-popup-icon amber"><i data-lucide="pentagon" style="width:18px;height:18px;"></i></div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Data Parsil (Kavling)</div>
|
||||
<div class="form-popup-coords">${points.length} titik · Luas : ${luasStr} m²</div>
|
||||
@@ -355,7 +367,7 @@
|
||||
</div>
|
||||
<input type="hidden" id="fp_geojson" value='${escapeHTML(geojson)}'>
|
||||
<input type="hidden" id="fp_luas" value="${luasArea}">
|
||||
<button class="btn-save" id="btnSimpanParsil" style="background:var(--c-warn);color:#111" onclick="window._parsilSimpan()">💾 Simpan Parsil</button>
|
||||
<button class="btn-save" id="btnSimpanParsil" onclick="window._parsilSimpan()"><i data-lucide="save" style="width:14px;height:14px;margin-right:4px;"></i> Simpan Parsil</button>
|
||||
<div class="form-status" id="parsilStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
@@ -363,9 +375,11 @@
|
||||
|
||||
setTimeout(() => { const el = document.getElementById('fp_nama'); if (el) el.focus(); }, 200);
|
||||
|
||||
// Kalau popup ditutup tanpa simpan: hapus preview
|
||||
// Reset active tool when popup is closed (either by saving or clicking close/outside)
|
||||
map.once('popupclose', () => {
|
||||
// preview sudah di-clear di stopDrawing
|
||||
if (typeof window._resetActiveTool === 'function') {
|
||||
window._resetActiveTool();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -379,20 +393,22 @@
|
||||
const btn = document.getElementById('btnSimpanParsil');
|
||||
|
||||
if (!nama) {
|
||||
statusEl.textContent = '⚠ Nama parsil wajib diisi.';
|
||||
statusEl.innerHTML = '<i data-lucide="alert-triangle" style="width:14px;height:14px;margin-right:4px;display:inline-block;vertical-align:middle;"></i> Nama parsil wajib diisi.';
|
||||
statusEl.className = 'form-status error';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
document.getElementById('fp_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
btn.innerHTML = '<span class="btn-spinner" style="margin-right: 6px; display: inline-block; vertical-align: middle;"></span> Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_parsil', nama);
|
||||
fd.append('status_kepemilikan', status);
|
||||
fd.append('geojson', geojson);
|
||||
fd.append('luas_m2', luas);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
fetch('api/parsil/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
@@ -400,7 +416,7 @@
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
addPolygonToMap(j.data);
|
||||
updateCount(Object.keys(allLayers).length, '🏘️');
|
||||
updateCount(Object.keys(allLayers).length, 'Parsil');
|
||||
const allItems = Object.values(allLayers).map(l => ({
|
||||
id: l._parsilId, name: l._parsilNama,
|
||||
badge: l._parsilStatus,
|
||||
@@ -412,10 +428,11 @@
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
statusEl.textContent = '✕ ' + err.message;
|
||||
statusEl.innerHTML = '<i data-lucide="x-circle" style="width:14px;height:14px;margin-right:4px;display:inline-block;vertical-align:middle;"></i> ' + escapeHTML(err.message);
|
||||
statusEl.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Parsil';
|
||||
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;margin-right:4px;"></i> Simpan Parsil';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+32
-22
@@ -33,7 +33,7 @@
|
||||
const isOpen = Number(poi.buka_24jam) === 1;
|
||||
const marker = L.marker(
|
||||
[poi.latitude, poi.longitude],
|
||||
{ icon: isOpen ? iconDefault : iconClosed, draggable: true, pane: 'pointPane' }
|
||||
{ icon: isOpen ? iconDefault : iconClosed, draggable: window._IS_ADMIN, pane: 'pointPane' }
|
||||
);
|
||||
|
||||
marker._poiId = poi.id;
|
||||
@@ -41,6 +41,7 @@
|
||||
marker._poiBuka = Number(poi.buka_24jam) === 1;
|
||||
allMarkers[poi.id] = marker;
|
||||
|
||||
if (window._IS_ADMIN) {
|
||||
// Drag to update position
|
||||
marker.on('dragend', function (e) {
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
@@ -48,6 +49,7 @@
|
||||
fd.append('id', poi.id);
|
||||
fd.append('latitude', lat.toFixed(8));
|
||||
fd.append('longitude', lng.toFixed(8));
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
fetch('api/point/update_posisi.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
@@ -57,45 +59,48 @@
|
||||
})
|
||||
.catch(err => showToast(err.message || 'Gagal update posisi.', 'error'));
|
||||
});
|
||||
}
|
||||
|
||||
// Popup info
|
||||
const noWa = poi.no_wa ? poi.no_wa.replace(/\D/g, '') : '';
|
||||
const waBtn = noWa
|
||||
? `<a class="btn-wa" href="https://wa.me/${noWa}" target="_blank" rel="noopener"
|
||||
style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:14px;padding:9px;background:#25d366;color:#fff;border:none;border-radius:7px;font-family:var(--font-body);font-size:13px;font-weight:700;text-decoration:none;">
|
||||
💬 Chat WhatsApp
|
||||
<i data-lucide="message-square" style="width:14px;height:14px;margin-right:4px;"></i> Chat WhatsApp
|
||||
</a>`
|
||||
: `<div style="font-size:12px;color:var(--c-muted);text-align:center;padding:8px 0;margin-top:8px;">Tidak ada nomor WA</div>`;
|
||||
|
||||
const buka24Badge = isOpen
|
||||
? `<span class="status-badge-inline" style="background:rgba(46,160,67,.2);color:#3fb950;border:1px solid rgba(46,160,67,.35);">🟢 Buka 24 Jam</span>`
|
||||
: `<span class="status-badge-inline" style="background:rgba(248,81,73,.12);color:#f85149;border:1px solid rgba(248,81,73,.3);">🔴 Tidak 24 Jam</span>`;
|
||||
? `<span class="status-badge-inline" style="background:rgba(46,160,67,.2);color:#3fb950;border:1px solid rgba(46,160,67,.35);display:inline-flex;align-items:center;gap:4px;"><i data-lucide="check" style="width:12px;height:12px;"></i> Buka 24 Jam</span>`
|
||||
: `<span class="status-badge-inline" style="background:rgba(248,81,73,.12);color:#f85149;border:1px solid rgba(248,81,73,.3);display:inline-flex;align-items:center;gap:4px;"><i data-lucide="x" style="width:12px;height:12px;"></i> Tidak 24 Jam</span>`;
|
||||
|
||||
const deleteBtn = window._IS_ADMIN ? `<button class="btn-hapus" onclick="window._pointHapus(${poi.id}, this)"><i data-lucide="trash-2" style="width:14px;height:14px;margin-right:4px;"></i> Hapus Lokasi</button>` : '';
|
||||
|
||||
marker.bindPopup(`
|
||||
<div class="info-popup">
|
||||
<div class="info-popup-header">
|
||||
<div class="info-popup-icon">📍</div>
|
||||
<div class="info-popup-icon"><i data-lucide="map-pin" style="width:18px;height:18px;"></i></div>
|
||||
<div>
|
||||
<div class="info-popup-name">${escapeHTML(poi.nama_tempat)}</div>
|
||||
<div class="info-popup-id">#${poi.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">📞</div>
|
||||
<div class="info-row-icon"><i data-lucide="phone" style="width:14px;height:14px;"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">No. WhatsApp</div>
|
||||
<div class="info-row-value">${poi.no_wa ? escapeHTML(poi.no_wa) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🕐</div>
|
||||
<div class="info-row-icon"><i data-lucide="clock" style="width:14px;height:14px;"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Jam Operasional</div>
|
||||
<div class="info-row-value">${buka24Badge}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-row-icon">🌐</div>
|
||||
<div class="info-row-icon"><i data-lucide="globe" style="width:14px;height:14px;"></i></div>
|
||||
<div>
|
||||
<div class="info-row-label">Koordinat</div>
|
||||
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
|
||||
@@ -104,7 +109,7 @@
|
||||
</div>
|
||||
</div>
|
||||
${waBtn}
|
||||
<button class="btn-hapus" onclick="window._pointHapus(${poi.id}, this)">🗑 Hapus Lokasi</button>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
`, { maxWidth: 300 });
|
||||
|
||||
@@ -120,7 +125,7 @@
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.status !== 'success') throw new Error(j.message);
|
||||
updateCount(j.total, '📍');
|
||||
updateCount(j.total, 'Point');
|
||||
j.data.forEach(addMarkerToMap);
|
||||
refreshPointList(j.data);
|
||||
})
|
||||
@@ -148,10 +153,11 @@
|
||||
showDeleteConfirm('Yakin ingin menghapus lokasi ini?').then(confirmed => {
|
||||
if (!confirmed) return;
|
||||
btnEl.disabled = true;
|
||||
btnEl.textContent = '⏳ Menghapus...';
|
||||
btnEl.innerHTML = '<span class="btn-spinner" style="margin-right: 6px; display: inline-block; vertical-align: middle;"></span> Menghapus...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('id', id);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
fetch('api/point/hapus.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
@@ -159,7 +165,7 @@
|
||||
if (j.status === 'success') {
|
||||
map.closePopup();
|
||||
if (allMarkers[id]) { layerGroup.removeLayer(allMarkers[id]); delete allMarkers[id]; }
|
||||
updateCount(Math.max(0, Object.keys(allMarkers).length), '📍');
|
||||
updateCount(Math.max(0, Object.keys(allMarkers).length), 'Point');
|
||||
// rebuild list dari allMarkers (ambil nama dari marker)
|
||||
const remaining = Object.keys(allMarkers).map(k => ({
|
||||
id: k,
|
||||
@@ -175,7 +181,8 @@
|
||||
.catch(err => {
|
||||
showToast(err.message || 'Gagal menghapus.', 'error');
|
||||
btnEl.disabled = false;
|
||||
btnEl.textContent = '🗑 Hapus Lokasi';
|
||||
btnEl.innerHTML = '<i data-lucide="trash-2" style="width:14px;height:14px;margin-right:4px;"></i> Hapus Lokasi';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -195,7 +202,7 @@
|
||||
.setContent(`
|
||||
<div class="form-popup">
|
||||
<div class="form-popup-header">
|
||||
<div class="form-popup-icon">➕</div>
|
||||
<div class="form-popup-icon"><i data-lucide="plus" style="width:18px;height:18px;"></i></div>
|
||||
<div>
|
||||
<div class="form-popup-title">Tambah Lokasi Baru</div>
|
||||
<div class="form-popup-coords">${lat}, ${lng}</div>
|
||||
@@ -212,13 +219,13 @@
|
||||
<div class="form-group">
|
||||
<label>Buka 24 Jam?</label>
|
||||
<select id="f_buka24">
|
||||
<option value="0">❌ Tidak</option>
|
||||
<option value="1">✅ Ya</option>
|
||||
<option value="0">Tidak</option>
|
||||
<option value="1">Ya</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" id="f_lat" value="${lat}">
|
||||
<input type="hidden" id="f_lng" value="${lng}">
|
||||
<button class="btn-save" id="btnSimpanPoi" onclick="window._pointSimpan()">💾 Simpan Lokasi</button>
|
||||
<button class="btn-save" id="btnSimpanPoi" onclick="window._pointSimpan()"><i data-lucide="save" style="width:14px;height:14px;margin-right:4px;"></i> Simpan Lokasi</button>
|
||||
<div class="form-status" id="poiStatus"></div>
|
||||
</div>
|
||||
`)
|
||||
@@ -238,14 +245,15 @@
|
||||
const btn = document.getElementById('btnSimpanPoi');
|
||||
|
||||
if (!nama) {
|
||||
status.textContent = '⚠ Nama tempat wajib diisi.';
|
||||
status.innerHTML = '<i data-lucide="alert-triangle" style="width:14px;height:14px;margin-right:4px;display:inline-block;vertical-align:middle;"></i> Nama tempat wajib diisi.';
|
||||
status.className = 'form-status error';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
document.getElementById('f_nama').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Menyimpan...';
|
||||
btn.innerHTML = '<span class="btn-spinner" style="margin-right: 6px; display: inline-block; vertical-align: middle;"></span> Menyimpan...';
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('nama_tempat', nama);
|
||||
@@ -253,6 +261,7 @@
|
||||
fd.append('buka_24jam', buka24);
|
||||
fd.append('latitude', lat);
|
||||
fd.append('longitude', lng);
|
||||
fd.append('csrf_token', window._CSRF_TOKEN || '');
|
||||
|
||||
fetch('api/point/simpan.php', { method: 'POST', body: fd })
|
||||
.then(r => r.json())
|
||||
@@ -261,7 +270,7 @@
|
||||
map.closePopup();
|
||||
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
||||
addMarkerToMap(j.data);
|
||||
updateCount(Object.keys(allMarkers).length, '📍');
|
||||
updateCount(Object.keys(allMarkers).length, 'Point');
|
||||
// Tambahkan item baru ke list
|
||||
const allItems = Object.values(allMarkers).map(m => ({
|
||||
id: m._poiId,
|
||||
@@ -275,10 +284,11 @@
|
||||
} else throw new Error(j.message);
|
||||
})
|
||||
.catch(err => {
|
||||
status.textContent = '✕ ' + err.message;
|
||||
status.innerHTML = '<i data-lucide="x-circle" style="width:14px;height:14px;margin-right:4px;display:inline-block;vertical-align:middle;"></i> ' + escapeHTML(err.message);
|
||||
status.className = 'form-status error';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '💾 Simpan Lokasi';
|
||||
btn.innerHTML = '<i data-lucide="save" style="width:14px;height:14px;margin-right:4px;"></i> Simpan Lokasi';
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
password VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- Default: admin / password
|
||||
-- Development seed only. Before public deployment, rotate this password immediately
|
||||
-- or replace this seed with an environment-specific admin creation step.
|
||||
INSERT IGNORE INTO users (username, password) VALUES (
|
||||
'admin',
|
||||
'$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi'
|
||||
|
||||
+3
-85
@@ -1,87 +1,5 @@
|
||||
<?php
|
||||
/**
|
||||
* simpan.php
|
||||
* Menerima data POST dari form dan menyimpannya ke tabel lokasi_usaha
|
||||
* Mengembalikan respons dalam format JSON
|
||||
*/
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
require_once 'koneksi.php';
|
||||
|
||||
// Hanya terima metode POST
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Ambil dan sanitasi input
|
||||
$nama_tempat = trim($_POST['nama_tempat'] ?? '');
|
||||
$no_wa = trim($_POST['no_wa'] ?? '');
|
||||
$buka_24jam = isset($_POST['buka_24jam']) ? (int)$_POST['buka_24jam'] : 0;
|
||||
$latitude = $_POST['latitude'] ?? '';
|
||||
$longitude = $_POST['longitude'] ?? '';
|
||||
|
||||
// Validasi input wajib
|
||||
if (empty($nama_tempat) || empty($latitude) || empty($longitude)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Nama tempat, latitude, dan longitude wajib diisi.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validasi format angka koordinat
|
||||
if (!is_numeric($latitude) || !is_numeric($longitude)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Format koordinat tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$latitude = (float)$latitude;
|
||||
$longitude = (float)$longitude;
|
||||
$buka_24jam = ($buka_24jam === 1) ? 1 : 0;
|
||||
|
||||
// Prepared statement untuk mencegah SQL Injection
|
||||
$stmt = $conn->prepare(
|
||||
"INSERT INTO lokasi_usaha (nama_tempat, no_wa, buka_24jam, latitude, longitude)
|
||||
VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
if (!$stmt) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Persiapan query gagal: ' . $conn->error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// bind_param dengan tipe data yang benar
|
||||
$stmt->bind_param(
|
||||
'ssidd',
|
||||
$nama_tempat,
|
||||
$no_wa,
|
||||
$buka_24jam,
|
||||
$latitude,
|
||||
$longitude
|
||||
);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$new_id = $conn->insert_id;
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => 'Data berhasil disimpan.',
|
||||
'data' => [
|
||||
'id' => $new_id,
|
||||
'nama_tempat' => $nama_tempat,
|
||||
'no_wa' => $no_wa,
|
||||
'buka_24jam' => $buka_24jam,
|
||||
'latitude' => $latitude,
|
||||
'longitude' => $longitude
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan data: ' . $stmt->error]);
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
http_response_code(410);
|
||||
json_error('Endpoint legacy sudah dinonaktifkan. Gunakan API di folder api/.', 410);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
$tests = [
|
||||
__DIR__ . DIRECTORY_SEPARATOR . 'test_layer_panes.php',
|
||||
__DIR__ . DIRECTORY_SEPARATOR . 'test_public_hardening_static.php',
|
||||
];
|
||||
|
||||
foreach ($tests as $test) {
|
||||
passthru(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($test), $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
exit($exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
echo "PASS: all Project 01 tests passed\n";
|
||||
@@ -32,9 +32,14 @@ foreach (['choroplethPane', 'parsilPane', 'jalanPane', 'drawPane', 'pointPane']
|
||||
assert_contains($index, 'map.createPane(name)', 'index.php');
|
||||
assert_contains($index, '.leaflet-pane.is-hit-disabled *', 'choropleth disabled pane CSS');
|
||||
assert_contains($index, 'function setDrawBlockingLayerHitTesting(enabled)', 'draw blocking pane helper');
|
||||
foreach (['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane', 'drawPane'] as $pane) {
|
||||
assert_contains($index, "['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane'].forEach", 'draw blocking pane list excludes drawPane');
|
||||
foreach (['choroplethPane', 'parsilPane', 'jalanPane', 'pointPane'] as $pane) {
|
||||
assert_contains($index, "'{$pane}'", 'draw blocking pane list');
|
||||
}
|
||||
assert_contains($index, 'function syncModeGlobals()', 'mode globals sync helper');
|
||||
assert_contains($index, 'window.currentMode = currentMode;', 'currentMode global sync');
|
||||
assert_contains($index, 'window.currentSubMode = currentSubMode;', 'currentSubMode global sync');
|
||||
assert_contains($index, 'window.activeTool = activeTool;', 'activeTool global sync');
|
||||
assert_contains($index, 'function setChoroplethHitTesting(enabled)', 'choropleth hit-testing helper');
|
||||
assert_contains($index, "pane.style.pointerEvents = enabled ? '' : 'none';", 'choropleth hit-testing toggle');
|
||||
assert_contains($index, "pane.classList.toggle('is-hit-disabled', !enabled);", 'choropleth disabled pane class toggle');
|
||||
@@ -48,6 +53,12 @@ assert_contains($index, 'onclick="cancelActiveDrawing()"', 'draw hint cancel but
|
||||
assert_contains($choropleth, "pane: 'choroplethPane'", 'choropleth layer options');
|
||||
assert_contains($choropleth, 'function isClassicDrawingActive()', 'choropleth draw guard');
|
||||
assert_contains($choropleth, 'if (isClassicDrawingActive()) return;', 'choropleth draw guard usage');
|
||||
assert_contains($choropleth, "window.activeTool === 'jalan'", 'choropleth active tool draw fallback');
|
||||
assert_contains($choropleth, "document.getElementById('drawHint')", 'choropleth draw hint fallback');
|
||||
assert_contains($choropleth, 'function forwardDrawingEventToMap(type, e)', 'choropleth drawing event forwarding helper');
|
||||
assert_contains($choropleth, "if (forwardDrawingEventToMap('click', e)) return;", 'choropleth forwards drawing clicks to map');
|
||||
assert_contains($choropleth, "layer.on('dblclick'", 'choropleth double click handler');
|
||||
assert_contains($choropleth, "if (forwardDrawingEventToMap('dblclick', e)) return;", 'choropleth forwards drawing double clicks to map');
|
||||
assert_contains($choropleth, 'function setLayerHitTesting(leafletLayer, enabled)', 'choropleth layer hit-testing helper');
|
||||
assert_contains($choropleth, "layer._path.style.pointerEvents = enabled ? '' : 'none';", 'choropleth path hit-testing toggle');
|
||||
assert_contains($choropleth, 'setHitTesting: setAllHitTesting', 'choropleth public hit-testing API');
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
|
||||
function read_required(string $relative): string
|
||||
{
|
||||
global $root;
|
||||
$path = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
if (!is_file($path)) {
|
||||
fwrite(STDERR, "Missing file: {$relative}\n");
|
||||
exit(1);
|
||||
}
|
||||
return file_get_contents($path);
|
||||
}
|
||||
|
||||
function assert_contains_text(string $haystack, string $needle, string $label): void
|
||||
{
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
fwrite(STDERR, "FAIL: {$label} must contain {$needle}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_not_contains_text(string $haystack, string $needle, string $label): void
|
||||
{
|
||||
if (strpos($haystack, $needle) !== false) {
|
||||
fwrite(STDERR, "FAIL: {$label} must not contain {$needle}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$helper = read_required('auth/helper.php');
|
||||
assert_contains_text($helper, 'function require_admin_post', 'auth helper admin POST guard');
|
||||
assert_contains_text($helper, 'function csrf_token', 'auth helper CSRF token');
|
||||
assert_contains_text($helper, 'function verify_csrf_token', 'auth helper CSRF verification');
|
||||
assert_contains_text($helper, "'secure' => is_https()", 'session cookie secure when HTTPS');
|
||||
assert_contains_text($helper, 'session_regenerate_id(true)', 'login/session fixation protection helper');
|
||||
assert_contains_text($helper, 'SESSION_IDLE_TIMEOUT', 'idle timeout constant');
|
||||
assert_contains_text($helper, 'LOGIN_MAX_ATTEMPTS', 'login throttling constant');
|
||||
assert_contains_text($helper, 'function login_throttle_key', 'login throttling key helper');
|
||||
|
||||
$validation = read_required('includes/validation.php');
|
||||
assert_contains_text($validation, 'function validate_lat_lng', 'coordinate validation helper');
|
||||
assert_contains_text($validation, 'function validate_geojson_geometry', 'GeoJSON validation helper');
|
||||
|
||||
$connection = read_required('koneksi.php');
|
||||
assert_not_contains_text($connection, '$conn->connect_error]', 'connection error should not leak raw details');
|
||||
assert_contains_text($connection, 'error_log', 'connection errors are logged server-side');
|
||||
|
||||
foreach ([
|
||||
'api/point/simpan.php',
|
||||
'api/point/hapus.php',
|
||||
'api/point/update_posisi.php',
|
||||
'api/jalan/simpan.php',
|
||||
'api/jalan/hapus.php',
|
||||
'api/parsil/simpan.php',
|
||||
'api/parsil/hapus.php',
|
||||
'api/choropleth/upload.php',
|
||||
'api/choropleth/toggle.php',
|
||||
'api/choropleth/hapus.php',
|
||||
] as $endpoint) {
|
||||
$source = read_required($endpoint);
|
||||
assert_contains_text($source, 'require_admin_post();', "{$endpoint} mutation guard");
|
||||
}
|
||||
|
||||
foreach (['simpan.php', 'hapus.php', 'update_posisi.php'] as $legacy) {
|
||||
$source = read_required($legacy);
|
||||
assert_contains_text($source, 'http_response_code(410);', "{$legacy} disabled status");
|
||||
}
|
||||
|
||||
$index = read_required('index.php');
|
||||
assert_contains_text($index, 'window._CSRF_TOKEN', 'index exposes CSRF token');
|
||||
|
||||
foreach ([
|
||||
'modules/point.js',
|
||||
'modules/jalan.js',
|
||||
'modules/parsil.js',
|
||||
'modules/choropleth.js',
|
||||
] as $module) {
|
||||
$source = read_required($module);
|
||||
assert_contains_text($source, "fd.append('csrf_token'", "{$module} sends CSRF token");
|
||||
}
|
||||
|
||||
$pointModule = read_required('modules/point.js');
|
||||
assert_contains_text($pointModule, 'draggable: window._IS_ADMIN', 'POI marker draggable only for admin');
|
||||
assert_contains_text($pointModule, 'window._IS_ADMIN ? `<button class="btn-hapus"', 'POI delete button admin-only');
|
||||
|
||||
$jalanModule = read_required('modules/jalan.js');
|
||||
assert_contains_text($jalanModule, 'window._IS_ADMIN ? `<button class="btn-hapus"', 'road delete button admin-only');
|
||||
|
||||
$parsilModule = read_required('modules/parsil.js');
|
||||
assert_contains_text($parsilModule, 'window._IS_ADMIN ? `<button class="btn-hapus"', 'parcel delete button admin-only');
|
||||
|
||||
$choroplethModule = read_required('modules/choropleth.js');
|
||||
assert_not_contains_text($choroplethModule, 'deleteLayer(${m.id},\'${safeNama}\')', 'choropleth avoids unsafe inline layer name');
|
||||
|
||||
$htaccess = read_required('.htaccess');
|
||||
assert_contains_text($htaccess, 'setup_database\.sql', 'SQL setup file blocked');
|
||||
assert_contains_text($htaccess, 'tests', 'tests directory blocked');
|
||||
assert_contains_text($htaccess, 'Header always set X-Frame-Options', 'security headers configured');
|
||||
|
||||
$apache = read_required('../docker/apache-webgis.conf');
|
||||
assert_contains_text($apache, '<FilesMatch', 'Apache file blocking configured');
|
||||
assert_contains_text($apache, 'Header always set X-Content-Type-Options', 'Apache security headers configured');
|
||||
|
||||
$schema = read_required('setup_database.sql');
|
||||
assert_not_contains_text($schema, 'Default: admin / password', 'schema must not advertise public default password');
|
||||
|
||||
$connection = read_required('koneksi.php');
|
||||
assert_contains_text($connection, "getenv('APP_DB_USER')", 'connection supports app DB user env');
|
||||
|
||||
echo "PASS: public hardening static guards are configured\n";
|
||||
+3
-48
@@ -1,50 +1,5 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once 'koneksi.php';
|
||||
require_once __DIR__ . '/auth/helper.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Metode tidak diizinkan.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$idRaw = $_POST['id'] ?? null;
|
||||
$latitudeRaw = $_POST['latitude'] ?? null;
|
||||
$longitudeRaw = $_POST['longitude'] ?? null;
|
||||
|
||||
if ($idRaw === null || $latitudeRaw === null || $longitudeRaw === null) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Data tidak lengkap.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!is_numeric($idRaw) || !is_numeric($latitudeRaw) || !is_numeric($longitudeRaw)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Format data tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int)$idRaw;
|
||||
$latitude = (float)$latitudeRaw;
|
||||
$longitude = (float)$longitudeRaw;
|
||||
|
||||
if ($id <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare(
|
||||
"UPDATE lokasi_usaha SET latitude = ?, longitude = ? WHERE id = ?"
|
||||
);
|
||||
$stmt->bind_param('ddi', $latitude, $longitude, $id);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
echo json_encode(['status' => 'success', 'message' => 'Posisi berhasil diperbarui.']);
|
||||
} 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);
|
||||
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
DB_HOST: db
|
||||
DB_USER: root
|
||||
DB_PASS: ${MARIADB_ROOT_PASSWORD:?Set MARIADB_ROOT_PASSWORD in Coolify}
|
||||
APP_DB_USER: ${APP_DB_USER:-}
|
||||
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-}
|
||||
DB_PORT: 3306
|
||||
DB_01_NAME: db_webgis_01
|
||||
DB_WEBGIS_NAME: db_webgis
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<Directory /var/www/html>
|
||||
AllowOverride All
|
||||
Options -Indexes
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<FilesMatch "^\.">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<FilesMatch "(^|/)(setup_database\.sql|README\.md)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
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"
|
||||
</IfModule>
|
||||
|
||||
ServerName localhost
|
||||
|
||||
Reference in New Issue
Block a user