commit a0c61f2bc2f3f1ff2346db5c02a6697f203be7a5 Author: ilham_gmail Date: Thu Jun 11 17:42:40 2026 +0700 Initial commit of unified WebGIS projects diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2e16bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# local environment configurations +**/.env +**/.env.local + +# dependencies +**/vendor/ +**/node_modules/ + +# OS/IDE files +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/audit_and_deployment_plan.md b/audit_and_deployment_plan.md new file mode 100644 index 0000000..4e32949 --- /dev/null +++ b/audit_and_deployment_plan.md @@ -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(`${item.nama}
...
${alamatEsc}
`); + ``` + Jika penyerang menyisipkan nama lokasi seperti ``, 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, "'"); + } + ``` + 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 ` (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 + 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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..59d6f04 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/init-db.sql b/init-db.sql new file mode 100644 index 0000000..cf6948d --- /dev/null +++ b/init-db.sql @@ -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; diff --git a/landing-page/index.html b/landing-page/index.html new file mode 100644 index 0000000..89cb884 --- /dev/null +++ b/landing-page/index.html @@ -0,0 +1,56 @@ + + + + + + WebGIS Portal - SIG UAS + + +

WebGIS Portal - Tugas Akhir SIG

+

Selamat datang di portal integrasi aplikasi Sistem Informasi Geografis (SIG) untuk UAS.

+ +

Daftar Aplikasi:

+ + +

Akun Demo Penilaian:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AplikasiRoleKredensial / EmailPassword / Token
Poverty MappingAdminadmin@example.compassword
Poverty MappingManagermanager@example.compassword
Tugas SPBUAdmin SPBUInput API Key di UI8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c
+ +
+
+
+

© 2026 Portal WebGIS UAS.

+
+ + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..f20905e --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/poverty-mapping/.env.example b/poverty-mapping/.env.example new file mode 100644 index 0000000..045c892 --- /dev/null +++ b/poverty-mapping/.env.example @@ -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 diff --git a/poverty-mapping/.gitignore b/poverty-mapping/.gitignore new file mode 100644 index 0000000..3b11003 --- /dev/null +++ b/poverty-mapping/.gitignore @@ -0,0 +1,9 @@ +/.env +/.env.local +/*.env +/uploads/ +/src/uploads/ +# ignore common editor temp files +*.swp +*.swo +.vscode/ \ No newline at end of file diff --git a/poverty-mapping/Dockerfile b/poverty-mapping/Dockerfile new file mode 100644 index 0000000..867d512 --- /dev/null +++ b/poverty-mapping/Dockerfile @@ -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 diff --git a/poverty-mapping/README.md b/poverty-mapping/README.md new file mode 100644 index 0000000..809a707 --- /dev/null +++ b/poverty-mapping/README.md @@ -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. diff --git a/poverty-mapping/composer.json b/poverty-mapping/composer.json new file mode 100644 index 0000000..5e867e9 --- /dev/null +++ b/poverty-mapping/composer.json @@ -0,0 +1,6 @@ +{ + "require": { + "phpmailer/phpmailer": "^7.1", + "vlucas/phpdotenv": "^5.6" + } +} diff --git a/poverty-mapping/composer.lock b/poverty-mapping/composer.lock new file mode 100644 index 0000000..1fdc4ce --- /dev/null +++ b/poverty-mapping/composer.lock @@ -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" +} diff --git a/poverty-mapping/docs/erd.md b/poverty-mapping/docs/erd.md new file mode 100644 index 0000000..e7d75ee --- /dev/null +++ b/poverty-mapping/docs/erd.md @@ -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. diff --git a/poverty-mapping/docs/openapi.js b/poverty-mapping/docs/openapi.js new file mode 100644 index 0000000..f817b89 --- /dev/null +++ b/poverty-mapping/docs/openapi.js @@ -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' } } + } + } + } +}; \ No newline at end of file diff --git a/poverty-mapping/docs/openapi.yaml b/poverty-mapping/docs/openapi.yaml new file mode 100644 index 0000000..b3a42c2 --- /dev/null +++ b/poverty-mapping/docs/openapi.yaml @@ -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 diff --git a/poverty-mapping/docs/swagger.html b/poverty-mapping/docs/swagger.html new file mode 100644 index 0000000..8929331 --- /dev/null +++ b/poverty-mapping/docs/swagger.html @@ -0,0 +1,37 @@ + + + + + + WebGIS API Docs + + + + +
+

WebGIS API Docs

+

OpenAPI documentation for auth, public map data, manager, and admin endpoints.

+
+
+ + + + + diff --git a/poverty-mapping/index.html b/poverty-mapping/index.html new file mode 100644 index 0000000..97d3aba --- /dev/null +++ b/poverty-mapping/index.html @@ -0,0 +1,2336 @@ + + + + + WebGIS + + + + + + + + +
+
+
+ Tamu + + + +
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + + + + +
+ + + + + + + + diff --git a/poverty-mapping/scripts/expire_assistance.php b/poverty-mapping/scripts/expire_assistance.php new file mode 100644 index 0000000..6e21bf8 --- /dev/null +++ b/poverty-mapping/scripts/expire_assistance.php @@ -0,0 +1,13 @@ +query($q)){ + echo "OK. Affected rows: " . $conn->affected_rows . "\n"; +} else { + echo "Failed: " . $conn->error . "\n"; +} +echo "Done.\n"; +?> diff --git a/poverty-mapping/scripts/run_migrations.php b/poverty-mapping/scripts/run_migrations.php new file mode 100644 index 0000000..275877c --- /dev/null +++ b/poverty-mapping/scripts/run_migrations.php @@ -0,0 +1,78 @@ +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"; diff --git a/poverty-mapping/sql/migrations/001_init_schema.sql b/poverty-mapping/sql/migrations/001_init_schema.sql new file mode 100644 index 0000000..9a29fa3 --- /dev/null +++ b/poverty-mapping/sql/migrations/001_init_schema.sql @@ -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); diff --git a/poverty-mapping/src/api/admin/approve_user.php b/poverty-mapping/src/api/admin/approve_user.php new file mode 100644 index 0000000..4c61db4 --- /dev/null +++ b/poverty-mapping/src/api/admin/approve_user.php @@ -0,0 +1,21 @@ +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]); +?> diff --git a/poverty-mapping/src/api/admin/list_pending_managers.php b/poverty-mapping/src/api/admin/list_pending_managers.php new file mode 100644 index 0000000..54b26f8 --- /dev/null +++ b/poverty-mapping/src/api/admin/list_pending_managers.php @@ -0,0 +1,17 @@ +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]); +?> diff --git a/poverty-mapping/src/api/admin/reject_user.php b/poverty-mapping/src/api/admin/reject_user.php new file mode 100644 index 0000000..fa807e9 --- /dev/null +++ b/poverty-mapping/src/api/admin/reject_user.php @@ -0,0 +1,21 @@ +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]); +?> diff --git a/poverty-mapping/src/api/auth/approve_manager.php b/poverty-mapping/src/api/auth/approve_manager.php new file mode 100644 index 0000000..e59f438 --- /dev/null +++ b/poverty-mapping/src/api/auth/approve_manager.php @@ -0,0 +1,21 @@ +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]); +?> \ No newline at end of file diff --git a/poverty-mapping/src/api/auth/login.php b/poverty-mapping/src/api/auth/login.php new file mode 100644 index 0000000..8b4182d --- /dev/null +++ b/poverty-mapping/src/api/auth/login.php @@ -0,0 +1,43 @@ +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 + ] +]); +?> diff --git a/poverty-mapping/src/api/auth/logout.php b/poverty-mapping/src/api/auth/logout.php new file mode 100644 index 0000000..37384fc --- /dev/null +++ b/poverty-mapping/src/api/auth/logout.php @@ -0,0 +1,7 @@ +true]); +?> diff --git a/poverty-mapping/src/api/auth/mail_helper.php b/poverty-mapping/src/api/auth/mail_helper.php new file mode 100644 index 0000000..50cd6d8 --- /dev/null +++ b/poverty-mapping/src/api/auth/mail_helper.php @@ -0,0 +1,102 @@ +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; + } +} + +?> diff --git a/poverty-mapping/src/api/auth/me.php b/poverty-mapping/src/api/auth/me.php new file mode 100644 index 0000000..1db5a1e --- /dev/null +++ b/poverty-mapping/src/api/auth/me.php @@ -0,0 +1,8 @@ +false]); exit; } +echo json_encode(['authenticated'=>true,'user'=>$u,'mode'=>auth_current_mode()]); +?> diff --git a/poverty-mapping/src/api/auth/pending_managers.php b/poverty-mapping/src/api/auth/pending_managers.php new file mode 100644 index 0000000..0e6487e --- /dev/null +++ b/poverty-mapping/src/api/auth/pending_managers.php @@ -0,0 +1,18 @@ +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]); +?> \ No newline at end of file diff --git a/poverty-mapping/src/api/auth/register.php b/poverty-mapping/src/api/auth/register.php new file mode 100644 index 0000000..ccdf6a4 --- /dev/null +++ b/poverty-mapping/src/api/auth/register.php @@ -0,0 +1,113 @@ +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']); +?> + diff --git a/poverty-mapping/src/api/auth/reject_manager.php b/poverty-mapping/src/api/auth/reject_manager.php new file mode 100644 index 0000000..52ede6e --- /dev/null +++ b/poverty-mapping/src/api/auth/reject_manager.php @@ -0,0 +1,21 @@ +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]); +?> \ No newline at end of file diff --git a/poverty-mapping/src/api/auth/send_verification_code.php b/poverty-mapping/src/api/auth/send_verification_code.php new file mode 100644 index 0000000..b1ed457 --- /dev/null +++ b/poverty-mapping/src/api/auth/send_verification_code.php @@ -0,0 +1,46 @@ +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); +?> diff --git a/poverty-mapping/src/api/geocode.php b/poverty-mapping/src/api/geocode.php new file mode 100644 index 0000000..811b5f4 --- /dev/null +++ b/poverty-mapping/src/api/geocode.php @@ -0,0 +1,183 @@ +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; diff --git a/poverty-mapping/src/api/get_client_config.php b/poverty-mapping/src/api/get_client_config.php new file mode 100644 index 0000000..44550b2 --- /dev/null +++ b/poverty-mapping/src/api/get_client_config.php @@ -0,0 +1,9 @@ +null]); + +?> diff --git a/poverty-mapping/src/api/get_lokasi.php b/poverty-mapping/src/api/get_lokasi.php new file mode 100644 index 0000000..381b50c --- /dev/null +++ b/poverty-mapping/src/api/get_lokasi.php @@ -0,0 +1,79 @@ +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); +?> diff --git a/poverty-mapping/src/api/hapus_lokasi.php b/poverty-mapping/src/api/hapus_lokasi.php new file mode 100644 index 0000000..37b73ad --- /dev/null +++ b/poverty-mapping/src/api/hapus_lokasi.php @@ -0,0 +1,98 @@ +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']); } +?> diff --git a/poverty-mapping/src/api/tambah_lokasi.php b/poverty-mapping/src/api/tambah_lokasi.php new file mode 100644 index 0000000..8add1e7 --- /dev/null +++ b/poverty-mapping/src/api/tambah_lokasi.php @@ -0,0 +1,194 @@ +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;$iexecute(); + $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;$iexecute(); + $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)); +?> diff --git a/poverty-mapping/src/api/update_lokasi.php b/poverty-mapping/src/api/update_lokasi.php new file mode 100644 index 0000000..f6fe977 --- /dev/null +++ b/poverty-mapping/src/api/update_lokasi.php @@ -0,0 +1,277 @@ +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; $iexecute(); +$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]); +} + +?> diff --git a/poverty-mapping/src/api/upload_photo.php b/poverty-mapping/src/api/upload_photo.php new file mode 100644 index 0000000..f4fb38d --- /dev/null +++ b/poverty-mapping/src/api/upload_photo.php @@ -0,0 +1,142 @@ +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]); +} + +?> diff --git a/poverty-mapping/src/config/auth.php b/poverty-mapping/src/config/auth.php new file mode 100644 index 0000000..567e301 --- /dev/null +++ b/poverty-mapping/src/config/auth.php @@ -0,0 +1,271 @@ + $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]); } +} diff --git a/poverty-mapping/src/config/config.php b/poverty-mapping/src/config/config.php new file mode 100644 index 0000000..b5b21f1 --- /dev/null +++ b/poverty-mapping/src/config/config.php @@ -0,0 +1,49 @@ + diff --git a/poverty-mapping/src/config/koneksi.php b/poverty-mapping/src/config/koneksi.php new file mode 100644 index 0000000..2423fa1 --- /dev/null +++ b/poverty-mapping/src/config/koneksi.php @@ -0,0 +1,15 @@ +connect_error) { + die("Koneksi gagal: " . $conn->connect_error); +} +?> diff --git a/poverty-mapping/uploads b/poverty-mapping/uploads new file mode 120000 index 0000000..0991b2f --- /dev/null +++ b/poverty-mapping/uploads @@ -0,0 +1 @@ +/home/ilham/webgis_uploads \ No newline at end of file diff --git a/tugas-spbu/.env.example b/tugas-spbu/.env.example new file mode 100644 index 0000000..1eaf75d --- /dev/null +++ b/tugas-spbu/.env.example @@ -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 diff --git a/tugas-spbu/.gitignore b/tugas-spbu/.gitignore new file mode 100644 index 0000000..e7c49e6 --- /dev/null +++ b/tugas-spbu/.gitignore @@ -0,0 +1,7 @@ +/.env +/.env.local +/*.env +# ignore common editor temp files +*.swp +*.swo +.vscode/ \ No newline at end of file diff --git a/tugas-spbu/Dockerfile b/tugas-spbu/Dockerfile new file mode 100644 index 0000000..93b506b --- /dev/null +++ b/tugas-spbu/Dockerfile @@ -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 diff --git a/tugas-spbu/README.md b/tugas-spbu/README.md new file mode 100644 index 0000000..c1b1521 --- /dev/null +++ b/tugas-spbu/README.md @@ -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 + +``` + +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). + diff --git a/tugas-spbu/index.html b/tugas-spbu/index.html new file mode 100644 index 0000000..322427c --- /dev/null +++ b/tugas-spbu/index.html @@ -0,0 +1,778 @@ + + + + WebGIS + + + + + + + + + + + + + + + + + + + + + + + +
+ Guest Mode + +
+ +
+ + + + \ No newline at end of file diff --git a/tugas-spbu/sql/webgis.sql b/tugas-spbu/sql/webgis.sql new file mode 100644 index 0000000..d45c174 --- /dev/null +++ b/tugas-spbu/sql/webgis.sql @@ -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 diff --git a/tugas-spbu/src/api/get_jalan.php b/tugas-spbu/src/api/get_jalan.php new file mode 100644 index 0000000..dd8b3b1 --- /dev/null +++ b/tugas-spbu/src/api/get_jalan.php @@ -0,0 +1,13 @@ +query("SELECT * FROM jalan"); +$data = []; + +while($row = $result->fetch_assoc()){ + $data[] = $row; +} + +echo json_encode($data); +?> diff --git a/tugas-spbu/src/api/get_lokasi.php b/tugas-spbu/src/api/get_lokasi.php new file mode 100644 index 0000000..bb03d5d --- /dev/null +++ b/tugas-spbu/src/api/get_lokasi.php @@ -0,0 +1,14 @@ +query("SELECT * FROM lokasi"); + +$data = []; + +while ($row = $result->fetch_assoc()) { + $data[] = $row; +} + +echo json_encode($data); +?> diff --git a/tugas-spbu/src/api/get_tanah.php b/tugas-spbu/src/api/get_tanah.php new file mode 100644 index 0000000..c3ada9d --- /dev/null +++ b/tugas-spbu/src/api/get_tanah.php @@ -0,0 +1,15 @@ +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); +?> diff --git a/tugas-spbu/src/api/hapus_jalan.php b/tugas-spbu/src/api/hapus_jalan.php new file mode 100644 index 0000000..e657531 --- /dev/null +++ b/tugas-spbu/src/api/hapus_jalan.php @@ -0,0 +1,26 @@ +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']); } +?> diff --git a/tugas-spbu/src/api/hapus_lokasi.php b/tugas-spbu/src/api/hapus_lokasi.php new file mode 100644 index 0000000..571c0d3 --- /dev/null +++ b/tugas-spbu/src/api/hapus_lokasi.php @@ -0,0 +1,28 @@ +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']); } +?> diff --git a/tugas-spbu/src/api/hapus_tanah.php b/tugas-spbu/src/api/hapus_tanah.php new file mode 100644 index 0000000..6a72f60 --- /dev/null +++ b/tugas-spbu/src/api/hapus_tanah.php @@ -0,0 +1,26 @@ +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']); } +?> diff --git a/tugas-spbu/src/api/tambah_jalan.php b/tugas-spbu/src/api/tambah_jalan.php new file mode 100644 index 0000000..f369f6a --- /dev/null +++ b/tugas-spbu/src/api/tambah_jalan.php @@ -0,0 +1,36 @@ +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(); + +?> diff --git a/tugas-spbu/src/api/tambah_lokasi.php b/tugas-spbu/src/api/tambah_lokasi.php new file mode 100644 index 0000000..f5a6bcc --- /dev/null +++ b/tugas-spbu/src/api/tambah_lokasi.php @@ -0,0 +1,50 @@ +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]); +} +?> diff --git a/tugas-spbu/src/api/tambah_tanah.php b/tugas-spbu/src/api/tambah_tanah.php new file mode 100644 index 0000000..2ec693f --- /dev/null +++ b/tugas-spbu/src/api/tambah_tanah.php @@ -0,0 +1,39 @@ +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(); + +?> diff --git a/tugas-spbu/src/api/update_jalan.php b/tugas-spbu/src/api/update_jalan.php new file mode 100644 index 0000000..1c3c88c --- /dev/null +++ b/tugas-spbu/src/api/update_jalan.php @@ -0,0 +1,36 @@ +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;$iexecute(); + $stmt->close(); +} + +echo json_encode(["msg"=>"ok"]); +?> diff --git a/tugas-spbu/src/api/update_lokasi.php b/tugas-spbu/src/api/update_lokasi.php new file mode 100644 index 0000000..a870f19 --- /dev/null +++ b/tugas-spbu/src/api/update_lokasi.php @@ -0,0 +1,57 @@ +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; $iexecute(); +$stmt->close(); + +if($res){ + echo json_encode(["success"=>true]); +} else { + http_response_code(500); + echo json_encode(["success"=>false, "error"=>$conn->error]); +} + +?> diff --git a/tugas-spbu/src/api/update_tanah.php b/tugas-spbu/src/api/update_tanah.php new file mode 100644 index 0000000..2f783f6 --- /dev/null +++ b/tugas-spbu/src/api/update_tanah.php @@ -0,0 +1,40 @@ +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;$iexecute(); + 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"]); +?> diff --git a/tugas-spbu/src/config/auth.php b/tugas-spbu/src/config/auth.php new file mode 100644 index 0000000..f329a75 --- /dev/null +++ b/tugas-spbu/src/config/auth.php @@ -0,0 +1,35 @@ + $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; + } +} diff --git a/tugas-spbu/src/config/config.php b/tugas-spbu/src/config/config.php new file mode 100644 index 0000000..998cefc --- /dev/null +++ b/tugas-spbu/src/config/config.php @@ -0,0 +1,29 @@ + diff --git a/tugas-spbu/src/config/koneksi.php b/tugas-spbu/src/config/koneksi.php new file mode 100644 index 0000000..6204b64 --- /dev/null +++ b/tugas-spbu/src/config/koneksi.php @@ -0,0 +1,15 @@ +connect_error) { + die("Koneksi gagal: " . $conn->connect_error); +} +?>