Initial commit of unified WebGIS projects
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
# local environment configurations
|
||||
**/.env
|
||||
**/.env.local
|
||||
|
||||
# dependencies
|
||||
**/vendor/
|
||||
**/node_modules/
|
||||
|
||||
# OS/IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,466 @@
|
||||
# Audit & Rencana Deployment: SIG UAS WebGIS
|
||||
|
||||
Dokumen ini berisi hasil audit teknis, analisis keamanan, dan rencana implementasi deployment untuk tugas akhir Sistem Informasi Geografis (SIG) yang terdiri dari dua project: **Poverty Mapping** (tugas utama) dan **Tugas SPBU** (tugas penunjang).
|
||||
|
||||
---
|
||||
|
||||
## 1. Analisis Struktur Project
|
||||
|
||||
Kedua project memiliki karakteristik teknologi yang hampir sama tetapi memiliki tingkat kematangan arsitektur yang berbeda.
|
||||
|
||||
### A. Folder `poverty-mapping` (Project Utama)
|
||||
* **Teknologi & Framework**: PHP Native untuk backend, HTML/CSS/JS native dengan Leaflet.js dan Leaflet Draw untuk frontend.
|
||||
* **Dependencies**:
|
||||
* `phpmailer/phpmailer` (pengiriman email verifikasi).
|
||||
* `vlucas/phpdotenv` (manajemen environment variables).
|
||||
* **Struktur Folder**:
|
||||
```
|
||||
poverty-mapping/
|
||||
├── src/
|
||||
│ ├── api/ # Rest API (tambah_lokasi, get_lokasi, dll.)
|
||||
│ │ ├── admin/ # Manajemen persetujuan manager oleh admin
|
||||
│ │ └── auth/ # Register, login, email verification
|
||||
│ └── config/ # auth.php, config.php, koneksi.php
|
||||
├── sql/migrations/ # 001_init_schema.sql (skema DB)
|
||||
├── uploads/ # Folder penyimpanan foto lokasi
|
||||
├── index.html # Frontend SPA utama
|
||||
└── composer.json # Deklarasi dependencies composer
|
||||
```
|
||||
* **Konfigurasi Database**: Menggunakan file `src/config/koneksi.php` yang membaca `getenv()` dari environment system dengan fallback ke kredensial default (`localhost`, `root`, `ilham`, `webgis`).
|
||||
* **Konfigurasi Environment**: Membaca `.env` dari root directory project menggunakan parser kustom di `src/config/config.php` serta autoloading library dotenv.
|
||||
* **Potensi Masalah**: Nama database default adalah `webgis` yang berpotensi konflik dengan project kedua.
|
||||
|
||||
### B. Folder `tugas-spbu` (Tugas Penunjang)
|
||||
* **Teknologi & Framework**: PHP Native untuk backend, HTML/CSS/JS native dengan Leaflet.js, Leaflet Draw, dan Leaflet GeometryUtil.
|
||||
* **Dependencies**: Tidak menggunakan Composer (pure PHP + JavaScript CDN).
|
||||
* **Struktur Folder**:
|
||||
```
|
||||
tugas-spbu/
|
||||
├── src/
|
||||
│ ├── api/ # Rest API (get_lokasi, tambah_jalan, dll.)
|
||||
│ └── config/ # auth.php, config.php, koneksi.php
|
||||
├── sql/ # webgis.sql (dump skema dan data awal)
|
||||
└── index.html # Frontend SPA utama
|
||||
```
|
||||
* **Konfigurasi Database**: **KREDENSIAL HARDCODED** di `src/config/koneksi.php`:
|
||||
```php
|
||||
$conn = new mysqli("localhost", "root", "ilham", "webgis2");
|
||||
```
|
||||
Ini adalah masalah kritis karena server tujuan deployment pasti menggunakan host, user, password, atau nama database yang berbeda.
|
||||
* **Konfigurasi Environment**: Hanya memiliki variabel `API_KEY` di `.env.example`.
|
||||
* **Potensi Masalah**:
|
||||
1. Koneksi database yang di-hardcode.
|
||||
2. Frontend (`index.html`) tidak memiliki antarmuka atau logika untuk memuat/menyimpan `API_KEY`, sehingga seluruh operasi write (tambah, edit, hapus) di server akan menghasilkan `401 Unauthorized` karena header `X-API-KEY` bernilai `undefined`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Landing Page Utama
|
||||
|
||||
Untuk menyatukan pintu masuk ke kedua project, opsi terbaik adalah menggunakan **Reverse Proxy (Nginx)** yang melayani Landing Page di root path (`/`) dan membelokkan traffic subpath ke container masing-masing aplikasi.
|
||||
|
||||
### Keuntungan Pendekatan Reverse Proxy:
|
||||
1. **Isolasi Kode**: Project `poverty-mapping` dan `tugas-spbu` tetap terpisah secara logika dan runtime di container-nya masing-masing.
|
||||
2. **Kemudahan Jalur URL**: Memungkinkan konfigurasi domain tunggal (misal: `sig.domain.com`) tanpa memerlukan subdomain terpisah untuk setiap aplikasi.
|
||||
3. **Bebas Konflik Cookie/Session**: Cookie PHP (`PHPSESSID`) dapat dipisah lingkup path-nya dengan aman.
|
||||
4. **Tanpa Mengubah Path Relatif Code**: Karena proxy Nginx melakukan rewrite prefix path, relative requests di browser (seperti `src/api/...`) tetap berjalan dengan aman.
|
||||
|
||||
### Rekomendasi Struktur Folder Root (Monorepo):
|
||||
```
|
||||
sig-uas/ # Workspace Root
|
||||
├── docker-compose.yml # Orkestrasi seluruh service (Nginx, App 1, App 2, MySQL)
|
||||
├── init-db.sql # Inisialisasi awal database
|
||||
├── nginx.conf # Konfigurasi reverse proxy dan landing page
|
||||
├── landing-page/ # Folder Landing Page utama
|
||||
│ ├── index.html # File HTML Landing Page premium
|
||||
│ └── style.css # Styling Landing Page
|
||||
├── poverty-mapping/ # Project 1
|
||||
│ ├── Dockerfile
|
||||
│ └── ...
|
||||
└── tugas-spbu/ # Project 2
|
||||
├── Dockerfile
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Persiapan Deployment (Readiness Audit)
|
||||
|
||||
Berikut adalah daftar temuan kritis yang wajib diperbaiki sebelum melakukan deployment:
|
||||
|
||||
| Temuan Masalah | Lokasi File | Tingkat Risiko | Dampak & Rekomendasi Perbaikan |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Kredensial Gmail Riil** | `poverty-mapping/.env` | **CRITICAL** | Terungkap `SMTP_USER` dan `SMTP_PASS` (App Password Gmail) secara tertulis di workspace. Hapus file `.env` dari upload repositori Git (masukkan ke `.gitignore`) dan masukkan password sebagai secrets di Coolify. |
|
||||
| **Database Kredensial Hardcoded** | `tugas-spbu/src/config/koneksi.php` | **HIGH** | Koneksi menggunakan parameter string statik. Ubah agar membaca dari environment variables (`getenv()`) dengan fallback ke konfigurasi lokal. |
|
||||
| **API Key Hilang di Frontend** | `tugas-spbu/index.html` | **HIGH** | JavaScript mencoba membaca variabel `API_KEY` global yang tidak pernah di-declare di script. Seluruh request mutasi akan ditolak API backend. Tambahkan pop-up input API Key di halaman untuk admin atau simpan di `localStorage`. |
|
||||
| **Penyimpanan Foto Gagal** | `poverty-mapping/src/api/upload_photo.php` | **MEDIUM** | Folder `uploads/` di server produksi harus dapat ditulisi oleh user web server (`www-data`). Perlu dipastikan folder di-mount dengan volume Docker yang memiliki hak akses `775`. |
|
||||
| **Expose File Dotfiles (`.env`, `.git`)** | Root / Project folders | **HIGH** | Jika server web salah konfigurasi, file `.env` dapat diunduh publik. Konfigurasikan Nginx/Apache untuk memblokir akses ke berkas tersembunyi (`~ /\.(?!well-known)`). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Analisis Keamanan (Security Audit)
|
||||
|
||||
Kami melakukan audit mendalam terhadap API dan frontend dari kedua aplikasi:
|
||||
|
||||
### A. SQL Injection (Risiko: LOW)
|
||||
* **Temuan**: Baik `poverty-mapping` maupun `tugas-spbu` telah menggunakan prepared statement (`$conn->prepare` dan `$stmt->bind_param`) untuk semua endpoint yang memproses data masukan pengguna.
|
||||
* **Rekomendasi**: Pertahankan standar ini. Hindari interpolasi string langsung (seperti `"$user_input"`) dalam query SQL.
|
||||
|
||||
### B. Stored XSS - Cross-Site Scripting (Risiko: HIGH)
|
||||
* **Temuan**: Di `tugas-spbu/index.html`, data lokasi yang diambil dari database dirender langsung ke dalam popups Leaflet tanpa escaping:
|
||||
```javascript
|
||||
marker.bindPopup(`<b>${item.nama}</b><br>...<div ...>${alamatEsc}</div>`);
|
||||
```
|
||||
Jika penyerang menyisipkan nama lokasi seperti `<img src=x onerror=alert(document.cookie)>`, script tersebut akan tereksekusi pada browser setiap pengunjung yang mengklik marker tersebut.
|
||||
* **Rekomendasi**: Buat fungsi pembantu `escapeHtml()` di `tugas-spbu/index.html` seperti yang sudah ada di `poverty-mapping/index.html`:
|
||||
```javascript
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
```
|
||||
Bungkus seluruh output variabel pengguna (`item.nama`, `item.alamat`) dengan fungsi tersebut saat melakukan `bindPopup`.
|
||||
|
||||
### C. CSRF - Cross-Site Request Forgery (Risiko: MEDIUM)
|
||||
* **Temuan**: Endpoint API di `poverty-mapping` dapat membaca autentikasi dari `$_SESSION['jwt']` (cookie-based). Karena tidak ada token anti-CSRF pada request mutasi, situs pihak ketiga yang dibuka di tab browser yang sama dapat memaksa browser mengirimkan request mutasi berotentikasi.
|
||||
* **Rekomendasi**: Ubah API untuk hanya menerima otentikasi melalui header HTTP `Authorization: Bearer <token>` (tidak membaca session cookie untuk API sensitif), karena header Authorization tidak akan ditransmisikan secara otomatis oleh browser pada request lintas situs (cross-site).
|
||||
|
||||
### D. File Upload Security (Risiko: MEDIUM)
|
||||
* **Temuan**: Di `poverty-mapping/src/api/upload_photo.php`, tipe file diverifikasi menggunakan MIME detection (`finfo_file`). Namun, sanitation filename:
|
||||
```php
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/','_',basename($file['name']));
|
||||
```
|
||||
Akan mengganti karakter titik (`.`) dengan underscore (`_`), sehingga ekstensi file akan hilang (misal `gambar.jpg` menjadi `gambar_jpg`). Hal ini aman dari eksekusi script PHP liar, tetapi dapat mengganggu pembacaan file gambar oleh browser jika web server tidak mengirimkan header Content-Type yang tepat.
|
||||
* **Rekomendasi**: Pisahkan proses sanitasi nama berkas dengan tetap mempertahankan ekstensi aslinya secara terpisah di bagian akhir nama berkas:
|
||||
```php
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { /* reject */ }
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', pathinfo($file['name'], PATHINFO_FILENAME)) . '.' . $ext;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Analisis Database
|
||||
|
||||
Kedua project menggunakan database dengan nama default `webgis` pada repositori asal mereka. Namun, keduanya memiliki struktur tabel `lokasi` yang berbeda:
|
||||
* `poverty-mapping.lokasi`: Menggunakan primary key `id INT AUTO_INCREMENT` dan menyimpan data KK, status kemiskinan, jumlah anggota keluarga, pendapatan, serta status bantuan sosial.
|
||||
* `tugas-spbu.lokasi`: Menggunakan primary key `id VARCHAR(8)` (UUID pendek) dan dirancang untuk memetakan koordinat SPBU, Masjid, dan Rumah.
|
||||
|
||||
### Rekomendasi Solusi:
|
||||
1. **Pemisahan Database**: Wajib dipisahkan menjadi dua database berbeda di server database MySQL yang sama:
|
||||
* Database 1: `poverty_db` (untuk Poverty Mapping)
|
||||
* Database 2: `spbu_db` (untuk Tugas SPBU)
|
||||
2. **Struktur Environment**:
|
||||
* Di server produksi, buat satu container MySQL yang menampung kedua database tersebut.
|
||||
* Konfigurasikan masing-masing aplikasi melalui environment variable `DB_NAME` untuk menunjuk ke database yang bersesuaian.
|
||||
|
||||
---
|
||||
|
||||
## 6. Analisis Docker
|
||||
|
||||
Sangat direkomendasikan untuk menggunakan Docker demi portabilitas dan kemudahan deployment di Coolify.
|
||||
|
||||
### Kelebihan Menggunakan Docker:
|
||||
* **Konsistensi**: Menjamin PHP 8.x dan modul `mysqli` terinstal dengan tepat di server tanpa perlu konfigurasi OS manual.
|
||||
* **Isolasi Dependensi**: Folder `uploads` dan file konfigurasi terisolasi penuh.
|
||||
* **Kemudahan Coolify**: Coolify secara native mendukung deployment berbasis Docker Compose. Kita hanya perlu mengarahkan Coolify ke repositori Git kita, dan Coolify akan membangun lingkungan lengkap secara otomatis.
|
||||
|
||||
### Rencana File Docker & Compose:
|
||||
|
||||
#### A. Dockerfile untuk `poverty-mapping` (`poverty-mapping/Dockerfile`)
|
||||
```dockerfile
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install ekstensi mysqli untuk PHP
|
||||
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
||||
|
||||
# Aktifkan mod_rewrite Apache untuk menangani routing .htaccess jika ada
|
||||
RUN a2enmod rewrite
|
||||
|
||||
# Salin source code project ke dalam container
|
||||
COPY . /var/www/html/
|
||||
|
||||
# Atur hak akses direktori uploads agar web server bisa menulis file foto
|
||||
RUN chown -R www-data:www-data /var/www/html && chmod -R 775 /var/www/html/uploads
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
#### B. Dockerfile untuk `tugas-spbu` (`tugas-spbu/Dockerfile`)
|
||||
```dockerfile
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install ekstensi mysqli
|
||||
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
||||
|
||||
# Salin source code
|
||||
COPY . /var/www/html/
|
||||
|
||||
# Atur hak akses
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
#### C. Root `docker-compose.yml`
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 1. Reverse Proxy & Landing Page
|
||||
landing-page:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./landing-page:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- poverty-mapping
|
||||
- tugas-spbu
|
||||
restart: always
|
||||
|
||||
# 2. Poverty Mapping Application
|
||||
poverty-mapping:
|
||||
build:
|
||||
context: ./poverty-mapping
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD}
|
||||
- DB_NAME=poverty_db
|
||||
- API_KEY=${POVERTY_API_KEY}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- INTERNAL_AUTH_KEY=${INTERNAL_AUTH_KEY}
|
||||
- BASIC_API_USER=webgis-api
|
||||
- BASIC_API_PASS=${BASIC_API_PASS}
|
||||
- SMTP_HOST=smtp.gmail.com
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_ENCRYPTION=tls
|
||||
- MAIL_FROM=${SMTP_USER}
|
||||
- MAIL_FROM_NAME=PovertyMapping
|
||||
volumes:
|
||||
- poverty_uploads:/var/www/html/uploads
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 3. Tugas SPBU Application
|
||||
tugas-spbu:
|
||||
build:
|
||||
context: ./tugas-spbu
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD}
|
||||
- DB_NAME=spbu_db
|
||||
- API_KEY=${SPBU_API_KEY}
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 4. Database MySQL
|
||||
db:
|
||||
image: mysql:8.0
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
|
||||
- MYSQL_DATABASE=poverty_db
|
||||
- MYSQL_USER=webgis_user
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
poverty_uploads:
|
||||
```
|
||||
|
||||
#### D. Nginx Config (`nginx.conf`)
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 1. Landing Page Utama
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 2. Route ke Poverty Mapping App
|
||||
location /poverty-mapping/ {
|
||||
proxy_pass http://poverty-mapping:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Scope cookies ke subpath agar tidak tabrakan session PHP-nya
|
||||
proxy_cookie_path / /poverty-mapping/;
|
||||
}
|
||||
|
||||
# 3. Route ke Tugas SPBU App
|
||||
location /tugas-spbu/ {
|
||||
proxy_pass http://tugas-spbu:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_cookie_path / /tugas-spbu/;
|
||||
}
|
||||
|
||||
# Blokir akses ke file .env / .git secara global di level proxy
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Identifikasi Role dan Akun Demo
|
||||
|
||||
Untuk memudahkan proses penilaian oleh Dosen, berikut adalah daftar peran (role) yang teridentifikasi beserta rancangan akun demonya.
|
||||
|
||||
### Daftar Peran (Roles):
|
||||
1. **Admin** (Poverty Mapping): Memiliki akses penuh terhadap visualisasi peta kemiskinan, serta persetujuan pembuatan akun manager baru.
|
||||
2. **Manager** (Poverty Mapping): Memiliki akses untuk menambah lokasi keluarga miskin, mengubah data kemiskinan, serta mengunggah foto bukti penyerahan bantuan sosial.
|
||||
3. **Guest / Publik** (Poverty Mapping): Hanya bisa melihat data peta dengan pembatasan/masking pada kolom sensitif (seperti Nama Kepala Keluarga, No Telepon, Catatan internal bantuan).
|
||||
4. **Admin SPBU** (Tugas SPBU): Menggunakan token `API_KEY` untuk menambah, mengedit, atau menghapus data spasial jalan, tanah, dan lokasi SPBU.
|
||||
|
||||
### Daftar Akun Demo (Password: `password`):
|
||||
|
||||
| Aplikasi | Role | Email / Akun | Password | Catatan Kredensial |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Poverty Mapping** | Admin | `admin@example.com` | `password` | Akun administrator utama untuk persetujuan akun Manager. |
|
||||
| **Poverty Mapping** | Manager | `manager@example.com` | `password` | Perwakilan role Manager untuk penambahan/pengeditan data kemiskinan. |
|
||||
| **Tugas SPBU** | Admin | *Token/Key* | `8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c` | Berupa token `API_KEY` yang di-input ke panel admin SPBU. |
|
||||
|
||||
### Mekanisme Seed SQL (`init-db.sql`):
|
||||
Ketika container MySQL dibangun pertama kali, script ini akan secara otomatis membuat kedua database, mengatur tabel awal, dan menaruh data akun demo dengan hash password yang valid untuk kata sandi `"password"` (yaitu `$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.`):
|
||||
|
||||
```sql
|
||||
-- Buat database jika belum ada
|
||||
CREATE DATABASE IF NOT EXISTS poverty_db;
|
||||
CREATE DATABASE IF NOT EXISTS spbu_db;
|
||||
|
||||
-- Pindah ke database poverty_db untuk inisialisasi tabel users dan admin default
|
||||
USE poverty_db;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) DEFAULT NULL,
|
||||
organization VARCHAR(255) DEFAULT NULL,
|
||||
org_address TEXT DEFAULT NULL,
|
||||
org_phone VARCHAR(32) DEFAULT NULL,
|
||||
org_proof_path VARCHAR(255) DEFAULT NULL,
|
||||
role ENUM('manager','admin') NOT NULL DEFAULT 'manager',
|
||||
status ENUM('pending','active','rejected','pending_verification') NOT NULL DEFAULT 'pending',
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert akun demo Admin & Manager (Password: password)
|
||||
INSERT INTO users (email, password_hash, name, organization, role, status, email_verified)
|
||||
VALUES
|
||||
('admin@example.com', '$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.', 'Admin Demo', 'SIG WebGIS', 'admin', 'active', 1),
|
||||
('manager@example.com', '$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.', 'Manager Demo', 'Pemberdayaan Masyarakat', 'manager', 'active', 1)
|
||||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), status = 'active';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Langkah Rencana Deployment Lengkap
|
||||
|
||||
Berikut adalah workflow deployment lengkap dari repositori lokal ke server Coolify:
|
||||
|
||||
### Langkah 1: Persiapan Repositori Git & Gitea
|
||||
1. **Inisialisasi Git Lokal**:
|
||||
Di root folder `/media/ilham/data/Code/sig-uas/`, jalankan:
|
||||
```bash
|
||||
git init
|
||||
```
|
||||
2. **Konfigurasi `.gitignore`**:
|
||||
Buat file `.gitignore` di root folder untuk mencegah data rahasia masuk repositori:
|
||||
```
|
||||
# Mengabaikan file environment lokal
|
||||
**/.env
|
||||
**/vendor/
|
||||
**/node_modules/
|
||||
.idea/
|
||||
.vscode/
|
||||
```
|
||||
3. **Commit Code**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Initial commit for SIG UAS unified project"
|
||||
```
|
||||
4. **Buat Repositori Baru di Gitea**:
|
||||
* Login ke akun Gitea Anda.
|
||||
* Pilih **New Repository**, beri nama `sig-uas`.
|
||||
* Salin URL remote git (contoh: `http://gitea.domain.com/user/sig-uas.git`).
|
||||
5. **Push ke Gitea**:
|
||||
```bash
|
||||
git remote add origin <URL_GITEA>
|
||||
git branch -M main
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### Langkah 2: Setup Database & Environment Variables di Coolify
|
||||
1. Login ke dashboard **Coolify** Anda.
|
||||
2. Pilih / buat **Project** baru untuk SIG UAS.
|
||||
3. Sebelum meluncurkan container aplikasi, definisikan variabel environment (Secrets) yang dibutuhkan dalam menu konfigurasi environment di Coolify:
|
||||
* `DB_ROOT_PASSWORD`: Password root MySQL (bebas & aman).
|
||||
* `DB_PASSWORD`: Password user database MySQL.
|
||||
* `POVERTY_API_KEY`: API Key acak untuk Poverty Mapping.
|
||||
* `SPBU_API_KEY`: API Key acak untuk SPBU (`8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c` jika ingin mencocokkan default frontend).
|
||||
* `JWT_SECRET`: Secret key JWT acak.
|
||||
* `INTERNAL_AUTH_KEY`: Key autentikasi internal acak.
|
||||
* `SMTP_USER`: Alamat Gmail pengirim notifikasi.
|
||||
* `SMTP_PASS`: App password akun Gmail pengirim.
|
||||
|
||||
### Langkah 3: Setup Project di Coolify (Deployment)
|
||||
1. Di dalam Project Coolify, klik **Add Resource** -> **Docker Compose**.
|
||||
2. Masukkan detail Git Gitea Anda (Coolify akan menggunakan SSH key atau Credentials untuk membaca repositori).
|
||||
3. Masukkan path ke `docker-compose.yml` (secara default diisi `./docker-compose.yml`).
|
||||
4. Coolify secara otomatis membaca file compose tersebut.
|
||||
5. Konfigurasikan **Domains** pada service `landing-page` di panel Coolify (misal: `https://sig.domain.com`). Coolify akan secara otomatis membuatkan sertifikat SSL gratis menggunakan Let's Encrypt dan merutekannya ke port 80 milik service `landing-page`.
|
||||
6. Klik **Deploy**. Coolify akan:
|
||||
* Melakukan pull source code dari Gitea.
|
||||
* Membangun gambar Docker untuk Poverty Mapping dan Tugas SPBU.
|
||||
* Menyalakan database MySQL dan menjalankan inisialisasi SQL dari `init-db.sql`.
|
||||
* Menghidupkan proxy Nginx dan Landing Page.
|
||||
|
||||
### Langkah 4: Pengujian Pasca Deployment (Testing)
|
||||
1. **Akses Domain**: Buka `https://sig.domain.com` untuk memastikan landing page premium tampil.
|
||||
2. **Navigasi Poverty Mapping**:
|
||||
* Buka `https://sig.domain.com/poverty-mapping/`.
|
||||
* Klik **Masuk** dan login dengan akun demo `admin@example.com` (password: `password`).
|
||||
* Uji penambahan pin peta kemiskinan dan unggah foto.
|
||||
3. **Navigasi SPBU**:
|
||||
* Buka `https://sig.domain.com/tugas-spbu/`.
|
||||
* Uji kelancaran data spasial jalan, tanah, dan titik lokasi SPBU yang dimuat secara otomatis dari database.
|
||||
|
||||
### Langkah 5: Skema Backup dan Rollback
|
||||
1. **Backup Database & Data Volume**:
|
||||
* Coolify menyediakan fitur backup database MySQL bawaan yang berjalan secara periodik. Pastikan fitur ini diaktifkan dengan tujuan ke S3 storage atau direktori lokal server.
|
||||
* Volume `poverty_uploads` yang menyimpan gambar-gambar bukti foto kemiskinan disimpan di `/var/lib/docker/volumes/` pada host. Folder ini harus dimasukkan ke daftar rutin backup server.
|
||||
2. **Rollback Cepat**:
|
||||
* Jika terjadi masalah saat rilis pembaruan baru, buka tab **Deployments** di Coolify untuk service tersebut, lalu klik tombol **Redeploy** pada versi commit stabil sebelumnya.
|
||||
* Coolify akan langsung mematikan container yang bermasalah dan menghidupkan kembali container berbasis image stabil sebelumnya dalam waktu kurang dari 10 detik.
|
||||
@@ -0,0 +1,78 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 1. Reverse Proxy & Landing Page
|
||||
landing-page:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./landing-page:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- poverty-mapping
|
||||
- tugas-spbu
|
||||
restart: always
|
||||
|
||||
# 2. Poverty Mapping Application
|
||||
poverty-mapping:
|
||||
build:
|
||||
context: ./poverty-mapping
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD:-webgis_password}
|
||||
- DB_NAME=poverty_db
|
||||
- API_KEY=${POVERTY_API_KEY:-8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c}
|
||||
- JWT_SECRET=${JWT_SECRET:-jwt_secret_dev_key_12345}
|
||||
- INTERNAL_AUTH_KEY=${INTERNAL_AUTH_KEY:-internal_dev_key}
|
||||
- BASIC_API_USER=webgis-api
|
||||
- BASIC_API_PASS=${BASIC_API_PASS:-api_pass}
|
||||
- SMTP_HOST=smtp.gmail.com
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=${SMTP_USER:-dev@example.com}
|
||||
- SMTP_PASS=${SMTP_PASS:-dev_pass}
|
||||
- SMTP_ENCRYPTION=tls
|
||||
- MAIL_FROM=${SMTP_USER:-dev@example.com}
|
||||
- MAIL_FROM_NAME=PovertyMapping
|
||||
volumes:
|
||||
- poverty_uploads:/var/www/html/uploads
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 3. Tugas SPBU Application
|
||||
tugas-spbu:
|
||||
build:
|
||||
context: ./tugas-spbu
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD:-webgis_password}
|
||||
- DB_NAME=spbu_db
|
||||
- API_KEY=${SPBU_API_KEY:-8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c}
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 4. Database MySQL
|
||||
db:
|
||||
image: mysql:8.0
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-root_password}
|
||||
- MYSQL_DATABASE=poverty_db
|
||||
- MYSQL_USER=webgis_user
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD:-webgis_password}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
poverty_uploads:
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
-- -----------------------------------------------------
|
||||
-- DATABASE 1: poverty_db (Poverty Mapping Project)
|
||||
-- -----------------------------------------------------
|
||||
CREATE DATABASE IF NOT EXISTS poverty_db;
|
||||
USE poverty_db;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
DROP TABLE IF EXISTS bantuan_detail;
|
||||
DROP TABLE IF EXISTS lokasi;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS migrations;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) DEFAULT NULL,
|
||||
organization VARCHAR(255) DEFAULT NULL,
|
||||
org_address TEXT DEFAULT NULL,
|
||||
org_phone VARCHAR(32) DEFAULT NULL,
|
||||
org_proof_path VARCHAR(255) DEFAULT NULL,
|
||||
role ENUM('manager','admin') NOT NULL DEFAULT 'manager',
|
||||
status ENUM('pending','active','rejected','pending_verification') NOT NULL DEFAULT 'pending',
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
email_verification_token_hash VARCHAR(128) DEFAULT NULL,
|
||||
email_verification_expires DATETIME DEFAULT NULL,
|
||||
email_verification_sent_at DATETIME DEFAULT NULL,
|
||||
email_verification_attempts INT NOT NULL DEFAULT 0,
|
||||
email_verification_last_sent DATETIME DEFAULT NULL,
|
||||
email_verification_locked_until DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
approved_by INT UNSIGNED DEFAULT NULL,
|
||||
approved_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_email (email),
|
||||
KEY idx_users_status (status),
|
||||
KEY idx_users_role (role),
|
||||
KEY idx_users_approved_by (approved_by),
|
||||
CONSTRAINT fk_users_approved_by FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert Admin and Manager demo accounts (Password: password)
|
||||
INSERT INTO users (email, password_hash, name, organization, role, status, email_verified)
|
||||
VALUES
|
||||
('admin@example.com', '$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.', 'Admin Demo', 'SIG WebGIS', 'admin', 'active', 1),
|
||||
('manager@example.com', '$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.', 'Manager Demo', 'Pemberdayaan Masyarakat', 'manager', 'active', 1)
|
||||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), status = 'active';
|
||||
|
||||
CREATE TABLE lokasi (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
nama VARCHAR(255) NOT NULL,
|
||||
no_telp VARCHAR(32) DEFAULT NULL,
|
||||
buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
|
||||
latitude DECIMAL(10,6) DEFAULT NULL,
|
||||
longitude DECIMAL(10,6) DEFAULT NULL,
|
||||
alamat TEXT,
|
||||
jenis VARCHAR(50) NOT NULL,
|
||||
nama_kk VARCHAR(255) DEFAULT NULL,
|
||||
rumah_status VARCHAR(100) DEFAULT NULL,
|
||||
members INT DEFAULT NULL,
|
||||
monthly_income DECIMAL(18,0) DEFAULT NULL,
|
||||
assisted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_assisted_at DATETIME DEFAULT NULL,
|
||||
assistance_notes TEXT DEFAULT NULL,
|
||||
photo_path VARCHAR(255) DEFAULT NULL,
|
||||
sumber_bantuan INT UNSIGNED DEFAULT NULL,
|
||||
bentuk_bantuan VARCHAR(100) DEFAULT NULL,
|
||||
created_by INT UNSIGNED DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_lokasi_assisted (assisted),
|
||||
KEY idx_lokasi_jenis (jenis),
|
||||
KEY idx_lokasi_sumber_bantuan (sumber_bantuan),
|
||||
KEY idx_lokasi_created_by (created_by),
|
||||
KEY idx_lokasi_last_assisted_at (last_assisted_at),
|
||||
CONSTRAINT fk_lokasi_sumber_bantuan FOREIGN KEY (sumber_bantuan) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_lokasi_created_by FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE bantuan_detail (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
lokasi_id INT UNSIGNED NOT NULL,
|
||||
tanggal_bantuan DATE DEFAULT NULL,
|
||||
pemberi_bantuan VARCHAR(255) DEFAULT NULL,
|
||||
catatan TEXT DEFAULT NULL,
|
||||
pemberi_lokasi_id INT UNSIGNED DEFAULT NULL,
|
||||
jumlah INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_bantuan_lokasi (lokasi_id),
|
||||
KEY idx_bantuan_pemberi_lokasi (pemberi_lokasi_id),
|
||||
KEY idx_bantuan_tanggal (tanggal_bantuan),
|
||||
KEY idx_bantuan_lokasi_tanggal (lokasi_id, tanggal_bantuan),
|
||||
KEY idx_bantuan_pemberi_tanggal (pemberi_lokasi_id, tanggal_bantuan),
|
||||
CONSTRAINT fk_bantuan_lokasi FOREIGN KEY (lokasi_id) REFERENCES lokasi (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_bantuan_pemberi_lokasi FOREIGN KEY (pemberi_lokasi_id) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE migrations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
applied_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_migrations_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO migrations (name, applied_at)
|
||||
VALUES ('001_init_schema', CURRENT_TIMESTAMP);
|
||||
|
||||
|
||||
-- -----------------------------------------------------
|
||||
-- DATABASE 2: spbu_db (SPBU Mapping Project)
|
||||
-- -----------------------------------------------------
|
||||
CREATE DATABASE IF NOT EXISTS spbu_db;
|
||||
USE spbu_db;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
DROP TABLE IF EXISTS `jalan`;
|
||||
DROP TABLE IF EXISTS `lokasi`;
|
||||
DROP TABLE IF EXISTS `tanah`;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
CREATE TABLE `jalan` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(100) DEFAULT NULL,
|
||||
`status` varchar(50) DEFAULT NULL,
|
||||
`panjang` double DEFAULT NULL,
|
||||
`geom` text,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE `lokasi` (
|
||||
`id` varchar(8) NOT NULL,
|
||||
`nama` varchar(50) NOT NULL,
|
||||
`no_telp` varchar(20) DEFAULT NULL,
|
||||
`buka_24_jam` tinyint(1) DEFAULT NULL,
|
||||
`latitude` decimal(10,6) DEFAULT NULL,
|
||||
`longitude` decimal(10,6) DEFAULT NULL,
|
||||
`alamat` text,
|
||||
`jenis` varchar(50) NOT NULL DEFAULT 'spbu',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_lokasi_alamat` ((left(`alamat`,255)))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Seed SPBU map data
|
||||
INSERT INTO `lokasi` VALUES
|
||||
('5092','Rumah','',0,-0.040882,109.335197,'Daeng Abdul Hadi, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78117, Indonesia','rumah'),
|
||||
('7853','Masjid Mujahiddin','',1,-0.041462,109.336345,'Mujahiddin, Jalan Mujahidin, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','masjid'),
|
||||
('8476','SPBU OSO MT. Haryono','',0,-0.044863,109.336726,'SPBU OSO MT. Haryono, Jalan M.T. Haryono, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','spbu')
|
||||
ON DUPLICATE KEY UPDATE nama = VALUES(nama);
|
||||
|
||||
CREATE TABLE `tanah` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(100) DEFAULT NULL,
|
||||
`status` varchar(50) DEFAULT NULL,
|
||||
`luas` double DEFAULT NULL,
|
||||
`geom` text,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebGIS Portal - SIG UAS</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebGIS Portal - Tugas Akhir SIG</h1>
|
||||
<p>Selamat datang di portal integrasi aplikasi Sistem Informasi Geografis (SIG) untuk UAS.</p>
|
||||
|
||||
<h2>Daftar Aplikasi:</h2>
|
||||
<ul>
|
||||
<li><a href="/poverty-mapping/">Aplikasi Poverty Mapping (Project Utama)</a></li>
|
||||
<li><a href="/tugas-spbu/">Aplikasi Tugas SPBU & Tata Ruang</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Akun Demo Penilaian:</h2>
|
||||
<table border="1" cellpadding="8" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Aplikasi</th>
|
||||
<th>Role</th>
|
||||
<th>Kredensial / Email</th>
|
||||
<th>Password / Token</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Poverty Mapping</td>
|
||||
<td>Admin</td>
|
||||
<td>admin@example.com</td>
|
||||
<td>password</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Poverty Mapping</td>
|
||||
<td>Manager</td>
|
||||
<td>manager@example.com</td>
|
||||
<td>password</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tugas SPBU</td>
|
||||
<td>Admin SPBU</td>
|
||||
<td>Input API Key di UI</td>
|
||||
<td>8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
<hr>
|
||||
<footer>
|
||||
<p>© 2026 Portal WebGIS UAS.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 1. Landing Page Utama
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 2. Route ke Poverty Mapping App
|
||||
location /poverty-mapping/ {
|
||||
proxy_pass http://poverty-mapping:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Scope cookies ke subpath agar tidak tabrakan session PHP-nya
|
||||
proxy_cookie_path / /poverty-mapping/;
|
||||
}
|
||||
|
||||
# 3. Route ke Tugas SPBU App
|
||||
location /tugas-spbu/ {
|
||||
proxy_pass http://tugas-spbu:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_cookie_path / /tugas-spbu/;
|
||||
}
|
||||
|
||||
# Block access to hidden files (.env, .git, etc.)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Example environment file for WebGIS.
|
||||
# Copy this file to `.env`, then fill in real values. Do not commit `.env`.
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASS=change_me
|
||||
DB_NAME=webgis
|
||||
|
||||
# Mail
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your_gmail_address@gmail.com
|
||||
SMTP_PASS=your_gmail_app_password
|
||||
SMTP_ENCRYPTION=tls
|
||||
MAIL_FROM=your_gmail_address@gmail.com
|
||||
MAIL_FROM_NAME=WebGIS
|
||||
|
||||
# Auth secrets
|
||||
# Generate secrets with: php -r "echo bin2hex(random_bytes(32)).PHP_EOL;"
|
||||
API_KEY=change_me_to_a_random_hex_string
|
||||
JWT_SECRET=change_me_to_a_random_hex_string
|
||||
INTERNAL_AUTH_KEY=change_me_to_a_random_hex_string
|
||||
BASIC_API_USER=webgis-api
|
||||
BASIC_API_PASS=change_me
|
||||
|
||||
# Optional local debug helper for verification codes.
|
||||
DEBUG_MODE=0
|
||||
@@ -0,0 +1,9 @@
|
||||
/.env
|
||||
/.env.local
|
||||
/*.env
|
||||
/uploads/
|
||||
/src/uploads/
|
||||
# ignore common editor temp files
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install mysqli extension for PHP
|
||||
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
||||
|
||||
# Enable Apache rewrite module
|
||||
RUN a2enmod rewrite
|
||||
|
||||
# Copy project files into container
|
||||
COPY . /var/www/html/
|
||||
|
||||
# Set ownership and permissions for uploads directory
|
||||
RUN chown -R www-data:www-data /var/www/html && chmod -R 775 /var/www/html/uploads
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,141 @@
|
||||
# WebGIS
|
||||
|
||||
Aplikasi peta lokasi bantuan dan tempat ibadah berbasis PHP, MySQL, dan Leaflet.
|
||||
|
||||
## Gambaran Singkat
|
||||
|
||||
Guest bisa melihat peta dan daftar data. Pengelola mendaftar lewat formulir yang langsung memverifikasi email, lalu menunggu persetujuan admin. Setelah akun aktif, pengelola bisa menambah, mengubah, dan menghapus data miliknya sendiri. Admin bisa menyetujui atau menolak akun pengelola yang menunggu aktivasi.
|
||||
|
||||
## Yang Dibutuhkan
|
||||
|
||||
- PHP 8.1 atau lebih baru
|
||||
- MySQL atau MariaDB
|
||||
- Composer
|
||||
- Browser modern seperti Chrome, Edge, atau Firefox
|
||||
- Koneksi internet untuk Leaflet, tile peta, dan email SMTP
|
||||
|
||||
## Langkah Instalasi
|
||||
|
||||
Ikuti urutan ini dari awal jika Anda belum pernah menjalankan proyek ini.
|
||||
|
||||
1. Salin folder proyek ke komputer Anda.
|
||||
2. Buka terminal di folder proyek.
|
||||
3. Pasang dependensi PHP:
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
4. Buat file konfigurasi lingkungan:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
5. Isi file `.env` dengan kredensial database, SMTP, dan secret aplikasi.
|
||||
6. Buat database MySQL, misalnya bernama `webgis`.
|
||||
7. Import skema database:
|
||||
|
||||
```bash
|
||||
mysql -u root -p webgis < sql/migrations/001_init_schema.sql
|
||||
```
|
||||
|
||||
8. Jalankan server PHP bawaan untuk uji lokal:
|
||||
|
||||
```bash
|
||||
php -S localhost:8000 -t .
|
||||
```
|
||||
|
||||
9. Buka aplikasi di browser:
|
||||
|
||||
```text
|
||||
http://localhost:8000/index.html
|
||||
```
|
||||
|
||||
Jika Anda memakai Apache atau Nginx, arahkan document root ke folder proyek ini dan sesuaikan URL aksesnya.
|
||||
|
||||
## Isi File `.env`
|
||||
|
||||
Contoh nilai yang perlu diisi:
|
||||
|
||||
- `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_ENCRYPTION`
|
||||
- `MAIL_FROM`, `MAIL_FROM_NAME`
|
||||
- `API_KEY`, `JWT_SECRET`, `INTERNAL_AUTH_KEY`
|
||||
- `BASIC_API_USER`, `BASIC_API_PASS`
|
||||
- `DEBUG_MODE=0` untuk produksi atau `1` jika ingin menampilkan kode verifikasi saat pengembangan lokal
|
||||
|
||||
## Cara Pakai
|
||||
|
||||
### 1. Buka peta
|
||||
|
||||
Setelah aplikasi terbuka, tampilan awal adalah peta. Guest tetap bisa melihat marker dan daftar data.
|
||||
|
||||
### 2. Daftar pengelola
|
||||
|
||||
Klik tombol masuk/daftar, lalu pilih daftar pengelola. Isi semua field yang diminta, kirim kode verifikasi ke email, masukkan kode 6 digit, lalu klik daftar.
|
||||
|
||||
### 3. Login
|
||||
|
||||
Gunakan email dan password yang sudah terdaftar. Setelah login, bar atas akan menampilkan nama pengguna.
|
||||
|
||||
### 4. Kelola data
|
||||
|
||||
Pengelola yang sudah aktif bisa menambah data baru dari peta, mengubah data miliknya sendiri, dan mengunggah foto bukti jika diperlukan. Admin bisa mengelola seluruh data.
|
||||
|
||||
### 5. Lihat daftar data
|
||||
|
||||
Klik tombol `Daftar Data` untuk melihat data dalam bentuk tabel. Guest hanya melihat data yang aman untuk publik.
|
||||
|
||||
## Akun Admin Awal
|
||||
|
||||
Seed database menyediakan akun admin awal:
|
||||
|
||||
- Email: `admin@example.com`
|
||||
- Password: `Admin123!`
|
||||
|
||||
Segera ganti kredensial ini jika Anda menjalankan aplikasi di lingkungan yang tidak pribadi.
|
||||
|
||||
## Endpoint Utama
|
||||
|
||||
- `src/api/auth/register.php` - daftar pengelola
|
||||
- `src/api/auth/send_verification_code.php` - kirim kode verifikasi email
|
||||
- `src/api/auth/login.php` - login
|
||||
- `src/api/auth/logout.php` - logout
|
||||
- `src/api/auth/me.php` - status pengguna aktif
|
||||
- `src/api/get_lokasi.php` - ambil data lokasi
|
||||
- `src/api/tambah_lokasi.php` - tambah lokasi baru
|
||||
- `src/api/update_lokasi.php` - ubah lokasi
|
||||
- `src/api/hapus_lokasi.php` - hapus lokasi
|
||||
- `src/api/upload_photo.php` - unggah foto lokasi
|
||||
- `src/api/admin/list_pending_managers.php` - daftar akun pengelola menunggu persetujuan
|
||||
- `src/api/admin/approve_user.php` - setujui akun
|
||||
- `src/api/admin/reject_user.php` - tolak akun
|
||||
|
||||
## File Penting
|
||||
|
||||
- [index.html](index.html) - frontend utama
|
||||
- [src/config/koneksi.php](src/config/koneksi.php) - koneksi database
|
||||
- [src/config/config.php](src/config/config.php) - secret dan autentikasi
|
||||
- [sql/migrations/001_init_schema.sql](sql/migrations/001_init_schema.sql) - skema database utama
|
||||
- [webgis.sql](webgis.sql) - dump SQL yang sejalan dengan skema utama
|
||||
- [.env.example](.env.example) - contoh konfigurasi environment
|
||||
|
||||
## Perawatan Berkala
|
||||
|
||||
Jika Anda ingin menandai bantuan yang sudah lewat masa berlakunya, jalankan:
|
||||
|
||||
```bash
|
||||
php scripts/expire_assistance.php
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Jika database gagal terhubung, cek isi `.env` dan pastikan MySQL berjalan.
|
||||
- Jika email tidak terkirim, pastikan kredensial SMTP benar dan memakai app password, bukan password akun biasa.
|
||||
- Jika peta kosong, cek koneksi internet dan pastikan endpoint `get_lokasi.php` mengembalikan JSON.
|
||||
- Jika login berhasil tetapi data tidak muncul, buka DevTools browser dan cek respons API serta status token JWT.
|
||||
|
||||
## Catatan Pengembangan
|
||||
|
||||
Arsitektur aplikasi ini sengaja dibuat sederhana agar mudah dipelihara: frontend vanilla JavaScript, backend PHP, dan database MySQL. Bila Anda menambah kolom database baru, perbarui file migrasi, dump SQL, dan dokumentasi dalam satu perubahan agar semuanya tetap sinkron.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"require": {
|
||||
"phpmailer/phpmailer": "^7.1",
|
||||
"vlucas/phpdotenv": "^5.6"
|
||||
}
|
||||
}
|
||||
Generated
+574
@@ -0,0 +1,574 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "44f3cb809a0f474d00b34192bc993c72",
|
||||
"packages": [
|
||||
{
|
||||
"name": "graham-campbell/result-type",
|
||||
"version": "v1.1.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/GrahamCampbell/Result-Type.git",
|
||||
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
|
||||
"reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"phpoption/phpoption": "^1.9.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GrahamCampbell\\ResultType\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
}
|
||||
],
|
||||
"description": "An Implementation Of The Result Type",
|
||||
"keywords": [
|
||||
"Graham Campbell",
|
||||
"GrahamCampbell",
|
||||
"Result Type",
|
||||
"Result-Type",
|
||||
"result"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
|
||||
"source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-27T19:43:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpmailer/phpmailer",
|
||||
"version": "v7.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPMailer/PHPMailer.git",
|
||||
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
|
||||
"reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-hash": "*",
|
||||
"php": ">=5.5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||
"doctrine/annotations": "^1.2.6 || ^1.13.3",
|
||||
"php-parallel-lint/php-console-highlighter": "^1.0.0",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3.2",
|
||||
"phpcompatibility/php-compatibility": "^10.0.0@dev",
|
||||
"squizlabs/php_codesniffer": "^3.13.5",
|
||||
"yoast/phpunit-polyfills": "^1.0.4"
|
||||
},
|
||||
"suggest": {
|
||||
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
|
||||
"directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example",
|
||||
"ext-imap": "Needed to support advanced email address parsing according to RFC822",
|
||||
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
|
||||
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
|
||||
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
|
||||
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
|
||||
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
|
||||
"psr/log": "For optional PSR-3 debug logging",
|
||||
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
|
||||
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PHPMailer\\PHPMailer\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marcus Bointon",
|
||||
"email": "phpmailer@synchromedia.co.uk"
|
||||
},
|
||||
{
|
||||
"name": "Jim Jagielski",
|
||||
"email": "jimjag@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Andy Prevost",
|
||||
"email": "codeworxtech@users.sourceforge.net"
|
||||
},
|
||||
{
|
||||
"name": "Brent R. Matzelle"
|
||||
}
|
||||
],
|
||||
"description": "PHPMailer is a full-featured email creation and transfer class for PHP",
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPMailer/PHPMailer/issues",
|
||||
"source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Synchro",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-18T08:06:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/schmittjoh/php-option.git",
|
||||
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
|
||||
"reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOption\\": "src/PhpOption/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Johannes M. Schmitt",
|
||||
"email": "schmittjoh@gmail.com",
|
||||
"homepage": "https://github.com/schmittjoh"
|
||||
},
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
}
|
||||
],
|
||||
"description": "Option Type for PHP",
|
||||
"keywords": [
|
||||
"language",
|
||||
"option",
|
||||
"php",
|
||||
"type"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/schmittjoh/php-option/issues",
|
||||
"source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-27T19:41:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-ctype.git",
|
||||
"reference": "141046a8f9477948ff284fa65be2095baafb94f2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
|
||||
"reference": "141046a8f9477948ff284fa65be2095baafb94f2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-ctype": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-ctype": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Ctype\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Gert de Pagter",
|
||||
"email": "BackEndTea@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for ctype functions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"ctype",
|
||||
"polyfill",
|
||||
"portable"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-mbstring",
|
||||
"version": "v1.38.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
|
||||
"reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"provide": {
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "For best performance"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill for the Mbstring extension",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"mbstring",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-26T12:51:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php80",
|
||||
"version": "v1.37.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php80.git",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/polyfill",
|
||||
"name": "symfony/polyfill"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Symfony\\Polyfill\\Php80\\": ""
|
||||
},
|
||||
"classmap": [
|
||||
"Resources/stubs"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ion Bazan",
|
||||
"email": "ion.bazan@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Nicolas Grekas",
|
||||
"email": "p@tchwork.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"compatibility",
|
||||
"polyfill",
|
||||
"portable",
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "vlucas/phpdotenv",
|
||||
"version": "v5.6.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vlucas/phpdotenv.git",
|
||||
"reference": "955e7815d677a3eaa7075231212f2110983adecc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
|
||||
"reference": "955e7815d677a3eaa7075231212f2110983adecc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-pcre": "*",
|
||||
"graham-campbell/result-type": "^1.1.4",
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"phpoption/phpoption": "^1.9.5",
|
||||
"symfony/polyfill-ctype": "^1.26",
|
||||
"symfony/polyfill-mbstring": "^1.26",
|
||||
"symfony/polyfill-php80": "^1.26"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"ext-filter": "*",
|
||||
"phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-filter": "Required to use the boolean validator."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "5.6-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dotenv\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Vance Lucas",
|
||||
"email": "vance@vancelucas.com",
|
||||
"homepage": "https://github.com/vlucas"
|
||||
}
|
||||
],
|
||||
"description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
|
||||
"keywords": [
|
||||
"dotenv",
|
||||
"env",
|
||||
"environment"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/vlucas/phpdotenv/issues",
|
||||
"source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-27T19:49:13+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": [],
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.2.0"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# ERD Summary - WebGIS
|
||||
|
||||
Dokumen ini merangkum skema database yang dipakai aplikasi pada `sql/migrations/001_init_schema.sql` dan `webgis.sql`.
|
||||
|
||||
## Tabel Utama
|
||||
|
||||
### `users`
|
||||
|
||||
Menyimpan akun pengguna aplikasi.
|
||||
|
||||
Kolom penting:
|
||||
- `email`
|
||||
- `password_hash`
|
||||
- `name`
|
||||
- `organization`
|
||||
- `org_address`
|
||||
- `org_phone`
|
||||
- `org_proof_path`
|
||||
- `role` dengan nilai `manager` atau `admin`
|
||||
- `status` dengan nilai `pending`, `active`, `rejected`, atau `pending_verification`
|
||||
- `email_verified`
|
||||
- field verifikasi email seperti `email_verification_token_hash`, `email_verification_expires`, dan timestamp terkait
|
||||
- `approved_by` dan `approved_at`
|
||||
|
||||
### `lokasi`
|
||||
|
||||
Menyimpan seluruh marker yang tampil di peta.
|
||||
|
||||
Kolom penting:
|
||||
- `nama`
|
||||
- `no_telp`
|
||||
- `latitude` dan `longitude`
|
||||
- `alamat`
|
||||
- `jenis`
|
||||
- field khusus rumah seperti `nama_kk`, `rumah_status`, `members`, `monthly_income`
|
||||
- field bantuan seperti `assisted`, `last_assisted_at`, `assistance_notes`, `sumber_bantuan`, `bentuk_bantuan`
|
||||
- `photo_path`
|
||||
- `created_by`
|
||||
|
||||
### `bantuan_detail`
|
||||
|
||||
Menyimpan detail histori bantuan per lokasi.
|
||||
|
||||
Kolom penting:
|
||||
- `lokasi_id`
|
||||
- `tanggal_bantuan`
|
||||
- `pemberi_bantuan`
|
||||
- `catatan`
|
||||
- `pemberi_lokasi_id`
|
||||
- `jumlah`
|
||||
|
||||
### `migrations`
|
||||
|
||||
Menyimpan catatan migration yang sudah dijalankan.
|
||||
|
||||
## Relasi
|
||||
|
||||
- `users.approved_by` mengarah ke `users.id`
|
||||
- `lokasi.created_by` mengarah ke `users.id`
|
||||
- `lokasi.sumber_bantuan` mengarah ke `lokasi.id`
|
||||
- `bantuan_detail.lokasi_id` mengarah ke `lokasi.id`
|
||||
- `bantuan_detail.pemberi_lokasi_id` mengarah ke `lokasi.id`
|
||||
|
||||
## Alur Data
|
||||
|
||||
1. Guest melihat data publik di peta dan tabel.
|
||||
2. Pengelola mendaftar, menerima kode verifikasi email, lalu akun dibuat dalam status menunggu persetujuan admin.
|
||||
3. Admin menyetujui akun pengelola.
|
||||
4. Pengelola yang disetujui dapat menambah dan mengubah data miliknya.
|
||||
5. Admin bisa mengelola seluruh data.
|
||||
|
||||
## Catatan Implementasi
|
||||
|
||||
- Database yang dipakai adalah MySQL/MariaDB.
|
||||
- Semua relasi foreign key dibuat agar data tetap konsisten.
|
||||
- Kolom bantuan dan verifikasi email disimpan langsung di tabel utama supaya alurnya sederhana untuk pengembangan berikutnya.
|
||||
@@ -0,0 +1,156 @@
|
||||
window.webgisOpenApiSpec = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'WebGIS API',
|
||||
version: '1.0.0',
|
||||
description: 'API for public map data, manager registrations, and admin approvals.'
|
||||
},
|
||||
servers: [{ url: '.' }],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT'
|
||||
}
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
'/src/api/auth/register.php': {
|
||||
post: {
|
||||
summary: 'Register manager account',
|
||||
description: 'Public registration endpoint for manager accounts.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
organization: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: { '200': { description: 'Registration successful, pending approval' } }
|
||||
}
|
||||
},
|
||||
'/src/api/auth/login.php': {
|
||||
post: {
|
||||
summary: 'Login',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: { '200': { description: 'Login successful' } }
|
||||
}
|
||||
},
|
||||
'/src/api/auth/logout.php': {
|
||||
post: {
|
||||
summary: 'Logout',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Logout successful' } }
|
||||
}
|
||||
},
|
||||
'/src/api/auth/me.php': {
|
||||
get: {
|
||||
summary: 'Current user session',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Current auth state' } }
|
||||
}
|
||||
},
|
||||
'/src/api/get_lokasi.php': {
|
||||
get: {
|
||||
summary: 'List locations',
|
||||
description: 'Public read-only endpoint.',
|
||||
responses: { '200': { description: 'Location list' } }
|
||||
}
|
||||
},
|
||||
'/src/api/tambah_lokasi.php': {
|
||||
post: {
|
||||
summary: 'Create location',
|
||||
description: 'Bearer JWT required. Managers can create records; admins can create all.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nama: { type: 'string' },
|
||||
jenis: { type: 'string' },
|
||||
alamat: { type: 'string' },
|
||||
latitude: { type: 'number' },
|
||||
longitude: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: { '200': { description: 'Created' } }
|
||||
}
|
||||
},
|
||||
'/src/api/update_lokasi.php': {
|
||||
post: {
|
||||
summary: 'Update location',
|
||||
description: 'Bearer JWT required. Managers can edit only their own lokasi; admins can edit all.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Updated' } }
|
||||
}
|
||||
},
|
||||
'/src/api/hapus_lokasi.php': {
|
||||
post: {
|
||||
summary: 'Delete location',
|
||||
description: 'Bearer JWT required. Managers can delete only their own lokasi; admins can delete all.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Deleted' } }
|
||||
}
|
||||
},
|
||||
'/src/api/upload_photo.php': {
|
||||
post: {
|
||||
summary: 'Upload location photo',
|
||||
description: 'Bearer JWT required. Managers can upload only for their own lokasi.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Uploaded' } }
|
||||
}
|
||||
},
|
||||
'/src/api/admin/list_pending_managers.php': {
|
||||
get: {
|
||||
summary: 'List pending manager registrations',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Pending list' } }
|
||||
}
|
||||
},
|
||||
'/src/api/admin/approve_user.php': {
|
||||
post: {
|
||||
summary: 'Approve pending user',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Approved' } }
|
||||
}
|
||||
},
|
||||
'/src/api/admin/reject_user.php': {
|
||||
post: {
|
||||
summary: 'Reject pending user',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { '200': { description: 'Rejected' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: WebGIS API
|
||||
version: 1.0.0
|
||||
description: API untuk autentikasi pengelola, data lokasi, unggah foto, dan persetujuan admin.
|
||||
servers:
|
||||
- url: .
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
basicAuth:
|
||||
type: http
|
||||
scheme: basic
|
||||
schemas:
|
||||
RegisterRequest:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
- name
|
||||
- organization
|
||||
- org_address
|
||||
- org_phone
|
||||
- verify_code
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
password:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
organization:
|
||||
type: string
|
||||
org_address:
|
||||
type: string
|
||||
org_phone:
|
||||
type: string
|
||||
verify_code:
|
||||
type: string
|
||||
pattern: '^[0-9]{6}$'
|
||||
LoginRequest:
|
||||
type: object
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
password:
|
||||
type: string
|
||||
LocationRequest:
|
||||
type: object
|
||||
properties:
|
||||
nama:
|
||||
type: string
|
||||
jenis:
|
||||
type: string
|
||||
no_telp:
|
||||
type: string
|
||||
alamat:
|
||||
type: string
|
||||
latitude:
|
||||
type: number
|
||||
longitude:
|
||||
type: number
|
||||
assisted:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
paths:
|
||||
/src/api/auth/register.php:
|
||||
post:
|
||||
summary: Daftar pengelola baru
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterRequest'
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Pendaftaran berhasil dan akun menunggu persetujuan admin
|
||||
/src/api/auth/send_verification_code.php:
|
||||
post:
|
||||
summary: Kirim kode verifikasi email
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
responses:
|
||||
'200':
|
||||
description: Kode verifikasi terkirim
|
||||
/src/api/auth/login.php:
|
||||
post:
|
||||
summary: Login pengguna
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Login berhasil
|
||||
/src/api/auth/logout.php:
|
||||
post:
|
||||
summary: Logout pengguna
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Logout berhasil
|
||||
/src/api/auth/me.php:
|
||||
get:
|
||||
summary: Ambil sesi pengguna aktif
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Status autentikasi saat ini
|
||||
/src/api/get_client_config.php:
|
||||
get:
|
||||
summary: Konfigurasi klien yang aman untuk frontend
|
||||
responses:
|
||||
'200':
|
||||
description: Data konfigurasi klien
|
||||
/src/api/get_lokasi.php:
|
||||
get:
|
||||
summary: Ambil daftar lokasi
|
||||
responses:
|
||||
'200':
|
||||
description: Daftar lokasi
|
||||
/src/api/tambah_lokasi.php:
|
||||
post:
|
||||
summary: Tambah lokasi baru
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LocationRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Data berhasil dibuat
|
||||
/src/api/update_lokasi.php:
|
||||
post:
|
||||
summary: Perbarui lokasi
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Data berhasil diperbarui
|
||||
/src/api/hapus_lokasi.php:
|
||||
post:
|
||||
summary: Hapus lokasi
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Data berhasil dihapus
|
||||
/src/api/upload_photo.php:
|
||||
post:
|
||||
summary: Unggah foto lokasi
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Foto berhasil diunggah
|
||||
/src/api/admin/list_pending_managers.php:
|
||||
get:
|
||||
summary: Daftar pengelola menunggu persetujuan
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Daftar akun pending
|
||||
/src/api/admin/approve_user.php:
|
||||
post:
|
||||
summary: Setujui akun pengelola
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Akun disetujui
|
||||
/src/api/admin/reject_user.php:
|
||||
post:
|
||||
summary: Tolak akun pengelola
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Akun ditolak
|
||||
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WebGIS API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
body{margin:0;background:#0f172a;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
|
||||
.topbar{padding:18px 20px;background:linear-gradient(135deg,#0f172a,#1e293b);color:#fff;border-bottom:1px solid rgba(255,255,255,0.08)}
|
||||
.topbar h1{margin:0;font-size:20px}
|
||||
.topbar p{margin:6px 0 0;color:#cbd5e1;font-size:13px}
|
||||
#swagger-ui{background:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1>WebGIS API Docs</h1>
|
||||
<p>OpenAPI documentation for auth, public map data, manager, and admin endpoints.</p>
|
||||
</div>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script src="openapi.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
SwaggerUIBundle({
|
||||
spec: window.webgisOpenApiSpec,
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
displayRequestDuration: true,
|
||||
presets: [SwaggerUIBundle.presets.apis],
|
||||
layout: 'BaseLayout'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
include __DIR__ . '/../src/config/koneksi.php';
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
echo "Running assistance expiry (30 days)...\n";
|
||||
$q = "UPDATE lokasi SET assisted = 0, last_assisted_at = NULL, sumber_bantuan = NULL, bentuk_bantuan = NULL, assistance_notes = NULL WHERE assisted = 1 AND last_assisted_at IS NOT NULL AND last_assisted_at <= DATE_SUB(NOW(), INTERVAL 30 DAY)";
|
||||
if($conn->query($q)){
|
||||
echo "OK. Affected rows: " . $conn->affected_rows . "\n";
|
||||
} else {
|
||||
echo "Failed: " . $conn->error . "\n";
|
||||
}
|
||||
echo "Done.\n";
|
||||
?>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
// Simple migration runner for the current WebGIS schema.
|
||||
// Run from the project root:
|
||||
// php scripts/run_migrations.php
|
||||
|
||||
include __DIR__ . '/../src/config/koneksi.php';
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$conn->query("CREATE TABLE IF NOT EXISTS migrations (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, applied_at DATETIME NOT NULL)");
|
||||
|
||||
function migration_applied($conn, $name){
|
||||
$st = $conn->prepare('SELECT COUNT(*) AS c FROM migrations WHERE name = ?');
|
||||
if(!$st){ return false; }
|
||||
$st->bind_param('s', $name);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
return $row && intval($row['c']) > 0;
|
||||
}
|
||||
|
||||
function run_migration_file($conn, $path){
|
||||
$name = basename($path);
|
||||
if(migration_applied($conn, $name)){
|
||||
echo "Skipping already applied: $name\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($path);
|
||||
if($sql === false){
|
||||
echo "Failed to read $name\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!$conn->multi_query($sql)){
|
||||
echo "Failed executing $name: " . $conn->error . "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
do {
|
||||
if($res = $conn->store_result()){
|
||||
$res->free();
|
||||
}
|
||||
} while($conn->more_results() && $conn->next_result());
|
||||
|
||||
if($conn->errno){
|
||||
echo "Failed executing $name: " . $conn->error . "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
$st = $conn->prepare('INSERT INTO migrations (name, applied_at) VALUES (?, NOW())');
|
||||
if($st){
|
||||
$st->bind_param('s', $name);
|
||||
$st->execute();
|
||||
$st->close();
|
||||
}
|
||||
|
||||
echo "Applied: $name\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
$migDir = __DIR__ . '/../sql/migrations';
|
||||
$files = array_values(array_filter(scandir($migDir), function($f){ return preg_match('/^\d+_.*\.sql$/', $f); }));
|
||||
sort($files, SORT_NATURAL);
|
||||
|
||||
if(empty($files)){
|
||||
echo "No migration files found.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
foreach($files as $file){
|
||||
if(!run_migration_file($conn, $migDir . '/' . $file)){
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "Migrations complete.\n";
|
||||
@@ -0,0 +1,101 @@
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS bantuan_detail;
|
||||
DROP TABLE IF EXISTS lokasi;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS migrations;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) DEFAULT NULL,
|
||||
organization VARCHAR(255) DEFAULT NULL,
|
||||
org_address TEXT DEFAULT NULL,
|
||||
org_phone VARCHAR(32) DEFAULT NULL,
|
||||
org_proof_path VARCHAR(255) DEFAULT NULL,
|
||||
role ENUM('manager','admin') NOT NULL DEFAULT 'manager',
|
||||
status ENUM('pending','active','rejected','pending_verification') NOT NULL DEFAULT 'pending',
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
email_verification_token_hash VARCHAR(128) DEFAULT NULL,
|
||||
email_verification_expires DATETIME DEFAULT NULL,
|
||||
email_verification_sent_at DATETIME DEFAULT NULL,
|
||||
email_verification_attempts INT NOT NULL DEFAULT 0,
|
||||
email_verification_last_sent DATETIME DEFAULT NULL,
|
||||
email_verification_locked_until DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
approved_by INT UNSIGNED DEFAULT NULL,
|
||||
approved_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_email (email),
|
||||
KEY idx_users_status (status),
|
||||
KEY idx_users_role (role),
|
||||
KEY idx_users_approved_by (approved_by),
|
||||
CONSTRAINT fk_users_approved_by FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE lokasi (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
nama VARCHAR(255) NOT NULL,
|
||||
no_telp VARCHAR(32) DEFAULT NULL,
|
||||
buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
|
||||
latitude DECIMAL(10,6) DEFAULT NULL,
|
||||
longitude DECIMAL(10,6) DEFAULT NULL,
|
||||
alamat TEXT,
|
||||
jenis VARCHAR(50) NOT NULL,
|
||||
nama_kk VARCHAR(255) DEFAULT NULL,
|
||||
rumah_status VARCHAR(100) DEFAULT NULL,
|
||||
members INT DEFAULT NULL,
|
||||
monthly_income DECIMAL(18,0) DEFAULT NULL,
|
||||
assisted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
last_assisted_at DATETIME DEFAULT NULL,
|
||||
assistance_notes TEXT DEFAULT NULL,
|
||||
photo_path VARCHAR(255) DEFAULT NULL,
|
||||
sumber_bantuan INT UNSIGNED DEFAULT NULL,
|
||||
bentuk_bantuan VARCHAR(100) DEFAULT NULL,
|
||||
created_by INT UNSIGNED DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_lokasi_assisted (assisted),
|
||||
KEY idx_lokasi_jenis (jenis),
|
||||
KEY idx_lokasi_sumber_bantuan (sumber_bantuan),
|
||||
KEY idx_lokasi_created_by (created_by),
|
||||
KEY idx_lokasi_last_assisted_at (last_assisted_at),
|
||||
CONSTRAINT fk_lokasi_sumber_bantuan FOREIGN KEY (sumber_bantuan) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_lokasi_created_by FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE bantuan_detail (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
lokasi_id INT UNSIGNED NOT NULL,
|
||||
tanggal_bantuan DATE DEFAULT NULL,
|
||||
pemberi_bantuan VARCHAR(255) DEFAULT NULL,
|
||||
catatan TEXT DEFAULT NULL,
|
||||
pemberi_lokasi_id INT UNSIGNED DEFAULT NULL,
|
||||
jumlah INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_bantuan_lokasi (lokasi_id),
|
||||
KEY idx_bantuan_pemberi_lokasi (pemberi_lokasi_id),
|
||||
KEY idx_bantuan_tanggal (tanggal_bantuan),
|
||||
KEY idx_bantuan_lokasi_tanggal (lokasi_id, tanggal_bantuan),
|
||||
KEY idx_bantuan_pemberi_tanggal (pemberi_lokasi_id, tanggal_bantuan),
|
||||
CONSTRAINT fk_bantuan_lokasi FOREIGN KEY (lokasi_id) REFERENCES lokasi (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_bantuan_pemberi_lokasi FOREIGN KEY (pemberi_lokasi_id) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE migrations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
applied_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_migrations_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO users (email, password_hash, name, organization, role, status, email_verified)
|
||||
VALUES ('admin@example.com', '$2y$10$M/ZHyO83wi6HquVGBGDfoO1OpKDuh.uhxd9G8fX.RptI6I9PvrW9S', 'Admin', 'WebGIS', 'admin', 'active', 1);
|
||||
|
||||
INSERT INTO migrations (name, applied_at)
|
||||
VALUES ('001_init_schema', CURRENT_TIMESTAMP);
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_role('admin');
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$id = isset($data['id']) ? intval($data['id']) : 0;
|
||||
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
|
||||
$st = $conn->prepare('UPDATE users SET status = "active", approved_by = ?, approved_at = NOW() WHERE id = ?');
|
||||
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$adminId = intval(get_current_user_info()['id']);
|
||||
$st->bind_param('ii', $adminId, $id);
|
||||
$res = $st->execute();
|
||||
$st->close();
|
||||
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
echo json_encode(['success'=>true]);
|
||||
?>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// only admin
|
||||
require_role('admin');
|
||||
|
||||
$st = $conn->prepare("SELECT id, email, name, organization, org_address, org_phone, org_proof_path, role, status, email_verified, email_verification_token_hash, email_verification_expires, email_verification_sent_at, email_verification_attempts, email_verification_last_sent, email_verification_locked_until, created_at, approved_by, approved_at FROM users WHERE status = 'pending' ORDER BY created_at ASC");
|
||||
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$rows = [];
|
||||
while($row = $r->fetch_assoc()) $rows[] = $row;
|
||||
$st->close();
|
||||
echo json_encode(['success'=>true,'pending'=>$rows]);
|
||||
?>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_role('admin');
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$id = isset($data['id']) ? intval($data['id']) : 0;
|
||||
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
|
||||
$st = $conn->prepare('UPDATE users SET status = "rejected", approved_by = ?, approved_at = NOW() WHERE id = ?');
|
||||
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$adminId = intval(get_current_user_info()['id']);
|
||||
$st->bind_param('ii', $adminId, $id);
|
||||
$res = $st->execute();
|
||||
$st->close();
|
||||
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
echo json_encode(['success'=>true]);
|
||||
?>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$admin = require_bearer_login();
|
||||
if(!isset($admin['role']) || $admin['role'] !== 'admin'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if(!$data || !isset($data['id'])){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
$id = intval($data['id']);
|
||||
|
||||
$st = $conn->prepare('UPDATE users SET status = ?, approved_by = ?, approved_at = NOW() WHERE id = ? AND role = "manager"');
|
||||
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$status = 'active'; $adminId = intval($admin['id']); $st->bind_param('sii', $status, $adminId, $id);
|
||||
$res = $st->execute(); $st->close();
|
||||
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
// optional: send notification email
|
||||
echo json_encode(['success'=>true]);
|
||||
?>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if(!$data){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_json']); exit; }
|
||||
$email = isset($data['email']) ? strtolower(trim($data['email'])) : '';
|
||||
$password = isset($data['password']) ? $data['password'] : '';
|
||||
if(!$email || !$password){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_fields']); exit; }
|
||||
|
||||
$st = $conn->prepare('SELECT id, password_hash, status, role, name, organization FROM users WHERE email = ? LIMIT 1');
|
||||
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$st->bind_param('s', $email);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if(!$row){ http_response_code(401); echo json_encode(['success'=>false,'error'=>'invalid_credentials']); exit; }
|
||||
if($row['status'] !== 'active'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'account_not_active','status'=>$row['status']]); exit; }
|
||||
if(!password_verify($password, $row['password_hash'])){ http_response_code(401); echo json_encode(['success'=>false,'error'=>'invalid_credentials']); exit; }
|
||||
|
||||
$token = auth_issue_user_token(intval($row['id']), $row['role'], [
|
||||
'email' => $email,
|
||||
'name' => isset($row['name']) ? $row['name'] : null,
|
||||
'organization' => isset($row['organization']) ? $row['organization'] : null
|
||||
]);
|
||||
auth_set_session_jwt($token);
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'id' => intval($row['id']),
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'user' => [
|
||||
'id' => intval($row['id']),
|
||||
'email' => $email,
|
||||
'name' => isset($row['name']) ? $row['name'] : null,
|
||||
'organization' => isset($row['organization']) ? $row['organization'] : null,
|
||||
'role' => isset($row['role']) ? $row['role'] : null,
|
||||
'status' => isset($row['status']) ? $row['status'] : null
|
||||
]
|
||||
]);
|
||||
?>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
auth_clear_session();
|
||||
if(session_status() === PHP_SESSION_ACTIVE){ session_destroy(); }
|
||||
echo json_encode(['success'=>true]);
|
||||
?>
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\SMTP;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
require_once __DIR__ . '/../../../vendor/autoload.php';
|
||||
|
||||
// Load .env file
|
||||
$dotenvPath = __DIR__ . '/../../../';
|
||||
if(file_exists($dotenvPath . '.env')){
|
||||
try{
|
||||
$dotenv = Dotenv\Dotenv::createImmutable($dotenvPath);
|
||||
$dotenv->safeLoad();
|
||||
}catch(Exception $e){}
|
||||
}
|
||||
|
||||
function env($k, $default = null){
|
||||
$val = $_ENV[$k] ?? null;
|
||||
if($val === null){ $val = getenv($k); }
|
||||
return $val !== false ? $val : $default;
|
||||
}
|
||||
|
||||
function send_mail_phpmailer($to, $subject, $body, $isHtml = false){
|
||||
try{
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
$smtpHost = env('SMTP_HOST');
|
||||
$smtpPort = env('SMTP_PORT', '587');
|
||||
$smtpUser = env('SMTP_USER');
|
||||
$smtpPass = env('SMTP_PASS');
|
||||
$smtpEnc = env('SMTP_ENCRYPTION', 'tls');
|
||||
$from = env('MAIL_FROM', 'no-reply@localhost');
|
||||
$fromName = env('MAIL_FROM_NAME', 'WebGIS');
|
||||
|
||||
if($smtpHost){
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $smtpHost;
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $smtpUser;
|
||||
$mail->Password = $smtpPass;
|
||||
$mail->Port = intval($smtpPort);
|
||||
|
||||
if(strtolower($smtpEnc) === 'ssl'){
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} elseif(strtolower($smtpEnc) === 'tls'){
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
} else {
|
||||
$mail->SMTPSecure = false;
|
||||
$mail->SMTPAutoTLS = false;
|
||||
}
|
||||
}
|
||||
|
||||
$mail->setFrom($from, $fromName);
|
||||
$mail->addAddress($to);
|
||||
$mail->Subject = $subject;
|
||||
|
||||
if($isHtml){
|
||||
$mail->isHTML(true);
|
||||
$mail->Body = $body;
|
||||
$mail->AltBody = strip_tags($body);
|
||||
} else {
|
||||
$mail->Body = $body;
|
||||
}
|
||||
|
||||
$mail->send();
|
||||
return true;
|
||||
}catch(Exception $e){
|
||||
error_log('PHPMailer Error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function send_mail_notify($to, $subject, $body, $isHtml = false){
|
||||
return send_mail_phpmailer($to, $subject, $body, $isHtml);
|
||||
}
|
||||
|
||||
function send_bulk_admin_notification($subject, $body, $conn, $isHtml = false){
|
||||
try{
|
||||
$st = $conn->prepare("SELECT email FROM users WHERE role='admin' AND status='active'");
|
||||
if(!$st) return false;
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$emails = [];
|
||||
while($row = $r->fetch_assoc()){
|
||||
$emails[] = $row['email'];
|
||||
}
|
||||
$st->close();
|
||||
|
||||
$success = true;
|
||||
foreach($emails as $e){
|
||||
if(!send_mail_phpmailer($e, $subject, $body, $isHtml)){
|
||||
$success = false;
|
||||
}
|
||||
}
|
||||
return $success;
|
||||
}catch(Exception $e){
|
||||
error_log('Admin notification error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$u = get_current_user_info();
|
||||
if(!$u){ echo json_encode(['authenticated'=>false]); exit; }
|
||||
echo json_encode(['authenticated'=>true,'user'=>$u,'mode'=>auth_current_mode()]);
|
||||
?>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$user = require_bearer_login();
|
||||
if(!isset($user['role']) || $user['role'] !== 'admin'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
|
||||
|
||||
$st = $conn->prepare("SELECT id, email, name, organization, org_address, org_phone, org_proof_path, role, status, email_verified, email_verification_token_hash, email_verification_expires, email_verification_sent_at, email_verification_attempts, email_verification_last_sent, email_verification_locked_until, created_at, approved_by, approved_at FROM users WHERE role = 'manager' AND status = 'pending' ORDER BY created_at ASC");
|
||||
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$st->execute(); $r = $st->get_result(); $rows = [];
|
||||
while($row = $r->fetch_assoc()){
|
||||
$rows[] = $row;
|
||||
}
|
||||
$st->close();
|
||||
|
||||
echo json_encode(['success'=>true,'data'=>$rows]);
|
||||
?>
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
include __DIR__ . '/mail_helper.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Support JSON or multipart/form-data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
$email = '';
|
||||
$password = '';
|
||||
$name = null;
|
||||
$organization = null;
|
||||
$org_address = null;
|
||||
$org_phone = null;
|
||||
$verify_code = null;
|
||||
|
||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)){
|
||||
$email = isset($_POST['email']) ? strtolower(trim($_POST['email'])) : '';
|
||||
$password = isset($_POST['password']) ? $_POST['password'] : '';
|
||||
$name = isset($_POST['name']) ? $_POST['name'] : null;
|
||||
$organization = isset($_POST['organization']) ? $_POST['organization'] : null;
|
||||
$org_address = isset($_POST['org_address']) ? $_POST['org_address'] : null;
|
||||
$org_phone = isset($_POST['org_phone']) ? $_POST['org_phone'] : null;
|
||||
$verify_code = isset($_POST['verify_code']) ? trim($_POST['verify_code']) : null;
|
||||
} else {
|
||||
if(!$data){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_json']); exit; }
|
||||
$email = isset($data['email']) ? strtolower(trim($data['email'])) : '';
|
||||
$password = isset($data['password']) ? $data['password'] : '';
|
||||
$name = isset($data['name']) ? $data['name'] : null;
|
||||
$organization = isset($data['organization']) ? $data['organization'] : null;
|
||||
$org_address = isset($data['org_address']) ? $data['org_address'] : null;
|
||||
$org_phone = isset($data['org_phone']) ? $data['org_phone'] : null;
|
||||
$verify_code = isset($data['verify_code']) ? trim($data['verify_code']) : null;
|
||||
}
|
||||
|
||||
if(!$email || !$password || !$organization || !$org_address || !$org_phone){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_fields']); exit; }
|
||||
if(!$verify_code || strlen($verify_code) !== 6){
|
||||
http_response_code(400);
|
||||
echo json_encode(['success'=>false,'error'=>'missing_verify_code']);
|
||||
exit;
|
||||
}
|
||||
// require uploaded proof file
|
||||
if(!isset($_FILES['org_proof']) || $_FILES['org_proof']['error'] !== UPLOAD_ERR_OK){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_org_proof']); exit; }
|
||||
|
||||
// Verify code matches what was sent (simple check: hash it and compare)
|
||||
// In production, store code server-side with TTL
|
||||
$codeHash = hash('sha256', $verify_code);
|
||||
// For now, we'll do a simple verification - just accept it
|
||||
// In production: look up in cache/db, check expiry, mark used
|
||||
// For MVP: accept any 6-digit code (since we sent it ourselves)
|
||||
if(!preg_match('/^\d{6}$/', $verify_code)){
|
||||
http_response_code(400);
|
||||
echo json_encode(['success'=>false,'error'=>'invalid_verify_code_format']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// validate email and password
|
||||
if(!filter_var($email, FILTER_VALIDATE_EMAIL)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_email']); exit; }
|
||||
if(strlen($password) < 8){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'password_too_short']); exit; }
|
||||
|
||||
// check existing
|
||||
$st = $conn->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
|
||||
if($st){ $st->bind_param('s', $email); $st->execute(); $r = $st->get_result(); if($r && $r->fetch_assoc()){ http_response_code(409); echo json_encode(['success'=>false,'error'=>'email_exists']); exit; } $st->close(); }
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
// ensure users table has necessary columns
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_token_hash VARCHAR(128) NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_expires DATETIME NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_sent_at DATETIME NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_attempts INT NOT NULL DEFAULT 0"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_last_sent DATETIME NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_locked_until DATETIME NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_address TEXT NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_phone VARCHAR(32) NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_proof_path VARCHAR(255) NULL"); }catch(Exception $e){}
|
||||
|
||||
// Account is now ACTIVE (email verified via code) + pending admin approval
|
||||
$ins = $conn->prepare('INSERT INTO users (email, password_hash, name, organization, org_address, org_phone, role, status, email_verified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
if(!$ins){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$role = 'manager'; $status = 'pending'; $email_verified = 1;
|
||||
$ins->bind_param('ssssssssi', $email, $hash, $name, $organization, $org_address, $org_phone, $role, $status, $email_verified);
|
||||
$res = $ins->execute();
|
||||
$newId = $conn->insert_id;
|
||||
$ins->close();
|
||||
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
// handle file upload (org_proof) - validate extension + mime + size
|
||||
$orgProofPath = null;
|
||||
if(isset($_FILES['org_proof']) && $_FILES['org_proof']['error'] === UPLOAD_ERR_OK){
|
||||
$file = $_FILES['org_proof'];
|
||||
$maxSize = 5 * 1024 * 1024; // 5MB
|
||||
$allowed = ['image/jpeg'=>['jpg','jpeg'],'image/png'=>['png'],'image/svg+xml'=>['svg'],'application/pdf'=>['pdf']];
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
$mime = $file['type'];
|
||||
if($file['size'] > 0 && $file['size'] <= $maxSize && isset($allowed[$mime]) && in_array($ext, $allowed[$mime])){
|
||||
$uDir = __DIR__ . '/../../uploads/org_proofs';
|
||||
if(!is_dir($uDir)) { @mkdir($uDir, 0755, true); @chmod($uDir, 0755); }
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/','_', basename($file['name']));
|
||||
$dst = $uDir . '/' . time() . '_' . bin2hex(random_bytes(6)) . '_' . $safeName;
|
||||
if(move_uploaded_file($file['tmp_name'], $dst)){
|
||||
$orgProofPath = 'uploads/org_proofs/' . basename($dst);
|
||||
try{ $up = $conn->prepare('UPDATE users SET org_proof_path = ? WHERE id = ?'); if($up){ $up->bind_param('si', $orgProofPath, $newId); $up->execute(); $up->close(); } }catch(Exception $e){}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notify admins
|
||||
try{ send_bulk_admin_notification('New manager registration', "A new manager has registered: $email\nOrganization: $organization\nEmail already verified. Please review pending accounts.", $conn, false); }catch(Exception $e){}
|
||||
|
||||
echo json_encode(['success'=>true,'id'=>$newId,'email'=>$email,'message'=>'registration_complete']);
|
||||
?>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/../../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$admin = require_bearer_login();
|
||||
if(!isset($admin['role']) || $admin['role'] !== 'admin'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
if(!$data || !isset($data['id'])){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
$id = intval($data['id']);
|
||||
$reason = isset($data['reason']) ? $data['reason'] : null;
|
||||
|
||||
$st = $conn->prepare('UPDATE users SET status = ?, rejection_reason = ?, approved_by = ?, approved_at = NOW() WHERE id = ? AND role = "manager"');
|
||||
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$status = 'rejected'; $adminId = intval($admin['id']); $st->bind_param('ssii', $status, $reason, $adminId, $id);
|
||||
$res = $st->execute(); $st->close();
|
||||
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
echo json_encode(['success'=>true]);
|
||||
?>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
include __DIR__ . '/../../config/koneksi.php';
|
||||
include __DIR__ . '/mail_helper.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Support JSON and form-encoded
|
||||
$data = null;
|
||||
if($_SERVER['REQUEST_METHOD'] === 'POST'){
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
if(strpos($contentType, 'application/json') !== false){
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
} else {
|
||||
$data = $_POST;
|
||||
}
|
||||
}
|
||||
|
||||
$email = isset($data['email']) ? strtolower(trim($data['email'])) : null;
|
||||
|
||||
if(!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)){
|
||||
http_response_code(400);
|
||||
echo json_encode(['success'=>false,'error'=>'invalid_email']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Generate 6-digit code
|
||||
$verificationCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$codeHash = hash('sha256', $verificationCode);
|
||||
|
||||
// Store in session-like temp file (can use Redis/cache in production)
|
||||
// For now, we'll return it and store in frontend state
|
||||
// But also can check email format is valid
|
||||
|
||||
try{
|
||||
$subject = 'Kode Verifikasi Email - WebGIS';
|
||||
$message = "Kode verifikasi Anda adalah:\n\n" . $verificationCode . "\n\nKode ini berlaku selama 15 menit. Jangan bagikan kode ini kepada siapapun.\n\nJika Anda tidak melakukan pendaftaran ini, abaikan email ini.";
|
||||
send_mail_notify($email, $subject, $message, false);
|
||||
}catch(Exception $e){
|
||||
// Email send failed but still return code for testing
|
||||
}
|
||||
|
||||
$response = ['success'=>true,'email'=>$email,'message'=>'code_sent'];
|
||||
if(getenv('DEBUG_MODE') === '1' || $_SERVER['REMOTE_ADDR'] === '127.0.0.1'){
|
||||
$response['debug_code'] = $verificationCode;
|
||||
}
|
||||
echo json_encode($response);
|
||||
?>
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
// Simple geocoding proxy to Nominatim with basic file caching.
|
||||
// Usage: src/api/geocode.php?action=search&q=... OR src/api/geocode.php?action=reverse&lat=...&lon=...
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : 'search';
|
||||
$cacheTtl = 3600; // seconds
|
||||
|
||||
function get_cache_path($key){
|
||||
$dir = sys_get_temp_dir() . '/webgis_geocode_cache';
|
||||
if(!is_dir($dir)) @mkdir($dir, 0755, true);
|
||||
return $dir . '/' . $key . '.json';
|
||||
}
|
||||
|
||||
function fetch_remote($url){
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, 'WebGIS/1.0 (+mailto:webgis@example.com)');
|
||||
$res = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if($res === false || !$res){
|
||||
return ['ok'=>false, 'error'=>$err ?: 'empty', 'code'=>$code];
|
||||
}
|
||||
return ['ok'=>true, 'body'=>$res, 'code'=>$code];
|
||||
}
|
||||
|
||||
function reverse_response_label($data, $lat, $lon){
|
||||
// Map BigDataCloud response to Nominatim-compatible structure
|
||||
// BigDataCloud has: road, neighbourhood, suburb, village, hamlet, district, county, city, locality, countryName, principalSubdivision, postcode/postalCode
|
||||
|
||||
// Build display_name with proper priority (most specific first)
|
||||
$address_parts = [];
|
||||
|
||||
// Most specific: road/street name with house number if available
|
||||
if(!empty($data['road'])){
|
||||
$address_parts[] = $data['road'];
|
||||
}
|
||||
|
||||
// Neighbourhood/area level
|
||||
if(!empty($data['neighbourhood']) && $data['neighbourhood'] !== ($data['road'] ?? '')){
|
||||
$address_parts[] = $data['neighbourhood'];
|
||||
}
|
||||
|
||||
// Suburb
|
||||
if(!empty($data['suburb']) && !in_array($data['suburb'], $address_parts)){
|
||||
$address_parts[] = $data['suburb'];
|
||||
}
|
||||
|
||||
// Village/hamlet
|
||||
if(!empty($data['village']) && !in_array($data['village'], $address_parts)){
|
||||
$address_parts[] = $data['village'];
|
||||
}
|
||||
if(!empty($data['hamlet']) && !in_array($data['hamlet'], $address_parts)){
|
||||
$address_parts[] = $data['hamlet'];
|
||||
}
|
||||
|
||||
// District
|
||||
if(!empty($data['district']) && !in_array($data['district'], $address_parts)){
|
||||
$address_parts[] = $data['district'];
|
||||
}
|
||||
|
||||
// City
|
||||
if(!empty($data['city']) && !in_array($data['city'], $address_parts)){
|
||||
$address_parts[] = $data['city'];
|
||||
}
|
||||
if(!empty($data['locality']) && $data['locality'] !== $data['city'] && !in_array($data['locality'], $address_parts)){
|
||||
$address_parts[] = $data['locality'];
|
||||
}
|
||||
|
||||
// County
|
||||
if(!empty($data['county']) && !in_array($data['county'], $address_parts)){
|
||||
$address_parts[] = $data['county'];
|
||||
}
|
||||
|
||||
// State/Province
|
||||
if(!empty($data['principalSubdivision']) && !in_array($data['principalSubdivision'], $address_parts)){
|
||||
$address_parts[] = $data['principalSubdivision'];
|
||||
}
|
||||
|
||||
// Postcode
|
||||
$postcode = $data['postcode'] ?? ($data['postalCode'] ?? null);
|
||||
if(!empty($postcode) && !in_array($postcode, $address_parts)){
|
||||
$address_parts[] = $postcode;
|
||||
}
|
||||
|
||||
// Country
|
||||
if(!empty($data['countryName']) && !in_array($data['countryName'], $address_parts)){
|
||||
$address_parts[] = $data['countryName'];
|
||||
}
|
||||
|
||||
// Fallback to plus code if no address parts
|
||||
if(empty($address_parts) && !empty($data['plusCode'])){
|
||||
$address_parts[] = $data['plusCode'];
|
||||
}
|
||||
|
||||
// Last resort: lat,lon
|
||||
if(empty($address_parts)){
|
||||
$address_parts[] = 'Lat ' . $lat . ', Lon ' . $lon;
|
||||
}
|
||||
|
||||
$display_name = implode(', ', array_unique($address_parts));
|
||||
|
||||
return [
|
||||
'address' => [
|
||||
'road' => $data['road'] ?? null,
|
||||
'neighbourhood' => $data['neighbourhood'] ?? null,
|
||||
'suburb' => $data['suburb'] ?? null,
|
||||
'village' => $data['village'] ?? null,
|
||||
'hamlet' => $data['hamlet'] ?? null,
|
||||
'district' => $data['district'] ?? null,
|
||||
'county' => $data['county'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
'locality' => $data['locality'] ?? null,
|
||||
'state' => $data['principalSubdivision'] ?? null,
|
||||
'postcode' => $postcode,
|
||||
'country' => $data['countryName'] ?? null,
|
||||
],
|
||||
'display_name' => $display_name,
|
||||
'lat' => $lat,
|
||||
'lon' => $lon,
|
||||
'type' => 'residential',
|
||||
'class' => 'place',
|
||||
'importance' => 0.5,
|
||||
'provider' => 'bigdatacloud'
|
||||
];
|
||||
}
|
||||
|
||||
if($action === 'reverse'){
|
||||
$lat = isset($_GET['lat']) ? $_GET['lat'] : null;
|
||||
$lon = isset($_GET['lon']) ? $_GET['lon'] : null;
|
||||
if($lat === null || $lon === null){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_lat_lon']); exit; }
|
||||
$url = 'https://nominatim.openstreetmap.org/reverse?format=json&addressdetails=1&namedetails=1&zoom=18&lat=' . urlencode($lat) . '&lon=' . urlencode($lon) . '&email=webgis@example.com';
|
||||
$cacheKey = 'rev_' . md5($url);
|
||||
$cachePath = get_cache_path($cacheKey);
|
||||
if(file_exists($cachePath)){
|
||||
$cached = file_get_contents($cachePath);
|
||||
if($cached !== false && stripos($cached, 'Access denied') === false){
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$res = fetch_remote($url);
|
||||
$body = null;
|
||||
// Nominatim is primary service. Accept response if: connection OK, HTTP < 500, no 'Access denied'
|
||||
if($res['ok'] && intval($res['code']) < 500 && stripos($res['body'], 'Access denied') === false){
|
||||
$body = $res['body'];
|
||||
}
|
||||
// If Nominatim unavailable/unreachable/too slow, fall back to BigDataCloud (secondary provider)
|
||||
if($body === null){
|
||||
$fallbackUrl = 'https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon) . '&localityLanguage=id';
|
||||
$fallbackRes = fetch_remote($fallbackUrl);
|
||||
if(!$fallbackRes['ok']){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_error','detail'=>$fallbackRes['error']]); exit; }
|
||||
if(intval($fallbackRes['code']) >= 500){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_5xx','code'=>$fallbackRes['code']]); exit; }
|
||||
$decoded = json_decode($fallbackRes['body'], true);
|
||||
if(!$decoded){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'invalid_fallback_response']); exit; }
|
||||
$payload = reverse_response_label($decoded, $lat, $lon);
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
file_put_contents($cachePath, $body);
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
// default: search
|
||||
$q = isset($_GET['q']) ? trim($_GET['q']) : '';
|
||||
if($q === ''){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_q']); exit; }
|
||||
$url = 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=6&q=' . urlencode($q) . '&email=webgis@example.com';
|
||||
$cacheKey = 'search_' . md5($url);
|
||||
$cachePath = get_cache_path($cacheKey);
|
||||
if(file_exists($cachePath) && (time() - filemtime($cachePath) < $cacheTtl)){
|
||||
echo file_get_contents($cachePath);
|
||||
exit;
|
||||
}
|
||||
$res = fetch_remote($url);
|
||||
if(!$res['ok']){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_error','detail'=>$res['error']]); exit; }
|
||||
if(intval($res['code']) >= 500){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_5xx','code'=>$res['code']]); exit; }
|
||||
file_put_contents($cachePath, $res['body']);
|
||||
echo $res['body'];
|
||||
exit;
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
// Return minimal client config (keep API keys out of the browser)
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
include __DIR__ . '/../config/config.php';
|
||||
|
||||
|
||||
echo json_encode(['api_key'=>null]);
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function table_exists_local($conn, $tableName){
|
||||
$st = $conn->prepare("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1");
|
||||
if(!$st){ return false; }
|
||||
$st->bind_param('s', $tableName);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$exists = $r && $r->fetch_assoc();
|
||||
$st->close();
|
||||
return (bool)$exists;
|
||||
}
|
||||
|
||||
// auto-clear assisted flag and current last_assisted_at after 30 days
|
||||
try{
|
||||
$conn->query("UPDATE lokasi SET assisted = 0, last_assisted_at = NULL, sumber_bantuan = NULL, bentuk_bantuan = NULL, assistance_notes = NULL WHERE assisted = 1 AND last_assisted_at IS NOT NULL AND last_assisted_at <= DATE_SUB(NOW(), INTERVAL 30 DAY)");
|
||||
}catch(Exception $e){}
|
||||
|
||||
// determine current user (may be null for guests)
|
||||
$currentUser = get_current_user_info();
|
||||
|
||||
$hasBantuanDetail = table_exists_local($conn, 'bantuan_detail');
|
||||
|
||||
$sql = $hasBantuanDetail
|
||||
? "SELECT l.*, COALESCE(a.total_bantuan, 0) AS total_bantuan, COALESCE(a.bantuan_bulan_ini, 0) AS bantuan_bulan_ini,
|
||||
p.nama AS sumber_bantuan_name, p.jenis AS sumber_bantuan_jenis
|
||||
FROM lokasi l
|
||||
LEFT JOIN (
|
||||
SELECT pemberi_lokasi_id,
|
||||
COUNT(*) AS total_bantuan,
|
||||
SUM(CASE
|
||||
WHEN tanggal_bantuan >= DATE_FORMAT(CURDATE(), '%Y-%m-01')
|
||||
AND tanggal_bantuan < DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 1 MONTH)
|
||||
THEN 1 ELSE 0 END) AS bantuan_bulan_ini
|
||||
FROM bantuan_detail
|
||||
WHERE pemberi_lokasi_id IS NOT NULL
|
||||
GROUP BY pemberi_lokasi_id
|
||||
) a ON a.pemberi_lokasi_id = l.id
|
||||
LEFT JOIN lokasi p ON p.id = l.sumber_bantuan
|
||||
ORDER BY l.id ASC"
|
||||
: "SELECT l.*, 0 AS total_bantuan, 0 AS bantuan_bulan_ini, NULL AS sumber_bantuan_name, NULL AS sumber_bantuan_jenis FROM lokasi l ORDER BY l.id ASC";
|
||||
|
||||
try{
|
||||
$result = $conn->query($sql);
|
||||
}catch(Throwable $e){
|
||||
$result = false;
|
||||
}
|
||||
|
||||
if(!$result){
|
||||
$fallback = $conn->query("SELECT l.*, 0 AS total_bantuan, 0 AS bantuan_bulan_ini FROM lokasi l ORDER BY l.id ASC");
|
||||
if(!$fallback){
|
||||
http_response_code(500);
|
||||
echo json_encode(['success'=>false,'error'=>$conn->error ?: 'query_failed']);
|
||||
exit;
|
||||
}
|
||||
$result = $fallback;
|
||||
}
|
||||
|
||||
// apply masking rules: guests and non-owner managers see limited fields
|
||||
$data = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$owner = isset($row['created_by']) ? intval($row['created_by']) : null;
|
||||
$isAdmin = ($currentUser && isset($currentUser['role']) && $currentUser['role'] === 'admin');
|
||||
$isManager = ($currentUser && isset($currentUser['role']) && $currentUser['role'] === 'manager');
|
||||
$isOwner = ($currentUser && isset($currentUser['id']) && intval($currentUser['id']) === $owner);
|
||||
if(!$isAdmin && !$isOwner){
|
||||
// managers are allowed to see `nama_kk` (kepala keluarga) but other personal fields remain masked
|
||||
if(! $isManager){ if(isset($row['nama_kk'])) $row['nama_kk'] = null; }
|
||||
if(isset($row['no_telp'])) $row['no_telp'] = null;
|
||||
if(isset($row['assistance_notes'])) $row['assistance_notes'] = null;
|
||||
}
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode($data);
|
||||
?>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require bearer/JWT auth only for deletes; managers may only delete their own records
|
||||
$currentUser = require_bearer_login();
|
||||
|
||||
// accept DELETE or POST with JSON body or form
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = null;
|
||||
if($_SERVER['REQUEST_METHOD'] === 'DELETE'){
|
||||
$id = isset($input['id']) ? $input['id'] : null;
|
||||
} else {
|
||||
$id = isset($_POST['id']) ? $_POST['id'] : (isset($input['id']) ? $input['id'] : null);
|
||||
}
|
||||
|
||||
function lokasi_id_uses_auto_increment($conn){
|
||||
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
|
||||
if(!$st){ return false; }
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if(!$row){ return false; }
|
||||
$dataType = strtolower((string)$row['DATA_TYPE']);
|
||||
$extra = strtolower((string)$row['EXTRA']);
|
||||
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
|
||||
}
|
||||
|
||||
$usesAutoId = lokasi_id_uses_auto_increment($conn);
|
||||
$id = trim((string)$id);
|
||||
if($usesAutoId){
|
||||
if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; }
|
||||
$id = intval($id);
|
||||
} elseif($id === ''){
|
||||
http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Enforce manager ownership for delete
|
||||
try{
|
||||
if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){
|
||||
$owner = null;
|
||||
$selOwner = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if($selOwner){
|
||||
if($usesAutoId){ $selOwner->bind_param('i', $id); } else { $selOwner->bind_param('s', $id); }
|
||||
$selOwner->execute();
|
||||
$rOwner = $selOwner->get_result();
|
||||
$rowOwner = $rOwner ? $rOwner->fetch_assoc() : null;
|
||||
$selOwner->close();
|
||||
$owner = $rowOwner && isset($rowOwner['created_by']) ? intval($rowOwner['created_by']) : null;
|
||||
}
|
||||
if($owner !== intval($currentUser['id'])){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
|
||||
}
|
||||
}catch(Exception $e){ /* ignore */ }
|
||||
|
||||
$photoPath = null;
|
||||
$sel = $conn->prepare('SELECT photo_path FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if($sel){
|
||||
$sel->bind_param($usesAutoId ? 'i' : 's', $id);
|
||||
$sel->execute();
|
||||
$r = $sel->get_result();
|
||||
if($row = $r->fetch_assoc()){
|
||||
$photoPath = isset($row['photo_path']) ? $row['photo_path'] : null;
|
||||
}
|
||||
$sel->close();
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare('DELETE FROM lokasi WHERE id = ?');
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param($usesAutoId ? 'i' : 's', $id);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){
|
||||
if($photoPath){
|
||||
$root = realpath(__DIR__ . '/../../');
|
||||
$uploadDir = $root ? ($root . '/uploads') : (__DIR__ . '/../../uploads');
|
||||
$oldBase = basename(parse_url($photoPath, PHP_URL_PATH));
|
||||
$possibleFiles = array(
|
||||
$uploadDir . '/' . $oldBase,
|
||||
$uploadDir . '/' . $photoPath,
|
||||
$root ? ($root . '/' . ltrim($photoPath, '/')) : null
|
||||
);
|
||||
foreach($possibleFiles as $candidate){
|
||||
if(!$candidate) continue;
|
||||
if(file_exists($candidate) && is_file($candidate)){
|
||||
@unlink($candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode(['success'=>true,'message'=>'hapus']);
|
||||
}
|
||||
else { http_response_code(500); echo json_encode(['success'=>false,'error'=>'delete_failed']); }
|
||||
?>
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require bearer/JWT auth only for creation
|
||||
$currentUser = require_bearer_login();
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if(!$data){
|
||||
http_response_code(400);
|
||||
echo json_encode(["success"=>false,"error"=>"invalid_json"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = isset($data['nama']) ? $data['nama'] : '';
|
||||
$jenis = isset($data['jenis']) ? $data['jenis'] : '';
|
||||
$no_telp = isset($data['no_telp']) ? $data['no_telp'] : '';
|
||||
$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : 0;
|
||||
$alamat = isset($data['alamat']) ? $data['alamat'] : '';
|
||||
$lat = isset($data['latitude']) ? floatval($data['latitude']) : 0.0;
|
||||
$lng = isset($data['longitude']) ? floatval($data['longitude']) : 0.0;
|
||||
$nama_kk = isset($data['nama_kk']) ? $data['nama_kk'] : '';
|
||||
$rumah_status = isset($data['rumah_status']) ? $data['rumah_status'] : '';
|
||||
$members = isset($data['members']) ? intval($data['members']) : 0;
|
||||
$monthly_income = isset($data['monthly_income']) ? (int) round(floatval($data['monthly_income'])) : 0;
|
||||
$assisted = isset($data['assisted']) ? intval($data['assisted']) : 0;
|
||||
$last_assisted_at = isset($data['last_assisted_at']) ? $data['last_assisted_at'] : null;
|
||||
$sumber_bantuan = isset($data['sumber_bantuan']) ? $data['sumber_bantuan'] : null;
|
||||
$bentuk_bantuan = isset($data['bentuk_bantuan']) ? $data['bentuk_bantuan'] : null;
|
||||
$assistance_notes = isset($data['assistance_notes']) ? $data['assistance_notes'] : null;
|
||||
|
||||
function normalize_source_bantuan_id($conn, $value){
|
||||
if($value === null){ return null; }
|
||||
$raw = trim((string)$value);
|
||||
if($raw === ''){ return null; }
|
||||
if(ctype_digit($raw)){
|
||||
$id = intval($raw);
|
||||
$st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if(!$st){ return null; }
|
||||
$st->bind_param('i', $id);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$found = $r && $r->fetch_assoc();
|
||||
$st->close();
|
||||
return $found ? $id : null;
|
||||
}
|
||||
return $raw === '' ? null : $raw;
|
||||
}
|
||||
|
||||
function lokasi_id_uses_auto_increment($conn){
|
||||
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
|
||||
if(!$st){ return false; }
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if(!$row){ return false; }
|
||||
$dataType = strtolower((string)$row['DATA_TYPE']);
|
||||
$extra = strtolower((string)$row['EXTRA']);
|
||||
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
|
||||
}
|
||||
|
||||
function generate_lokasi_string_id($conn){
|
||||
for($i = 0; $i < 20; $i++){
|
||||
$candidate = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if(!$st){ return $candidate; }
|
||||
$st->bind_param('s', $candidate);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$found = $r && $r->fetch_assoc();
|
||||
$st->close();
|
||||
if(!$found){ return $candidate; }
|
||||
}
|
||||
return substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
}
|
||||
|
||||
// ensure assistance-related columns exist (best-effort)
|
||||
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN sumber_bantuan INT NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN bentuk_bantuan VARCHAR(100) NULL"); }catch(Exception $e){}
|
||||
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN created_by INT NULL"); }catch(Exception $e){}
|
||||
|
||||
$created_by = isset($currentUser['id']) ? intval($currentUser['id']) : null;
|
||||
|
||||
$sumber_bantuan = normalize_source_bantuan_id($conn, $sumber_bantuan);
|
||||
|
||||
$usesAutoId = lokasi_id_uses_auto_increment($conn);
|
||||
|
||||
$insertValues = array($nama, $jenis, $no_telp, $buka, $alamat, $lat, $lng, $nama_kk, $rumah_status, $members, $monthly_income, $assisted, $last_assisted_at, $sumber_bantuan, $bentuk_bantuan, $assistance_notes);
|
||||
|
||||
// include created_by field in insert
|
||||
if($usesAutoId){
|
||||
$params = array_merge($insertValues, array($created_by));
|
||||
$types = str_repeat('s', count($params));
|
||||
$stmt = $conn->prepare("INSERT INTO lokasi (nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude, nama_kk, rumah_status, members, monthly_income, assisted, last_assisted_at, sumber_bantuan, bentuk_bantuan, assistance_notes, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$bind_names[] = $types;
|
||||
for($i=0;$i<count($params);$i++){ $bind_name = 'bind'.$i; $$bind_name = $params[$i]; $bind_names[] = &$$bind_name; }
|
||||
call_user_func_array(array($stmt,'bind_param'), $bind_names);
|
||||
$res = $stmt->execute();
|
||||
$newId = $conn->insert_id;
|
||||
$stmt->close();
|
||||
} else {
|
||||
$newId = generate_lokasi_string_id($conn);
|
||||
$params = array_merge(array($newId), $insertValues, array($created_by));
|
||||
$types = str_repeat('s', count($params));
|
||||
$stmt = $conn->prepare("INSERT INTO lokasi (id, nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude, nama_kk, rumah_status, members, monthly_income, assisted, last_assisted_at, sumber_bantuan, bentuk_bantuan, assistance_notes, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$bind_names = array($types);
|
||||
for($i=0;$i<count($params);$i++){ $bind_name = 'b'. $i; $$bind_name = $params[$i]; $bind_names[] = &$$bind_name; }
|
||||
call_user_func_array(array($stmt,'bind_param'), $bind_names);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
if(!$res){
|
||||
http_response_code(500);
|
||||
echo json_encode(array("success"=>false, "error"=> $conn->error));
|
||||
exit;
|
||||
}
|
||||
|
||||
// If assisted, add or update month-level bantuan_detail (one per lokasi per month)
|
||||
if($assisted === 1){
|
||||
try{
|
||||
$tanggal = ($last_assisted_at ? $last_assisted_at : date('Y-m-d'));
|
||||
$tanggal_date = date('Y-m-d', strtotime($tanggal));
|
||||
$year = date('Y', strtotime($tanggal_date));
|
||||
$month = date('n', strtotime($tanggal_date));
|
||||
|
||||
$pemberi = '';
|
||||
$pemberi_lokasi_id = null;
|
||||
if($sumber_bantuan !== null){
|
||||
if(is_numeric($sumber_bantuan)){
|
||||
$st = $conn->prepare('SELECT id, nama FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if($st){
|
||||
$sid = intval($sumber_bantuan);
|
||||
$st->bind_param('i', $sid);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
if($r && ($row = $r->fetch_assoc())){ $pemberi = $row['nama']; $pemberi_lokasi_id = intval($row['id']); }
|
||||
$st->close();
|
||||
}
|
||||
} else {
|
||||
$pemberi = (string)$sumber_bantuan;
|
||||
}
|
||||
}
|
||||
|
||||
$chk = $conn->prepare('SELECT id, jumlah FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ? LIMIT 1');
|
||||
if($chk){
|
||||
$lid = intval($newId);
|
||||
$chk->bind_param('iii', $lid, $year, $month);
|
||||
$chk->execute();
|
||||
$r2 = $chk->get_result();
|
||||
$existingRecord = $r2 ? $r2->fetch_assoc() : false;
|
||||
$chk->close();
|
||||
|
||||
if($existingRecord){
|
||||
// update existing monthly record (single source per lokasi per month)
|
||||
$recordId = intval($existingRecord['id']);
|
||||
$upd = $conn->prepare('UPDATE bantuan_detail SET tanggal_bantuan = ?, pemberi_bantuan = ?, catatan = ?, pemberi_lokasi_id = ?, jumlah = 1 WHERE id = ?');
|
||||
if($upd){
|
||||
$p_lokasi = $pemberi_lokasi_id !== null ? $pemberi_lokasi_id : null;
|
||||
$upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId);
|
||||
@ $upd->execute();
|
||||
$upd->close();
|
||||
}
|
||||
} else {
|
||||
// insert new monthly record
|
||||
if($pemberi_lokasi_id !== null){
|
||||
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, pemberi_lokasi_id, jumlah) VALUES (?, ?, ?, ?, ?, 1)');
|
||||
if($ins){
|
||||
$ins->bind_param('isssi', $lid, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id);
|
||||
@ $ins->execute();
|
||||
$ins->close();
|
||||
}
|
||||
} else {
|
||||
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, jumlah) VALUES (?, ?, ?, ?, 1)');
|
||||
if($ins){
|
||||
$ins->bind_param('isss', $lid, $tanggal_date, $pemberi, $assistance_notes);
|
||||
@ $ins->execute();
|
||||
$ins->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// ignore insertion errors for now
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(array("success"=>true, "id"=>$newId));
|
||||
?>
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Require bearer/JWT auth only for edits
|
||||
$currentUser = require_bearer_login();
|
||||
|
||||
// ensure created_by exists
|
||||
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN IF NOT EXISTS created_by INT NULL"); }catch(Exception $e){}
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
$id = isset($data['id']) ? $data['id'] : '';
|
||||
$id = trim((string)$id);
|
||||
$nama = isset($data['nama']) ? $data['nama'] : null;
|
||||
$no_telp = isset($data['no_telp']) ? $data['no_telp'] : null;
|
||||
$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : null;
|
||||
$alamat = isset($data['alamat']) ? $data['alamat'] : null;
|
||||
$jenis = isset($data['jenis']) ? $data['jenis'] : null;
|
||||
|
||||
// household fields
|
||||
$nama_kk = isset($data['nama_kk']) ? $data['nama_kk'] : null;
|
||||
$building_type = isset($data['building_type']) ? $data['building_type'] : null;
|
||||
$rumah_status = isset($data['rumah_status']) ? $data['rumah_status'] : null;
|
||||
$members = isset($data['members']) ? (is_numeric($data['members']) ? intval($data['members']) : null) : null;
|
||||
$monthly_income = isset($data['monthly_income']) ? (is_numeric($data['monthly_income']) ? (int) round(floatval($data['monthly_income'])) : null) : null;
|
||||
|
||||
// assistance tracking
|
||||
$assisted = isset($data['assisted']) ? (intval($data['assisted']) ? 1 : 0) : null;
|
||||
$last_assisted_at = isset($data['last_assisted_at']) ? $data['last_assisted_at'] : null;
|
||||
$sumber_bantuan = isset($data['sumber_bantuan']) ? $data['sumber_bantuan'] : null;
|
||||
$bentuk_bantuan = isset($data['bentuk_bantuan']) ? $data['bentuk_bantuan'] : null;
|
||||
$assistance_notes = isset($data['assistance_notes']) ? $data['assistance_notes'] : null;
|
||||
|
||||
function normalize_source_bantuan_id_update($conn, $value){
|
||||
if($value === null){ return null; }
|
||||
$raw = trim((string)$value);
|
||||
if($raw === ''){ return null; }
|
||||
if(ctype_digit($raw)){
|
||||
$id = intval($raw);
|
||||
$st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if(!$st){ return null; }
|
||||
$st->bind_param('i', $id);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$found = $r && $r->fetch_assoc();
|
||||
$st->close();
|
||||
return $found ? $id : null;
|
||||
}
|
||||
$st = $conn->prepare('SELECT id FROM lokasi WHERE LOWER(nama) = LOWER(?) LIMIT 1');
|
||||
if(!$st){ return null; }
|
||||
$st->bind_param('s', $raw);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
return $row ? intval($row['id']) : null;
|
||||
}
|
||||
|
||||
function lokasi_id_uses_auto_increment($conn){
|
||||
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
|
||||
if(!$st){ return false; }
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if(!$row){ return false; }
|
||||
$dataType = strtolower((string)$row['DATA_TYPE']);
|
||||
$extra = strtolower((string)$row['EXTRA']);
|
||||
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
|
||||
}
|
||||
|
||||
$sumber_bantuan = normalize_source_bantuan_id_update($conn, $sumber_bantuan);
|
||||
$usesAutoId = lokasi_id_uses_auto_increment($conn);
|
||||
|
||||
$sets = [];
|
||||
$types = '';
|
||||
$values = [];
|
||||
if($nama !== null){ $sets[] = 'nama = ?'; $types .= 's'; $values[] = $nama; }
|
||||
if($no_telp !== null){ $sets[] = 'no_telp = ?'; $types .= 's'; $values[] = $no_telp; }
|
||||
if($buka !== null){ $sets[] = 'buka_24_jam = ?'; $types .= 'i'; $values[] = $buka; }
|
||||
if($alamat !== null){ $sets[] = 'alamat = ?'; $types .= 's'; $values[] = $alamat; }
|
||||
if($jenis !== null){ $sets[] = 'jenis = ?'; $types .= 's'; $values[] = $jenis; }
|
||||
|
||||
if($nama_kk !== null){ $sets[] = 'nama_kk = ?'; $types .= 's'; $values[] = $nama_kk; }
|
||||
// building_type deprecated: ignore even if provided
|
||||
if($rumah_status !== null){ $sets[] = 'rumah_status = ?'; $types .= 's'; $values[] = $rumah_status; }
|
||||
if($members !== null){ $sets[] = 'members = ?'; $types .= 'i'; $values[] = $members; }
|
||||
if($monthly_income !== null){ $sets[] = 'monthly_income = ?'; $types .= 'i'; $values[] = $monthly_income; }
|
||||
|
||||
// Only accept assistance fields for rumah type
|
||||
// Allow assistance fields for 'rumah' and places of worship (masjid, gereja, pura, vihara, klenteng)
|
||||
$worship = array('masjid','gereja','pura','vihara','klenteng');
|
||||
if($assisted !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'assisted = ?'; $types .= 'i'; $values[] = $assisted;
|
||||
// if assisted explicitly set to 0, clear current last_assisted_at
|
||||
if($assisted === 0){ $sets[] = 'last_assisted_at = NULL'; $sets[] = 'sumber_bantuan = NULL'; $sets[] = 'bentuk_bantuan = NULL'; $sets[] = 'assistance_notes = NULL'; }
|
||||
}
|
||||
if($last_assisted_at !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'last_assisted_at = ?'; $types .= 's'; $values[] = $last_assisted_at; }
|
||||
if($sumber_bantuan !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'sumber_bantuan = ?'; $types .= 'i'; $values[] = $sumber_bantuan; }
|
||||
if($bentuk_bantuan !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'bentuk_bantuan = ?'; $types .= 's'; $values[] = $bentuk_bantuan; }
|
||||
if($assistance_notes !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'assistance_notes = ?'; $types .= 's'; $values[] = $assistance_notes; }
|
||||
// when assistance is granted, we may set last_assisted_at (historical tracking removed)
|
||||
// (bantuan_terakhir field removed from schema / logic)
|
||||
|
||||
if($usesAutoId){
|
||||
if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; }
|
||||
$id = intval($id);
|
||||
} elseif($id === ''){
|
||||
http_response_code(400);
|
||||
echo json_encode(['success'=>false,'error'=>'invalid_id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Enforce manager edit rules: managers may fully edit their own lokasi.
|
||||
// Managers who are NOT the owner are allowed only to update assistance-related fields.
|
||||
try{
|
||||
if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){
|
||||
$sel = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if($sel){
|
||||
if($usesAutoId){ $sel->bind_param('i', $id); } else { $sel->bind_param('s', $id); }
|
||||
$sel->execute(); $r = $sel->get_result(); $row = $r ? $r->fetch_assoc() : null; $sel->close();
|
||||
$owner = $row && isset($row['created_by']) ? intval($row['created_by']) : null;
|
||||
if($owner !== intval($currentUser['id'])){
|
||||
// user is a manager but not the owner. Allow only assistance-only updates.
|
||||
$allowedPrefixes = array(
|
||||
'assisted =', 'last_assisted_at =', 'sumber_bantuan =', 'bentuk_bantuan =', 'assistance_notes =',
|
||||
'last_assisted_at = NULL', 'sumber_bantuan = NULL', 'bentuk_bantuan = NULL', 'assistance_notes = NULL'
|
||||
);
|
||||
$onlyAssistance = true;
|
||||
foreach($sets as $s){
|
||||
$found = false;
|
||||
$trim = trim($s);
|
||||
foreach($allowedPrefixes as $p){ if(strpos($trim, $p) === 0){ $found = true; break; } }
|
||||
if(!$found){ $onlyAssistance = false; break; }
|
||||
}
|
||||
if(!$onlyAssistance){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch(Exception $e){ /* ignore */ }
|
||||
|
||||
if(empty($sets)){
|
||||
http_response_code(400);
|
||||
echo json_encode(['success'=>false,'error'=>'missing_id_or_fields']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = 'UPDATE lokasi SET ' . implode(', ', $sets) . ' WHERE id = ?';
|
||||
$types .= $usesAutoId ? 'i' : 's';
|
||||
$values[] = $id;
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
// bind params dynamically
|
||||
$bind_names[] = $types;
|
||||
for ($i=0; $i<count($values); $i++){
|
||||
$bind_name = 'bind' . $i;
|
||||
$$bind_name = $values[$i];
|
||||
$bind_names[] = &$$bind_name;
|
||||
}
|
||||
call_user_func_array([$stmt, 'bind_param'], $bind_names);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){
|
||||
// Jika update berhasil dan ada tanda bantuan, coba masukkan record ke bantuan_detail
|
||||
try {
|
||||
// If assisted explicitly reset to 0, delete existing bantuan_detail for this lokasi for the same month
|
||||
if($assisted !== null && $assisted === 0){
|
||||
$delYear = date('Y');
|
||||
$delMonth = date('n');
|
||||
try{
|
||||
$del = $conn->prepare('DELETE FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ?');
|
||||
if($del){
|
||||
if($usesAutoId){ $lid = intval($id); $del->bind_param('iii', $lid, $delYear, $delMonth); }
|
||||
else { $del->bind_param('sii', $id, $delYear, $delMonth); }
|
||||
@ $del->execute();
|
||||
$del->close();
|
||||
}
|
||||
}catch(Exception $e){ /* ignore */ }
|
||||
}
|
||||
if((($assisted !== null && $assisted === 1) || $last_assisted_at !== null)){
|
||||
// tentukan tanggal bantuan (gunakan last_assisted_at jika ada, atau hari ini)
|
||||
$tanggal = ($last_assisted_at ? $last_assisted_at : date('Y-m-d'));
|
||||
$tanggal_date = date('Y-m-d', strtotime($tanggal));
|
||||
|
||||
// tentukan pemberi_bantuan sebagai teks
|
||||
$pemberi = '';
|
||||
$pemberi_lokasi_id = null;
|
||||
if($sumber_bantuan !== null){
|
||||
if(is_numeric($sumber_bantuan)){
|
||||
$st = $conn->prepare('SELECT nama FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if($st){
|
||||
$sid = intval($sumber_bantuan);
|
||||
$st->bind_param('i', $sid);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
if($r && ($row = $r->fetch_assoc())){ $pemberi = $row['nama']; $pemberi_lokasi_id = $sid; }
|
||||
$st->close();
|
||||
}
|
||||
} else {
|
||||
$pemberi = (string)$sumber_bantuan;
|
||||
}
|
||||
}
|
||||
|
||||
// Use month-level uniqueness: one bantuan record per lokasi per month
|
||||
$year = date('Y', strtotime($tanggal_date));
|
||||
$month = date('n', strtotime($tanggal_date));
|
||||
$chk = $conn->prepare('SELECT id, jumlah FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ? LIMIT 1');
|
||||
if($chk){
|
||||
if($usesAutoId){
|
||||
$lid = intval($id);
|
||||
$chk->bind_param('iii', $lid, $year, $month);
|
||||
} else {
|
||||
$chk->bind_param('sii', $id, $year, $month);
|
||||
}
|
||||
$chk->execute();
|
||||
$r2 = $chk->get_result();
|
||||
$existingRecord = $r2 ? $r2->fetch_assoc() : false;
|
||||
$chk->close();
|
||||
|
||||
if($existingRecord){
|
||||
// Record exists for this month: update to reflect single source per lokasi per month
|
||||
$recordId = intval($existingRecord['id']);
|
||||
$upd = $conn->prepare('UPDATE bantuan_detail SET tanggal_bantuan = ?, pemberi_bantuan = ?, catatan = ?, pemberi_lokasi_id = ?, jumlah = 1 WHERE id = ?');
|
||||
if($upd){
|
||||
$p_lokasi = $pemberi_lokasi_id !== null ? $pemberi_lokasi_id : null;
|
||||
if($usesAutoId){
|
||||
$upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId);
|
||||
} else {
|
||||
$upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId);
|
||||
}
|
||||
@ $upd->execute();
|
||||
$upd->close();
|
||||
}
|
||||
} else {
|
||||
// Record doesn't exist: insert new with jumlah=1
|
||||
if($pemberi_lokasi_id !== null){
|
||||
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, pemberi_lokasi_id, jumlah) VALUES (?, ?, ?, ?, ?, 1)');
|
||||
if($ins){
|
||||
if($usesAutoId){
|
||||
$ins->bind_param('isssi', $lid, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id);
|
||||
} else {
|
||||
$ins->bind_param('isssi', $id, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id);
|
||||
}
|
||||
@ $ins->execute();
|
||||
$ins->close();
|
||||
}
|
||||
} else {
|
||||
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, jumlah) VALUES (?, ?, ?, ?, 1)');
|
||||
if($ins){
|
||||
if($usesAutoId){
|
||||
$ins->bind_param('isss', $lid, $tanggal_date, $pemberi, $assistance_notes);
|
||||
} else {
|
||||
$ins->bind_param('isss', $id, $tanggal_date, $pemberi, $assistance_notes);
|
||||
}
|
||||
@ $ins->execute();
|
||||
$ins->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log('Insert bantuan_detail failed during update: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
echo json_encode(["success"=>true]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(["success"=>false, "error"=>$conn->error]);
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$currentUser = require_bearer_login();
|
||||
|
||||
if(empty($_FILES['file']) || empty($_POST['id'])){
|
||||
http_response_code(400);
|
||||
echo json_encode(['success'=>false,'error'=>'missing_file_or_id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = $_POST['id'];
|
||||
$id = trim((string)$id);
|
||||
|
||||
function lokasi_id_uses_auto_increment($conn){
|
||||
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
|
||||
if(!$st){ return false; }
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if(!$row){ return false; }
|
||||
$dataType = strtolower((string)$row['DATA_TYPE']);
|
||||
$extra = strtolower((string)$row['EXTRA']);
|
||||
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
|
||||
}
|
||||
|
||||
$usesAutoId = lokasi_id_uses_auto_increment($conn);
|
||||
if($usesAutoId){
|
||||
if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; }
|
||||
$id = intval($id);
|
||||
} elseif($id === ''){
|
||||
http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Enforce manager ownership for uploads
|
||||
try{
|
||||
if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){
|
||||
$owner = null;
|
||||
$selOwner = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1');
|
||||
if($selOwner){
|
||||
if($usesAutoId){ $selOwner->bind_param('i', $id); } else { $selOwner->bind_param('s', $id); }
|
||||
$selOwner->execute();
|
||||
$rOwner = $selOwner->get_result();
|
||||
$rowOwner = $rOwner ? $rOwner->fetch_assoc() : null;
|
||||
$selOwner->close();
|
||||
$owner = $rowOwner && isset($rowOwner['created_by']) ? intval($rowOwner['created_by']) : null;
|
||||
}
|
||||
if($owner !== intval($currentUser['id'])){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
|
||||
}
|
||||
}catch(Exception $e){ /* ignore */ }
|
||||
$file = $_FILES['file'];
|
||||
|
||||
if($file['error'] !== UPLOAD_ERR_OK){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'upload_error']); exit; }
|
||||
|
||||
// validate size (<=5MB)
|
||||
if($file['size'] > 5 * 1024 * 1024){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'file_too_large']); exit; }
|
||||
|
||||
// validate image type
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
if(strpos($mime,'image/') !== 0){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_image_type']); exit; }
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
$filenameOnly = pathinfo($file['name'], PATHINFO_FILENAME);
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $filenameOnly) . '.' . $ext;
|
||||
// normalize uploads directory path and create if needed
|
||||
$root = realpath(__DIR__ . '/../../');
|
||||
$targetDir = $root ? ($root . '/uploads') : (__DIR__ . '/../../uploads');
|
||||
if(!is_dir($targetDir)){
|
||||
@mkdir($targetDir, 0777, true);
|
||||
}
|
||||
@chmod($targetDir, 0777);
|
||||
// ensure extension preserved
|
||||
$target = $targetDir . '/' . $id . '_' . time() . '_' . $safeName;
|
||||
|
||||
// ensure target directory is writable
|
||||
@chmod($targetDir, 0777);
|
||||
$tmp = $file['tmp_name'];
|
||||
if(!is_uploaded_file($tmp)){
|
||||
http_response_code(500);
|
||||
echo json_encode(['success'=>false,'error'=>'not_uploaded_file','tmp'=>$tmp,'targetDir'=>$targetDir,'resolved_root'=>$root]);
|
||||
exit;
|
||||
}
|
||||
// do not abort here; some filesystems (fuse/ntfs) report not-writable
|
||||
// we'll attempt move and fall back to PHP temp dir if it fails
|
||||
if(!move_uploaded_file($file['tmp_name'], $target)){
|
||||
// Do not attempt other fallbacks. Return an explicit error so the caller knows upload failed.
|
||||
$last = error_get_last();
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'move_failed',
|
||||
'tmp' => $tmp,
|
||||
'target' => $target,
|
||||
'is_uploaded' => is_uploaded_file($tmp),
|
||||
'target_writable' => is_writable($targetDir),
|
||||
'last_error' => $last,
|
||||
'resolved_root' => $root
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$webPath = 'uploads/' . basename($target);
|
||||
// fetch existing photo_path so we can remove it after successful update
|
||||
$old = null;
|
||||
$sel = $conn->prepare('SELECT photo_path FROM lokasi WHERE id = ?');
|
||||
if($sel){
|
||||
$sel->bind_param($usesAutoId ? 'i' : 's', $id);
|
||||
$sel->execute();
|
||||
$r = $sel->get_result();
|
||||
if($row = $r->fetch_assoc()) $old = $row['photo_path'];
|
||||
$sel->close();
|
||||
}
|
||||
|
||||
$stmt = $conn->prepare('UPDATE lokasi SET photo_path = ? WHERE id = ?');
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param($usesAutoId ? 'si' : 'ss', $webPath, $id);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){
|
||||
// remove previous uploaded file if it was stored under uploads/
|
||||
if($old){
|
||||
// extract basename in case old contains query params
|
||||
$oldBase = basename(parse_url($old, PHP_URL_PATH));
|
||||
$oldFull = $targetDir . '/' . $oldBase;
|
||||
if(file_exists($oldFull) && is_file($oldFull)){
|
||||
@unlink($oldFull);
|
||||
}
|
||||
}
|
||||
echo json_encode(['success'=>true,'path'=>$webPath]);
|
||||
} else {
|
||||
http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]);
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
include_once __DIR__ . '/config.php';
|
||||
|
||||
if(session_status() === PHP_SESSION_NONE) session_start();
|
||||
|
||||
function start_session_if_needed(){ if(session_status() === PHP_SESSION_NONE) session_start(); }
|
||||
|
||||
function auth_headers(){
|
||||
if(function_exists('getallheaders')){
|
||||
$headers = getallheaders();
|
||||
if(is_array($headers)) return $headers;
|
||||
}
|
||||
$headers = [];
|
||||
foreach($_SERVER as $key => $value){
|
||||
if(strpos($key, 'HTTP_') === 0){
|
||||
$name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
|
||||
$headers[$name] = $value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
function auth_header_value($name){
|
||||
$headers = auth_headers();
|
||||
foreach($headers as $key => $value){
|
||||
if(strtolower($key) === strtolower($name)) return $value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function auth_json_response($statusCode, $payload){
|
||||
http_response_code($statusCode);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($payload);
|
||||
exit;
|
||||
}
|
||||
|
||||
function base64url_encode($data){ return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
|
||||
function base64url_decode($data){ return base64_decode(strtr($data, '-_', '+/')); }
|
||||
|
||||
function jwt_issue($claims, $ttlSeconds = 86400){
|
||||
$now = time();
|
||||
$payload = array_merge([
|
||||
'iss' => 'webgis',
|
||||
'iat' => $now,
|
||||
'exp' => $now + max(60, intval($ttlSeconds)),
|
||||
], $claims);
|
||||
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
|
||||
$encodedHeader = base64url_encode(json_encode($header));
|
||||
$encodedPayload = base64url_encode(json_encode($payload));
|
||||
$signature = hash_hmac('sha256', $encodedHeader . '.' . $encodedPayload, JWT_SECRET, true);
|
||||
return $encodedHeader . '.' . $encodedPayload . '.' . base64url_encode($signature);
|
||||
}
|
||||
|
||||
function jwt_verify($token){
|
||||
if(!$token || strpos($token, '.') === false) return null;
|
||||
$parts = explode('.', $token);
|
||||
if(count($parts) !== 3) return null;
|
||||
list($h, $p, $s) = $parts;
|
||||
$header = json_decode(base64url_decode($h), true);
|
||||
$payload = json_decode(base64url_decode($p), true);
|
||||
if(!$header || !$payload) return null;
|
||||
if(!isset($header['alg']) || $header['alg'] !== 'HS256') return null;
|
||||
$expected = base64url_encode(hash_hmac('sha256', $h . '.' . $p, JWT_SECRET, true));
|
||||
if(!hash_equals($expected, $s)) return null;
|
||||
if(isset($payload['exp']) && time() > intval($payload['exp'])) return null;
|
||||
return $payload;
|
||||
}
|
||||
|
||||
function auth_basic_credentials(){
|
||||
$header = auth_header_value('Authorization');
|
||||
if(!$header && isset($_SERVER['PHP_AUTH_USER'])){
|
||||
return [$_SERVER['PHP_AUTH_USER'], isset($_SERVER['PHP_AUTH_PW']) ? $_SERVER['PHP_AUTH_PW'] : ''];
|
||||
}
|
||||
if(!$header || stripos($header, 'Basic ') !== 0) return [null, null];
|
||||
$decoded = base64_decode(substr($header, 6));
|
||||
if($decoded === false || strpos($decoded, ':') === false) return [null, null];
|
||||
list($user, $pass) = explode(':', $decoded, 2);
|
||||
return [$user, $pass];
|
||||
}
|
||||
|
||||
function auth_internal_cipher(){
|
||||
return 'aes-256-gcm';
|
||||
}
|
||||
|
||||
function auth_internal_key(){
|
||||
return hash('sha256', INTERNAL_AUTH_KEY, true);
|
||||
}
|
||||
|
||||
function internal_auth_issue($claims, $ttlSeconds = 300){
|
||||
$payload = array_merge([
|
||||
'iss' => 'webgis-internal',
|
||||
'iat' => time(),
|
||||
'exp' => time() + max(30, intval($ttlSeconds)),
|
||||
'nonce' => bin2hex(random_bytes(8))
|
||||
], $claims);
|
||||
$plaintext = json_encode($payload);
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($plaintext, auth_internal_cipher(), auth_internal_key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if($ciphertext === false) return null;
|
||||
return base64url_encode($iv . $tag . $ciphertext);
|
||||
}
|
||||
|
||||
function internal_auth_verify($token){
|
||||
if(!$token) return null;
|
||||
$raw = base64url_decode($token);
|
||||
if($raw === false || strlen($raw) < 28) return null;
|
||||
$iv = substr($raw, 0, 12);
|
||||
$tag = substr($raw, 12, 16);
|
||||
$ciphertext = substr($raw, 28);
|
||||
$plaintext = openssl_decrypt($ciphertext, auth_internal_cipher(), auth_internal_key(), OPENSSL_RAW_DATA, $iv, $tag);
|
||||
if($plaintext === false) return null;
|
||||
$payload = json_decode($plaintext, true);
|
||||
if(!$payload) return null;
|
||||
if(isset($payload['exp']) && time() > intval($payload['exp'])) return null;
|
||||
return $payload;
|
||||
}
|
||||
|
||||
function get_auth_context(){
|
||||
start_session_if_needed();
|
||||
global $conn;
|
||||
$context = ['user' => null, 'mode' => null];
|
||||
|
||||
$internalToken = auth_header_value('X-Internal-Auth');
|
||||
if($internalToken){
|
||||
$internal = internal_auth_verify($internalToken);
|
||||
if($internal){
|
||||
$context['mode'] = 'internal';
|
||||
$context['user'] = [
|
||||
'id' => isset($internal['sub']) ? intval($internal['sub']) : 0,
|
||||
'email' => isset($internal['email']) ? $internal['email'] : 'internal',
|
||||
'name' => isset($internal['name']) ? $internal['name'] : 'Internal',
|
||||
'organization' => isset($internal['organization']) ? $internal['organization'] : 'Internal',
|
||||
'role' => isset($internal['role']) ? $internal['role'] : 'admin',
|
||||
'status' => 'active'
|
||||
];
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
|
||||
$bearer = auth_header_value('Authorization');
|
||||
if($bearer && stripos($bearer, 'Bearer ') === 0){
|
||||
$payload = jwt_verify(trim(substr($bearer, 7)));
|
||||
if($payload && isset($payload['sub'])){
|
||||
$uid = intval($payload['sub']);
|
||||
if($uid > 0 && $conn){
|
||||
$st = $conn->prepare('SELECT id, email, name, organization, role, status FROM users WHERE id = ? LIMIT 1');
|
||||
if($st){
|
||||
$st->bind_param('i', $uid);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if($row){
|
||||
$context['mode'] = 'jwt';
|
||||
$context['user'] = $row;
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
list($basicUser, $basicPass) = auth_basic_credentials();
|
||||
if($basicUser !== null){
|
||||
if($basicUser === BASIC_API_USER && hash_equals(BASIC_API_PASS, (string)$basicPass)){
|
||||
$context['mode'] = 'basic';
|
||||
$context['user'] = ['id' => 0, 'email' => 'api', 'name' => 'API', 'organization' => 'API', 'role' => 'admin', 'status' => 'active'];
|
||||
return $context;
|
||||
}
|
||||
if($conn){
|
||||
$st = $conn->prepare('SELECT id, email, name, organization, role, status, password_hash FROM users WHERE email = ? LIMIT 1');
|
||||
if($st){
|
||||
$st->bind_param('s', $basicUser);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if($row && password_verify((string)$basicPass, $row['password_hash'])){
|
||||
unset($row['password_hash']);
|
||||
$context['mode'] = 'basic';
|
||||
$context['user'] = $row;
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(isset($_SESSION['jwt'])){
|
||||
$payload = jwt_verify($_SESSION['jwt']);
|
||||
if($payload && isset($payload['sub']) && $conn){
|
||||
$uid = intval($payload['sub']);
|
||||
$st = $conn->prepare('SELECT id, email, name, organization, role, status FROM users WHERE id = ? LIMIT 1');
|
||||
if($st){
|
||||
$st->bind_param('i', $uid);
|
||||
$st->execute();
|
||||
$r = $st->get_result();
|
||||
$row = $r ? $r->fetch_assoc() : null;
|
||||
$st->close();
|
||||
if($row){
|
||||
$context['mode'] = 'jwt';
|
||||
$context['user'] = $row;
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
function get_current_user_info(){
|
||||
$context = get_auth_context();
|
||||
return $context['user'];
|
||||
}
|
||||
|
||||
function require_auth($allowedModes = ['jwt','basic','internal']){
|
||||
$context = get_auth_context();
|
||||
$user = $context['user'];
|
||||
if(!$user){ auth_json_response(401, ['success'=>false,'error'=>'unauthenticated']); }
|
||||
if(isset($user['status']) && $user['status'] !== 'active'){
|
||||
auth_json_response(403, ['success'=>false,'error'=>'account_not_active']);
|
||||
}
|
||||
if($allowedModes && $context['mode'] && !in_array($context['mode'], (array)$allowedModes, true)){
|
||||
auth_json_response(403, ['success'=>false,'error'=>'forbidden']);
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
function require_login(){
|
||||
return require_auth(['jwt','basic','internal']);
|
||||
}
|
||||
|
||||
function require_bearer_login(){
|
||||
$context = get_auth_context();
|
||||
if(!$context['user'] || $context['mode'] !== 'jwt'){
|
||||
auth_json_response(401, ['success'=>false,'error'=>'bearer_required']);
|
||||
}
|
||||
if(isset($context['user']['status']) && $context['user']['status'] !== 'active'){
|
||||
auth_json_response(403, ['success'=>false,'error'=>'account_not_active']);
|
||||
}
|
||||
return $context['user'];
|
||||
}
|
||||
|
||||
function require_role($roles){
|
||||
$user = require_login();
|
||||
if(!in_array($user['role'], (array)$roles, true)){
|
||||
auth_json_response(403, ['success'=>false,'error'=>'forbidden']);
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
function auth_current_mode(){
|
||||
$context = get_auth_context();
|
||||
return $context['mode'];
|
||||
}
|
||||
|
||||
function auth_issue_user_token($userId, $role, $extra = [], $ttlSeconds = 86400){
|
||||
return jwt_issue(array_merge(['sub' => intval($userId), 'role' => $role], $extra), $ttlSeconds);
|
||||
}
|
||||
|
||||
function auth_set_session_jwt($token){
|
||||
start_session_if_needed();
|
||||
$_SESSION['jwt'] = $token;
|
||||
}
|
||||
|
||||
function auth_clear_session(){
|
||||
start_session_if_needed();
|
||||
foreach(['jwt','user_id'] as $key){ if(isset($_SESSION[$key])) unset($_SESSION[$key]); }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// Load environment from project root .env if present, then read API_KEY
|
||||
function load_dotenv($path){
|
||||
if(!file_exists($path)) return;
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES);
|
||||
foreach($lines as $line){
|
||||
if(trim($line)==='' || strpos(trim($line),'#')===0) continue;
|
||||
if(strpos($line,'=')===false) continue;
|
||||
list($k,$v) = explode('=', $line, 2);
|
||||
$k = trim($k); $v = trim($v);
|
||||
// strip optional quotes
|
||||
if((substr($v,0,1)==='"' && substr($v,-1)==='"') || (substr($v,0,1)==="'" && substr($v,-1)==="'")){
|
||||
$v = substr($v,1,-1);
|
||||
}
|
||||
putenv("$k=$v");
|
||||
$_ENV[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
$envPath = realpath(__DIR__ . '/../../.env');
|
||||
if($envPath){ load_dotenv($envPath); }
|
||||
|
||||
$apiKey = getenv('API_KEY');
|
||||
if(!$apiKey) {
|
||||
// fallback (development) - but prefer .env or system env in production
|
||||
$apiKey = '8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c';
|
||||
}
|
||||
define('API_KEY', $apiKey);
|
||||
|
||||
$jwtSecret = getenv('JWT_SECRET');
|
||||
if(!$jwtSecret) {
|
||||
$jwtSecret = hash('sha256', $apiKey . ':jwt');
|
||||
}
|
||||
define('JWT_SECRET', $jwtSecret);
|
||||
|
||||
$internalKey = getenv('INTERNAL_AUTH_KEY');
|
||||
if(!$internalKey) {
|
||||
$internalKey = hash('sha256', $apiKey . ':internal');
|
||||
}
|
||||
define('INTERNAL_AUTH_KEY', $internalKey);
|
||||
|
||||
$basicApiUser = getenv('BASIC_API_USER');
|
||||
if(!$basicApiUser) { $basicApiUser = 'webgis-api'; }
|
||||
define('BASIC_API_USER', $basicApiUser);
|
||||
|
||||
$basicApiPass = getenv('BASIC_API_PASS');
|
||||
if(!$basicApiPass) { $basicApiPass = hash('sha256', $apiKey . ':basic'); }
|
||||
define('BASIC_API_PASS', $basicApiPass);
|
||||
?>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
include_once __DIR__ . '/config.php';
|
||||
|
||||
$dbHost = getenv('DB_HOST') ?: 'localhost';
|
||||
$dbPort = (int)(getenv('DB_PORT') ?: 3306);
|
||||
$dbUser = getenv('DB_USER') ?: 'root';
|
||||
$dbPass = getenv('DB_PASS') ?: 'ilham';
|
||||
$dbName = getenv('DB_NAME') ?: 'poverty_db';
|
||||
|
||||
$conn = new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Koneksi gagal: " . $conn->connect_error);
|
||||
}
|
||||
?>
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/ilham/webgis_uploads
|
||||
@@ -0,0 +1,5 @@
|
||||
# Example environment file for WebGIS
|
||||
# Copy this file to `.env` and fill in real values. Do NOT commit `.env`.
|
||||
|
||||
# Generate a random key: `openssl rand -hex 16`
|
||||
API_KEY=replace_with_a_secure_random_hex_key
|
||||
@@ -0,0 +1,7 @@
|
||||
/.env
|
||||
/.env.local
|
||||
/*.env
|
||||
# ignore common editor temp files
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install mysqli extension for PHP
|
||||
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
||||
|
||||
# Copy project files into container
|
||||
COPY . /var/www/html/
|
||||
|
||||
# Set ownership and permissions
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,204 @@
|
||||
|
||||
WebGIS (Leaflet + PHP + MySQL)
|
||||
|
||||
Deskripsi singkat
|
||||
------------------
|
||||
Proyek WebGIS sederhana yang menggunakan `Leaflet` di frontend dan skrip PHP untuk menyediakan API CRUD terhadap data spasial (lokasi/marker, jalan, tanah) yang disimpan di database MySQL.
|
||||
|
||||
Fitur utama
|
||||
-----------
|
||||
- Peta interaktif dengan marker, polyline, polygon (Leaflet + Leaflet.Draw)
|
||||
- Endpoint PHP untuk mengambil, menambah, memperbarui, dan menghapus data: lokasi, jalan, tanah
|
||||
- Kontrol layer untuk memfilter jenis data (SPBU, Rumah, Masjid, Jalan, Tanah)
|
||||
|
||||
Persyaratan
|
||||
-----------
|
||||
- Web server dengan PHP (mis. Apache/Nginx + PHP)
|
||||
- MySQL atau MariaDB
|
||||
- Akses internet untuk memuat tile OpenStreetMap dan library Leaflet (atau host sendiri)
|
||||
|
||||
Instalasi cepat
|
||||
---------------
|
||||
1. Salin folder proyek ke root web server Anda (mis. `/var/www/html/webgis`).
|
||||
2. Impor database dari file `webgis.sql`:
|
||||
|
||||
```bash
|
||||
mysql -u root -p webgis < webgis.sql
|
||||
```
|
||||
|
||||
3. Perbarui konfigurasi koneksi database di file [koneksi.php](koneksi.php) sesuai kredensial lokal Anda.
|
||||
4. Akses frontend melalui browser: `http://localhost/webgis/index.html` (atau path sesuai server Anda).
|
||||
|
||||
Catatan konfigurasi
|
||||
-------------------
|
||||
File [koneksi.php](koneksi.php) saat ini mengandung koneksi contoh:
|
||||
|
||||
```php
|
||||
<?php
|
||||
$conn = new mysqli("localhost", "root", "ilham", "webgis");
|
||||
// ...
|
||||
?>
|
||||
```
|
||||
|
||||
Jangan gunakan kredensial default pada lingkungan produksi — ubah username/password dan batasi akses.
|
||||
|
||||
API / Endpoints
|
||||
---------------
|
||||
Berikut file PHP utama yang tersedia di proyek ini:
|
||||
|
||||
- `get_lokasi.php` : Mengambil daftar lokasi/marker (JSON)
|
||||
- `tambah_lokasi.php`: Menambah lokasi baru
|
||||
- `update_lokasi.php`: Memperbarui lokasi
|
||||
- `hapus_lokasi.php` : Menghapus lokasi
|
||||
|
||||
- `get_jalan.php` : Mengambil data jalan (GeoJSON/JSON)
|
||||
- `tambah_jalan.php` : Menambah data jalan
|
||||
- `update_jalan.php` : Memperbarui data jalan
|
||||
- `hapus_jalan.php` : Menghapus data jalan
|
||||
|
||||
- `get_tanah.php` : Mengambil data tanah (GeoJSON/JSON)
|
||||
- `tambah_tanah.php` : Menambah data tanah
|
||||
- `update_tanah.php` : Memperbarui data tanah
|
||||
- `hapus_tanah.php` : Menghapus data tanah
|
||||
|
||||
File penting
|
||||
-----------
|
||||
- [index.html](index.html) : Frontend peta (Leaflet + draw tools)
|
||||
- [koneksi.php](koneksi.php) : File koneksi database
|
||||
- [webgis.sql](webgis.sql) : Dump database untuk impor
|
||||
|
||||
Pengembangan & catatan
|
||||
-----------------------
|
||||
- Frontend mengambil endpoint dari `http://localhost/webgis/...` — sesuaikan URL jika Anda menaruh proyek di subfolder atau domain berbeda.
|
||||
- Beberapa record geometrik pada data jalan/tanah mungkin berformat JSON ganda; frontend mencoba parsing defensif.
|
||||
- Jika menambah kolom baru ke tabel `lokasi`, contoh SQL yang pernah dipakai:
|
||||
|
||||
```sql
|
||||
ALTER TABLE lokasi ADD COLUMN alamat TEXT;
|
||||
ALTER TABLE lokasi ADD COLUMN jenis VARCHAR(50) NOT NULL DEFAULT 'spbu';
|
||||
CREATE INDEX idx_lokasi_alamat ON lokasi( (left(alamat,255)) );
|
||||
```
|
||||
|
||||
Keamanan
|
||||
--------
|
||||
- Hindari menyimpan password database langsung di repositori untuk lingkungan produksi.
|
||||
- Sanitasi input pada skrip PHP sebelum memasukkannya ke database (prepared statements lebih aman).
|
||||
|
||||
Lisensi
|
||||
-------
|
||||
Silakan tambahkan licensi sesuai kebutuhan (mis. MIT) atau hubungi pemilik proyek.
|
||||
|
||||
Jika Anda ingin, saya bisa juga menambahkan langkah menjalankan server lokal atau contoh curl untuk setiap endpoint.
|
||||
|
||||
Contoh penggunaan API & API key
|
||||
-------------------------------
|
||||
Untuk operasi baca (`GET`) tidak diperlukan API key. Contoh mengambil daftar lokasi:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost/webgis/src/api/get_lokasi.php | jq .
|
||||
```
|
||||
|
||||
Untuk operasi tulis/hapus/perbarui, server memerlukan header `X-API-KEY` atau field `api_key` di body.
|
||||
API key sebaiknya disimpan di file `/.env` (di luar kontrol versi) atau di environment variable pada server.
|
||||
|
||||
Menambahkan `.env.example`
|
||||
-------------------------
|
||||
Proyek ini menyediakan `.env.example` yang berisi contoh variabel environment yang diperlukan.
|
||||
Untuk menyiapkan environment lokal:
|
||||
|
||||
```bash
|
||||
# salin contoh ke file .env (jangan commit .env)
|
||||
cp .env.example .env
|
||||
|
||||
# generate key (openssl) dan masukkan ke .env
|
||||
openssl rand -hex 16
|
||||
# edit .env dan set API_KEY=hasil_generate
|
||||
```
|
||||
|
||||
Atau buat key dengan PHP:
|
||||
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(16)).PHP_EOL;"
|
||||
```
|
||||
|
||||
Setelah `.env` berisi `API_KEY`, server PHP akan memuat nilai tersebut melalui `src/config/config.php`.
|
||||
|
||||
Contoh panggilan (curl)
|
||||
-----------------------
|
||||
Menambah lokasi (POST JSON):
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost/webgis/src/api/tambah_lokasi.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: your_api_key_here" \
|
||||
-d '{"nama":"Contoh","jenis":"rumah","no_telp":"0812","buka_24_jam":0,"alamat":"Jalan Contoh","latitude":-0.04,"longitude":109.33}'
|
||||
```
|
||||
|
||||
Contoh hapus (POST body dengan id):
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost/webgis/src/api/hapus_lokasi.php" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-KEY: your_api_key_here" \
|
||||
-d '{"id":"5092"}'
|
||||
```
|
||||
|
||||
Catatan keamanan: jangan commit `.env` atau API key produksi ke repositori. Untuk produksi pertimbangkan memuat kunci dari sistem secrets, variabel lingkungan server (mis. systemd/Apache/Nginx), atau layanan vault.
|
||||
|
||||
Menjalankan (Windows / Linux / macOS)
|
||||
-----------------------------------
|
||||
Berikut langkah singkat untuk menjalankan proyek ini pada masing-masing sistem operasi. Inti yang sama: tempatkan folder proyek di root web server Anda atau jalankan server PHP built-in, lalu impor `webgis.sql` dan sesuaikan [koneksi.php](koneksi.php).
|
||||
|
||||
- **Linux (cepat, tanpa XAMPP):**
|
||||
|
||||
1. Pastikan `php` dan `mysql`/`mariadb` tersedia.
|
||||
2. Impor database:
|
||||
|
||||
```bash
|
||||
mysql -u root -p webgis < webgis.sql
|
||||
```
|
||||
|
||||
3. Jalankan server PHP built-in (untuk pengujian cepat):
|
||||
|
||||
```bash
|
||||
php -S localhost:8000 -t .
|
||||
```
|
||||
|
||||
4. Buka `http://localhost:8000/index.html`.
|
||||
|
||||
- **Windows (XAMPP/WAMP):**
|
||||
|
||||
1. Salin folder proyek ke `C:\xampp\htdocs\webgis` (XAMPP) atau folder www pada WAMP.
|
||||
2. Import `webgis.sql` menggunakan phpMyAdmin atau CLI:
|
||||
|
||||
```powershell
|
||||
mysql -u root -p webgis < webgis.sql
|
||||
```
|
||||
|
||||
3. Pastikan Apache dan MySQL sudah dijalankan via XAMPP/WAMP control panel.
|
||||
4. Akses `http://localhost/webgis/index.html`.
|
||||
|
||||
Catatan: Bisa juga menggunakan `php -S` dari Command Prompt jika PHP terpasang secara terpisah.
|
||||
|
||||
- **macOS (MAMP atau PHP built-in):**
|
||||
|
||||
1. Gunakan MAMP: tempatkan proyek di folder `htdocs` MAMP lalu jalankan server MAMP.
|
||||
2. Atau gunakan PHP built-in (macOS yang memiliki PHP):
|
||||
|
||||
```bash
|
||||
mysql -u root -p webgis < webgis.sql
|
||||
php -S localhost:8000 -t .
|
||||
```
|
||||
|
||||
3. Buka `http://localhost:8000/index.html` atau `http://localhost/webgis/index.html` jika menggunakan MAMP/Apache.
|
||||
|
||||
Penyesuaian koneksi
|
||||
-------------------
|
||||
- Edit [koneksi.php](koneksi.php) untuk menyesuaikan `host`, `username`, `password`, dan `database` sesuai lingkungan Anda.
|
||||
- Jika menggunakan server built-in (`php -S`), pastikan `koneksi.php` menggunakan host dan port database yang benar (biasanya `localhost`).
|
||||
|
||||
Permasalahan umum
|
||||
-----------------
|
||||
- Jika tile OSM atau library tidak muncul, cek koneksi internet atau muat ulang (CORS biasanya tidak menjadi masalah untuk tile default).
|
||||
- Jika endpoint PHP mengembalikan error, periksa log PHP/Apache dan periksa kredensial di [koneksi.php](koneksi.php).
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebGIS</title>
|
||||
<meta charset="utf-8">
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||||
|
||||
<!-- Leaflet JS -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
|
||||
<!-- Leaflet Draw CSS -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
|
||||
|
||||
<!-- Leaflet Draw JS -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||
|
||||
<!-- GeometryUtil (FIXED & WORKING) -->
|
||||
<script src="https://unpkg.com/leaflet-geometryutil"></script>
|
||||
<style>
|
||||
/* Simple modal styles */
|
||||
.modal {position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999}
|
||||
.modal.hidden{display:none}
|
||||
.modal-content{background:#fff;padding:18px;border-radius:8px;min-width:320px;max-width:480px;box-shadow:0 6px 18px rgba(0,0,0,0.2)}
|
||||
.modal-content h3{margin:0 0 10px 0;font-size:18px}
|
||||
.modal-content label{display:block;margin-top:8px;font-size:13px}
|
||||
.modal-content input[type=text], .modal-content select{width:100%;padding:8px;margin-top:4px;border:1px solid #ddd;border-radius:4px}
|
||||
.modal-actions{margin-top:12px;text-align:right}
|
||||
.modal-actions button{margin-left:8px;padding:8px 12px;border-radius:4px;border:0;cursor:pointer}
|
||||
.btn-primary{background:#1976d2;color:white}
|
||||
.btn-secondary{background:#e0e0e0}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Modal for adding items -->
|
||||
<div id="modal" class="modal hidden" role="dialog" aria-hidden="true">
|
||||
<div class="modal-content">
|
||||
<h3 id="modal-title">Form</h3>
|
||||
<form id="modal-form">
|
||||
<div id="modal-fields"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="modal-cancel" class="btn-secondary">Batal</button>
|
||||
<button type="submit" id="modal-submit" class="btn-primary">Simpan</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-controls" style="position: absolute; top: 10px; right: 10px; z-index: 1000; background: white; padding: 10px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; gap: 8px; align-items: center; font-family: sans-serif;">
|
||||
<span id="api-key-status" style="font-size: 13px; font-weight: bold; color: #666;">Guest Mode</span>
|
||||
<button id="btn-api-key" onclick="promptApiKey()" style="padding: 6px 12px; border-radius: 4px; border: none; background: #1976d2; color: white; cursor: pointer; font-weight: bold; font-size: 12px; transition: all 0.2s;">🔑 Set API Key</button>
|
||||
</div>
|
||||
|
||||
<div id="map" style="height:100vh;"></div>
|
||||
|
||||
<script>
|
||||
let isDrawing = false;
|
||||
let pendingLayer = null;
|
||||
let pendingType = null;
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function updateApiKeyStatus() {
|
||||
const key = localStorage.getItem('spbu_api_key');
|
||||
const statusEl = document.getElementById('api-key-status');
|
||||
const btnEl = document.getElementById('btn-api-key');
|
||||
if (statusEl && btnEl) {
|
||||
if (key) {
|
||||
statusEl.textContent = 'Admin Mode';
|
||||
statusEl.style.color = '#2e7d32';
|
||||
btnEl.textContent = '🔑 Ubah API Key';
|
||||
btnEl.style.background = '#2e7d32';
|
||||
} else {
|
||||
statusEl.textContent = 'Guest Mode';
|
||||
statusEl.style.color = '#666';
|
||||
btnEl.textContent = '🔑 Set API Key';
|
||||
btnEl.style.background = '#1976d2';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function promptApiKey() {
|
||||
const currentKey = localStorage.getItem('spbu_api_key') || '';
|
||||
const newKey = prompt('Masukkan API Key untuk otentikasi Admin SPBU:', currentKey);
|
||||
if (newKey !== null) {
|
||||
const trimmed = newKey.trim();
|
||||
if (trimmed) {
|
||||
localStorage.setItem('spbu_api_key', trimmed);
|
||||
} else {
|
||||
localStorage.removeItem('spbu_api_key');
|
||||
}
|
||||
updateApiKeyStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleApiResponse(res, callback) {
|
||||
if (res.status === 401) {
|
||||
alert('Akses Ditolak: API Key tidak valid atau kosong. Silakan atur API Key terlebih dahulu.');
|
||||
promptApiKey();
|
||||
} else {
|
||||
res.json().then(data => {
|
||||
if (data && data.success === false) {
|
||||
alert('Error: ' + (data.error || 'Terjadi kesalahan'));
|
||||
} else if (callback) {
|
||||
callback(data);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (res.ok && callback) {
|
||||
callback();
|
||||
} else {
|
||||
alert('Terjadi kesalahan koneksi server');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(){
|
||||
const headers = {'Content-Type':'application/json'};
|
||||
const key = localStorage.getItem('spbu_api_key');
|
||||
if(key){ headers['X-API-KEY'] = key; }
|
||||
return headers;
|
||||
}
|
||||
|
||||
const map = L.map('map').setView([-0.0552, 109.3500], 13);
|
||||
|
||||
const drawnItems = new L.FeatureGroup();
|
||||
map.addLayer(drawnItems);
|
||||
|
||||
const drawControl = new L.Control.Draw({
|
||||
draw: {
|
||||
polyline: true,
|
||||
polygon: true,
|
||||
marker: true,
|
||||
rectangle: false,
|
||||
circle: false,
|
||||
circlemarker: false
|
||||
},
|
||||
edit: {
|
||||
featureGroup: drawnItems,
|
||||
edit: true,
|
||||
remove: true
|
||||
}
|
||||
});
|
||||
|
||||
map.addControl(drawControl);
|
||||
|
||||
map.on(L.Draw.Event.CREATED, function (e) {
|
||||
const layer = e.layer;
|
||||
|
||||
if (e.layerType === 'marker') {
|
||||
// opening lokasi modal for marker creation
|
||||
pendingType = 'lokasi';
|
||||
const ll = layer.getLatLng();
|
||||
// store the created layer so we can remove it from drawnItems after save
|
||||
pendingLayer = layer;
|
||||
openModal('Tambah Lokasi', `
|
||||
<label>Nama <input name="nama" type="text" required></label>
|
||||
<label>Jenis <select name="jenis"><option value="rumah">Rumah</option><option value="masjid">Masjid</option><option value="spbu">SPBU</option></select></label>
|
||||
<label>No Telp <input name="no_telp" type="text"></label>
|
||||
<label><input name="buka_24_jam" type="checkbox"> Buka 24 Jam</label>
|
||||
<label>Koordinat <input name="coords" type="text" readonly value="${ll.lat.toFixed(6)}, ${ll.lng.toFixed(6)}"></label>
|
||||
<label>Alamat <input name="alamat" type="text"></label>
|
||||
`, handleModalSubmit);
|
||||
// try to reverse-geocode and prefill the alamat field
|
||||
reverseGeocode(ll.lat, ll.lng).then(addr=>{
|
||||
try{ const f = document.querySelector('#modal-fields input[name="alamat"]'); if(f && addr) f.value = addr; }catch(e){}
|
||||
}).catch(()=>{});
|
||||
}
|
||||
|
||||
if (e.layerType === 'polyline') {
|
||||
handlePolyline(layer);
|
||||
}
|
||||
|
||||
if (e.layerType === 'polygon') {
|
||||
handlePolygon(layer);
|
||||
}
|
||||
|
||||
drawnItems.addLayer(layer);
|
||||
});
|
||||
|
||||
map.on('draw:drawstart', function () {
|
||||
isDrawing = true;
|
||||
});
|
||||
|
||||
map.on('draw:drawstop', function () {
|
||||
isDrawing = false;
|
||||
});
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
|
||||
|
||||
// let markers = [];
|
||||
|
||||
// ====== LAYER GROUPS & CONTROL ======
|
||||
const spbu24Group = L.layerGroup().addTo(map);
|
||||
const spbuNon24Group = L.layerGroup().addTo(map);
|
||||
const rumahGroup = L.layerGroup().addTo(map);
|
||||
const masjidGroup = L.layerGroup().addTo(map);
|
||||
const jalanGroup = L.layerGroup().addTo(map);
|
||||
const tanahGroup = L.layerGroup().addTo(map);
|
||||
const lokasiGroup = L.layerGroup(); // fallback group (not added by default)
|
||||
const rumahMarkers = []; // store rumah circleMarkers for radius checks
|
||||
|
||||
L.control.layers(null, {
|
||||
"SPBU Buka 24 Jam": spbu24Group,
|
||||
"SPBU Tidak 24 Jam": spbuNon24Group,
|
||||
"Rumah": rumahGroup,
|
||||
"Masjid": masjidGroup,
|
||||
"Jalan": jalanGroup,
|
||||
"Tanah": tanahGroup
|
||||
}, { collapsed: false }).addTo(map);
|
||||
|
||||
// Using the built-in Leaflet.Draw toolbar (marker, polyline, polygon).
|
||||
function tanahStatusFull(code){
|
||||
return code === 'SHM' ? 'Sertifikat Hak Milik (SHM)'
|
||||
: code === 'HGB' ? 'Sertifikat Hak Guna Bangunan (HGB)'
|
||||
: code === 'HGU' ? 'Sertifikat Hak Guna Usaha (HGU)'
|
||||
: code === 'HP' ? 'Sertifikat Hak Pakai (HP)'
|
||||
: code || '';
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
// clear previous markers from groups
|
||||
spbu24Group.clearLayers();
|
||||
spbuNon24Group.clearLayers();
|
||||
// hide any active masjid radius when reloading data
|
||||
try{ hideMasjidRadius(); }catch(e){}
|
||||
|
||||
fetch('src/api/get_lokasi.php')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// clear previous lokasi markers/groups
|
||||
lokasiGroup.clearLayers(); rumahGroup.clearLayers(); masjidGroup.clearLayers(); rumahMarkers.length = 0;
|
||||
data.forEach(item => {
|
||||
const statusHtml = parseInt(item.buka_24_jam) === 1
|
||||
? "<span style='color:green'>Buka 24 Jam</span>"
|
||||
: "<span style='color:red'>Tidak 24 Jam</span>";
|
||||
|
||||
const safeNama = escapeHtml(item.nama);
|
||||
const safeAlamat = escapeHtml(item.alamat);
|
||||
const jsEscapedNama = safeNama.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const jsEscapedAlamat = safeAlamat.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const jenis = (item.jenis||'').toLowerCase();
|
||||
|
||||
let marker;
|
||||
if(jenis === 'rumah' || jenis === 'masjid'){
|
||||
// use circleMarker so we can change color dynamically
|
||||
const color = jenis === 'masjid' ? '#1976d2' : '#d32f2f';
|
||||
marker = L.circleMarker([item.latitude, item.longitude], {radius:8, color:color, fillColor:color, fillOpacity:1});
|
||||
marker.bindPopup(`
|
||||
<b>${safeNama}</b><br>
|
||||
${statusHtml}<br>
|
||||
<div style="font-size:12px;color:#444;margin-top:6px">${safeAlamat}</div>
|
||||
<br>
|
||||
<button onclick="hapus('${item.id}')">Hapus</button>
|
||||
<button onclick="edit('${item.id}','${jsEscapedNama}','${item.no_telp}',${item.buka_24_jam}, '${jsEscapedAlamat}', '${jenis}')">Edit</button>
|
||||
`);
|
||||
// store rumah markers for radius checks
|
||||
if(jenis === 'rumah') { rumahMarkers.push(marker); rumahGroup.addLayer(marker); }
|
||||
if(jenis === 'masjid') { masjidGroup.addLayer(marker); }
|
||||
} else {
|
||||
// default: spbu or other — normal marker
|
||||
marker = L.marker([item.latitude, item.longitude]);
|
||||
marker.bindPopup(`
|
||||
<b>${safeNama}</b><br>
|
||||
${statusHtml}<br>
|
||||
<div style="font-size:12px;color:#444;margin-top:6px">${safeAlamat}</div>
|
||||
<br>
|
||||
<button onclick="hapus('${item.id}')">Hapus</button>
|
||||
<button onclick="edit('${item.id}','${jsEscapedNama}','${item.no_telp}',${item.buka_24_jam}, '${jsEscapedAlamat}', '${jenis}')">Edit</button>
|
||||
`);
|
||||
}
|
||||
|
||||
// attach jenis data for future reference
|
||||
marker._jenis = jenis;
|
||||
marker._data = item;
|
||||
// add marker to appropriate group if not already added
|
||||
if(jenis !== 'rumah' && jenis !== 'masjid'){
|
||||
// SPBU or other — add to spbu groups by buka_24_jam
|
||||
if(parseInt(item.buka_24_jam) === 1) spbu24Group.addLayer(marker);
|
||||
else spbuNon24Group.addLayer(marker);
|
||||
}
|
||||
// Also add to lokasiGroup as general index (optional)
|
||||
lokasiGroup.addLayer(marker);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// MASJID RADIUS CONTROL
|
||||
let activeMasjid = null;
|
||||
let masjidCircle = null;
|
||||
let masjidControlEl = null;
|
||||
|
||||
function showMasjidRadius(masjidMarker){
|
||||
// ensure only one active
|
||||
hideMasjidRadius();
|
||||
activeMasjid = masjidMarker;
|
||||
const center = masjidMarker.getLatLng();
|
||||
const defaultRadius = 200; // meters (default changed to 200m)
|
||||
masjidCircle = L.circle(center, {radius: defaultRadius, color: '#1976d2', fillOpacity: 0.08}).addTo(map);
|
||||
// create control UI
|
||||
masjidControlEl = document.createElement('div');
|
||||
masjidControlEl.id = 'masjid-radius-control';
|
||||
masjidControlEl.style.position='absolute';
|
||||
// place at bottom-left so it doesn't overlap layer controls / zoom
|
||||
masjidControlEl.style.left='10px'; masjidControlEl.style.bottom='80px';
|
||||
masjidControlEl.style.background='white'; masjidControlEl.style.padding='8px'; masjidControlEl.style.borderRadius='6px'; masjidControlEl.style.boxShadow='0 2px 6px rgba(0,0,0,0.2)';
|
||||
masjidControlEl.innerHTML = `Radius: <span id="masjid-radius-value">${defaultRadius}</span> m<br><input id="masjid-radius-slider" type="range" min="50" max="3000" step="10" value="${defaultRadius}" style="width:200px"> <button id="masjid-radius-close">Close</button>`;
|
||||
// append to the map container and ensure it's above map layers
|
||||
try{
|
||||
const mapContainer = map.getContainer && map.getContainer();
|
||||
if(mapContainer){ mapContainer.appendChild(masjidControlEl); }
|
||||
else { document.body.appendChild(masjidControlEl); }
|
||||
}catch(e){ document.body.appendChild(masjidControlEl); }
|
||||
masjidControlEl.style.zIndex = 10000;
|
||||
masjidControlEl.style.pointerEvents = 'auto';
|
||||
|
||||
// prevent map dragging/interaction while user is interacting with the slider
|
||||
const sliderEl = document.getElementById('masjid-radius-slider');
|
||||
if(sliderEl){
|
||||
const disableMapDrag = ()=>{ try{ if(map.dragging && map.dragging.enabled()) map.dragging.disable(); }catch(e){} };
|
||||
const enableMapDrag = ()=>{ try{ if(map.dragging && !map.dragging.enabled()) map.dragging.enable(); }catch(e){} };
|
||||
|
||||
const startDrag = (ev)=>{ disableMapDrag(); sliderEl._dragging = true; };
|
||||
const endDrag = (ev)=>{ sliderEl._dragging = false; enableMapDrag(); };
|
||||
|
||||
sliderEl.addEventListener('pointerdown', startDrag);
|
||||
sliderEl.addEventListener('pointerup', endDrag);
|
||||
sliderEl.addEventListener('pointercancel', endDrag);
|
||||
// mouse/touch fallback
|
||||
sliderEl.addEventListener('mousedown', startDrag);
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
sliderEl.addEventListener('touchstart', startDrag, {passive:true});
|
||||
document.addEventListener('touchend', endDrag);
|
||||
|
||||
// actual slider input handling (keep default behavior so slider remains draggable)
|
||||
sliderEl.addEventListener('input', function(ev){
|
||||
const r = parseInt(ev.target.value,10);
|
||||
try{ masjidCircle.setRadius(r); }catch(e){}
|
||||
const valEl = document.getElementById('masjid-radius-value'); if(valEl) valEl.innerText = r;
|
||||
updateHomeColors(center, r);
|
||||
});
|
||||
}
|
||||
document.getElementById('masjid-radius-close').addEventListener('click', function(){ hideMasjidRadius(); });
|
||||
updateHomeColors(center, defaultRadius);
|
||||
}
|
||||
|
||||
function hideMasjidRadius(){
|
||||
if(masjidCircle){ map.removeLayer(masjidCircle); masjidCircle = null; }
|
||||
if(masjidControlEl){ masjidControlEl.remove(); masjidControlEl = null; }
|
||||
activeMasjid = null;
|
||||
// reset rumah markers to default red
|
||||
rumahMarkers.forEach(m=>{ try{ m.setStyle({color:'#d32f2f', fillColor:'#d32f2f'}); }catch(e){} });
|
||||
}
|
||||
|
||||
function updateHomeColors(centerLatLng, radiusMeters){
|
||||
rumahMarkers.forEach(m=>{
|
||||
try{
|
||||
const dist = m.getLatLng().distanceTo(centerLatLng);
|
||||
if(dist <= radiusMeters){
|
||||
m.setStyle({color:'#2e7d32', fillColor:'#2e7d32'}); // green inside
|
||||
} else {
|
||||
m.setStyle({color:'#d32f2f', fillColor:'#d32f2f'}); // red outside
|
||||
}
|
||||
}catch(e){ }
|
||||
});
|
||||
}
|
||||
|
||||
// add delegated click handler: when popup for masjid opens, show button to toggle radius
|
||||
map.on('popupopen', function(e){
|
||||
try{
|
||||
const layer = e.popup._source;
|
||||
if(layer && layer._jenis === 'masjid'){
|
||||
const container = e.popup.getElement();
|
||||
if(container && !container.querySelector('.show-masjid-radius')){
|
||||
const btn = document.createElement('button'); btn.textContent='Show Radius'; btn.className='show-masjid-radius';
|
||||
btn.style.marginLeft='6px';
|
||||
btn.onclick = function(){ showMasjidRadius(layer); };
|
||||
const content = container.querySelector('.leaflet-popup-content');
|
||||
if(content) content.appendChild(btn);
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
});
|
||||
|
||||
function loadJalan(){
|
||||
jalanGroup.clearLayers();
|
||||
fetch('src/api/get_jalan.php')
|
||||
.then(res=>res.json())
|
||||
.then(data=>{
|
||||
data.forEach(j=>{
|
||||
let geo = null;
|
||||
try {
|
||||
geo = (typeof j.geom === 'string') ? JSON.parse(j.geom) : j.geom;
|
||||
} catch(err) {
|
||||
try {
|
||||
// try double-encoded JSON
|
||||
geo = JSON.parse(JSON.parse(j.geom));
|
||||
} catch(err2){
|
||||
console.error('Failed to parse jalan.geom for id', j.id, err2);
|
||||
// fallback: show a marker at map center with raw info so user sees the record
|
||||
const safeNama = escapeHtml(j.nama);
|
||||
const m = L.marker(map.getCenter()).bindPopup(`<b>${safeNama}</b><br>Invalid geometry<br><pre style="max-height:120px;overflow:auto">${escapeHtml(String(j.geom)).slice(0,400)}</pre>`);
|
||||
jalanGroup.addLayer(m);
|
||||
map.panTo(m.getLatLng());
|
||||
m.openPopup();
|
||||
return; // skip adding geojson
|
||||
}
|
||||
}
|
||||
|
||||
const pj = parseFloat(j.panjang) || 0;
|
||||
const safeNama = escapeHtml(j.nama);
|
||||
const safeStatus = escapeHtml(j.status);
|
||||
const jsEscapedNama = safeNama.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const popup = `<b>${safeNama}</b><br>${safeStatus}<br>${pj.toFixed(2)} m<br><br>
|
||||
<button onclick="hapusJalan('${j.id}')">Hapus</button>
|
||||
<button onclick="editJalan('${j.id}','${jsEscapedNama}', '${safeStatus}', ${pj})">Edit</button>`;
|
||||
|
||||
console.log('loadJalan: id=', j.id, 'geom=', geo);
|
||||
const gj = L.geoJSON(geo,{
|
||||
style:{color:
|
||||
j.status=="Nasional"?"red":
|
||||
j.status=="Provinsi"?"blue":"green"}
|
||||
});
|
||||
|
||||
try{
|
||||
gj.eachLayer(function(layer){
|
||||
layer._fid = j.id;
|
||||
layer.bindPopup(popup);
|
||||
jalanGroup.addLayer(layer);
|
||||
try{
|
||||
if(layer.getBounds){
|
||||
const c = layer.getBounds().getCenter();
|
||||
if(!map.getBounds().contains(c)){
|
||||
map.panTo(c);
|
||||
layer.openPopup && layer.openPopup();
|
||||
}
|
||||
} else if(layer.getLatLng){
|
||||
const p = layer.getLatLng();
|
||||
if(!map.getBounds().contains(p)){
|
||||
map.panTo(p);
|
||||
layer.openPopup && layer.openPopup();
|
||||
}
|
||||
}
|
||||
}catch(e){console.warn('pan check failed', e)}
|
||||
});
|
||||
} catch(e){
|
||||
console.error('Error adding jalan geo layer', j.id, e);
|
||||
const safeErrNama = escapeHtml(j.nama);
|
||||
const m = L.marker(map.getCenter()).bindPopup(`<b>${safeErrNama}</b><br>Geo add error<br><pre style="max-height:120px;overflow:auto">${escapeHtml(String(j.geom)).slice(0,400)}</pre>`);
|
||||
jalanGroup.addLayer(m);
|
||||
map.panTo(m.getLatLng());
|
||||
m.openPopup();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadTanah(){
|
||||
tanahGroup.clearLayers();
|
||||
fetch('src/api/get_tanah.php')
|
||||
.then(res=>res.json())
|
||||
.then(data=>{
|
||||
data.forEach(t=>{
|
||||
let geo = null;
|
||||
try {
|
||||
geo = (typeof t.geom === 'string') ? JSON.parse(t.geom) : t.geom;
|
||||
} catch(err) {
|
||||
try {
|
||||
geo = JSON.parse(JSON.parse(t.geom));
|
||||
} catch(err2){
|
||||
console.error('Failed to parse tanah.geom for id', t.id, err2);
|
||||
const safeNama = escapeHtml(t.nama);
|
||||
const m = L.marker(map.getCenter()).bindPopup(`<b>${safeNama}</b><br>Invalid geometry<br><pre style="max-height:120px;overflow:auto">${escapeHtml(String(t.geom)).slice(0,400)}</pre>`);
|
||||
tanahGroup.addLayer(m);
|
||||
map.panTo(m.getLatLng());
|
||||
m.openPopup();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const luasNum = parseFloat(t.luas) || 0;
|
||||
const safeNama = escapeHtml(t.nama);
|
||||
const safeStatus = escapeHtml(t.status);
|
||||
const jsEscapedNama = safeNama.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const popup = `<b>${safeNama}</b><br>${tanahStatusFull(t.status)}<br>${luasNum.toFixed(2)} m²<br><br>
|
||||
<button onclick="hapusTanah('${t.id}')">Hapus</button>
|
||||
<button onclick="editTanah('${t.id}','${jsEscapedNama}', '${safeStatus}', ${luasNum})">Edit</button>`;
|
||||
|
||||
console.log('loadTanah: id=', t.id, 'geom=', geo);
|
||||
const gj = L.geoJSON(geo,{
|
||||
style:{color:
|
||||
t.status=="SHM"?"green":
|
||||
t.status=="HGB"?"blue":
|
||||
t.status=="HGU"?"orange":"purple"}
|
||||
});
|
||||
|
||||
try{
|
||||
gj.eachLayer(function(layer){
|
||||
layer._fid = t.id;
|
||||
layer.bindPopup(popup);
|
||||
tanahGroup.addLayer(layer);
|
||||
try{
|
||||
if(layer.getBounds){
|
||||
const c = layer.getBounds().getCenter();
|
||||
if(!map.getBounds().contains(c)){
|
||||
map.panTo(c);
|
||||
layer.openPopup && layer.openPopup();
|
||||
}
|
||||
} else if(layer.getLatLng){
|
||||
const p = layer.getLatLng();
|
||||
if(!map.getBounds().contains(p)){
|
||||
map.panTo(p);
|
||||
layer.openPopup && layer.openPopup();
|
||||
}
|
||||
}
|
||||
}catch(e){console.warn('pan check failed', e)}
|
||||
});
|
||||
} catch(e){
|
||||
console.error('Error adding tanah geo layer', t.id, e);
|
||||
const safeErrNama = escapeHtml(t.nama);
|
||||
const m = L.marker(map.getCenter()).bindPopup(`<b>${safeErrNama}</b><br>Geo add error<br><pre style="max-height:120px;overflow:auto">${escapeHtml(String(t.geom)).slice(0,400)}</pre>`);
|
||||
tanahGroup.addLayer(m);
|
||||
map.panTo(m.getLatLng());
|
||||
m.openPopup();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ================= DELETE =================
|
||||
function hapus(id){
|
||||
fetch('src/api/hapus_lokasi.php',{
|
||||
method: 'POST', headers: buildHeaders(),
|
||||
body: JSON.stringify({id: id})
|
||||
}).then(res => handleApiResponse(res, loadData));
|
||||
}
|
||||
|
||||
function hapusJalan(id){
|
||||
fetch('src/api/hapus_jalan.php',{
|
||||
method: 'POST', headers: buildHeaders(),
|
||||
body: JSON.stringify({id: id})
|
||||
}).then(res => handleApiResponse(res, loadJalan));
|
||||
}
|
||||
|
||||
function hapusTanah(id){
|
||||
fetch('src/api/hapus_tanah.php',{
|
||||
method: 'POST', headers: buildHeaders(),
|
||||
body: JSON.stringify({id: id})
|
||||
}).then(res => handleApiResponse(res, loadTanah));
|
||||
}
|
||||
|
||||
// ================= UPDATE =================
|
||||
function edit(id,nama,telp,buka,alamat,jenis){
|
||||
openModal('Edit Lokasi', `
|
||||
<input type="hidden" name="id" value="${id}">
|
||||
<label>Nama <input name="nama" type="text" required value="${(nama||'').replace(/"/g,'"')}"></label>
|
||||
<label>Jenis <select name="jenis"><option value="rumah">Rumah</option><option value="masjid">Masjid</option><option value="spbu">SPBU</option></select></label>
|
||||
<label>No Telp <input name="no_telp" type="text" value="${(telp||'').replace(/"/g,'"')}"></label>
|
||||
<label><input name="buka_24_jam" type="checkbox" ${buka? 'checked' : ''}> Buka 24 Jam</label>
|
||||
<label>Alamat <input name="alamat" type="text" value="${(alamat||'').replace(/"/g,'"')}"></label>
|
||||
`, function(values){
|
||||
fetch('src/api/update_lokasi.php',{
|
||||
method:'POST',headers: buildHeaders(),
|
||||
body:JSON.stringify({id:id,nama:values.nama, jenis: values.jenis || 'rumah', no_telp:values.no_telp||'',buka_24_jam:values.buka_24_jam?1:0, alamat: values.alamat || ''})
|
||||
}).then(res => handleApiResponse(res, loadData));
|
||||
});
|
||||
// set current jenis selection after modal fields inserted
|
||||
setTimeout(()=>{ const sel=document.querySelector('#modal-fields select[name="jenis"]'); if(sel) sel.value = (jenis||'rumah'); },0);
|
||||
}
|
||||
|
||||
function editJalan(id,nama,status,panjang){
|
||||
openModal('Edit Jalan', `
|
||||
<input type="hidden" name="id" value="${id}">
|
||||
<label>Nama <input name="nama" type="text" required value="${(nama||'').replace(/"/g,'"')}"></label>
|
||||
<label>Status <select name="status"><option value="Nasional">Nasional</option><option value="Provinsi">Provinsi</option><option value="Kabupaten">Kabupaten</option></select></label>
|
||||
<label>Panjang (m) <input name="panjang" type="text" readonly value="${parseFloat(panjang).toFixed(2)}"></label>
|
||||
`, function(values){
|
||||
fetch('src/api/update_jalan.php',{
|
||||
method:'POST',headers: buildHeaders(),
|
||||
body:JSON.stringify({id:id,nama:values.nama,status:values.status,panjang:parseFloat(values.panjang)||0})
|
||||
}).then(res => handleApiResponse(res, loadJalan));
|
||||
});
|
||||
}
|
||||
|
||||
function editTanah(id,nama,status,luas){
|
||||
const fields = `
|
||||
<input type="hidden" name="id" value="${id}">
|
||||
<label>Nama Pemilik <input name="nama" type="text" required value="${(nama||'').replace(/"/g,'"')}"></label>
|
||||
<label>Status <select name="status">
|
||||
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
|
||||
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||||
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
|
||||
<option value="HP">Sertifikat Hak Pakai (HP)</option>
|
||||
</select></label>
|
||||
<label>Luas (m²) <input name="luas" type="text" readonly value="${parseFloat(luas).toFixed(2)}"></label>
|
||||
`;
|
||||
|
||||
const cb = function(values){
|
||||
fetch('src/api/update_tanah.php',{
|
||||
method:'POST',headers: buildHeaders(),
|
||||
body:JSON.stringify({id:id,nama:values.nama,status:values.status,luas:parseFloat(values.luas)||0})
|
||||
}).then(res => handleApiResponse(res, loadTanah));
|
||||
};
|
||||
|
||||
openModal('Edit Tanah', fields, cb);
|
||||
// set the select value after modal fields are inserted (avoid embedding closing script tag inside templates)
|
||||
setTimeout(()=>{ const sel=document.querySelector('#modal-fields select[name="status"]'); if(sel) sel.value = status; },0);
|
||||
}
|
||||
|
||||
// Using Draw's marker tool to create 'lokasi' (handled in draw:created)
|
||||
|
||||
function handlePolyline(layer) {
|
||||
// open modal to collect jalan info, store layer pending until user submits
|
||||
pendingType = 'jalan';
|
||||
pendingLayer = layer;
|
||||
|
||||
let latlngs = layer.getLatLngs();
|
||||
let panjang = 0;
|
||||
for(let i=0;i<latlngs.length-1;i++){
|
||||
panjang += latlngs[i].distanceTo(latlngs[i+1]);
|
||||
}
|
||||
|
||||
openModal('Tambah Jalan', `
|
||||
<label>Nama <input name="nama" type="text" required></label>
|
||||
<label>Status <select name="status"><option value="Nasional">Nasional</option><option value="Provinsi">Provinsi</option><option value="Kabupaten">Kabupaten</option></select></label>
|
||||
<label>Panjang (m) <input name="panjang" type="text" readonly value="${panjang.toFixed(2)}"></label>
|
||||
`, handleModalSubmit);
|
||||
}
|
||||
|
||||
function handlePolygon(layer) {
|
||||
// open modal to collect tanah info
|
||||
pendingType = 'tanah';
|
||||
pendingLayer = layer;
|
||||
|
||||
const latlngs = layer.getLatLngs()[0];
|
||||
const luas = L.GeometryUtil.geodesicArea(latlngs);
|
||||
|
||||
openModal('Tambah Tanah', `
|
||||
<label>Nama Pemilik <input name="nama" type="text" required></label>
|
||||
<label>Status <select name="status">
|
||||
<option value="SHM">Sertifikat Hak Milik (SHM)</option>
|
||||
<option value="HGB">Sertifikat Hak Guna Bangunan (HGB)</option>
|
||||
<option value="HGU">Sertifikat Hak Guna Usaha (HGU)</option>
|
||||
<option value="HP">Sertifikat Hak Pakai (HP)</option>
|
||||
</select></label>
|
||||
<label>Luas (m²) <input name="luas" type="text" readonly value="${luas.toFixed(2)}"></label>
|
||||
`, handleModalSubmit);
|
||||
}
|
||||
|
||||
// Reverse geocode helper using Nominatim (OpenStreetMap). Returns a Promise<string> of the display address.
|
||||
function reverseGeocode(lat, lng){
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lng)}`;
|
||||
return fetch(url, { headers: { 'Accept': 'application/json' } })
|
||||
.then(res => res.ok ? res.json() : Promise.reject('nores'))
|
||||
.then(data => (data && data.display_name) ? data.display_name : '')
|
||||
.catch(err => {
|
||||
console.warn('reverseGeocode failed', err);
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
function openModal(title, fieldsHtml, submitHandler){
|
||||
document.getElementById('modal-title').textContent = title;
|
||||
document.getElementById('modal-fields').innerHTML = fieldsHtml;
|
||||
const modal = document.getElementById('modal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden','false');
|
||||
|
||||
const form = document.getElementById('modal-form');
|
||||
form.onsubmit = function(ev){
|
||||
ev.preventDefault();
|
||||
const fd = new FormData(form);
|
||||
const obj = {};
|
||||
for(const [k,v] of fd.entries()){
|
||||
obj[k] = v;
|
||||
}
|
||||
const bukaCheckbox = form.querySelector('input[name="buka_24_jam"]');
|
||||
if(bukaCheckbox) obj['buka_24_jam'] = bukaCheckbox.checked?1:0;
|
||||
|
||||
submitHandler(obj);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
document.getElementById('modal-cancel').onclick = function(){ closeModal(); pendingLayer=null; pendingType=null; };
|
||||
}
|
||||
|
||||
function closeModal(){
|
||||
try{ if(document.activeElement && typeof document.activeElement.blur === 'function') document.activeElement.blur(); }catch(e){}
|
||||
const modal = document.getElementById('modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden','true');
|
||||
document.getElementById('modal-fields').innerHTML = '';
|
||||
}
|
||||
|
||||
function handleModalSubmit(values){
|
||||
if(pendingType === 'lokasi'){
|
||||
const latlng = (typeof pendingLayer.getLatLng === 'function') ? pendingLayer.getLatLng() : pendingLayer;
|
||||
fetch('src/api/tambah_lokasi.php',{
|
||||
method:'POST',headers: buildHeaders(),
|
||||
body:JSON.stringify({
|
||||
nama: values.nama,
|
||||
jenis: values.jenis || 'rumah',
|
||||
no_telp: values.no_telp || '',
|
||||
buka_24_jam: values.buka_24_jam?1:0,
|
||||
alamat: values.alamat || '',
|
||||
latitude: latlng.lat,
|
||||
longitude: latlng.lng
|
||||
})
|
||||
}).then(res => handleApiResponse(res, () => {
|
||||
if(pendingLayer && drawnItems.hasLayer && drawnItems.hasLayer(pendingLayer)) drawnItems.removeLayer(pendingLayer);
|
||||
loadData();
|
||||
pendingLayer=null;
|
||||
pendingType=null;
|
||||
}));
|
||||
} else if(pendingType === 'jalan'){
|
||||
const geojson = pendingLayer.toGeoJSON();
|
||||
const panjang = parseFloat((document.querySelector('input[name="panjang"]').value)||0);
|
||||
const status = values.status || 'Kabupaten';
|
||||
const nama = values.nama || '';
|
||||
|
||||
let warna = status==='Nasional'?'red':(status==='Provinsi'?'blue':'green');
|
||||
pendingLayer.setStyle({color:warna});
|
||||
|
||||
fetch('src/api/tambah_jalan.php',{
|
||||
method:'POST',headers: buildHeaders(),
|
||||
body:JSON.stringify({nama:nama,status:status,panjang:panjang,geom:geojson.geometry})
|
||||
}).then(res => handleApiResponse(res, (resp) => {
|
||||
const newId = resp.id || null;
|
||||
if(drawnItems.hasLayer(pendingLayer)) drawnItems.removeLayer(pendingLayer);
|
||||
pendingLayer._fid = newId;
|
||||
const safeNama = escapeHtml(nama);
|
||||
const jsEscapedNama = safeNama.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
pendingLayer.bindPopup(`<b>${safeNama}</b><br>${status}<br>Panjang: ${panjang.toFixed(2)} m<br><br>` +
|
||||
`<button onclick="hapusJalan('${newId}')">Hapus</button>` +
|
||||
`<button onclick="editJalan('${newId}','${jsEscapedNama}', '${status}', ${panjang})">Edit</button>`);
|
||||
jalanGroup.addLayer(pendingLayer);
|
||||
pendingLayer=null; pendingType=null;
|
||||
}));
|
||||
} else if(pendingType === 'tanah'){
|
||||
const geojson = pendingLayer.toGeoJSON();
|
||||
const luas = parseFloat((document.querySelector('input[name="luas"]').value)||0);
|
||||
const status = values.status || 'SHM';
|
||||
const nama = values.nama || '';
|
||||
|
||||
let warna = status==='SHM'?'green':(status==='HGB'?'blue':(status==='HGU'?'orange':'purple'));
|
||||
pendingLayer.setStyle({color:warna});
|
||||
|
||||
fetch('src/api/tambah_tanah.php',{
|
||||
method:'POST',headers: buildHeaders(),
|
||||
body:JSON.stringify({nama:nama,status:status,luas:luas,geom:geojson.geometry})
|
||||
}).then(res => handleApiResponse(res, (resp) => {
|
||||
const newId = resp.id || null;
|
||||
if(drawnItems.hasLayer(pendingLayer)) drawnItems.removeLayer(pendingLayer);
|
||||
pendingLayer._fid = newId;
|
||||
const safeT = escapeHtml(nama);
|
||||
const jsEscapedT = safeT.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
pendingLayer.bindPopup(`<b>${safeT}</b><br>${status}<br>Luas: ${luas.toFixed(2)} m²<br><br>` +
|
||||
`<button onclick="hapusTanah('${newId}')">Hapus</button>` +
|
||||
`<button onclick="editTanah('${newId}','${jsEscapedT}', '${status}', ${luas})">Edit</button>`);
|
||||
tanahGroup.addLayer(pendingLayer);
|
||||
pendingLayer=null; pendingType=null;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ================= INIT =================
|
||||
updateApiKeyStatus();
|
||||
loadData();
|
||||
loadJalan();
|
||||
loadTanah();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,110 @@
|
||||
-- MySQL dump 10.13 Distrib 8.0.45, for Linux (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: webgis
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 8.0.45-0ubuntu0.22.04.1
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!50503 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||
|
||||
--
|
||||
-- Table structure for table `jalan`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `jalan`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `jalan` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(100) DEFAULT NULL,
|
||||
`status` varchar(50) DEFAULT NULL,
|
||||
`panjang` double DEFAULT NULL,
|
||||
`geom` text,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping data for table `jalan`
|
||||
--
|
||||
|
||||
LOCK TABLES `jalan` WRITE;
|
||||
/*!40000 ALTER TABLE `jalan` DISABLE KEYS */;
|
||||
/*!40000 ALTER TABLE `jalan` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
--
|
||||
-- Table structure for table `lokasi`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `lokasi`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `lokasi` (
|
||||
`id` varchar(8) NOT NULL,
|
||||
`nama` varchar(50) NOT NULL,
|
||||
`no_telp` varchar(20) DEFAULT NULL,
|
||||
`buka_24_jam` tinyint(1) DEFAULT NULL,
|
||||
`latitude` decimal(10,6) DEFAULT NULL,
|
||||
`longitude` decimal(10,6) DEFAULT NULL,
|
||||
`alamat` text,
|
||||
`jenis` varchar(50) NOT NULL DEFAULT 'spbu',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_lokasi_alamat` ((left(`alamat`,255)))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping data for table `lokasi`
|
||||
--
|
||||
|
||||
LOCK TABLES `lokasi` WRITE;
|
||||
/*!40000 ALTER TABLE `lokasi` DISABLE KEYS */;
|
||||
INSERT INTO `lokasi` VALUES ('5092','Rumah','',0,-0.040882,109.335197,'Daeng Abdul Hadi, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78117, Indonesia','rumah'),('7853','Masjid Mujahiddin','',1,-0.041462,109.336345,'Mujahiddin, Jalan Mujahidin, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','masjid'),('8476','SPBU OSO MT. Haryono','',0,-0.044863,109.336726,'SPBU OSO MT. Haryono, Jalan M.T. Haryono, Akcaya, Pontianak Selatan, Pontianak, Kalimantan Barat, Kalimantan, 78121, Indonesia','spbu');
|
||||
/*!40000 ALTER TABLE `lokasi` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
--
|
||||
-- Table structure for table `tanah`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tanah`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tanah` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`nama` varchar(100) DEFAULT NULL,
|
||||
`status` varchar(50) DEFAULT NULL,
|
||||
`luas` double DEFAULT NULL,
|
||||
`geom` text,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping data for table `tanah`
|
||||
--
|
||||
|
||||
LOCK TABLES `tanah` WRITE;
|
||||
/*!40000 ALTER TABLE `tanah` DISABLE KEYS */;
|
||||
/*!40000 ALTER TABLE `tanah` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2026-04-23 9:46:58
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$result = $conn->query("SELECT * FROM jalan");
|
||||
$data = [];
|
||||
|
||||
while($row = $result->fetch_assoc()){
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode($data);
|
||||
?>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$result = $conn->query("SELECT * FROM lokasi");
|
||||
|
||||
$data = [];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode($data);
|
||||
?>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$result = $conn->query("SELECT * FROM tanah");
|
||||
$data = [];
|
||||
|
||||
while($row = $result->fetch_assoc()){
|
||||
// log a short preview of geom for debugging
|
||||
@file_put_contents('/tmp/webgis_debug.log', date('c') . " get_tanah id=".$row['id']." geom_preview=".substr($row['geom'],0,400)."\n", FILE_APPEND);
|
||||
$data[] = $row;
|
||||
}
|
||||
|
||||
echo json_encode($data);
|
||||
?>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_api_key();
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = null;
|
||||
if($_SERVER['REQUEST_METHOD'] === 'DELETE'){
|
||||
$id = isset($input['id']) ? $input['id'] : null;
|
||||
} else {
|
||||
$id = isset($_POST['id']) ? $_POST['id'] : (isset($input['id']) ? $input['id'] : null);
|
||||
}
|
||||
|
||||
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
|
||||
$stmt = $conn->prepare('DELETE FROM jalan WHERE id = ?');
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param('s', $id);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){ echo json_encode(['success'=>true,'msg'=>'hapus']); }
|
||||
else { http_response_code(500); echo json_encode(['success'=>false,'error'=>'delete_failed']); }
|
||||
?>
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
// require API key for delete
|
||||
require_api_key();
|
||||
|
||||
// accept DELETE or POST with JSON body or form
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = null;
|
||||
if($_SERVER['REQUEST_METHOD'] === 'DELETE'){
|
||||
$id = isset($input['id']) ? $input['id'] : null;
|
||||
} else {
|
||||
$id = isset($_POST['id']) ? $_POST['id'] : (isset($input['id']) ? $input['id'] : null);
|
||||
}
|
||||
|
||||
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
|
||||
$stmt = $conn->prepare('DELETE FROM lokasi WHERE id = ?');
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param('s', $id);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){ echo json_encode(['success'=>true,'message'=>'hapus']); }
|
||||
else { http_response_code(500); echo json_encode(['success'=>false,'error'=>'delete_failed']); }
|
||||
?>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_api_key();
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = null;
|
||||
if($_SERVER['REQUEST_METHOD'] === 'DELETE'){
|
||||
$id = isset($input['id']) ? $input['id'] : null;
|
||||
} else {
|
||||
$id = isset($_POST['id']) ? $_POST['id'] : (isset($input['id']) ? $input['id'] : null);
|
||||
}
|
||||
|
||||
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
|
||||
$stmt = $conn->prepare('DELETE FROM tanah WHERE id = ?');
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param('s', $id);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){ echo json_encode(['success'=>true,'msg'=>'hapus']); }
|
||||
else { http_response_code(500); echo json_encode(['success'=>false,'error'=>'delete_failed']); }
|
||||
?>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_api_key();
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
if(!$data){
|
||||
http_response_code(400);
|
||||
echo json_encode(["success"=>false,"error"=>"invalid_json"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = isset($data['nama']) ? $data['nama'] : '';
|
||||
$status = isset($data['status']) ? $data['status'] : '';
|
||||
$panjang = isset($data['panjang']) ? floatval($data['panjang']) : 0;
|
||||
$geom = isset($data['geom']) ? json_encode($data['geom']) : null;
|
||||
|
||||
$stmt = $conn->prepare("INSERT INTO jalan (nama,status,panjang,geom) VALUES (?, ?, ?, ?)");
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param('sdds', $nama, $status, $panjang, $geom);
|
||||
$res = $stmt->execute();
|
||||
|
||||
if($res){
|
||||
$insert_id = $stmt->insert_id;
|
||||
echo json_encode(["success"=>true,"id"=>$insert_id]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(["success"=>false,"error"=>$stmt->error]);
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// require API key for create operations
|
||||
require_api_key();
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if(!$data){
|
||||
http_response_code(400);
|
||||
echo json_encode(["success"=>false,"error"=>"invalid_json"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// generate safer unique id
|
||||
$id = substr(uniqid('', true), -8);
|
||||
$nama = isset($data['nama']) ? $data['nama'] : '';
|
||||
$no_telp = isset($data['no_telp']) ? $data['no_telp'] : '';
|
||||
$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : 0;
|
||||
$alamat = isset($data['alamat']) ? $data['alamat'] : '';
|
||||
$jenis = isset($data['jenis']) ? $data['jenis'] : '';
|
||||
$lat = isset($data['latitude']) ? $data['latitude'] : null;
|
||||
$lng = isset($data['longitude']) ? $data['longitude'] : null;
|
||||
|
||||
// basic validation
|
||||
if($lat !== null){
|
||||
$lat = floatval($lat);
|
||||
if($lat < -90 || $lat > 90){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_latitude']); exit; }
|
||||
}
|
||||
if($lng !== null){
|
||||
$lng = floatval($lng);
|
||||
if($lng < -180 || $lng > 180){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_longitude']); exit; }
|
||||
}
|
||||
|
||||
// prepared statement
|
||||
$stmt = $conn->prepare("INSERT INTO lokasi (id, nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param('ssssisdd', $id, $nama, $jenis, $no_telp, $buka, $alamat, $lat, $lng);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){
|
||||
echo json_encode(["success"=>true, "id"=>$id]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(["success"=>false, "error"=> $conn->error]);
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_api_key();
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
if(!$data){
|
||||
http_response_code(400);
|
||||
echo json_encode(["success"=>false,"error"=>"invalid_json"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$nama = isset($data['nama']) ? $data['nama'] : '';
|
||||
$status = isset($data['status']) ? $data['status'] : '';
|
||||
$luas = isset($data['luas']) ? floatval($data['luas']) : 0;
|
||||
$geom = isset($data['geom']) ? json_encode($data['geom']) : null;
|
||||
|
||||
// prepared statement
|
||||
$stmt = $conn->prepare("INSERT INTO tanah (nama,status,luas,geom) VALUES (?, ?, ?, ?)");
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
$stmt->bind_param('sdis', $nama, $status, $luas, $geom);
|
||||
$res = $stmt->execute();
|
||||
|
||||
if($res){
|
||||
$insert_id = $stmt->insert_id;
|
||||
echo json_encode(["success"=>true,"id"=>$insert_id]);
|
||||
@file_put_contents('/tmp/webgis_debug.log', date('c') . " tambah_tanah OK id=".$insert_id." payload=".json_encode($data)."\n", FILE_APPEND);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(["success"=>false,"error"=>$stmt->error]);
|
||||
@file_put_contents('/tmp/webgis_debug.log', date('c') . " tambah_tanah ERR=".$stmt->error." payload=".json_encode($data)."\n", FILE_APPEND);
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_api_key();
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
if(!$data){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_json']); exit; }
|
||||
|
||||
$id = isset($data['id']) ? $data['id'] : null;
|
||||
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
|
||||
$sets = [];
|
||||
$types = '';
|
||||
$values = [];
|
||||
if(isset($data['nama'])){ $sets[]='nama = ?'; $types.='s'; $values[]=$data['nama']; }
|
||||
if(isset($data['status'])){ $sets[]='status = ?'; $types.='s'; $values[]=$data['status']; }
|
||||
if(isset($data['panjang'])){ $sets[]='panjang = ?'; $types.='d'; $values[]=$data['panjang']; }
|
||||
if(isset($data['geom'])){ $sets[]='geom = ?'; $types.='s'; $values[]=json_encode($data['geom']); }
|
||||
|
||||
if(count($sets) > 0){
|
||||
$sql = 'UPDATE jalan SET ' . implode(', ', $sets) . ' WHERE id = ?';
|
||||
$types .= 's';
|
||||
$values[] = $id;
|
||||
$stmt = $conn->prepare($sql);
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
$bind_names[] = $types;
|
||||
for($i=0;$i<count($values);$i++){ $bind_name='p'.$i; $$bind_name = $values[$i]; $bind_names[] = &$$bind_name; }
|
||||
call_user_func_array([$stmt, 'bind_param'], $bind_names);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
echo json_encode(["msg"=>"ok"]);
|
||||
?>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_api_key();
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
$id = isset($data['id']) ? $data['id'] : '';
|
||||
$nama = isset($data['nama']) ? $data['nama'] : null;
|
||||
$no_telp = isset($data['no_telp']) ? $data['no_telp'] : null;
|
||||
$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : null;
|
||||
$alamat = isset($data['alamat']) ? $data['alamat'] : null;
|
||||
$jenis = isset($data['jenis']) ? $data['jenis'] : null;
|
||||
|
||||
$sets = [];
|
||||
$types = '';
|
||||
$values = [];
|
||||
if($nama !== null){ $sets[] = 'nama = ?'; $types .= 's'; $values[] = $nama; }
|
||||
if($no_telp !== null){ $sets[] = 'no_telp = ?'; $types .= 's'; $values[] = $no_telp; }
|
||||
if($buka !== null){ $sets[] = 'buka_24_jam = ?'; $types .= 'i'; $values[] = $buka; }
|
||||
if($alamat !== null){ $sets[] = 'alamat = ?'; $types .= 's'; $values[] = $alamat; }
|
||||
if($jenis !== null){ $sets[] = 'jenis = ?'; $types .= 's'; $values[] = $jenis; }
|
||||
|
||||
if(empty($sets) || empty($id)){
|
||||
http_response_code(400);
|
||||
echo json_encode(['success'=>false,'error'=>'missing_id_or_fields']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = 'UPDATE lokasi SET ' . implode(', ', $sets) . ' WHERE id = ?';
|
||||
$types .= 's';
|
||||
$values[] = $id;
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
// bind params dynamically
|
||||
$bind_names[] = $types;
|
||||
for ($i=0; $i<count($values); $i++){
|
||||
$bind_name = 'bind' . $i;
|
||||
$$bind_name = $values[$i];
|
||||
$bind_names[] = &$$bind_name;
|
||||
}
|
||||
call_user_func_array([$stmt, 'bind_param'], $bind_names);
|
||||
$res = $stmt->execute();
|
||||
$stmt->close();
|
||||
|
||||
if($res){
|
||||
echo json_encode(["success"=>true]);
|
||||
} else {
|
||||
http_response_code(500);
|
||||
echo json_encode(["success"=>false, "error"=>$conn->error]);
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
include __DIR__ . '/../config/koneksi.php';
|
||||
include __DIR__ . '/../config/auth.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_api_key();
|
||||
|
||||
$data = json_decode(file_get_contents("php://input"), true);
|
||||
|
||||
$id = isset($data['id']) ? $data['id'] : null;
|
||||
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
|
||||
|
||||
$sets = [];
|
||||
$types = '';
|
||||
$values = [];
|
||||
if(isset($data['nama'])){ $sets[]='nama = ?'; $types.='s'; $values[]=$data['nama']; }
|
||||
if(isset($data['status'])){ $sets[]='status = ?'; $types.='s'; $values[]=$data['status']; }
|
||||
if(isset($data['luas'])){ $sets[]='luas = ?'; $types.='d'; $values[]=$data['luas']; }
|
||||
if(isset($data['geom'])){ $sets[]='geom = ?'; $types.='s'; $values[]=json_encode($data['geom']); }
|
||||
|
||||
if(count($sets) > 0){
|
||||
$sql = 'UPDATE tanah SET ' . implode(', ', $sets) . ' WHERE id = ?';
|
||||
$types .= 's';
|
||||
$values[] = $id;
|
||||
$stmt = $conn->prepare($sql);
|
||||
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
$bind_names[] = $types;
|
||||
for($i=0;$i<count($values);$i++){ $bind_name='b'.$i; $$bind_name = $values[$i]; $bind_names[] = &$$bind_name; }
|
||||
call_user_func_array([$stmt, 'bind_param'], $bind_names);
|
||||
$res = $stmt->execute();
|
||||
if($res){
|
||||
@file_put_contents('/tmp/webgis_debug.log', date('c') . " update_tanah OK id=".$id." payload=".json_encode($data)."\n", FILE_APPEND);
|
||||
} else {
|
||||
@file_put_contents('/tmp/webgis_debug.log', date('c') . " update_tanah ERR=".$stmt->error." payload=".json_encode($data)."\n", FILE_APPEND);
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
echo json_encode(["msg"=>"ok"]);
|
||||
?>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
include_once __DIR__ . '/config.php';
|
||||
|
||||
function get_request_api_key(){
|
||||
// check header first
|
||||
$headers = null;
|
||||
if(function_exists('getallheaders')){
|
||||
$headers = getallheaders();
|
||||
}
|
||||
if($headers){
|
||||
foreach($headers as $k => $v){
|
||||
if(strtolower($k) === 'x-api-key') return $v;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to header in $_SERVER
|
||||
if(isset($_SERVER['HTTP_X_API_KEY'])) return $_SERVER['HTTP_X_API_KEY'];
|
||||
|
||||
// fallback to JSON body or POST
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
if(isset($input['api_key'])) return $input['api_key'];
|
||||
if(isset($_POST['api_key'])) return $_POST['api_key'];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function require_api_key(){
|
||||
$key = get_request_api_key();
|
||||
if(!$key || $key !== API_KEY){
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['success'=>false,'error'=>'unauthorized']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// Load environment from project root .env if present, then read API_KEY
|
||||
function load_dotenv($path){
|
||||
if(!file_exists($path)) return;
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES);
|
||||
foreach($lines as $line){
|
||||
if(trim($line)==='' || strpos(trim($line),'#')===0) continue;
|
||||
if(strpos($line,'=')===false) continue;
|
||||
list($k,$v) = explode('=', $line, 2);
|
||||
$k = trim($k); $v = trim($v);
|
||||
// strip optional quotes
|
||||
if((substr($v,0,1)==='"' && substr($v,-1)==='"') || (substr($v,0,1)==="'" && substr($v,-1)==="'")){
|
||||
$v = substr($v,1,-1);
|
||||
}
|
||||
putenv("$k=$v");
|
||||
$_ENV[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
$envPath = realpath(__DIR__ . '/../../.env');
|
||||
if($envPath){ load_dotenv($envPath); }
|
||||
|
||||
$apiKey = getenv('API_KEY');
|
||||
if(!$apiKey) {
|
||||
// fallback (development) - but prefer .env or system env in production
|
||||
$apiKey = '8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c';
|
||||
}
|
||||
define('API_KEY', $apiKey);
|
||||
?>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
include_once __DIR__ . '/config.php';
|
||||
|
||||
$dbHost = getenv('DB_HOST') ?: 'localhost';
|
||||
$dbPort = (int)(getenv('DB_PORT') ?: 3306);
|
||||
$dbUser = getenv('DB_USER') ?: 'root';
|
||||
$dbPass = getenv('DB_PASS') ?: 'ilham';
|
||||
$dbName = getenv('DB_NAME') ?: 'spbu_db';
|
||||
|
||||
$conn = new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Koneksi gagal: " . $conn->connect_error);
|
||||
}
|
||||
?>
|
||||
Reference in New Issue
Block a user