# Server pembaruan & dasbor admin (PHP + MySQL)

Melayani `https://parentalcontrol.hanyajasa.com/update/…` dan `/admin/` di domain yang sama dengan backend Kotlin
(akun, pairing, sinkronisasi), yang diteruskan Nginx ke `127.0.0.1:8080`. Lihat `deploy/nginx.conf.example`.

| Alamat | Dibuat oleh | Fungsi |
|---|---|---|
| `/update/check.php` | PHP | Aplikasi bertanya: ada versi baru? wajib update? |
| `/update/apk/*.apk` | Nginx (statis) | Unduhan APK |
| `/admin/` | PHP | Dasbor: rilis, paksa update, daftar perangkat, audit |
| `/v1/*`, `/privasi`, `/health` | Backend Kotlin | Akun, pairing, sinkronisasi, kebijakan privasi |

## Cara kerja pembaruan wajib

Server menyimpan **rilis terbaru** dan **versi minimum** per saluran (`stable`). Aplikasi mengirim nomor versinya
(`versionCode`); jawabannya:

| Kondisi | Status | Yang terjadi di aplikasi |
|---|---|---|
| versi aplikasi ≥ rilis terbaru | `UP_TO_DATE` | tidak ada |
| versi aplikasi < rilis terbaru, ≥ minimum | `UPDATE_AVAILABLE` | dialog "Pembaruan tersedia" (bisa "Nanti"; muncul lagi sehari kemudian) |
| versi aplikasi < minimum | `UPDATE_REQUIRED` | **layar penuh**: aplikasi tidak bisa dipakai sampai diperbarui |

**Memaksa update** = menaikkan versi minimum (di dasbor: *Ringkasan → Paksa pembaruan*, atau centang *Wajibkan*
saat menerbitkan rilis). **Mencabut paksaan** = isi 0. Jika minimum lebih besar daripada rilis terbaru yang aktif,
server otomatis menurunkannya ke rilis terbaru, jadi aplikasi tidak pernah dipaksa menuju berkas yang tidak ada.

Hal yang sengaja dirancang begitu:
- Pengawasan di HP anak (layanan latar belakang) **tetap berjalan** selama layar pembaruan wajib tampil. Yang terkunci hanya tampilan aplikasi.
- Jika HP offline, jawaban terakhir dari server tetap berlaku: pembaruan wajib tidak hilang hanya karena internet dimatikan.
- Aplikasi memeriksa saat dibuka (paling cepat tiap 15 menit), dan lewat tombol *Periksa pembaruan* (Akun / layar anak / layar awal).

## Keamanan pembaruan (penting)

Server ini **tidak pernah memegang kunci tanda tangan** Anda. Aplikasi mengunduh APK lalu, sebelum memasangnya, memeriksa:
1. alamat unduhan: `https`, host dan port sama dengan server pembaruan, di dalam folder `/update/`, berakhiran `.apk`;
2. ukuran dan **SHA-256** sama dengan yang diumumkan server (unduhan dihentikan bila lebih besar dari yang dijanjikan);
3. nama paket, **nomor versi** di dalam APK sama dengan yang diumumkan, dan lebih baru dari yang terpasang;
4. APK **ditandatangani dengan kunci yang sama** dengan aplikasi yang terpasang.

Karena poin 4, orang yang membobol server ini (atau akun admin) tetap **tidak bisa** memasukkan APK palsu ke HP pengguna:
Android menolak APK yang kuncinya berbeda. Ia hanya bisa mengganggu (misalnya memaksa update ke berkas yang gagal terpasang),
yang dapat dibatalkan dengan menurunkan versi minimum. Jaga file keystore Anda: simpan di komputer build, **bukan di server**, dan buat cadangan.
**Jika keystore hilang, aplikasi yang terpasang tidak bisa diperbarui lagi** (harus dihapus dan dipasang ulang).

## Instalasi di Ubuntu

Prasyarat: Nginx, PHP 8.1+ (FPM, ekstensi `pdo_mysql`), MySQL 5.7+/8 atau MariaDB 10.3+, HTTPS (certbot).

