Compare commits
17 Commits
66da318100
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 31cb3a7ad0 | |||
| 1ac0e76fc0 | |||
| c17a09d849 | |||
| d99d92a27c | |||
| 4fa2cd162c | |||
| a907d9ced2 | |||
| 23e7f159d6 | |||
| 4476152587 | |||
| 077d120586 | |||
| f59fd5a24a | |||
| 56bad098a3 | |||
| da9796755a | |||
| 4ad0081a02 | |||
| cb3e18fc64 | |||
| 4f52db834e | |||
| 1e0044c260 | |||
| 3d281d570e |
@@ -11,3 +11,6 @@
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# uploaded files
|
||||
**/uploads/
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
# Audit & Rencana Deployment: SIG UAS WebGIS
|
||||
|
||||
Dokumen ini berisi hasil audit teknis, analisis keamanan, dan rencana implementasi deployment untuk tugas akhir Sistem Informasi Geografis (SIG) yang terdiri dari dua project: **Poverty Mapping** (tugas utama) dan **Tugas SPBU** (tugas penunjang).
|
||||
|
||||
---
|
||||
|
||||
## 1. Analisis Struktur Project
|
||||
|
||||
Kedua project memiliki karakteristik teknologi yang hampir sama tetapi memiliki tingkat kematangan arsitektur yang berbeda.
|
||||
|
||||
### A. Folder `poverty-mapping` (Project Utama)
|
||||
* **Teknologi & Framework**: PHP Native untuk backend, HTML/CSS/JS native dengan Leaflet.js dan Leaflet Draw untuk frontend.
|
||||
* **Dependencies**:
|
||||
* `phpmailer/phpmailer` (pengiriman email verifikasi).
|
||||
* `vlucas/phpdotenv` (manajemen environment variables).
|
||||
* **Struktur Folder**:
|
||||
```
|
||||
poverty-mapping/
|
||||
├── src/
|
||||
│ ├── api/ # Rest API (tambah_lokasi, get_lokasi, dll.)
|
||||
│ │ ├── admin/ # Manajemen persetujuan manager oleh admin
|
||||
│ │ └── auth/ # Register, login, email verification
|
||||
│ └── config/ # auth.php, config.php, koneksi.php
|
||||
├── sql/migrations/ # 001_init_schema.sql (skema DB)
|
||||
├── uploads/ # Folder penyimpanan foto lokasi
|
||||
├── index.html # Frontend SPA utama
|
||||
└── composer.json # Deklarasi dependencies composer
|
||||
```
|
||||
* **Konfigurasi Database**: Menggunakan file `src/config/koneksi.php` yang membaca `getenv()` dari environment system dengan fallback ke kredensial default (`localhost`, `root`, `ilham`, `webgis`).
|
||||
* **Konfigurasi Environment**: Membaca `.env` dari root directory project menggunakan parser kustom di `src/config/config.php` serta autoloading library dotenv.
|
||||
* **Potensi Masalah**: Nama database default adalah `webgis` yang berpotensi konflik dengan project kedua.
|
||||
|
||||
### B. Folder `tugas-spbu` (Tugas Penunjang)
|
||||
* **Teknologi & Framework**: PHP Native untuk backend, HTML/CSS/JS native dengan Leaflet.js, Leaflet Draw, dan Leaflet GeometryUtil.
|
||||
* **Dependencies**: Tidak menggunakan Composer (pure PHP + JavaScript CDN).
|
||||
* **Struktur Folder**:
|
||||
```
|
||||
tugas-spbu/
|
||||
├── src/
|
||||
│ ├── api/ # Rest API (get_lokasi, tambah_jalan, dll.)
|
||||
│ └── config/ # auth.php, config.php, koneksi.php
|
||||
├── sql/ # webgis.sql (dump skema dan data awal)
|
||||
└── index.html # Frontend SPA utama
|
||||
```
|
||||
* **Konfigurasi Database**: **KREDENSIAL HARDCODED** di `src/config/koneksi.php`:
|
||||
```php
|
||||
$conn = new mysqli("localhost", "root", "ilham", "webgis2");
|
||||
```
|
||||
Ini adalah masalah kritis karena server tujuan deployment pasti menggunakan host, user, password, atau nama database yang berbeda.
|
||||
* **Konfigurasi Environment**: Hanya memiliki variabel `API_KEY` di `.env.example`.
|
||||
* **Potensi Masalah**:
|
||||
1. Koneksi database yang di-hardcode.
|
||||
2. Frontend (`index.html`) tidak memiliki antarmuka atau logika untuk memuat/menyimpan `API_KEY`, sehingga seluruh operasi write (tambah, edit, hapus) di server akan menghasilkan `401 Unauthorized` karena header `X-API-KEY` bernilai `undefined`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Landing Page Utama
|
||||
|
||||
Untuk menyatukan pintu masuk ke kedua project, opsi terbaik adalah menggunakan **Reverse Proxy (Nginx)** yang melayani Landing Page di root path (`/`) dan membelokkan traffic subpath ke container masing-masing aplikasi.
|
||||
|
||||
### Keuntungan Pendekatan Reverse Proxy:
|
||||
1. **Isolasi Kode**: Project `poverty-mapping` dan `tugas-spbu` tetap terpisah secara logika dan runtime di container-nya masing-masing.
|
||||
2. **Kemudahan Jalur URL**: Memungkinkan konfigurasi domain tunggal (misal: `sig.domain.com`) tanpa memerlukan subdomain terpisah untuk setiap aplikasi.
|
||||
3. **Bebas Konflik Cookie/Session**: Cookie PHP (`PHPSESSID`) dapat dipisah lingkup path-nya dengan aman.
|
||||
4. **Tanpa Mengubah Path Relatif Code**: Karena proxy Nginx melakukan rewrite prefix path, relative requests di browser (seperti `src/api/...`) tetap berjalan dengan aman.
|
||||
|
||||
### Rekomendasi Struktur Folder Root (Monorepo):
|
||||
```
|
||||
sig-uas/ # Workspace Root
|
||||
├── docker-compose.yml # Orkestrasi seluruh service (Nginx, App 1, App 2, MySQL)
|
||||
├── init-db.sql # Inisialisasi awal database
|
||||
├── nginx.conf # Konfigurasi reverse proxy dan landing page
|
||||
├── landing-page/ # Folder Landing Page utama
|
||||
│ ├── index.html # File HTML Landing Page premium
|
||||
│ └── style.css # Styling Landing Page
|
||||
├── poverty-mapping/ # Project 1
|
||||
│ ├── Dockerfile
|
||||
│ └── ...
|
||||
└── tugas-spbu/ # Project 2
|
||||
├── Dockerfile
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Persiapan Deployment (Readiness Audit)
|
||||
|
||||
Berikut adalah daftar temuan kritis yang wajib diperbaiki sebelum melakukan deployment:
|
||||
|
||||
| Temuan Masalah | Lokasi File | Tingkat Risiko | Dampak & Rekomendasi Perbaikan |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Kredensial Gmail Riil** | `poverty-mapping/.env` | **CRITICAL** | Terungkap `SMTP_USER` dan `SMTP_PASS` (App Password Gmail) secara tertulis di workspace. Hapus file `.env` dari upload repositori Git (masukkan ke `.gitignore`) dan masukkan password sebagai secrets di Coolify. |
|
||||
| **Database Kredensial Hardcoded** | `tugas-spbu/src/config/koneksi.php` | **HIGH** | Koneksi menggunakan parameter string statik. Ubah agar membaca dari environment variables (`getenv()`) dengan fallback ke konfigurasi lokal. |
|
||||
| **API Key Hilang di Frontend** | `tugas-spbu/index.html` | **HIGH** | JavaScript mencoba membaca variabel `API_KEY` global yang tidak pernah di-declare di script. Seluruh request mutasi akan ditolak API backend. Tambahkan pop-up input API Key di halaman untuk admin atau simpan di `localStorage`. |
|
||||
| **Penyimpanan Foto Gagal** | `poverty-mapping/src/api/upload_photo.php` | **MEDIUM** | Folder `uploads/` di server produksi harus dapat ditulisi oleh user web server (`www-data`). Perlu dipastikan folder di-mount dengan volume Docker yang memiliki hak akses `775`. |
|
||||
| **Expose File Dotfiles (`.env`, `.git`)** | Root / Project folders | **HIGH** | Jika server web salah konfigurasi, file `.env` dapat diunduh publik. Konfigurasikan Nginx/Apache untuk memblokir akses ke berkas tersembunyi (`~ /\.(?!well-known)`). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Analisis Keamanan (Security Audit)
|
||||
|
||||
Kami melakukan audit mendalam terhadap API dan frontend dari kedua aplikasi:
|
||||
|
||||
### A. SQL Injection (Risiko: LOW)
|
||||
* **Temuan**: Baik `poverty-mapping` maupun `tugas-spbu` telah menggunakan prepared statement (`$conn->prepare` dan `$stmt->bind_param`) untuk semua endpoint yang memproses data masukan pengguna.
|
||||
* **Rekomendasi**: Pertahankan standar ini. Hindari interpolasi string langsung (seperti `"$user_input"`) dalam query SQL.
|
||||
|
||||
### B. Stored XSS - Cross-Site Scripting (Risiko: HIGH)
|
||||
* **Temuan**: Di `tugas-spbu/index.html`, data lokasi yang diambil dari database dirender langsung ke dalam popups Leaflet tanpa escaping:
|
||||
```javascript
|
||||
marker.bindPopup(`<b>${item.nama}</b><br>...<div ...>${alamatEsc}</div>`);
|
||||
```
|
||||
Jika penyerang menyisipkan nama lokasi seperti `<img src=x onerror=alert(document.cookie)>`, script tersebut akan tereksekusi pada browser setiap pengunjung yang mengklik marker tersebut.
|
||||
* **Rekomendasi**: Buat fungsi pembantu `escapeHtml()` di `tugas-spbu/index.html` seperti yang sudah ada di `poverty-mapping/index.html`:
|
||||
```javascript
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
```
|
||||
Bungkus seluruh output variabel pengguna (`item.nama`, `item.alamat`) dengan fungsi tersebut saat melakukan `bindPopup`.
|
||||
|
||||
### C. CSRF - Cross-Site Request Forgery (Risiko: MEDIUM)
|
||||
* **Temuan**: Endpoint API di `poverty-mapping` dapat membaca autentikasi dari `$_SESSION['jwt']` (cookie-based). Karena tidak ada token anti-CSRF pada request mutasi, situs pihak ketiga yang dibuka di tab browser yang sama dapat memaksa browser mengirimkan request mutasi berotentikasi.
|
||||
* **Rekomendasi**: Ubah API untuk hanya menerima otentikasi melalui header HTTP `Authorization: Bearer <token>` (tidak membaca session cookie untuk API sensitif), karena header Authorization tidak akan ditransmisikan secara otomatis oleh browser pada request lintas situs (cross-site).
|
||||
|
||||
### D. File Upload Security (Risiko: MEDIUM)
|
||||
* **Temuan**: Di `poverty-mapping/src/api/upload_photo.php`, tipe file diverifikasi menggunakan MIME detection (`finfo_file`). Namun, sanitation filename:
|
||||
```php
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/','_',basename($file['name']));
|
||||
```
|
||||
Akan mengganti karakter titik (`.`) dengan underscore (`_`), sehingga ekstensi file akan hilang (misal `gambar.jpg` menjadi `gambar_jpg`). Hal ini aman dari eksekusi script PHP liar, tetapi dapat mengganggu pembacaan file gambar oleh browser jika web server tidak mengirimkan header Content-Type yang tepat.
|
||||
* **Rekomendasi**: Pisahkan proses sanitasi nama berkas dengan tetap mempertahankan ekstensi aslinya secara terpisah di bagian akhir nama berkas:
|
||||
```php
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { /* reject */ }
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', pathinfo($file['name'], PATHINFO_FILENAME)) . '.' . $ext;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Analisis Database
|
||||
|
||||
Kedua project menggunakan database dengan nama default `webgis` pada repositori asal mereka. Namun, keduanya memiliki struktur tabel `lokasi` yang berbeda:
|
||||
* `poverty-mapping.lokasi`: Menggunakan primary key `id INT AUTO_INCREMENT` dan menyimpan data KK, status kemiskinan, jumlah anggota keluarga, pendapatan, serta status bantuan sosial.
|
||||
* `tugas-spbu.lokasi`: Menggunakan primary key `id VARCHAR(8)` (UUID pendek) dan dirancang untuk memetakan koordinat SPBU, Masjid, dan Rumah.
|
||||
|
||||
### Rekomendasi Solusi:
|
||||
1. **Pemisahan Database**: Wajib dipisahkan menjadi dua database berbeda di server database MySQL yang sama:
|
||||
* Database 1: `poverty_db` (untuk Poverty Mapping)
|
||||
* Database 2: `spbu_db` (untuk Tugas SPBU)
|
||||
2. **Struktur Environment**:
|
||||
* Di server produksi, buat satu container MySQL yang menampung kedua database tersebut.
|
||||
* Konfigurasikan masing-masing aplikasi melalui environment variable `DB_NAME` untuk menunjuk ke database yang bersesuaian.
|
||||
|
||||
---
|
||||
|
||||
## 6. Analisis Docker
|
||||
|
||||
Sangat direkomendasikan untuk menggunakan Docker demi portabilitas dan kemudahan deployment di Coolify.
|
||||
|
||||
### Kelebihan Menggunakan Docker:
|
||||
* **Konsistensi**: Menjamin PHP 8.x dan modul `mysqli` terinstal dengan tepat di server tanpa perlu konfigurasi OS manual.
|
||||
* **Isolasi Dependensi**: Folder `uploads` dan file konfigurasi terisolasi penuh.
|
||||
* **Kemudahan Coolify**: Coolify secara native mendukung deployment berbasis Docker Compose. Kita hanya perlu mengarahkan Coolify ke repositori Git kita, dan Coolify akan membangun lingkungan lengkap secara otomatis.
|
||||
|
||||
### Rencana File Docker & Compose:
|
||||
|
||||
#### A. Dockerfile untuk `poverty-mapping` (`poverty-mapping/Dockerfile`)
|
||||
```dockerfile
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install ekstensi mysqli untuk PHP
|
||||
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
||||
|
||||
# Aktifkan mod_rewrite Apache untuk menangani routing .htaccess jika ada
|
||||
RUN a2enmod rewrite
|
||||
|
||||
# Salin source code project ke dalam container
|
||||
COPY . /var/www/html/
|
||||
|
||||
# Atur hak akses direktori uploads agar web server bisa menulis file foto
|
||||
RUN chown -R www-data:www-data /var/www/html && chmod -R 775 /var/www/html/uploads
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
#### B. Dockerfile untuk `tugas-spbu` (`tugas-spbu/Dockerfile`)
|
||||
```dockerfile
|
||||
FROM php:8.2-apache
|
||||
|
||||
# Install ekstensi mysqli
|
||||
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
|
||||
|
||||
# Salin source code
|
||||
COPY . /var/www/html/
|
||||
|
||||
# Atur hak akses
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
#### C. Root `docker-compose.yml`
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 1. Reverse Proxy & Landing Page
|
||||
landing-page:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./landing-page:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- poverty-mapping
|
||||
- tugas-spbu
|
||||
restart: always
|
||||
|
||||
# 2. Poverty Mapping Application
|
||||
poverty-mapping:
|
||||
build:
|
||||
context: ./poverty-mapping
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD}
|
||||
- DB_NAME=poverty_db
|
||||
- API_KEY=${POVERTY_API_KEY}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- INTERNAL_AUTH_KEY=${INTERNAL_AUTH_KEY}
|
||||
- BASIC_API_USER=webgis-api
|
||||
- BASIC_API_PASS=${BASIC_API_PASS}
|
||||
- SMTP_HOST=smtp.gmail.com
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- SMTP_ENCRYPTION=tls
|
||||
- MAIL_FROM=${SMTP_USER}
|
||||
- MAIL_FROM_NAME=PovertyMapping
|
||||
volumes:
|
||||
- poverty_uploads:/var/www/html/uploads
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 3. Tugas SPBU Application
|
||||
tugas-spbu:
|
||||
build:
|
||||
context: ./tugas-spbu
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD}
|
||||
- DB_NAME=spbu_db
|
||||
- API_KEY=${SPBU_API_KEY}
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 4. Database MySQL
|
||||
db:
|
||||
image: mysql:8.0
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
|
||||
- MYSQL_DATABASE=poverty_db
|
||||
- MYSQL_USER=webgis_user
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
poverty_uploads:
|
||||
```
|
||||
|
||||
#### D. Nginx Config (`nginx.conf`)
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 1. Landing Page Utama
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# 2. Route ke Poverty Mapping App
|
||||
location /poverty-mapping/ {
|
||||
proxy_pass http://poverty-mapping:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Scope cookies ke subpath agar tidak tabrakan session PHP-nya
|
||||
proxy_cookie_path / /poverty-mapping/;
|
||||
}
|
||||
|
||||
# 3. Route ke Tugas SPBU App
|
||||
location /tugas-spbu/ {
|
||||
proxy_pass http://tugas-spbu:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_cookie_path / /tugas-spbu/;
|
||||
}
|
||||
|
||||
# Blokir akses ke file .env / .git secara global di level proxy
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Identifikasi Role dan Akun Demo
|
||||
|
||||
Untuk memudahkan proses penilaian oleh Dosen, berikut adalah daftar peran (role) yang teridentifikasi beserta rancangan akun demonya.
|
||||
|
||||
### Daftar Peran (Roles):
|
||||
1. **Admin** (Poverty Mapping): Memiliki akses penuh terhadap visualisasi peta kemiskinan, serta persetujuan pembuatan akun manager baru.
|
||||
2. **Manager** (Poverty Mapping): Memiliki akses untuk menambah lokasi keluarga miskin, mengubah data kemiskinan, serta mengunggah foto bukti penyerahan bantuan sosial.
|
||||
3. **Guest / Publik** (Poverty Mapping): Hanya bisa melihat data peta dengan pembatasan/masking pada kolom sensitif (seperti Nama Kepala Keluarga, No Telepon, Catatan internal bantuan).
|
||||
4. **Admin SPBU** (Tugas SPBU): Menggunakan token `API_KEY` untuk menambah, mengedit, atau menghapus data spasial jalan, tanah, dan lokasi SPBU.
|
||||
|
||||
### Daftar Akun Demo (Password: `password`):
|
||||
|
||||
| Aplikasi | Role | Email / Akun | Password | Catatan Kredensial |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Poverty Mapping** | Admin | `admin@example.com` | `password` | Akun administrator utama untuk persetujuan akun Manager. |
|
||||
| **Poverty Mapping** | Manager | `manager@example.com` | `password` | Perwakilan role Manager untuk penambahan/pengeditan data kemiskinan. |
|
||||
| **Tugas SPBU** | Admin | *Token/Key* | `8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c` | Berupa token `API_KEY` yang di-input ke panel admin SPBU. |
|
||||
|
||||
### Mekanisme Seed SQL (`init-db.sql`):
|
||||
Ketika container MySQL dibangun pertama kali, script ini akan secara otomatis membuat kedua database, mengatur tabel awal, dan menaruh data akun demo dengan hash password yang valid untuk kata sandi `"password"` (yaitu `$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.`):
|
||||
|
||||
```sql
|
||||
-- Buat database jika belum ada
|
||||
CREATE DATABASE IF NOT EXISTS poverty_db;
|
||||
CREATE DATABASE IF NOT EXISTS spbu_db;
|
||||
|
||||
-- Pindah ke database poverty_db untuk inisialisasi tabel users dan admin default
|
||||
USE poverty_db;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) DEFAULT NULL,
|
||||
organization VARCHAR(255) DEFAULT NULL,
|
||||
org_address TEXT DEFAULT NULL,
|
||||
org_phone VARCHAR(32) DEFAULT NULL,
|
||||
org_proof_path VARCHAR(255) DEFAULT NULL,
|
||||
role ENUM('manager','admin') NOT NULL DEFAULT 'manager',
|
||||
status ENUM('pending','active','rejected','pending_verification') NOT NULL DEFAULT 'pending',
|
||||
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Insert akun demo Admin & Manager (Password: password)
|
||||
INSERT INTO users (email, password_hash, name, organization, role, status, email_verified)
|
||||
VALUES
|
||||
('admin@example.com', '$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.', 'Admin Demo', 'SIG WebGIS', 'admin', 'active', 1),
|
||||
('manager@example.com', '$2y$10$7YbX4D/QUo1OFJw.ZxyDl.plX03wY/7POuPNx3/ZfyX0vWwaKPNR.', 'Manager Demo', 'Pemberdayaan Masyarakat', 'manager', 'active', 1)
|
||||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), status = 'active';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Langkah Rencana Deployment Lengkap
|
||||
|
||||
Berikut adalah workflow deployment lengkap dari repositori lokal ke server Coolify:
|
||||
|
||||
### Langkah 1: Persiapan Repositori Git & Gitea
|
||||
1. **Inisialisasi Git Lokal**:
|
||||
Di root folder `/media/ilham/data/Code/sig-uas/`, jalankan:
|
||||
```bash
|
||||
git init
|
||||
```
|
||||
2. **Konfigurasi `.gitignore`**:
|
||||
Buat file `.gitignore` di root folder untuk mencegah data rahasia masuk repositori:
|
||||
```
|
||||
# Mengabaikan file environment lokal
|
||||
**/.env
|
||||
**/vendor/
|
||||
**/node_modules/
|
||||
.idea/
|
||||
.vscode/
|
||||
```
|
||||
3. **Commit Code**:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Initial commit for SIG UAS unified project"
|
||||
```
|
||||
4. **Buat Repositori Baru di Gitea**:
|
||||
* Login ke akun Gitea Anda.
|
||||
* Pilih **New Repository**, beri nama `sig-uas`.
|
||||
* Salin URL remote git (contoh: `http://gitea.domain.com/user/sig-uas.git`).
|
||||
5. **Push ke Gitea**:
|
||||
```bash
|
||||
git remote add origin <URL_GITEA>
|
||||
git branch -M main
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### Langkah 2: Setup Database & Environment Variables di Coolify
|
||||
1. Login ke dashboard **Coolify** Anda.
|
||||
2. Pilih / buat **Project** baru untuk SIG UAS.
|
||||
3. Sebelum meluncurkan container aplikasi, definisikan variabel environment (Secrets) yang dibutuhkan dalam menu konfigurasi environment di Coolify:
|
||||
* `DB_ROOT_PASSWORD`: Password root MySQL (bebas & aman).
|
||||
* `DB_PASSWORD`: Password user database MySQL.
|
||||
* `POVERTY_API_KEY`: API Key acak untuk Poverty Mapping.
|
||||
* `SPBU_API_KEY`: API Key acak untuk SPBU (`8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c` jika ingin mencocokkan default frontend).
|
||||
* `JWT_SECRET`: Secret key JWT acak.
|
||||
* `INTERNAL_AUTH_KEY`: Key autentikasi internal acak.
|
||||
* `SMTP_USER`: Alamat Gmail pengirim notifikasi.
|
||||
* `SMTP_PASS`: App password akun Gmail pengirim.
|
||||
|
||||
### Langkah 3: Setup Project di Coolify (Deployment)
|
||||
1. Di dalam Project Coolify, klik **Add Resource** -> **Docker Compose**.
|
||||
2. Masukkan detail Git Gitea Anda (Coolify akan menggunakan SSH key atau Credentials untuk membaca repositori).
|
||||
3. Masukkan path ke `docker-compose.yml` (secara default diisi `./docker-compose.yml`).
|
||||
4. Coolify secara otomatis membaca file compose tersebut.
|
||||
5. Konfigurasikan **Domains** pada service `landing-page` di panel Coolify (misal: `https://sig.domain.com`). Coolify akan secara otomatis membuatkan sertifikat SSL gratis menggunakan Let's Encrypt dan merutekannya ke port 80 milik service `landing-page`.
|
||||
6. Klik **Deploy**. Coolify akan:
|
||||
* Melakukan pull source code dari Gitea.
|
||||
* Membangun gambar Docker untuk Poverty Mapping dan Tugas SPBU.
|
||||
* Menyalakan database MySQL dan menjalankan inisialisasi SQL dari `init-db.sql`.
|
||||
* Menghidupkan proxy Nginx dan Landing Page.
|
||||
|
||||
### Langkah 4: Pengujian Pasca Deployment (Testing)
|
||||
1. **Akses Domain**: Buka `https://sig.domain.com` untuk memastikan landing page premium tampil.
|
||||
2. **Navigasi Poverty Mapping**:
|
||||
* Buka `https://sig.domain.com/poverty-mapping/`.
|
||||
* Klik **Masuk** dan login dengan akun demo `admin@example.com` (password: `password`).
|
||||
* Uji penambahan pin peta kemiskinan dan unggah foto.
|
||||
3. **Navigasi SPBU**:
|
||||
* Buka `https://sig.domain.com/tugas-spbu/`.
|
||||
* Uji kelancaran data spasial jalan, tanah, dan titik lokasi SPBU yang dimuat secara otomatis dari database.
|
||||
|
||||
### Langkah 5: Skema Backup dan Rollback
|
||||
1. **Backup Database & Data Volume**:
|
||||
* Coolify menyediakan fitur backup database MySQL bawaan yang berjalan secara periodik. Pastikan fitur ini diaktifkan dengan tujuan ke S3 storage atau direktori lokal server.
|
||||
* Volume `poverty_uploads` yang menyimpan gambar-gambar bukti foto kemiskinan disimpan di `/var/lib/docker/volumes/` pada host. Folder ini harus dimasukkan ke daftar rutin backup server.
|
||||
2. **Rollback Cepat**:
|
||||
* Jika terjadi masalah saat rilis pembaruan baru, buka tab **Deployments** di Coolify untuk service tersebut, lalu klik tombol **Redeploy** pada versi commit stabil sebelumnya.
|
||||
* Coolify akan langsung mematikan container yang bermasalah dan menghidupkan kembali container berbasis image stabil sebelumnya dalam waktu kurang dari 10 detik.
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM mysql:8.0
|
||||
|
||||
# Copy the initialization SQL script into the container
|
||||
COPY ./init-db.sql /docker-entrypoint-initdb.d/init-db.sql
|
||||
@@ -0,0 +1,81 @@
|
||||
services:
|
||||
# 1. Reverse Proxy & Landing Page (Local)
|
||||
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 (Local with hot-reload)
|
||||
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_HOST:-smtp.gmail.com}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_ENCRYPTION=${SMTP_ENCRYPTION:-tls}
|
||||
- MAIL_FROM=${MAIL_FROM:-}
|
||||
- MAIL_FROM_NAME=PovertyMapping
|
||||
volumes:
|
||||
- ./poverty-mapping/src:/var/www/html/src
|
||||
- poverty_uploads:/var/www/html/uploads
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 3. Tugas SPBU Application (Local with hot-reload)
|
||||
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}
|
||||
volumes:
|
||||
- ./tugas-spbu/src:/var/www/html/src
|
||||
depends_on:
|
||||
- db
|
||||
restart: always
|
||||
|
||||
# 4. Database MySQL (Local)
|
||||
db:
|
||||
image: mysql:8.0
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
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:
|
||||
+11
-13
@@ -1,12 +1,9 @@
|
||||
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
|
||||
build:
|
||||
context: .
|
||||
dockerfile: landing-page.Dockerfile
|
||||
depends_on:
|
||||
- poverty-mapping
|
||||
- tugas-spbu
|
||||
@@ -21,7 +18,7 @@ services:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD:-webgis_password}
|
||||
- DB_PASS=${DB_PASS:-webgis_password}
|
||||
- DB_NAME=poverty_db
|
||||
- API_KEY=${POVERTY_API_KEY:-8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c}
|
||||
- JWT_SECRET=${JWT_SECRET:-jwt_secret_dev_key_12345}
|
||||
@@ -50,7 +47,7 @@ services:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=webgis_user
|
||||
- DB_PASS=${DB_PASSWORD:-webgis_password}
|
||||
- DB_PASS=${DB_PASS:-webgis_password}
|
||||
- DB_NAME=spbu_db
|
||||
- API_KEY=${SPBU_API_KEY:-8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c}
|
||||
depends_on:
|
||||
@@ -59,16 +56,17 @@ services:
|
||||
|
||||
# 4. Database MySQL
|
||||
db:
|
||||
image: mysql:8.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: db.Dockerfile
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-root_password}
|
||||
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-root_password}
|
||||
- MYSQL_DATABASE=poverty_db
|
||||
- MYSQL_USER=webgis_user
|
||||
- MYSQL_PASSWORD=${DB_PASSWORD:-webgis_password}
|
||||
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-webgis_password}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||
- mysql_data_v2:/var/lib/mysql
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
|
||||
+12
@@ -9,6 +9,7 @@ DROP TABLE IF EXISTS bantuan_detail;
|
||||
DROP TABLE IF EXISTS lokasi;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS migrations;
|
||||
DROP TABLE IF EXISTS verification_attempts;
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
CREATE TABLE users (
|
||||
@@ -32,6 +33,7 @@ CREATE TABLE users (
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
approved_by INT UNSIGNED DEFAULT NULL,
|
||||
approved_at DATETIME DEFAULT NULL,
|
||||
rejection_reason TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_users_email (email),
|
||||
KEY idx_users_status (status),
|
||||
@@ -40,6 +42,16 @@ CREATE TABLE users (
|
||||
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 verification_attempts (
|
||||
email VARCHAR(255) NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
last_attempt_at DATETIME NOT NULL,
|
||||
locked_until DATETIME DEFAULT NULL,
|
||||
code_hash VARCHAR(128) DEFAULT NULL,
|
||||
expires_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (email)
|
||||
) 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy landing page static files
|
||||
COPY ./landing-page /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
+134
-57
@@ -102,8 +102,8 @@
|
||||
.layer-legend .legend-marker{display:inline-flex;align-items:flex-end;justify-content:center;width:16px;height:22px;flex:0 0 16px}
|
||||
.layer-legend .legend-marker svg{display:block;width:16px;height:22px}
|
||||
.layer-legend .legend-text{white-space:nowrap}
|
||||
.btn-primary{background:#2563eb;color:#fff;padding:8px 12px;border:0;border-radius:8px}
|
||||
.btn-secondary{background:#f3f4f6;color:#111;padding:8px 12px;border:0;border-radius:8px}
|
||||
.btn-primary{background:#2563eb;color:#fff;padding:8px 12px;border:0;border-radius:8px;cursor:pointer}
|
||||
.btn-secondary{background:#f3f4f6;color:#111;padding:8px 12px;border:0;border-radius:8px;cursor:pointer}
|
||||
.modal-actions{margin-top:14px;text-align:right}
|
||||
/* file name display next to file input */
|
||||
.file-name{display:inline-block;margin-left:8px;color:#374151;font-size:13px}
|
||||
@@ -125,9 +125,9 @@
|
||||
.modal-content{background:#fff;padding:18px;border-radius:10px;min-width:320px;max-width:520px;width:420px;max-height:80vh;overflow:auto}
|
||||
.modal-content label{display:block;margin-top:8px}
|
||||
.modal-actions{margin-top:12px;text-align:right}
|
||||
.btn-primary{background:#1976d2;color:#fff;padding:8px 12px;border:0;border-radius:6px}
|
||||
.btn-secondary{background:#eee;padding:8px 12px;border:0;border-radius:6px}
|
||||
.btn-destructive{background:#d32f2f;color:#fff;padding:8px 12px;border:0;border-radius:6px}
|
||||
.btn-primary{background:#1976d2;color:#fff;padding:8px 12px;border:0;border-radius:6px;cursor:pointer}
|
||||
.btn-secondary{background:#eee;padding:8px 12px;border:0;border-radius:6px;cursor:pointer}
|
||||
.btn-destructive{background:#d32f2f;color:#fff;padding:8px 12px;border:0;border-radius:6px;cursor:pointer}
|
||||
.btn-destructive:hover{opacity:0.95}
|
||||
.leaflet-popup-content{margin:14px 16px}
|
||||
.leaflet-popup-content-wrapper{border-radius:16px}
|
||||
@@ -241,6 +241,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-reject-modal" class="modal hidden" role="dialog" aria-hidden="true" style="z-index:11000">
|
||||
<div class="modal-content" style="max-width:480px;width:min(480px,90vw)">
|
||||
<h3>Alasan Penolakan</h3>
|
||||
<div style="font-size:13px;color:#475569;margin-bottom:12px">Silakan masukkan alasan penolakan untuk pengelola ini. Alasan akan dikirimkan ke email pendaftar.</div>
|
||||
<div style="margin-bottom:16px">
|
||||
<textarea id="admin-reject-reason-input" style="width:100%;height:100px;padding:8px;border:1px solid #cbd5e1;border-radius:6px;box-sizing:border-box" placeholder="Tulis alasan di sini..."></textarea>
|
||||
</div>
|
||||
<div class="modal-actions" style="display:flex;justify-content:flex-end;gap:8px">
|
||||
<button type="button" id="admin-reject-cancel" class="btn-secondary">Batal</button>
|
||||
<button type="button" id="admin-reject-submit" class="btn-destructive">Tolak Pendaftaran</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Verification Modal -->
|
||||
|
||||
<!-- Image viewer modal (separate from form modal) -->
|
||||
@@ -504,6 +518,10 @@ function openAuthModal(mode){
|
||||
const submitBtn = document.getElementById('auth-modal-submit');
|
||||
const modal = document.getElementById('auth-modal');
|
||||
|
||||
if(submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Reset registration verification state
|
||||
if(mode === 'login'){
|
||||
authRegisterState = { code_sent: false, code: null, email: null };
|
||||
@@ -585,14 +603,23 @@ function openAuthModal(mode){
|
||||
});
|
||||
}
|
||||
}
|
||||
form.onsubmit = async function(ev){
|
||||
const form = document.getElementById('auth-modal-form');
|
||||
if(form) form.onsubmit = async function(ev){
|
||||
ev.preventDefault();
|
||||
|
||||
let endpoint = 'src/api/auth/login.php';
|
||||
if(mode === 'register') endpoint = 'src/api/auth/register.php';
|
||||
if(mode === 'forgot_password') endpoint = 'src/api/auth/reset_password.php';
|
||||
const submitBtn = form.querySelector('button[type="submit"]') || document.getElementById('auth-modal-submit');
|
||||
const originalText = submitBtn ? submitBtn.textContent : 'Kirim';
|
||||
|
||||
try{
|
||||
if(submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Memproses...';
|
||||
}
|
||||
|
||||
try {
|
||||
let endpoint = 'src/api/auth/login.php';
|
||||
if(mode === 'register') endpoint = 'src/api/auth/register.php';
|
||||
if(mode === 'forgot_password') endpoint = 'src/api/auth/reset_password.php';
|
||||
|
||||
let resp, jr;
|
||||
if(mode === 'register' || mode === 'forgot_password'){
|
||||
// Validate password length and match
|
||||
@@ -603,24 +630,42 @@ function openAuthModal(mode){
|
||||
|
||||
if(password.length < 8){
|
||||
showToast('Password minimal harus 8 karakter', 'error', 5000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
if(password !== passwordConfirm){
|
||||
showToast('Konfirmasi password tidak cocok', 'error', 5000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
|
||||
if(mode === 'register'){
|
||||
// client-side validation for org_proof: size <=5MB and allowed extensions
|
||||
const fileInput = form.querySelector('input[name="org_proof"]') || document.getElementById('org_proof_input');
|
||||
if(!fileInput){ showToast('Input bukti organisasi tidak ditemukan', 'error', 4000); return; }
|
||||
if(!(fileInput.files && fileInput.files.length > 0)){ showToast('Mohon unggah bukti organisasi (foto/pdf).', 'error', 5000); return; }
|
||||
if(!fileInput){
|
||||
showToast('Input bukti organisasi tidak ditemukan', 'error', 4000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
if(!(fileInput.files && fileInput.files.length > 0)){
|
||||
showToast('Mohon unggah bukti organisasi (foto/pdf).', 'error', 5000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
const f = fileInput.files[0];
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
const allowed = ['jpg','jpeg','png','svg','pdf'];
|
||||
const ext = (f.name || '').split('.').pop().toLowerCase();
|
||||
if(f.size > maxSize){ showToast('Ukuran file bukti organisasi maksimal 5MB', 'error', 5000); return; }
|
||||
if(!allowed.includes(ext)){ showToast('Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.', 'error', 5000); return; }
|
||||
if(f.size > maxSize){
|
||||
showToast('Ukuran file bukti organisasi maksimal 5MB', 'error', 5000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
if(!allowed.includes(ext)){
|
||||
showToast('Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.', 'error', 5000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check verify code
|
||||
@@ -628,6 +673,7 @@ function openAuthModal(mode){
|
||||
const code = codeInput ? codeInput.value.trim() : '';
|
||||
if(!code || code.length !== 6 || !/^\d{6}$/.test(code)){
|
||||
showToast('Masukkan kode verifikasi 6 digit yang valid', 'error', 4000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -651,6 +697,7 @@ function openAuthModal(mode){
|
||||
}
|
||||
if(!resp.ok || !jr || jr.success === false){
|
||||
showToast((jr && (jr.error || jr.status || jr.message)) ? (jr.error || jr.status || jr.message) : 'Gagal memproses respons dari server', 'error', 5000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
return;
|
||||
}
|
||||
if(mode === 'register'){
|
||||
@@ -669,7 +716,11 @@ function openAuthModal(mode){
|
||||
showToast('Berhasil masuk', 'success', 3000);
|
||||
loadData();
|
||||
}
|
||||
}catch(e){ console.error(e); showToast('Gagal memproses autentikasi', 'error', 5000); }
|
||||
}catch(e){
|
||||
console.error(e);
|
||||
showToast('Gagal memproses autentikasi', 'error', 5000);
|
||||
if(submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalText; }
|
||||
}
|
||||
};
|
||||
|
||||
// Setup event listeners for registration and forgot password "Send Verification Code" buttons
|
||||
@@ -686,23 +737,15 @@ function openAuthModal(mode){
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side rate limiting check
|
||||
// Client-side cooldown check
|
||||
const limitKey = 'webgis_limit_' + btoa(email);
|
||||
let limitInfo = { attempts: 0, lastTime: 0, lockedUntil: 0 };
|
||||
let limitInfo = { lastTime: 0 };
|
||||
try{
|
||||
const cached = localStorage.getItem(limitKey);
|
||||
if(cached) limitInfo = JSON.parse(cached);
|
||||
}catch(e){}
|
||||
|
||||
const now = Date.now();
|
||||
if(limitInfo.lockedUntil && now < limitInfo.lockedUntil){
|
||||
const diff = limitInfo.lockedUntil - now;
|
||||
const hours = Math.floor(diff / (3600 * 1000));
|
||||
const mins = Math.ceil((diff % (3600 * 1000)) / (60 * 1000));
|
||||
showToast(`Batas pengiriman kode tercapai. Anda harus menunggu ${hours} jam ${mins} menit lagi.`, 'error', 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
if(limitInfo.lastTime && now - limitInfo.lastTime < 30 * 1000){
|
||||
const remaining = Math.ceil((30 * 1000 - (now - limitInfo.lastTime)) / 1000);
|
||||
showToast(`Silakan tunggu ${remaining} detik sebelum mengirim kembali.`, 'error', 3000);
|
||||
@@ -724,26 +767,13 @@ function openAuthModal(mode){
|
||||
if(!resp.ok || (jr && jr.success === false)){
|
||||
const errMessage = jr && jr.message ? jr.message : (jr && jr.error ? jr.error : 'Gagal mengirim kode');
|
||||
showToast(errMessage, 'error', 5000);
|
||||
|
||||
// If locked on server side, sync client side state
|
||||
if(jr && jr.error === 'locked_24h' && jr.locked_until){
|
||||
limitInfo.lockedUntil = jr.locked_until;
|
||||
try{ localStorage.setItem(limitKey, JSON.stringify(limitInfo)); }catch(e){}
|
||||
}
|
||||
|
||||
sendCodeBtn.disabled = false;
|
||||
sendCodeBtn.textContent = 'Kirim Kode Verifikasi';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update client side rate limiting on success
|
||||
limitInfo.attempts = (limitInfo.lockedUntil && now >= limitInfo.lockedUntil) ? 1 : (limitInfo.attempts + 1);
|
||||
// Update client side cooldown info on success
|
||||
limitInfo.lastTime = now;
|
||||
if(limitInfo.attempts >= 3){
|
||||
limitInfo.lockedUntil = now + 24 * 3600 * 1000;
|
||||
} else {
|
||||
limitInfo.lockedUntil = 0;
|
||||
}
|
||||
try{ localStorage.setItem(limitKey, JSON.stringify(limitInfo)); }catch(e){}
|
||||
|
||||
authRegisterState.code_sent = true;
|
||||
@@ -755,10 +785,10 @@ function openAuthModal(mode){
|
||||
codeInput.value = jr.debug_code;
|
||||
showToast('Gagal mengirim email. Menggunakan kode pengujian: ' + jr.debug_code, 'warning', 8000);
|
||||
}
|
||||
} else {
|
||||
showToast('Kode verifikasi terkirim ke email Anda! Silakan cek email Anda.', 'success', 6000);
|
||||
}
|
||||
|
||||
showToast('Kode verifikasi terkirim ke email Anda! Silakan cek email Anda.', 'success', 6000);
|
||||
|
||||
// Cooldown countdown timer
|
||||
let cooldown = 30;
|
||||
sendCodeBtn.disabled = true;
|
||||
@@ -798,7 +828,14 @@ function closeAuthModal(){
|
||||
modal.setAttribute('aria-hidden','true');
|
||||
// Reset form states
|
||||
const form = document.getElementById('auth-modal-form');
|
||||
if(form) form.reset();
|
||||
if(form) {
|
||||
form.reset();
|
||||
const submitBtn = form.querySelector('button[type="submit"]') || document.getElementById('auth-modal-submit');
|
||||
if(submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Kirim';
|
||||
}
|
||||
}
|
||||
authRegisterState = { code_sent: false, code: null, email: null };
|
||||
}
|
||||
|
||||
@@ -895,22 +932,61 @@ async function approvePendingUser(id){
|
||||
}catch(e){ console.error(e); showToast('Gagal menyetujui user', 'error', 4000); }
|
||||
}
|
||||
|
||||
async function rejectPendingUser(id){
|
||||
const reason = prompt("Masukkan alasan penolakan:");
|
||||
if (reason === null) return; // user cancelled
|
||||
const trimmedReason = reason.trim();
|
||||
if (!trimmedReason) {
|
||||
showToast("Alasan penolakan wajib diisi!", "error", 4000);
|
||||
return;
|
||||
function closeRejectModal(){
|
||||
const modal = document.getElementById('admin-reject-modal');
|
||||
if(modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden','true');
|
||||
}
|
||||
try{
|
||||
const resp = await fetch('src/api/admin/reject_user.php', { method: 'POST', headers: buildHeaders(), body: JSON.stringify({ id: id, reason: trimmedReason }), credentials: 'same-origin' });
|
||||
const jr = await resp.json().catch(()=>null);
|
||||
if(!resp.ok || !jr || !jr.success){ showToast('Gagal menolak user', 'error', 4000); return; }
|
||||
showToast('User ditolak', 'success', 3000);
|
||||
closePendingManagerDetail();
|
||||
openAdminDashboard();
|
||||
}catch(e){ console.error(e); showToast('Gagal menolak user', 'error', 4000); }
|
||||
}
|
||||
|
||||
function rejectPendingUser(id){
|
||||
const modal = document.getElementById('admin-reject-modal');
|
||||
const input = document.getElementById('admin-reject-reason-input');
|
||||
const submitBtn = document.getElementById('admin-reject-submit');
|
||||
const cancelBtn = document.getElementById('admin-reject-cancel');
|
||||
if(!modal || !input || !submitBtn || !cancelBtn) return;
|
||||
|
||||
input.value = '';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Tolak Pendaftaran';
|
||||
|
||||
cancelBtn.onclick = function(){
|
||||
closeRejectModal();
|
||||
};
|
||||
|
||||
submitBtn.onclick = async function(){
|
||||
const reason = input.value.trim();
|
||||
if(!reason){
|
||||
showToast('Alasan penolakan wajib diisi!', 'error', 4000);
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Memproses...';
|
||||
|
||||
try{
|
||||
const resp = await fetch('src/api/admin/reject_user.php', { method: 'POST', headers: buildHeaders(), body: JSON.stringify({ id: id, reason: reason }), credentials: 'same-origin' });
|
||||
const jr = await resp.json().catch(()=>null);
|
||||
if(!resp.ok || !jr || !jr.success){
|
||||
showToast('Gagal menolak user', 'error', 4000);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Tolak Pendaftaran';
|
||||
return;
|
||||
}
|
||||
showToast('User ditolak', 'success', 3000);
|
||||
closeRejectModal();
|
||||
closePendingManagerDetail();
|
||||
openAdminDashboard();
|
||||
}catch(e){
|
||||
console.error(e);
|
||||
showToast('Gagal menolak user', 'error', 4000);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Tolak Pendaftaran';
|
||||
}
|
||||
};
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden','false');
|
||||
}
|
||||
|
||||
function closeAdminDashboard(){
|
||||
@@ -2388,6 +2464,7 @@ try{
|
||||
document.getElementById('auth-modal').addEventListener('click', function(ev){ if(ev.target === this) closeAuthModal(); });
|
||||
document.getElementById('admin-modal').addEventListener('click', function(ev){ if(ev.target === this) closeAdminDashboard(); });
|
||||
document.getElementById('admin-detail-modal').addEventListener('click', function(ev){ if(ev.target === this) closePendingManagerDetail(); });
|
||||
document.getElementById('admin-reject-modal').addEventListener('click', function(ev){ if(ev.target === this) closeRejectModal(); });
|
||||
|
||||
// Admin dashboard wiring is handled by openAdminDashboard() above.
|
||||
}catch(e){}
|
||||
|
||||
@@ -8,7 +8,10 @@ require_role('admin');
|
||||
|
||||
// Ensure column exists
|
||||
try {
|
||||
$conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS rejection_reason TEXT NULL");
|
||||
$check = $conn->query("SHOW COLUMNS FROM users LIKE 'rejection_reason'");
|
||||
if($check && $check->num_rows === 0){
|
||||
$conn->query("ALTER TABLE users ADD COLUMN rejection_reason TEXT NULL");
|
||||
}
|
||||
} catch(Exception $e){}
|
||||
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -33,11 +36,10 @@ if($uStmt){
|
||||
$uStmt->close();
|
||||
}
|
||||
|
||||
// 2. Perform the update
|
||||
$st = $conn->prepare('UPDATE users SET status = "rejected", rejection_reason = ?, approved_by = ?, approved_at = NOW() WHERE id = ?');
|
||||
// 2. Perform the delete
|
||||
$st = $conn->prepare('DELETE FROM users 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('sii', $reason, $adminId, $id);
|
||||
$st->bind_param('i', $id);
|
||||
$res = $st->execute();
|
||||
$st->close();
|
||||
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
|
||||
|
||||
@@ -9,7 +9,7 @@ require_once __DIR__ . '/../../../vendor/autoload.php';
|
||||
$dotenvPath = __DIR__ . '/../../../';
|
||||
if(file_exists($dotenvPath . '.env')){
|
||||
try{
|
||||
$dotenv = Dotenv\Dotenv::createImmutable($dotenvPath);
|
||||
$dotenv = Dotenv\Dotenv::createUnsafeMutable($dotenvPath);
|
||||
$dotenv->safeLoad();
|
||||
}catch(Exception $e){}
|
||||
}
|
||||
@@ -34,12 +34,21 @@ function send_mail_phpmailer($to, $subject, $body, $isHtml = false){
|
||||
|
||||
if($smtpHost){
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $smtpHost;
|
||||
$mail->Host = gethostbyname($smtpHost); // Force IPv4
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $smtpUser;
|
||||
$mail->Password = $smtpPass;
|
||||
$mail->Port = intval($smtpPort);
|
||||
|
||||
// Bypass SSL verification issues with IP hostname in Docker
|
||||
$mail->SMTPOptions = array(
|
||||
'ssl' => array(
|
||||
'verify_peer' => false,
|
||||
'verify_peer_name' => false,
|
||||
'allow_self_signed' => true
|
||||
)
|
||||
);
|
||||
|
||||
if(strtolower($smtpEnc) === 'ssl'){
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} elseif(strtolower($smtpEnc) === 'tls'){
|
||||
|
||||
@@ -28,10 +28,10 @@ try {
|
||||
email VARCHAR(255) PRIMARY KEY,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
last_attempt_at DATETIME NOT NULL,
|
||||
locked_until DATETIME NULL
|
||||
locked_until DATETIME NULL,
|
||||
code_hash VARCHAR(128) NULL,
|
||||
expires_at DATETIME NULL
|
||||
)");
|
||||
$conn->query("ALTER TABLE verification_attempts ADD COLUMN IF NOT EXISTS code_hash VARCHAR(128) NULL");
|
||||
$conn->query("ALTER TABLE verification_attempts ADD COLUMN IF NOT EXISTS expires_at DATETIME NULL");
|
||||
} catch (Exception $e) {}
|
||||
|
||||
$nowStr = date('Y-m-d H:i:s');
|
||||
@@ -92,18 +92,21 @@ try{
|
||||
|
||||
// Update/Insert attempts on database
|
||||
if($row){
|
||||
$newAttempts = ($row['locked_until'] && $nowTime >= strtotime($row['locked_until'])) ? 1 : ($row['attempts'] + 1);
|
||||
$newLockedUntil = ($newAttempts >= 3) ? date('Y-m-d H:i:s', $nowTime + 24 * 3600) : null;
|
||||
$newAttempts = $row['attempts'];
|
||||
if($mailSuccess){
|
||||
$newAttempts = ($row['locked_until'] && $nowTime >= strtotime($row['locked_until'])) ? 1 : ($row['attempts'] + 1);
|
||||
}
|
||||
$newLockedUntil = ($newAttempts >= 3) ? date('Y-m-d H:i:s', $nowTime + 24 * 3600) : $row['locked_until'];
|
||||
|
||||
$up = $conn->prepare("UPDATE verification_attempts SET attempts = ?, last_attempt_at = ?, locked_until = ?, code_hash = ?, expires_at = ? WHERE email = ?");
|
||||
$up->bind_param('isssss', $newAttempts, $nowStr, $newLockedUntil, $codeHash, $expiresAt, $email);
|
||||
$up->execute();
|
||||
$up->close();
|
||||
} else {
|
||||
$one = 1;
|
||||
$attemptsVal = $mailSuccess ? 1 : 0;
|
||||
$nullVal = null;
|
||||
$ins = $conn->prepare("INSERT INTO verification_attempts (email, attempts, last_attempt_at, locked_until, code_hash, expires_at) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
$ins->bind_param('sissss', $email, $one, $nowStr, $nullVal, $codeHash, $expiresAt);
|
||||
$ins->bind_param('sissss', $email, $attemptsVal, $nowStr, $nullVal, $codeHash, $expiresAt);
|
||||
$ins->execute();
|
||||
$ins->close();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
ini_set('display_errors', '0');
|
||||
error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
include_once __DIR__ . '/config.php';
|
||||
|
||||
$dbHost = getenv('DB_HOST') ?: 'localhost';
|
||||
|
||||
Reference in New Issue
Block a user