Initial commit of unified WebGIS projects

This commit is contained in:
ilham_gmail
2026-06-11 17:42:40 +07:00
commit a0c61f2bc2
64 changed files with 7924 additions and 0 deletions
+29
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
/.env
/.env.local
/*.env
/uploads/
/src/uploads/
# ignore common editor temp files
*.swp
*.swo
.vscode/
+15
View File
@@ -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
+141
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"require": {
"phpmailer/phpmailer": "^7.1",
"vlucas/phpdotenv": "^5.6"
}
}
+574
View File
@@ -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"
}
+76
View File
@@ -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.
+156
View File
@@ -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' } }
}
}
}
};
+208
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGIS API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body{margin:0;background:#0f172a;font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
.topbar{padding:18px 20px;background:linear-gradient(135deg,#0f172a,#1e293b);color:#fff;border-bottom:1px solid rgba(255,255,255,0.08)}
.topbar h1{margin:0;font-size:20px}
.topbar p{margin:6px 0 0;color:#cbd5e1;font-size:13px}
#swagger-ui{background:#fff}
</style>
</head>
<body>
<div class="topbar">
<h1>WebGIS API Docs</h1>
<p>OpenAPI documentation for auth, public map data, manager, and admin endpoints.</p>
</div>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="openapi.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({
spec: window.webgisOpenApiSpec,
dom_id: '#swagger-ui',
deepLinking: true,
displayRequestDuration: true,
presets: [SwaggerUIBundle.presets.apis],
layout: 'BaseLayout'
});
};
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
<?php
include __DIR__ . '/../src/config/koneksi.php';
header('Content-Type: text/plain; charset=utf-8');
echo "Running assistance expiry (30 days)...\n";
$q = "UPDATE lokasi SET assisted = 0, last_assisted_at = NULL, sumber_bantuan = NULL, bentuk_bantuan = NULL, assistance_notes = NULL WHERE assisted = 1 AND last_assisted_at IS NOT NULL AND last_assisted_at <= DATE_SUB(NOW(), INTERVAL 30 DAY)";
if($conn->query($q)){
echo "OK. Affected rows: " . $conn->affected_rows . "\n";
} else {
echo "Failed: " . $conn->error . "\n";
}
echo "Done.\n";
?>
@@ -0,0 +1,78 @@
<?php
// Simple migration runner for the current WebGIS schema.
// Run from the project root:
// php scripts/run_migrations.php
include __DIR__ . '/../src/config/koneksi.php';
header('Content-Type: text/plain; charset=utf-8');
$conn->query("CREATE TABLE IF NOT EXISTS migrations (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, applied_at DATETIME NOT NULL)");
function migration_applied($conn, $name){
$st = $conn->prepare('SELECT COUNT(*) AS c FROM migrations WHERE name = ?');
if(!$st){ return false; }
$st->bind_param('s', $name);
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
return $row && intval($row['c']) > 0;
}
function run_migration_file($conn, $path){
$name = basename($path);
if(migration_applied($conn, $name)){
echo "Skipping already applied: $name\n";
return true;
}
$sql = file_get_contents($path);
if($sql === false){
echo "Failed to read $name\n";
return false;
}
if(!$conn->multi_query($sql)){
echo "Failed executing $name: " . $conn->error . "\n";
return false;
}
do {
if($res = $conn->store_result()){
$res->free();
}
} while($conn->more_results() && $conn->next_result());
if($conn->errno){
echo "Failed executing $name: " . $conn->error . "\n";
return false;
}
$st = $conn->prepare('INSERT INTO migrations (name, applied_at) VALUES (?, NOW())');
if($st){
$st->bind_param('s', $name);
$st->execute();
$st->close();
}
echo "Applied: $name\n";
return true;
}
$migDir = __DIR__ . '/../sql/migrations';
$files = array_values(array_filter(scandir($migDir), function($f){ return preg_match('/^\d+_.*\.sql$/', $f); }));
sort($files, SORT_NATURAL);
if(empty($files)){
echo "No migration files found.\n";
exit(0);
}
foreach($files as $file){
if(!run_migration_file($conn, $migDir . '/' . $file)){
exit(1);
}
}
echo "Migrations complete.\n";
@@ -0,0 +1,101 @@
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS bantuan_detail;
DROP TABLE IF EXISTS lokasi;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS migrations;
SET FOREIGN_KEY_CHECKS = 1;
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) DEFAULT NULL,
organization VARCHAR(255) DEFAULT NULL,
org_address TEXT DEFAULT NULL,
org_phone VARCHAR(32) DEFAULT NULL,
org_proof_path VARCHAR(255) DEFAULT NULL,
role ENUM('manager','admin') NOT NULL DEFAULT 'manager',
status ENUM('pending','active','rejected','pending_verification') NOT NULL DEFAULT 'pending',
email_verified TINYINT(1) NOT NULL DEFAULT 0,
email_verification_token_hash VARCHAR(128) DEFAULT NULL,
email_verification_expires DATETIME DEFAULT NULL,
email_verification_sent_at DATETIME DEFAULT NULL,
email_verification_attempts INT NOT NULL DEFAULT 0,
email_verification_last_sent DATETIME DEFAULT NULL,
email_verification_locked_until DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
approved_by INT UNSIGNED DEFAULT NULL,
approved_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_users_email (email),
KEY idx_users_status (status),
KEY idx_users_role (role),
KEY idx_users_approved_by (approved_by),
CONSTRAINT fk_users_approved_by FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE lokasi (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nama VARCHAR(255) NOT NULL,
no_telp VARCHAR(32) DEFAULT NULL,
buka_24_jam TINYINT(1) NOT NULL DEFAULT 0,
latitude DECIMAL(10,6) DEFAULT NULL,
longitude DECIMAL(10,6) DEFAULT NULL,
alamat TEXT,
jenis VARCHAR(50) NOT NULL,
nama_kk VARCHAR(255) DEFAULT NULL,
rumah_status VARCHAR(100) DEFAULT NULL,
members INT DEFAULT NULL,
monthly_income DECIMAL(18,0) DEFAULT NULL,
assisted TINYINT(1) NOT NULL DEFAULT 0,
last_assisted_at DATETIME DEFAULT NULL,
assistance_notes TEXT DEFAULT NULL,
photo_path VARCHAR(255) DEFAULT NULL,
sumber_bantuan INT UNSIGNED DEFAULT NULL,
bentuk_bantuan VARCHAR(100) DEFAULT NULL,
created_by INT UNSIGNED DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_lokasi_assisted (assisted),
KEY idx_lokasi_jenis (jenis),
KEY idx_lokasi_sumber_bantuan (sumber_bantuan),
KEY idx_lokasi_created_by (created_by),
KEY idx_lokasi_last_assisted_at (last_assisted_at),
CONSTRAINT fk_lokasi_sumber_bantuan FOREIGN KEY (sumber_bantuan) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT fk_lokasi_created_by FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE bantuan_detail (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
lokasi_id INT UNSIGNED NOT NULL,
tanggal_bantuan DATE DEFAULT NULL,
pemberi_bantuan VARCHAR(255) DEFAULT NULL,
catatan TEXT DEFAULT NULL,
pemberi_lokasi_id INT UNSIGNED DEFAULT NULL,
jumlah INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_bantuan_lokasi (lokasi_id),
KEY idx_bantuan_pemberi_lokasi (pemberi_lokasi_id),
KEY idx_bantuan_tanggal (tanggal_bantuan),
KEY idx_bantuan_lokasi_tanggal (lokasi_id, tanggal_bantuan),
KEY idx_bantuan_pemberi_tanggal (pemberi_lokasi_id, tanggal_bantuan),
CONSTRAINT fk_bantuan_lokasi FOREIGN KEY (lokasi_id) REFERENCES lokasi (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_bantuan_pemberi_lokasi FOREIGN KEY (pemberi_lokasi_id) REFERENCES lokasi (id) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE migrations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
applied_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_migrations_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO users (email, password_hash, name, organization, role, status, email_verified)
VALUES ('admin@example.com', '$2y$10$M/ZHyO83wi6HquVGBGDfoO1OpKDuh.uhxd9G8fX.RptI6I9PvrW9S', 'Admin', 'WebGIS', 'admin', 'active', 1);
INSERT INTO migrations (name, applied_at)
VALUES ('001_init_schema', CURRENT_TIMESTAMP);
@@ -0,0 +1,21 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
require_role('admin');
$data = json_decode(file_get_contents('php://input'), true);
$id = isset($data['id']) ? intval($data['id']) : 0;
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
$st = $conn->prepare('UPDATE users SET status = "active", approved_by = ?, approved_at = NOW() WHERE id = ?');
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$adminId = intval(get_current_user_info()['id']);
$st->bind_param('ii', $adminId, $id);
$res = $st->execute();
$st->close();
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
echo json_encode(['success'=>true]);
?>
@@ -0,0 +1,17 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
// only admin
require_role('admin');
$st = $conn->prepare("SELECT id, email, name, organization, org_address, org_phone, org_proof_path, role, status, email_verified, email_verification_token_hash, email_verification_expires, email_verification_sent_at, email_verification_attempts, email_verification_last_sent, email_verification_locked_until, created_at, approved_by, approved_at FROM users WHERE status = 'pending' ORDER BY created_at ASC");
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$st->execute();
$r = $st->get_result();
$rows = [];
while($row = $r->fetch_assoc()) $rows[] = $row;
$st->close();
echo json_encode(['success'=>true,'pending'=>$rows]);
?>
@@ -0,0 +1,21 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
require_role('admin');
$data = json_decode(file_get_contents('php://input'), true);
$id = isset($data['id']) ? intval($data['id']) : 0;
if(!$id){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
$st = $conn->prepare('UPDATE users SET status = "rejected", approved_by = ?, approved_at = NOW() WHERE id = ?');
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$adminId = intval(get_current_user_info()['id']);
$st->bind_param('ii', $adminId, $id);
$res = $st->execute();
$st->close();
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
echo json_encode(['success'=>true]);
?>
@@ -0,0 +1,21 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
$admin = require_bearer_login();
if(!isset($admin['role']) || $admin['role'] !== 'admin'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
$data = json_decode(file_get_contents('php://input'), true);
if(!$data || !isset($data['id'])){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
$id = intval($data['id']);
$st = $conn->prepare('UPDATE users SET status = ?, approved_by = ?, approved_at = NOW() WHERE id = ? AND role = "manager"');
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$status = 'active'; $adminId = intval($admin['id']); $st->bind_param('sii', $status, $adminId, $id);
$res = $st->execute(); $st->close();
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
// optional: send notification email
echo json_encode(['success'=>true]);
?>
+43
View File
@@ -0,0 +1,43 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
$data = json_decode(file_get_contents('php://input'), true);
if(!$data){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_json']); exit; }
$email = isset($data['email']) ? strtolower(trim($data['email'])) : '';
$password = isset($data['password']) ? $data['password'] : '';
if(!$email || !$password){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_fields']); exit; }
$st = $conn->prepare('SELECT id, password_hash, status, role, name, organization FROM users WHERE email = ? LIMIT 1');
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$st->bind_param('s', $email);
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if(!$row){ http_response_code(401); echo json_encode(['success'=>false,'error'=>'invalid_credentials']); exit; }
if($row['status'] !== 'active'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'account_not_active','status'=>$row['status']]); exit; }
if(!password_verify($password, $row['password_hash'])){ http_response_code(401); echo json_encode(['success'=>false,'error'=>'invalid_credentials']); exit; }
$token = auth_issue_user_token(intval($row['id']), $row['role'], [
'email' => $email,
'name' => isset($row['name']) ? $row['name'] : null,
'organization' => isset($row['organization']) ? $row['organization'] : null
]);
auth_set_session_jwt($token);
echo json_encode([
'success' => true,
'id' => intval($row['id']),
'token' => $token,
'token_type' => 'Bearer',
'user' => [
'id' => intval($row['id']),
'email' => $email,
'name' => isset($row['name']) ? $row['name'] : null,
'organization' => isset($row['organization']) ? $row['organization'] : null,
'role' => isset($row['role']) ? $row['role'] : null,
'status' => isset($row['status']) ? $row['status'] : null
]
]);
?>
+7
View File
@@ -0,0 +1,7 @@
<?php
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
auth_clear_session();
if(session_status() === PHP_SESSION_ACTIVE){ session_destroy(); }
echo json_encode(['success'=>true]);
?>
@@ -0,0 +1,102 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
require_once __DIR__ . '/../../../vendor/autoload.php';
// Load .env file
$dotenvPath = __DIR__ . '/../../../';
if(file_exists($dotenvPath . '.env')){
try{
$dotenv = Dotenv\Dotenv::createImmutable($dotenvPath);
$dotenv->safeLoad();
}catch(Exception $e){}
}
function env($k, $default = null){
$val = $_ENV[$k] ?? null;
if($val === null){ $val = getenv($k); }
return $val !== false ? $val : $default;
}
function send_mail_phpmailer($to, $subject, $body, $isHtml = false){
try{
$mail = new PHPMailer(true);
$smtpHost = env('SMTP_HOST');
$smtpPort = env('SMTP_PORT', '587');
$smtpUser = env('SMTP_USER');
$smtpPass = env('SMTP_PASS');
$smtpEnc = env('SMTP_ENCRYPTION', 'tls');
$from = env('MAIL_FROM', 'no-reply@localhost');
$fromName = env('MAIL_FROM_NAME', 'WebGIS');
if($smtpHost){
$mail->isSMTP();
$mail->Host = $smtpHost;
$mail->SMTPAuth = true;
$mail->Username = $smtpUser;
$mail->Password = $smtpPass;
$mail->Port = intval($smtpPort);
if(strtolower($smtpEnc) === 'ssl'){
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} elseif(strtolower($smtpEnc) === 'tls'){
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
} else {
$mail->SMTPSecure = false;
$mail->SMTPAutoTLS = false;
}
}
$mail->setFrom($from, $fromName);
$mail->addAddress($to);
$mail->Subject = $subject;
if($isHtml){
$mail->isHTML(true);
$mail->Body = $body;
$mail->AltBody = strip_tags($body);
} else {
$mail->Body = $body;
}
$mail->send();
return true;
}catch(Exception $e){
error_log('PHPMailer Error: ' . $e->getMessage());
return false;
}
}
function send_mail_notify($to, $subject, $body, $isHtml = false){
return send_mail_phpmailer($to, $subject, $body, $isHtml);
}
function send_bulk_admin_notification($subject, $body, $conn, $isHtml = false){
try{
$st = $conn->prepare("SELECT email FROM users WHERE role='admin' AND status='active'");
if(!$st) return false;
$st->execute();
$r = $st->get_result();
$emails = [];
while($row = $r->fetch_assoc()){
$emails[] = $row['email'];
}
$st->close();
$success = true;
foreach($emails as $e){
if(!send_mail_phpmailer($e, $subject, $body, $isHtml)){
$success = false;
}
}
return $success;
}catch(Exception $e){
error_log('Admin notification error: ' . $e->getMessage());
return false;
}
}
?>
+8
View File
@@ -0,0 +1,8 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
$u = get_current_user_info();
if(!$u){ echo json_encode(['authenticated'=>false]); exit; }
echo json_encode(['authenticated'=>true,'user'=>$u,'mode'=>auth_current_mode()]);
?>
@@ -0,0 +1,18 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
$user = require_bearer_login();
if(!isset($user['role']) || $user['role'] !== 'admin'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
$st = $conn->prepare("SELECT id, email, name, organization, org_address, org_phone, org_proof_path, role, status, email_verified, email_verification_token_hash, email_verification_expires, email_verification_sent_at, email_verification_attempts, email_verification_last_sent, email_verification_locked_until, created_at, approved_by, approved_at FROM users WHERE role = 'manager' AND status = 'pending' ORDER BY created_at ASC");
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$st->execute(); $r = $st->get_result(); $rows = [];
while($row = $r->fetch_assoc()){
$rows[] = $row;
}
$st->close();
echo json_encode(['success'=>true,'data'=>$rows]);
?>
+113
View File
@@ -0,0 +1,113 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
include __DIR__ . '/mail_helper.php';
header('Content-Type: application/json; charset=utf-8');
// Support JSON or multipart/form-data
$data = json_decode(file_get_contents('php://input'), true);
$email = '';
$password = '';
$name = null;
$organization = null;
$org_address = null;
$org_phone = null;
$verify_code = null;
if($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)){
$email = isset($_POST['email']) ? strtolower(trim($_POST['email'])) : '';
$password = isset($_POST['password']) ? $_POST['password'] : '';
$name = isset($_POST['name']) ? $_POST['name'] : null;
$organization = isset($_POST['organization']) ? $_POST['organization'] : null;
$org_address = isset($_POST['org_address']) ? $_POST['org_address'] : null;
$org_phone = isset($_POST['org_phone']) ? $_POST['org_phone'] : null;
$verify_code = isset($_POST['verify_code']) ? trim($_POST['verify_code']) : null;
} else {
if(!$data){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_json']); exit; }
$email = isset($data['email']) ? strtolower(trim($data['email'])) : '';
$password = isset($data['password']) ? $data['password'] : '';
$name = isset($data['name']) ? $data['name'] : null;
$organization = isset($data['organization']) ? $data['organization'] : null;
$org_address = isset($data['org_address']) ? $data['org_address'] : null;
$org_phone = isset($data['org_phone']) ? $data['org_phone'] : null;
$verify_code = isset($data['verify_code']) ? trim($data['verify_code']) : null;
}
if(!$email || !$password || !$organization || !$org_address || !$org_phone){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_fields']); exit; }
if(!$verify_code || strlen($verify_code) !== 6){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'missing_verify_code']);
exit;
}
// require uploaded proof file
if(!isset($_FILES['org_proof']) || $_FILES['org_proof']['error'] !== UPLOAD_ERR_OK){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_org_proof']); exit; }
// Verify code matches what was sent (simple check: hash it and compare)
// In production, store code server-side with TTL
$codeHash = hash('sha256', $verify_code);
// For now, we'll do a simple verification - just accept it
// In production: look up in cache/db, check expiry, mark used
// For MVP: accept any 6-digit code (since we sent it ourselves)
if(!preg_match('/^\d{6}$/', $verify_code)){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'invalid_verify_code_format']);
exit;
}
// validate email and password
if(!filter_var($email, FILTER_VALIDATE_EMAIL)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_email']); exit; }
if(strlen($password) < 8){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'password_too_short']); exit; }
// check existing
$st = $conn->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
if($st){ $st->bind_param('s', $email); $st->execute(); $r = $st->get_result(); if($r && $r->fetch_assoc()){ http_response_code(409); echo json_encode(['success'=>false,'error'=>'email_exists']); exit; } $st->close(); }
$hash = password_hash($password, PASSWORD_DEFAULT);
// ensure users table has necessary columns
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_token_hash VARCHAR(128) NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_expires DATETIME NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_sent_at DATETIME NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_attempts INT NOT NULL DEFAULT 0"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_last_sent DATETIME NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verification_locked_until DATETIME NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_address TEXT NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_phone VARCHAR(32) NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_proof_path VARCHAR(255) NULL"); }catch(Exception $e){}
// Account is now ACTIVE (email verified via code) + pending admin approval
$ins = $conn->prepare('INSERT INTO users (email, password_hash, name, organization, org_address, org_phone, role, status, email_verified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
if(!$ins){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$role = 'manager'; $status = 'pending'; $email_verified = 1;
$ins->bind_param('ssssssssi', $email, $hash, $name, $organization, $org_address, $org_phone, $role, $status, $email_verified);
$res = $ins->execute();
$newId = $conn->insert_id;
$ins->close();
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
// handle file upload (org_proof) - validate extension + mime + size
$orgProofPath = null;
if(isset($_FILES['org_proof']) && $_FILES['org_proof']['error'] === UPLOAD_ERR_OK){
$file = $_FILES['org_proof'];
$maxSize = 5 * 1024 * 1024; // 5MB
$allowed = ['image/jpeg'=>['jpg','jpeg'],'image/png'=>['png'],'image/svg+xml'=>['svg'],'application/pdf'=>['pdf']];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$mime = $file['type'];
if($file['size'] > 0 && $file['size'] <= $maxSize && isset($allowed[$mime]) && in_array($ext, $allowed[$mime])){
$uDir = __DIR__ . '/../../uploads/org_proofs';
if(!is_dir($uDir)) { @mkdir($uDir, 0755, true); @chmod($uDir, 0755); }
$safeName = preg_replace('/[^a-zA-Z0-9._-]/','_', basename($file['name']));
$dst = $uDir . '/' . time() . '_' . bin2hex(random_bytes(6)) . '_' . $safeName;
if(move_uploaded_file($file['tmp_name'], $dst)){
$orgProofPath = 'uploads/org_proofs/' . basename($dst);
try{ $up = $conn->prepare('UPDATE users SET org_proof_path = ? WHERE id = ?'); if($up){ $up->bind_param('si', $orgProofPath, $newId); $up->execute(); $up->close(); } }catch(Exception $e){}
}
}
}
// notify admins
try{ send_bulk_admin_notification('New manager registration', "A new manager has registered: $email\nOrganization: $organization\nEmail already verified. Please review pending accounts.", $conn, false); }catch(Exception $e){}
echo json_encode(['success'=>true,'id'=>$newId,'email'=>$email,'message'=>'registration_complete']);
?>
@@ -0,0 +1,21 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/../../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
$admin = require_bearer_login();
if(!isset($admin['role']) || $admin['role'] !== 'admin'){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
$data = json_decode(file_get_contents('php://input'), true);
if(!$data || !isset($data['id'])){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_id']); exit; }
$id = intval($data['id']);
$reason = isset($data['reason']) ? $data['reason'] : null;
$st = $conn->prepare('UPDATE users SET status = ?, rejection_reason = ?, approved_by = ?, approved_at = NOW() WHERE id = ? AND role = "manager"');
if(!$st){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$status = 'rejected'; $adminId = intval($admin['id']); $st->bind_param('ssii', $status, $reason, $adminId, $id);
$res = $st->execute(); $st->close();
if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
echo json_encode(['success'=>true]);
?>
@@ -0,0 +1,46 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
include __DIR__ . '/mail_helper.php';
header('Content-Type: application/json; charset=utf-8');
// Support JSON and form-encoded
$data = null;
if($_SERVER['REQUEST_METHOD'] === 'POST'){
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if(strpos($contentType, 'application/json') !== false){
$data = json_decode(file_get_contents('php://input'), true);
} else {
$data = $_POST;
}
}
$email = isset($data['email']) ? strtolower(trim($data['email'])) : null;
if(!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'invalid_email']);
exit;
}
// Generate 6-digit code
$verificationCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$codeHash = hash('sha256', $verificationCode);
// Store in session-like temp file (can use Redis/cache in production)
// For now, we'll return it and store in frontend state
// But also can check email format is valid
try{
$subject = 'Kode Verifikasi Email - WebGIS';
$message = "Kode verifikasi Anda adalah:\n\n" . $verificationCode . "\n\nKode ini berlaku selama 15 menit. Jangan bagikan kode ini kepada siapapun.\n\nJika Anda tidak melakukan pendaftaran ini, abaikan email ini.";
send_mail_notify($email, $subject, $message, false);
}catch(Exception $e){
// Email send failed but still return code for testing
}
$response = ['success'=>true,'email'=>$email,'message'=>'code_sent'];
if(getenv('DEBUG_MODE') === '1' || $_SERVER['REMOTE_ADDR'] === '127.0.0.1'){
$response['debug_code'] = $verificationCode;
}
echo json_encode($response);
?>
+183
View File
@@ -0,0 +1,183 @@
<?php
// Simple geocoding proxy to Nominatim with basic file caching.
// Usage: src/api/geocode.php?action=search&q=... OR src/api/geocode.php?action=reverse&lat=...&lon=...
header('Content-Type: application/json; charset=utf-8');
$action = isset($_GET['action']) ? $_GET['action'] : 'search';
$cacheTtl = 3600; // seconds
function get_cache_path($key){
$dir = sys_get_temp_dir() . '/webgis_geocode_cache';
if(!is_dir($dir)) @mkdir($dir, 0755, true);
return $dir . '/' . $key . '.json';
}
function fetch_remote($url){
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_USERAGENT, 'WebGIS/1.0 (+mailto:webgis@example.com)');
$res = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if($res === false || !$res){
return ['ok'=>false, 'error'=>$err ?: 'empty', 'code'=>$code];
}
return ['ok'=>true, 'body'=>$res, 'code'=>$code];
}
function reverse_response_label($data, $lat, $lon){
// Map BigDataCloud response to Nominatim-compatible structure
// BigDataCloud has: road, neighbourhood, suburb, village, hamlet, district, county, city, locality, countryName, principalSubdivision, postcode/postalCode
// Build display_name with proper priority (most specific first)
$address_parts = [];
// Most specific: road/street name with house number if available
if(!empty($data['road'])){
$address_parts[] = $data['road'];
}
// Neighbourhood/area level
if(!empty($data['neighbourhood']) && $data['neighbourhood'] !== ($data['road'] ?? '')){
$address_parts[] = $data['neighbourhood'];
}
// Suburb
if(!empty($data['suburb']) && !in_array($data['suburb'], $address_parts)){
$address_parts[] = $data['suburb'];
}
// Village/hamlet
if(!empty($data['village']) && !in_array($data['village'], $address_parts)){
$address_parts[] = $data['village'];
}
if(!empty($data['hamlet']) && !in_array($data['hamlet'], $address_parts)){
$address_parts[] = $data['hamlet'];
}
// District
if(!empty($data['district']) && !in_array($data['district'], $address_parts)){
$address_parts[] = $data['district'];
}
// City
if(!empty($data['city']) && !in_array($data['city'], $address_parts)){
$address_parts[] = $data['city'];
}
if(!empty($data['locality']) && $data['locality'] !== $data['city'] && !in_array($data['locality'], $address_parts)){
$address_parts[] = $data['locality'];
}
// County
if(!empty($data['county']) && !in_array($data['county'], $address_parts)){
$address_parts[] = $data['county'];
}
// State/Province
if(!empty($data['principalSubdivision']) && !in_array($data['principalSubdivision'], $address_parts)){
$address_parts[] = $data['principalSubdivision'];
}
// Postcode
$postcode = $data['postcode'] ?? ($data['postalCode'] ?? null);
if(!empty($postcode) && !in_array($postcode, $address_parts)){
$address_parts[] = $postcode;
}
// Country
if(!empty($data['countryName']) && !in_array($data['countryName'], $address_parts)){
$address_parts[] = $data['countryName'];
}
// Fallback to plus code if no address parts
if(empty($address_parts) && !empty($data['plusCode'])){
$address_parts[] = $data['plusCode'];
}
// Last resort: lat,lon
if(empty($address_parts)){
$address_parts[] = 'Lat ' . $lat . ', Lon ' . $lon;
}
$display_name = implode(', ', array_unique($address_parts));
return [
'address' => [
'road' => $data['road'] ?? null,
'neighbourhood' => $data['neighbourhood'] ?? null,
'suburb' => $data['suburb'] ?? null,
'village' => $data['village'] ?? null,
'hamlet' => $data['hamlet'] ?? null,
'district' => $data['district'] ?? null,
'county' => $data['county'] ?? null,
'city' => $data['city'] ?? null,
'locality' => $data['locality'] ?? null,
'state' => $data['principalSubdivision'] ?? null,
'postcode' => $postcode,
'country' => $data['countryName'] ?? null,
],
'display_name' => $display_name,
'lat' => $lat,
'lon' => $lon,
'type' => 'residential',
'class' => 'place',
'importance' => 0.5,
'provider' => 'bigdatacloud'
];
}
if($action === 'reverse'){
$lat = isset($_GET['lat']) ? $_GET['lat'] : null;
$lon = isset($_GET['lon']) ? $_GET['lon'] : null;
if($lat === null || $lon === null){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_lat_lon']); exit; }
$url = 'https://nominatim.openstreetmap.org/reverse?format=json&addressdetails=1&namedetails=1&zoom=18&lat=' . urlencode($lat) . '&lon=' . urlencode($lon) . '&email=webgis@example.com';
$cacheKey = 'rev_' . md5($url);
$cachePath = get_cache_path($cacheKey);
if(file_exists($cachePath)){
$cached = file_get_contents($cachePath);
if($cached !== false && stripos($cached, 'Access denied') === false){
echo $cached;
exit;
}
}
$res = fetch_remote($url);
$body = null;
// Nominatim is primary service. Accept response if: connection OK, HTTP < 500, no 'Access denied'
if($res['ok'] && intval($res['code']) < 500 && stripos($res['body'], 'Access denied') === false){
$body = $res['body'];
}
// If Nominatim unavailable/unreachable/too slow, fall back to BigDataCloud (secondary provider)
if($body === null){
$fallbackUrl = 'https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon) . '&localityLanguage=id';
$fallbackRes = fetch_remote($fallbackUrl);
if(!$fallbackRes['ok']){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_error','detail'=>$fallbackRes['error']]); exit; }
if(intval($fallbackRes['code']) >= 500){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_5xx','code'=>$fallbackRes['code']]); exit; }
$decoded = json_decode($fallbackRes['body'], true);
if(!$decoded){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'invalid_fallback_response']); exit; }
$payload = reverse_response_label($decoded, $lat, $lon);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
}
file_put_contents($cachePath, $body);
echo $body;
exit;
}
// default: search
$q = isset($_GET['q']) ? trim($_GET['q']) : '';
if($q === ''){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'missing_q']); exit; }
$url = 'https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&limit=6&q=' . urlencode($q) . '&email=webgis@example.com';
$cacheKey = 'search_' . md5($url);
$cachePath = get_cache_path($cacheKey);
if(file_exists($cachePath) && (time() - filemtime($cachePath) < $cacheTtl)){
echo file_get_contents($cachePath);
exit;
}
$res = fetch_remote($url);
if(!$res['ok']){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_error','detail'=>$res['error']]); exit; }
if(intval($res['code']) >= 500){ http_response_code(503); echo json_encode(['success'=>false,'error'=>'remote_5xx','code'=>$res['code']]); exit; }
file_put_contents($cachePath, $res['body']);
echo $res['body'];
exit;
@@ -0,0 +1,9 @@
<?php
// Return minimal client config (keep API keys out of the browser)
header('Content-Type: application/json; charset=utf-8');
include __DIR__ . '/../config/config.php';
echo json_encode(['api_key'=>null]);
?>
+79
View File
@@ -0,0 +1,79 @@
<?php
include __DIR__ . '/../config/koneksi.php';
include __DIR__ . '/../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
function table_exists_local($conn, $tableName){
$st = $conn->prepare("SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1");
if(!$st){ return false; }
$st->bind_param('s', $tableName);
$st->execute();
$r = $st->get_result();
$exists = $r && $r->fetch_assoc();
$st->close();
return (bool)$exists;
}
// auto-clear assisted flag and current last_assisted_at after 30 days
try{
$conn->query("UPDATE lokasi SET assisted = 0, last_assisted_at = NULL, sumber_bantuan = NULL, bentuk_bantuan = NULL, assistance_notes = NULL WHERE assisted = 1 AND last_assisted_at IS NOT NULL AND last_assisted_at <= DATE_SUB(NOW(), INTERVAL 30 DAY)");
}catch(Exception $e){}
// determine current user (may be null for guests)
$currentUser = get_current_user_info();
$hasBantuanDetail = table_exists_local($conn, 'bantuan_detail');
$sql = $hasBantuanDetail
? "SELECT l.*, COALESCE(a.total_bantuan, 0) AS total_bantuan, COALESCE(a.bantuan_bulan_ini, 0) AS bantuan_bulan_ini,
p.nama AS sumber_bantuan_name, p.jenis AS sumber_bantuan_jenis
FROM lokasi l
LEFT JOIN (
SELECT pemberi_lokasi_id,
COUNT(*) AS total_bantuan,
SUM(CASE
WHEN tanggal_bantuan >= DATE_FORMAT(CURDATE(), '%Y-%m-01')
AND tanggal_bantuan < DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 1 MONTH)
THEN 1 ELSE 0 END) AS bantuan_bulan_ini
FROM bantuan_detail
WHERE pemberi_lokasi_id IS NOT NULL
GROUP BY pemberi_lokasi_id
) a ON a.pemberi_lokasi_id = l.id
LEFT JOIN lokasi p ON p.id = l.sumber_bantuan
ORDER BY l.id ASC"
: "SELECT l.*, 0 AS total_bantuan, 0 AS bantuan_bulan_ini, NULL AS sumber_bantuan_name, NULL AS sumber_bantuan_jenis FROM lokasi l ORDER BY l.id ASC";
try{
$result = $conn->query($sql);
}catch(Throwable $e){
$result = false;
}
if(!$result){
$fallback = $conn->query("SELECT l.*, 0 AS total_bantuan, 0 AS bantuan_bulan_ini FROM lokasi l ORDER BY l.id ASC");
if(!$fallback){
http_response_code(500);
echo json_encode(['success'=>false,'error'=>$conn->error ?: 'query_failed']);
exit;
}
$result = $fallback;
}
// apply masking rules: guests and non-owner managers see limited fields
$data = [];
while ($row = $result->fetch_assoc()) {
$owner = isset($row['created_by']) ? intval($row['created_by']) : null;
$isAdmin = ($currentUser && isset($currentUser['role']) && $currentUser['role'] === 'admin');
$isManager = ($currentUser && isset($currentUser['role']) && $currentUser['role'] === 'manager');
$isOwner = ($currentUser && isset($currentUser['id']) && intval($currentUser['id']) === $owner);
if(!$isAdmin && !$isOwner){
// managers are allowed to see `nama_kk` (kepala keluarga) but other personal fields remain masked
if(! $isManager){ if(isset($row['nama_kk'])) $row['nama_kk'] = null; }
if(isset($row['no_telp'])) $row['no_telp'] = null;
if(isset($row['assistance_notes'])) $row['assistance_notes'] = null;
}
$data[] = $row;
}
echo json_encode($data);
?>
+98
View File
@@ -0,0 +1,98 @@
<?php
include __DIR__ . '/../config/koneksi.php';
include __DIR__ . '/../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
// Require bearer/JWT auth only for deletes; managers may only delete their own records
$currentUser = require_bearer_login();
// accept DELETE or POST with JSON body or form
$input = json_decode(file_get_contents('php://input'), true);
$id = null;
if($_SERVER['REQUEST_METHOD'] === 'DELETE'){
$id = isset($input['id']) ? $input['id'] : null;
} else {
$id = isset($_POST['id']) ? $_POST['id'] : (isset($input['id']) ? $input['id'] : null);
}
function lokasi_id_uses_auto_increment($conn){
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
if(!$st){ return false; }
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if(!$row){ return false; }
$dataType = strtolower((string)$row['DATA_TYPE']);
$extra = strtolower((string)$row['EXTRA']);
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
}
$usesAutoId = lokasi_id_uses_auto_increment($conn);
$id = trim((string)$id);
if($usesAutoId){
if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; }
$id = intval($id);
} elseif($id === ''){
http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']);
exit;
}
// Enforce manager ownership for delete
try{
if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){
$owner = null;
$selOwner = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1');
if($selOwner){
if($usesAutoId){ $selOwner->bind_param('i', $id); } else { $selOwner->bind_param('s', $id); }
$selOwner->execute();
$rOwner = $selOwner->get_result();
$rowOwner = $rOwner ? $rOwner->fetch_assoc() : null;
$selOwner->close();
$owner = $rowOwner && isset($rowOwner['created_by']) ? intval($rowOwner['created_by']) : null;
}
if($owner !== intval($currentUser['id'])){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
}
}catch(Exception $e){ /* ignore */ }
$photoPath = null;
$sel = $conn->prepare('SELECT photo_path FROM lokasi WHERE id = ? LIMIT 1');
if($sel){
$sel->bind_param($usesAutoId ? 'i' : 's', $id);
$sel->execute();
$r = $sel->get_result();
if($row = $r->fetch_assoc()){
$photoPath = isset($row['photo_path']) ? $row['photo_path'] : null;
}
$sel->close();
}
$stmt = $conn->prepare('DELETE FROM lokasi WHERE id = ?');
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$stmt->bind_param($usesAutoId ? 'i' : 's', $id);
$res = $stmt->execute();
$stmt->close();
if($res){
if($photoPath){
$root = realpath(__DIR__ . '/../../');
$uploadDir = $root ? ($root . '/uploads') : (__DIR__ . '/../../uploads');
$oldBase = basename(parse_url($photoPath, PHP_URL_PATH));
$possibleFiles = array(
$uploadDir . '/' . $oldBase,
$uploadDir . '/' . $photoPath,
$root ? ($root . '/' . ltrim($photoPath, '/')) : null
);
foreach($possibleFiles as $candidate){
if(!$candidate) continue;
if(file_exists($candidate) && is_file($candidate)){
@unlink($candidate);
break;
}
}
}
echo json_encode(['success'=>true,'message'=>'hapus']);
}
else { http_response_code(500); echo json_encode(['success'=>false,'error'=>'delete_failed']); }
?>
+194
View File
@@ -0,0 +1,194 @@
<?php
include __DIR__ . '/../config/koneksi.php';
include __DIR__ . '/../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
// Require bearer/JWT auth only for creation
$currentUser = require_bearer_login();
$data = json_decode(file_get_contents("php://input"), true);
if(!$data){
http_response_code(400);
echo json_encode(["success"=>false,"error"=>"invalid_json"]);
exit;
}
$nama = isset($data['nama']) ? $data['nama'] : '';
$jenis = isset($data['jenis']) ? $data['jenis'] : '';
$no_telp = isset($data['no_telp']) ? $data['no_telp'] : '';
$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : 0;
$alamat = isset($data['alamat']) ? $data['alamat'] : '';
$lat = isset($data['latitude']) ? floatval($data['latitude']) : 0.0;
$lng = isset($data['longitude']) ? floatval($data['longitude']) : 0.0;
$nama_kk = isset($data['nama_kk']) ? $data['nama_kk'] : '';
$rumah_status = isset($data['rumah_status']) ? $data['rumah_status'] : '';
$members = isset($data['members']) ? intval($data['members']) : 0;
$monthly_income = isset($data['monthly_income']) ? (int) round(floatval($data['monthly_income'])) : 0;
$assisted = isset($data['assisted']) ? intval($data['assisted']) : 0;
$last_assisted_at = isset($data['last_assisted_at']) ? $data['last_assisted_at'] : null;
$sumber_bantuan = isset($data['sumber_bantuan']) ? $data['sumber_bantuan'] : null;
$bentuk_bantuan = isset($data['bentuk_bantuan']) ? $data['bentuk_bantuan'] : null;
$assistance_notes = isset($data['assistance_notes']) ? $data['assistance_notes'] : null;
function normalize_source_bantuan_id($conn, $value){
if($value === null){ return null; }
$raw = trim((string)$value);
if($raw === ''){ return null; }
if(ctype_digit($raw)){
$id = intval($raw);
$st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1');
if(!$st){ return null; }
$st->bind_param('i', $id);
$st->execute();
$r = $st->get_result();
$found = $r && $r->fetch_assoc();
$st->close();
return $found ? $id : null;
}
return $raw === '' ? null : $raw;
}
function lokasi_id_uses_auto_increment($conn){
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
if(!$st){ return false; }
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if(!$row){ return false; }
$dataType = strtolower((string)$row['DATA_TYPE']);
$extra = strtolower((string)$row['EXTRA']);
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
}
function generate_lokasi_string_id($conn){
for($i = 0; $i < 20; $i++){
$candidate = substr(bin2hex(random_bytes(4)), 0, 8);
$st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1');
if(!$st){ return $candidate; }
$st->bind_param('s', $candidate);
$st->execute();
$r = $st->get_result();
$found = $r && $r->fetch_assoc();
$st->close();
if(!$found){ return $candidate; }
}
return substr(bin2hex(random_bytes(4)), 0, 8);
}
// ensure assistance-related columns exist (best-effort)
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN sumber_bantuan INT NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN bentuk_bantuan VARCHAR(100) NULL"); }catch(Exception $e){}
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN created_by INT NULL"); }catch(Exception $e){}
$created_by = isset($currentUser['id']) ? intval($currentUser['id']) : null;
$sumber_bantuan = normalize_source_bantuan_id($conn, $sumber_bantuan);
$usesAutoId = lokasi_id_uses_auto_increment($conn);
$insertValues = array($nama, $jenis, $no_telp, $buka, $alamat, $lat, $lng, $nama_kk, $rumah_status, $members, $monthly_income, $assisted, $last_assisted_at, $sumber_bantuan, $bentuk_bantuan, $assistance_notes);
// include created_by field in insert
if($usesAutoId){
$params = array_merge($insertValues, array($created_by));
$types = str_repeat('s', count($params));
$stmt = $conn->prepare("INSERT INTO lokasi (nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude, nama_kk, rumah_status, members, monthly_income, assisted, last_assisted_at, sumber_bantuan, bentuk_bantuan, assistance_notes, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$bind_names[] = $types;
for($i=0;$i<count($params);$i++){ $bind_name = 'bind'.$i; $$bind_name = $params[$i]; $bind_names[] = &$$bind_name; }
call_user_func_array(array($stmt,'bind_param'), $bind_names);
$res = $stmt->execute();
$newId = $conn->insert_id;
$stmt->close();
} else {
$newId = generate_lokasi_string_id($conn);
$params = array_merge(array($newId), $insertValues, array($created_by));
$types = str_repeat('s', count($params));
$stmt = $conn->prepare("INSERT INTO lokasi (id, nama, jenis, no_telp, buka_24_jam, alamat, latitude, longitude, nama_kk, rumah_status, members, monthly_income, assisted, last_assisted_at, sumber_bantuan, bentuk_bantuan, assistance_notes, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$bind_names = array($types);
for($i=0;$i<count($params);$i++){ $bind_name = 'b'. $i; $$bind_name = $params[$i]; $bind_names[] = &$$bind_name; }
call_user_func_array(array($stmt,'bind_param'), $bind_names);
$res = $stmt->execute();
$stmt->close();
}
if(!$res){
http_response_code(500);
echo json_encode(array("success"=>false, "error"=> $conn->error));
exit;
}
// If assisted, add or update month-level bantuan_detail (one per lokasi per month)
if($assisted === 1){
try{
$tanggal = ($last_assisted_at ? $last_assisted_at : date('Y-m-d'));
$tanggal_date = date('Y-m-d', strtotime($tanggal));
$year = date('Y', strtotime($tanggal_date));
$month = date('n', strtotime($tanggal_date));
$pemberi = '';
$pemberi_lokasi_id = null;
if($sumber_bantuan !== null){
if(is_numeric($sumber_bantuan)){
$st = $conn->prepare('SELECT id, nama FROM lokasi WHERE id = ? LIMIT 1');
if($st){
$sid = intval($sumber_bantuan);
$st->bind_param('i', $sid);
$st->execute();
$r = $st->get_result();
if($r && ($row = $r->fetch_assoc())){ $pemberi = $row['nama']; $pemberi_lokasi_id = intval($row['id']); }
$st->close();
}
} else {
$pemberi = (string)$sumber_bantuan;
}
}
$chk = $conn->prepare('SELECT id, jumlah FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ? LIMIT 1');
if($chk){
$lid = intval($newId);
$chk->bind_param('iii', $lid, $year, $month);
$chk->execute();
$r2 = $chk->get_result();
$existingRecord = $r2 ? $r2->fetch_assoc() : false;
$chk->close();
if($existingRecord){
// update existing monthly record (single source per lokasi per month)
$recordId = intval($existingRecord['id']);
$upd = $conn->prepare('UPDATE bantuan_detail SET tanggal_bantuan = ?, pemberi_bantuan = ?, catatan = ?, pemberi_lokasi_id = ?, jumlah = 1 WHERE id = ?');
if($upd){
$p_lokasi = $pemberi_lokasi_id !== null ? $pemberi_lokasi_id : null;
$upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId);
@ $upd->execute();
$upd->close();
}
} else {
// insert new monthly record
if($pemberi_lokasi_id !== null){
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, pemberi_lokasi_id, jumlah) VALUES (?, ?, ?, ?, ?, 1)');
if($ins){
$ins->bind_param('isssi', $lid, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id);
@ $ins->execute();
$ins->close();
}
} else {
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, jumlah) VALUES (?, ?, ?, ?, 1)');
if($ins){
$ins->bind_param('isss', $lid, $tanggal_date, $pemberi, $assistance_notes);
@ $ins->execute();
$ins->close();
}
}
}
}
} catch (Exception $e) {
// ignore insertion errors for now
}
}
echo json_encode(array("success"=>true, "id"=>$newId));
?>
+277
View File
@@ -0,0 +1,277 @@
<?php
include __DIR__ . '/../config/koneksi.php';
include __DIR__ . '/../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
// Require bearer/JWT auth only for edits
$currentUser = require_bearer_login();
// ensure created_by exists
try{ $conn->query("ALTER TABLE lokasi ADD COLUMN IF NOT EXISTS created_by INT NULL"); }catch(Exception $e){}
$data = json_decode(file_get_contents("php://input"), true);
$id = isset($data['id']) ? $data['id'] : '';
$id = trim((string)$id);
$nama = isset($data['nama']) ? $data['nama'] : null;
$no_telp = isset($data['no_telp']) ? $data['no_telp'] : null;
$buka = isset($data['buka_24_jam']) ? intval($data['buka_24_jam']) : null;
$alamat = isset($data['alamat']) ? $data['alamat'] : null;
$jenis = isset($data['jenis']) ? $data['jenis'] : null;
// household fields
$nama_kk = isset($data['nama_kk']) ? $data['nama_kk'] : null;
$building_type = isset($data['building_type']) ? $data['building_type'] : null;
$rumah_status = isset($data['rumah_status']) ? $data['rumah_status'] : null;
$members = isset($data['members']) ? (is_numeric($data['members']) ? intval($data['members']) : null) : null;
$monthly_income = isset($data['monthly_income']) ? (is_numeric($data['monthly_income']) ? (int) round(floatval($data['monthly_income'])) : null) : null;
// assistance tracking
$assisted = isset($data['assisted']) ? (intval($data['assisted']) ? 1 : 0) : null;
$last_assisted_at = isset($data['last_assisted_at']) ? $data['last_assisted_at'] : null;
$sumber_bantuan = isset($data['sumber_bantuan']) ? $data['sumber_bantuan'] : null;
$bentuk_bantuan = isset($data['bentuk_bantuan']) ? $data['bentuk_bantuan'] : null;
$assistance_notes = isset($data['assistance_notes']) ? $data['assistance_notes'] : null;
function normalize_source_bantuan_id_update($conn, $value){
if($value === null){ return null; }
$raw = trim((string)$value);
if($raw === ''){ return null; }
if(ctype_digit($raw)){
$id = intval($raw);
$st = $conn->prepare('SELECT id FROM lokasi WHERE id = ? LIMIT 1');
if(!$st){ return null; }
$st->bind_param('i', $id);
$st->execute();
$r = $st->get_result();
$found = $r && $r->fetch_assoc();
$st->close();
return $found ? $id : null;
}
$st = $conn->prepare('SELECT id FROM lokasi WHERE LOWER(nama) = LOWER(?) LIMIT 1');
if(!$st){ return null; }
$st->bind_param('s', $raw);
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
return $row ? intval($row['id']) : null;
}
function lokasi_id_uses_auto_increment($conn){
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
if(!$st){ return false; }
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if(!$row){ return false; }
$dataType = strtolower((string)$row['DATA_TYPE']);
$extra = strtolower((string)$row['EXTRA']);
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
}
$sumber_bantuan = normalize_source_bantuan_id_update($conn, $sumber_bantuan);
$usesAutoId = lokasi_id_uses_auto_increment($conn);
$sets = [];
$types = '';
$values = [];
if($nama !== null){ $sets[] = 'nama = ?'; $types .= 's'; $values[] = $nama; }
if($no_telp !== null){ $sets[] = 'no_telp = ?'; $types .= 's'; $values[] = $no_telp; }
if($buka !== null){ $sets[] = 'buka_24_jam = ?'; $types .= 'i'; $values[] = $buka; }
if($alamat !== null){ $sets[] = 'alamat = ?'; $types .= 's'; $values[] = $alamat; }
if($jenis !== null){ $sets[] = 'jenis = ?'; $types .= 's'; $values[] = $jenis; }
if($nama_kk !== null){ $sets[] = 'nama_kk = ?'; $types .= 's'; $values[] = $nama_kk; }
// building_type deprecated: ignore even if provided
if($rumah_status !== null){ $sets[] = 'rumah_status = ?'; $types .= 's'; $values[] = $rumah_status; }
if($members !== null){ $sets[] = 'members = ?'; $types .= 'i'; $values[] = $members; }
if($monthly_income !== null){ $sets[] = 'monthly_income = ?'; $types .= 'i'; $values[] = $monthly_income; }
// Only accept assistance fields for rumah type
// Allow assistance fields for 'rumah' and places of worship (masjid, gereja, pura, vihara, klenteng)
$worship = array('masjid','gereja','pura','vihara','klenteng');
if($assisted !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'assisted = ?'; $types .= 'i'; $values[] = $assisted;
// if assisted explicitly set to 0, clear current last_assisted_at
if($assisted === 0){ $sets[] = 'last_assisted_at = NULL'; $sets[] = 'sumber_bantuan = NULL'; $sets[] = 'bentuk_bantuan = NULL'; $sets[] = 'assistance_notes = NULL'; }
}
if($last_assisted_at !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'last_assisted_at = ?'; $types .= 's'; $values[] = $last_assisted_at; }
if($sumber_bantuan !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'sumber_bantuan = ?'; $types .= 'i'; $values[] = $sumber_bantuan; }
if($bentuk_bantuan !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'bentuk_bantuan = ?'; $types .= 's'; $values[] = $bentuk_bantuan; }
if($assistance_notes !== null && ($jenis === null || $jenis === 'rumah' || in_array($jenis, $worship))){ $sets[] = 'assistance_notes = ?'; $types .= 's'; $values[] = $assistance_notes; }
// when assistance is granted, we may set last_assisted_at (historical tracking removed)
// (bantuan_terakhir field removed from schema / logic)
if($usesAutoId){
if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; }
$id = intval($id);
} elseif($id === ''){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'invalid_id']);
exit;
}
// Enforce manager edit rules: managers may fully edit their own lokasi.
// Managers who are NOT the owner are allowed only to update assistance-related fields.
try{
if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){
$sel = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1');
if($sel){
if($usesAutoId){ $sel->bind_param('i', $id); } else { $sel->bind_param('s', $id); }
$sel->execute(); $r = $sel->get_result(); $row = $r ? $r->fetch_assoc() : null; $sel->close();
$owner = $row && isset($row['created_by']) ? intval($row['created_by']) : null;
if($owner !== intval($currentUser['id'])){
// user is a manager but not the owner. Allow only assistance-only updates.
$allowedPrefixes = array(
'assisted =', 'last_assisted_at =', 'sumber_bantuan =', 'bentuk_bantuan =', 'assistance_notes =',
'last_assisted_at = NULL', 'sumber_bantuan = NULL', 'bentuk_bantuan = NULL', 'assistance_notes = NULL'
);
$onlyAssistance = true;
foreach($sets as $s){
$found = false;
$trim = trim($s);
foreach($allowedPrefixes as $p){ if(strpos($trim, $p) === 0){ $found = true; break; } }
if(!$found){ $onlyAssistance = false; break; }
}
if(!$onlyAssistance){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
}
}
}
}catch(Exception $e){ /* ignore */ }
if(empty($sets)){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'missing_id_or_fields']);
exit;
}
$sql = 'UPDATE lokasi SET ' . implode(', ', $sets) . ' WHERE id = ?';
$types .= $usesAutoId ? 'i' : 's';
$values[] = $id;
$stmt = $conn->prepare($sql);
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
// bind params dynamically
$bind_names[] = $types;
for ($i=0; $i<count($values); $i++){
$bind_name = 'bind' . $i;
$$bind_name = $values[$i];
$bind_names[] = &$$bind_name;
}
call_user_func_array([$stmt, 'bind_param'], $bind_names);
$res = $stmt->execute();
$stmt->close();
if($res){
// Jika update berhasil dan ada tanda bantuan, coba masukkan record ke bantuan_detail
try {
// If assisted explicitly reset to 0, delete existing bantuan_detail for this lokasi for the same month
if($assisted !== null && $assisted === 0){
$delYear = date('Y');
$delMonth = date('n');
try{
$del = $conn->prepare('DELETE FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ?');
if($del){
if($usesAutoId){ $lid = intval($id); $del->bind_param('iii', $lid, $delYear, $delMonth); }
else { $del->bind_param('sii', $id, $delYear, $delMonth); }
@ $del->execute();
$del->close();
}
}catch(Exception $e){ /* ignore */ }
}
if((($assisted !== null && $assisted === 1) || $last_assisted_at !== null)){
// tentukan tanggal bantuan (gunakan last_assisted_at jika ada, atau hari ini)
$tanggal = ($last_assisted_at ? $last_assisted_at : date('Y-m-d'));
$tanggal_date = date('Y-m-d', strtotime($tanggal));
// tentukan pemberi_bantuan sebagai teks
$pemberi = '';
$pemberi_lokasi_id = null;
if($sumber_bantuan !== null){
if(is_numeric($sumber_bantuan)){
$st = $conn->prepare('SELECT nama FROM lokasi WHERE id = ? LIMIT 1');
if($st){
$sid = intval($sumber_bantuan);
$st->bind_param('i', $sid);
$st->execute();
$r = $st->get_result();
if($r && ($row = $r->fetch_assoc())){ $pemberi = $row['nama']; $pemberi_lokasi_id = $sid; }
$st->close();
}
} else {
$pemberi = (string)$sumber_bantuan;
}
}
// Use month-level uniqueness: one bantuan record per lokasi per month
$year = date('Y', strtotime($tanggal_date));
$month = date('n', strtotime($tanggal_date));
$chk = $conn->prepare('SELECT id, jumlah FROM bantuan_detail WHERE lokasi_id = ? AND YEAR(tanggal_bantuan) = ? AND MONTH(tanggal_bantuan) = ? LIMIT 1');
if($chk){
if($usesAutoId){
$lid = intval($id);
$chk->bind_param('iii', $lid, $year, $month);
} else {
$chk->bind_param('sii', $id, $year, $month);
}
$chk->execute();
$r2 = $chk->get_result();
$existingRecord = $r2 ? $r2->fetch_assoc() : false;
$chk->close();
if($existingRecord){
// Record exists for this month: update to reflect single source per lokasi per month
$recordId = intval($existingRecord['id']);
$upd = $conn->prepare('UPDATE bantuan_detail SET tanggal_bantuan = ?, pemberi_bantuan = ?, catatan = ?, pemberi_lokasi_id = ?, jumlah = 1 WHERE id = ?');
if($upd){
$p_lokasi = $pemberi_lokasi_id !== null ? $pemberi_lokasi_id : null;
if($usesAutoId){
$upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId);
} else {
$upd->bind_param('sssii', $tanggal_date, $pemberi, $assistance_notes, $p_lokasi, $recordId);
}
@ $upd->execute();
$upd->close();
}
} else {
// Record doesn't exist: insert new with jumlah=1
if($pemberi_lokasi_id !== null){
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, pemberi_lokasi_id, jumlah) VALUES (?, ?, ?, ?, ?, 1)');
if($ins){
if($usesAutoId){
$ins->bind_param('isssi', $lid, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id);
} else {
$ins->bind_param('isssi', $id, $tanggal_date, $pemberi, $assistance_notes, $pemberi_lokasi_id);
}
@ $ins->execute();
$ins->close();
}
} else {
$ins = $conn->prepare('INSERT INTO bantuan_detail (lokasi_id, tanggal_bantuan, pemberi_bantuan, catatan, jumlah) VALUES (?, ?, ?, ?, 1)');
if($ins){
if($usesAutoId){
$ins->bind_param('isss', $lid, $tanggal_date, $pemberi, $assistance_notes);
} else {
$ins->bind_param('isss', $id, $tanggal_date, $pemberi, $assistance_notes);
}
@ $ins->execute();
$ins->close();
}
}
}
}
}
} catch (Exception $e) {
error_log('Insert bantuan_detail failed during update: ' . $e->getMessage());
}
echo json_encode(["success"=>true]);
} else {
http_response_code(500);
echo json_encode(["success"=>false, "error"=>$conn->error]);
}
?>
+142
View File
@@ -0,0 +1,142 @@
<?php
include __DIR__ . '/../config/koneksi.php';
include __DIR__ . '/../config/auth.php';
header('Content-Type: application/json; charset=utf-8');
$currentUser = require_bearer_login();
if(empty($_FILES['file']) || empty($_POST['id'])){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'missing_file_or_id']);
exit;
}
$id = $_POST['id'];
$id = trim((string)$id);
function lokasi_id_uses_auto_increment($conn){
$st = $conn->prepare("SELECT DATA_TYPE, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lokasi' AND COLUMN_NAME = 'id' LIMIT 1");
if(!$st){ return false; }
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if(!$row){ return false; }
$dataType = strtolower((string)$row['DATA_TYPE']);
$extra = strtolower((string)$row['EXTRA']);
return strpos($extra, 'auto_increment') !== false || in_array($dataType, array('tinyint','smallint','mediumint','int','bigint'), true);
}
$usesAutoId = lokasi_id_uses_auto_increment($conn);
if($usesAutoId){
if(!ctype_digit($id)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']); exit; }
$id = intval($id);
} elseif($id === ''){
http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_id']);
exit;
}
// Enforce manager ownership for uploads
try{
if(isset($currentUser['role']) && $currentUser['role'] === 'manager'){
$owner = null;
$selOwner = $conn->prepare('SELECT created_by FROM lokasi WHERE id = ? LIMIT 1');
if($selOwner){
if($usesAutoId){ $selOwner->bind_param('i', $id); } else { $selOwner->bind_param('s', $id); }
$selOwner->execute();
$rOwner = $selOwner->get_result();
$rowOwner = $rOwner ? $rOwner->fetch_assoc() : null;
$selOwner->close();
$owner = $rowOwner && isset($rowOwner['created_by']) ? intval($rowOwner['created_by']) : null;
}
if($owner !== intval($currentUser['id'])){ http_response_code(403); echo json_encode(['success'=>false,'error'=>'forbidden']); exit; }
}
}catch(Exception $e){ /* ignore */ }
$file = $_FILES['file'];
if($file['error'] !== UPLOAD_ERR_OK){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'upload_error']); exit; }
// validate size (<=5MB)
if($file['size'] > 5 * 1024 * 1024){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'file_too_large']); exit; }
// validate image type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if(strpos($mime,'image/') !== 0){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_image_type']); exit; }
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$filenameOnly = pathinfo($file['name'], PATHINFO_FILENAME);
$safeName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $filenameOnly) . '.' . $ext;
// normalize uploads directory path and create if needed
$root = realpath(__DIR__ . '/../../');
$targetDir = $root ? ($root . '/uploads') : (__DIR__ . '/../../uploads');
if(!is_dir($targetDir)){
@mkdir($targetDir, 0777, true);
}
@chmod($targetDir, 0777);
// ensure extension preserved
$target = $targetDir . '/' . $id . '_' . time() . '_' . $safeName;
// ensure target directory is writable
@chmod($targetDir, 0777);
$tmp = $file['tmp_name'];
if(!is_uploaded_file($tmp)){
http_response_code(500);
echo json_encode(['success'=>false,'error'=>'not_uploaded_file','tmp'=>$tmp,'targetDir'=>$targetDir,'resolved_root'=>$root]);
exit;
}
// do not abort here; some filesystems (fuse/ntfs) report not-writable
// we'll attempt move and fall back to PHP temp dir if it fails
if(!move_uploaded_file($file['tmp_name'], $target)){
// Do not attempt other fallbacks. Return an explicit error so the caller knows upload failed.
$last = error_get_last();
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'move_failed',
'tmp' => $tmp,
'target' => $target,
'is_uploaded' => is_uploaded_file($tmp),
'target_writable' => is_writable($targetDir),
'last_error' => $last,
'resolved_root' => $root
]);
exit;
}
$webPath = 'uploads/' . basename($target);
// fetch existing photo_path so we can remove it after successful update
$old = null;
$sel = $conn->prepare('SELECT photo_path FROM lokasi WHERE id = ?');
if($sel){
$sel->bind_param($usesAutoId ? 'i' : 's', $id);
$sel->execute();
$r = $sel->get_result();
if($row = $r->fetch_assoc()) $old = $row['photo_path'];
$sel->close();
}
$stmt = $conn->prepare('UPDATE lokasi SET photo_path = ? WHERE id = ?');
if(!$stmt){ http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]); exit; }
$stmt->bind_param($usesAutoId ? 'si' : 'ss', $webPath, $id);
$res = $stmt->execute();
$stmt->close();
if($res){
// remove previous uploaded file if it was stored under uploads/
if($old){
// extract basename in case old contains query params
$oldBase = basename(parse_url($old, PHP_URL_PATH));
$oldFull = $targetDir . '/' . $oldBase;
if(file_exists($oldFull) && is_file($oldFull)){
@unlink($oldFull);
}
}
echo json_encode(['success'=>true,'path'=>$webPath]);
} else {
http_response_code(500); echo json_encode(['success'=>false,'error'=>$conn->error]);
}
?>
+271
View File
@@ -0,0 +1,271 @@
<?php
include_once __DIR__ . '/config.php';
if(session_status() === PHP_SESSION_NONE) session_start();
function start_session_if_needed(){ if(session_status() === PHP_SESSION_NONE) session_start(); }
function auth_headers(){
if(function_exists('getallheaders')){
$headers = getallheaders();
if(is_array($headers)) return $headers;
}
$headers = [];
foreach($_SERVER as $key => $value){
if(strpos($key, 'HTTP_') === 0){
$name = str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($key, 5)))));
$headers[$name] = $value;
}
}
return $headers;
}
function auth_header_value($name){
$headers = auth_headers();
foreach($headers as $key => $value){
if(strtolower($key) === strtolower($name)) return $value;
}
return null;
}
function auth_json_response($statusCode, $payload){
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload);
exit;
}
function base64url_encode($data){ return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
function base64url_decode($data){ return base64_decode(strtr($data, '-_', '+/')); }
function jwt_issue($claims, $ttlSeconds = 86400){
$now = time();
$payload = array_merge([
'iss' => 'webgis',
'iat' => $now,
'exp' => $now + max(60, intval($ttlSeconds)),
], $claims);
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
$encodedHeader = base64url_encode(json_encode($header));
$encodedPayload = base64url_encode(json_encode($payload));
$signature = hash_hmac('sha256', $encodedHeader . '.' . $encodedPayload, JWT_SECRET, true);
return $encodedHeader . '.' . $encodedPayload . '.' . base64url_encode($signature);
}
function jwt_verify($token){
if(!$token || strpos($token, '.') === false) return null;
$parts = explode('.', $token);
if(count($parts) !== 3) return null;
list($h, $p, $s) = $parts;
$header = json_decode(base64url_decode($h), true);
$payload = json_decode(base64url_decode($p), true);
if(!$header || !$payload) return null;
if(!isset($header['alg']) || $header['alg'] !== 'HS256') return null;
$expected = base64url_encode(hash_hmac('sha256', $h . '.' . $p, JWT_SECRET, true));
if(!hash_equals($expected, $s)) return null;
if(isset($payload['exp']) && time() > intval($payload['exp'])) return null;
return $payload;
}
function auth_basic_credentials(){
$header = auth_header_value('Authorization');
if(!$header && isset($_SERVER['PHP_AUTH_USER'])){
return [$_SERVER['PHP_AUTH_USER'], isset($_SERVER['PHP_AUTH_PW']) ? $_SERVER['PHP_AUTH_PW'] : ''];
}
if(!$header || stripos($header, 'Basic ') !== 0) return [null, null];
$decoded = base64_decode(substr($header, 6));
if($decoded === false || strpos($decoded, ':') === false) return [null, null];
list($user, $pass) = explode(':', $decoded, 2);
return [$user, $pass];
}
function auth_internal_cipher(){
return 'aes-256-gcm';
}
function auth_internal_key(){
return hash('sha256', INTERNAL_AUTH_KEY, true);
}
function internal_auth_issue($claims, $ttlSeconds = 300){
$payload = array_merge([
'iss' => 'webgis-internal',
'iat' => time(),
'exp' => time() + max(30, intval($ttlSeconds)),
'nonce' => bin2hex(random_bytes(8))
], $claims);
$plaintext = json_encode($payload);
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt($plaintext, auth_internal_cipher(), auth_internal_key(), OPENSSL_RAW_DATA, $iv, $tag);
if($ciphertext === false) return null;
return base64url_encode($iv . $tag . $ciphertext);
}
function internal_auth_verify($token){
if(!$token) return null;
$raw = base64url_decode($token);
if($raw === false || strlen($raw) < 28) return null;
$iv = substr($raw, 0, 12);
$tag = substr($raw, 12, 16);
$ciphertext = substr($raw, 28);
$plaintext = openssl_decrypt($ciphertext, auth_internal_cipher(), auth_internal_key(), OPENSSL_RAW_DATA, $iv, $tag);
if($plaintext === false) return null;
$payload = json_decode($plaintext, true);
if(!$payload) return null;
if(isset($payload['exp']) && time() > intval($payload['exp'])) return null;
return $payload;
}
function get_auth_context(){
start_session_if_needed();
global $conn;
$context = ['user' => null, 'mode' => null];
$internalToken = auth_header_value('X-Internal-Auth');
if($internalToken){
$internal = internal_auth_verify($internalToken);
if($internal){
$context['mode'] = 'internal';
$context['user'] = [
'id' => isset($internal['sub']) ? intval($internal['sub']) : 0,
'email' => isset($internal['email']) ? $internal['email'] : 'internal',
'name' => isset($internal['name']) ? $internal['name'] : 'Internal',
'organization' => isset($internal['organization']) ? $internal['organization'] : 'Internal',
'role' => isset($internal['role']) ? $internal['role'] : 'admin',
'status' => 'active'
];
return $context;
}
}
$bearer = auth_header_value('Authorization');
if($bearer && stripos($bearer, 'Bearer ') === 0){
$payload = jwt_verify(trim(substr($bearer, 7)));
if($payload && isset($payload['sub'])){
$uid = intval($payload['sub']);
if($uid > 0 && $conn){
$st = $conn->prepare('SELECT id, email, name, organization, role, status FROM users WHERE id = ? LIMIT 1');
if($st){
$st->bind_param('i', $uid);
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if($row){
$context['mode'] = 'jwt';
$context['user'] = $row;
return $context;
}
}
}
}
}
list($basicUser, $basicPass) = auth_basic_credentials();
if($basicUser !== null){
if($basicUser === BASIC_API_USER && hash_equals(BASIC_API_PASS, (string)$basicPass)){
$context['mode'] = 'basic';
$context['user'] = ['id' => 0, 'email' => 'api', 'name' => 'API', 'organization' => 'API', 'role' => 'admin', 'status' => 'active'];
return $context;
}
if($conn){
$st = $conn->prepare('SELECT id, email, name, organization, role, status, password_hash FROM users WHERE email = ? LIMIT 1');
if($st){
$st->bind_param('s', $basicUser);
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if($row && password_verify((string)$basicPass, $row['password_hash'])){
unset($row['password_hash']);
$context['mode'] = 'basic';
$context['user'] = $row;
return $context;
}
}
}
}
if(isset($_SESSION['jwt'])){
$payload = jwt_verify($_SESSION['jwt']);
if($payload && isset($payload['sub']) && $conn){
$uid = intval($payload['sub']);
$st = $conn->prepare('SELECT id, email, name, organization, role, status FROM users WHERE id = ? LIMIT 1');
if($st){
$st->bind_param('i', $uid);
$st->execute();
$r = $st->get_result();
$row = $r ? $r->fetch_assoc() : null;
$st->close();
if($row){
$context['mode'] = 'jwt';
$context['user'] = $row;
return $context;
}
}
}
}
return $context;
}
function get_current_user_info(){
$context = get_auth_context();
return $context['user'];
}
function require_auth($allowedModes = ['jwt','basic','internal']){
$context = get_auth_context();
$user = $context['user'];
if(!$user){ auth_json_response(401, ['success'=>false,'error'=>'unauthenticated']); }
if(isset($user['status']) && $user['status'] !== 'active'){
auth_json_response(403, ['success'=>false,'error'=>'account_not_active']);
}
if($allowedModes && $context['mode'] && !in_array($context['mode'], (array)$allowedModes, true)){
auth_json_response(403, ['success'=>false,'error'=>'forbidden']);
}
return $user;
}
function require_login(){
return require_auth(['jwt','basic','internal']);
}
function require_bearer_login(){
$context = get_auth_context();
if(!$context['user'] || $context['mode'] !== 'jwt'){
auth_json_response(401, ['success'=>false,'error'=>'bearer_required']);
}
if(isset($context['user']['status']) && $context['user']['status'] !== 'active'){
auth_json_response(403, ['success'=>false,'error'=>'account_not_active']);
}
return $context['user'];
}
function require_role($roles){
$user = require_login();
if(!in_array($user['role'], (array)$roles, true)){
auth_json_response(403, ['success'=>false,'error'=>'forbidden']);
}
return $user;
}
function auth_current_mode(){
$context = get_auth_context();
return $context['mode'];
}
function auth_issue_user_token($userId, $role, $extra = [], $ttlSeconds = 86400){
return jwt_issue(array_merge(['sub' => intval($userId), 'role' => $role], $extra), $ttlSeconds);
}
function auth_set_session_jwt($token){
start_session_if_needed();
$_SESSION['jwt'] = $token;
}
function auth_clear_session(){
start_session_if_needed();
foreach(['jwt','user_id'] as $key){ if(isset($_SESSION[$key])) unset($_SESSION[$key]); }
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// Load environment from project root .env if present, then read API_KEY
function load_dotenv($path){
if(!file_exists($path)) return;
$lines = file($path, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES);
foreach($lines as $line){
if(trim($line)==='' || strpos(trim($line),'#')===0) continue;
if(strpos($line,'=')===false) continue;
list($k,$v) = explode('=', $line, 2);
$k = trim($k); $v = trim($v);
// strip optional quotes
if((substr($v,0,1)==='"' && substr($v,-1)==='"') || (substr($v,0,1)==="'" && substr($v,-1)==="'")){
$v = substr($v,1,-1);
}
putenv("$k=$v");
$_ENV[$k] = $v;
}
}
$envPath = realpath(__DIR__ . '/../../.env');
if($envPath){ load_dotenv($envPath); }
$apiKey = getenv('API_KEY');
if(!$apiKey) {
// fallback (development) - but prefer .env or system env in production
$apiKey = '8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c';
}
define('API_KEY', $apiKey);
$jwtSecret = getenv('JWT_SECRET');
if(!$jwtSecret) {
$jwtSecret = hash('sha256', $apiKey . ':jwt');
}
define('JWT_SECRET', $jwtSecret);
$internalKey = getenv('INTERNAL_AUTH_KEY');
if(!$internalKey) {
$internalKey = hash('sha256', $apiKey . ':internal');
}
define('INTERNAL_AUTH_KEY', $internalKey);
$basicApiUser = getenv('BASIC_API_USER');
if(!$basicApiUser) { $basicApiUser = 'webgis-api'; }
define('BASIC_API_USER', $basicApiUser);
$basicApiPass = getenv('BASIC_API_PASS');
if(!$basicApiPass) { $basicApiPass = hash('sha256', $apiKey . ':basic'); }
define('BASIC_API_PASS', $basicApiPass);
?>
+15
View File
@@ -0,0 +1,15 @@
<?php
include_once __DIR__ . '/config.php';
$dbHost = getenv('DB_HOST') ?: 'localhost';
$dbPort = (int)(getenv('DB_PORT') ?: 3306);
$dbUser = getenv('DB_USER') ?: 'root';
$dbPass = getenv('DB_PASS') ?: 'ilham';
$dbName = getenv('DB_NAME') ?: 'poverty_db';
$conn = new mysqli($dbHost, $dbUser, $dbPass, $dbName, $dbPort);
if ($conn->connect_error) {
die("Koneksi gagal: " . $conn->connect_error);
}
?>
+1
View File
@@ -0,0 +1 @@
/home/ilham/webgis_uploads