```bash
sudo apt install nginx php-fpm php-mysql mysql-server certbot python3-certbot-nginx
```

**1. Basis data** (pengguna aplikasi hanya diberi hak DML; skema diimpor oleh root):
```bash
sudo mysql -e "CREATE DATABASE parentalcontrol CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  CREATE USER 'parentalcontrol'@'localhost' IDENTIFIED BY 'GANTI-DENGAN-SANDI-ACAK';
  GRANT SELECT, INSERT, UPDATE, DELETE ON parentalcontrol.* TO 'parentalcontrol'@'localhost';"
sudo mysql parentalcontrol < schema.sql
```

**2. Salin berkas** (tanpa folder `tests`):
```bash
sudo mkdir -p /var/www/parentalcontrol
sudo rsync -a --exclude tests --exclude config.php server-php/ /var/www/parentalcontrol/
sudo chown -R root:root /var/www/parentalcontrol
sudo chown www-data:www-data /var/www/parentalcontrol/public/update/apk   # dasbor menulis APK di sini
```

**3. Konfigurasi** (menyimpan sandi basis data, jangan masuk git):
```bash
sudo cp config.sample.php /var/www/parentalcontrol/config.php
sudo nano /var/www/parentalcontrol/config.php          # isi db.pass; periksa base_url dan package_name
sudo chown root:www-data /var/www/parentalcontrol/config.php && sudo chmod 640 /var/www/parentalcontrol/config.php
```

**4. PHP**: izinkan unggahan APK dan sembunyikan versi PHP, `/etc/php/8.x/fpm/conf.d/90-parental.ini`:
```ini
upload_max_filesize = 80M
post_max_size = 85M
expose_php = Off
```
`sudo systemctl restart php8.x-fpm`. (Tanpa ini, gunakan `scp` untuk menyalin APK ke folder `public/update/apk/`; dasbor
menawarkan berkas yang sudah ada di sana.)

**5. Nginx**: pakai `deploy/nginx.conf.example` (sesuaikan nama soket PHP-FPM), lalu
`sudo nginx -t && sudo systemctl reload nginx`. Direkomendasikan membatasi `/admin/` ke alamat IP Anda (ada contohnya di berkas itu).

**6. Admin pertama** (akun admin hanya bisa dibuat lewat server, tidak lewat web):
```bash
cd /var/www/parentalcontrol && sudo -u www-data php bin/create-admin.php ryan
```
Kata sandi minimal 12 karakter. Untuk mengganti kata sandi: tambahkan `--reset`.

**7. Pembersihan terjadwal** (opsional; `check.php` juga membersihkan sesekali):
```
17 3 * * *  www-data  php /var/www/parentalcontrol/bin/prune.php
```

**8. Coba**:
```bash
curl "https://parentalcontrol.hanyajasa.com/update/check.php?package=com.hanyajasa.parentalcontrol&versionCode=1"
# {"status":"UP_TO_DATE", ...}  -> berhasil. Lalu buka https://parentalcontrol.hanyajasa.com/admin/
```

## Alur menerbitkan versi baru

1. Di `gradle.properties` proyek Android, **naikkan `appVersionCode`** (bilangan bulat yang hanya boleh naik) dan ubah `appVersionName`.
2. Bangun rilis bertanda tangan. Sekali saja, buat keystore dan simpan sandinya di `~/.gradle/gradle.properties` (bukan di repo):
   ```bash
   keytool -genkeypair -v -keystore parentalcontrol.jks -alias parentalcontrol -keyalg RSA -keysize 4096 -validity 10000
   ```
   ```properties
   releaseStoreFile=C:/keys/parentalcontrol.jks
   releaseStorePassword=...
   releaseKeyAlias=parentalcontrol
   releaseKeyPassword=...
   ```
   Lalu `./gradlew assembleRelease` → `app/build/outputs/apk/release/app-release.apk`.
3. Dasbor → **Rilis → Rilis baru**: isi nomor versi (**harus sama persis** dengan `appVersionCode`) dan nama versi, unggah APK (atau
   `scp` dulu ke `public/update/apk/` lalu pilih dari daftar), tulis catatan rilis, centang *Wajibkan* bila perlu → **Publikasikan**.
4. Pastikan di sebuah HP: buka aplikasi → Akun → *Periksa pembaruan*.

Salah menaikkan nomor? Server menolak nomor yang tidak lebih besar dari rilis sebelumnya. Rilis bermasalah? **Tarik** di tab Rilis:
rilis sebelumnya menjadi yang terbaru lagi, dan paksaan yang menunjuk ke rilis yang ditarik otomatis diturunkan.

## API publik: `GET /update/check.php`

Parameter: `package` (wajib, harus `com.hanyajasa.parentalcontrol`), `versionCode` (wajib, bilangan bulat ≥ 0), `channel` (opsional, bawaan `stable`).
Opsional, **hanya dikirim aplikasi setelah pengguna menyetujui kebijakan privasi**: `installId` (UUID acak), `role` (`PARENT`/`CHILD`/`NONE`), `sdk`, `versionName`.

```json
{
  "status": "UPDATE_REQUIRED",
  "currentVersionCode": 1,
  "minSupportedVersionCode": 3,
  "message": "Perbaikan keamanan penting.",
  "latest": {
    "versionCode": 3,
    "versionName": "1.2.0",
    "apkUrl": "https://parentalcontrol.hanyajasa.com/update/apk/parentalcontrol-1.2.0-3.apk",
    "sha256": "…64 hex…",
    "sizeBytes": 4412345,
    "releaseNotes": "…"
  },
  "serverTime": "2026-10-30T12:00:00+00:00"
}
```
`latest` bernilai `null` bila belum ada rilis. Kesalahan: `400 {"error": "..."}` (parameter salah), `405` (bukan GET), `500`.
Jawaban tidak pernah di-cache (`Cache-Control: no-store`).

## Data yang disimpan (cocok dengan kebijakan privasi)

Tabel `devices`: ID instalasi acak, paket, saluran, nomor dan nama versi, versi Android (API), peran, waktu pertama dan terakhir terlihat.
Tidak ada akun, email, anak, lokasi, atau nomor perangkat. Dihapus otomatis **180 hari** setelah terakhir terlihat (`device_retention_days`),
dan bisa dihapus manual lewat tombol *Lupakan* di dasbor. Jika Anda mengubah angka 180, ubah juga `backend/src/main/resources/privasi.html`
(sebuah tes memeriksa keduanya) dan naikkan versi persetujuan.

## Keamanan dasbor

- Hanya lewat HTTPS; kuki sesi `HttpOnly`, `Secure`, `SameSite=Strict`; id sesi diganti saat login (anti session fixation); sesi habis setelah 30 menit tak aktif.
- Token CSRF di setiap formulir; semua perubahan lewat POST. Kebijakan keamanan konten ketat (tanpa JavaScript, tanpa gaya sebaris), tidak bisa dibingkai.
- Kata sandi disimpan sebagai hash (`password_hash`); 5 kegagalan per pengguna/alamat atau 20 per alamat mengunci login 15 menit; waktu respons sama untuk pengguna yang tidak ada.
- Nama berkas APK dipilih server (bukan nama dari klien), jalur berkas divalidasi, dan berkas harus berupa arsip ZIP.
- Semua perubahan tercatat di tab *Audit*.
- Tambahkan lapisan kedua: batasi `/admin/` ke IP Anda di Nginx, atau pakai `auth_basic`.

## Pengujian

Butuh PHP CLI dengan ekstensi `pdo_sqlite` (tes memakai SQLite dan `schema.sql` yang sama, tanpa MySQL):
```bash
php tests/run.php     # tes unit: aturan keputusan, publikasi, registri perangkat, login, tampilan (44 tes)
php tests/smoke.php   # tes uji ujung-ke-ujung lewat HTTP sungguhan: login, CSRF, unggah APK, paksa update (20 tes)
```
Catatan: tes belum dijalankan terhadap MySQL sungguhan. Setelah instalasi, jalankan langkah **8. Coba** dan satu siklus rilis di dasbor.
Jika ada galat SQL, periksa log PHP-FPM (`/var/log/php8.x-fpm.log`) dan Nginx (`/var/log/nginx/error.log`).
