# CDN Vault v0.3.0 — kode lengkap

Paket PHP cPanel dengan login email/password/CAPTCHA, pendaftaran yang disetujui admin, dashboard privat berdasarkan sesi, navigasi aktif sesuai role dan pengamanan permintaan. Panduan instalasi serta pembaruan ada pada README.md; batas keamanan, WAF/DNSSEC dan patch PHP ada pada KEAMANAN.md. ZIP dan dokumen ini berisi sumber yang sama. Konfigurasi produksi dan kredensial tidak disertakan.

## Daftar berkas

- `KEAMANAN.md`
- `README.md`
- `VALIDASI.md`
- `cdnvault_app/.htaccess`
- `cdnvault_app/autoload.php`
- `cdnvault_app/bootstrap.php`
- `cdnvault_app/config/.htaccess`
- `cdnvault_app/config/config.example.php`
- `cdnvault_app/cron/.htaccess`
- `cdnvault_app/cron/cleanup.php`
- `cdnvault_app/database/.htaccess`
- `cdnvault_app/database/schema.sql`
- `cdnvault_app/routes.php`
- `cdnvault_app/src/.htaccess`
- `cdnvault_app/src/Auth.php`
- `cdnvault_app/src/Captcha.php`
- `cdnvault_app/src/Client.php`
- `cdnvault_app/src/Config.php`
- `cdnvault_app/src/Controllers/AdminController.php`
- `cdnvault_app/src/Controllers/AdminUserController.php`
- `cdnvault_app/src/Controllers/AuthController.php`
- `cdnvault_app/src/Controllers/DashboardController.php`
- `cdnvault_app/src/Controllers/UpgradeController.php`
- `cdnvault_app/src/Csrf.php`
- `cdnvault_app/src/DB.php`
- `cdnvault_app/src/HttpException.php`
- `cdnvault_app/src/Installer.php`
- `cdnvault_app/src/Logger.php`
- `cdnvault_app/src/Mailer.php`
- `cdnvault_app/src/RateLimiter.php`
- `cdnvault_app/src/RequestGuard.php`
- `cdnvault_app/src/Response.php`
- `cdnvault_app/src/Router.php`
- `cdnvault_app/src/Services/.htaccess`
- `cdnvault_app/src/Services/DashboardService.php`
- `cdnvault_app/src/Services/UpgradeService.php`
- `cdnvault_app/src/Services/UserAdminService.php`
- `cdnvault_app/src/Session.php`
- `cdnvault_app/src/Validator.php`
- `cdnvault_app/src/View.php`
- `cdnvault_app/src/helpers.php`
- `cdnvault_app/storage/.htaccess`
- `cdnvault_app/storage/cache/.htaccess`
- `cdnvault_app/storage/logs/.htaccess`
- `cdnvault_app/storage/private/.htaccess`
- `cdnvault_app/storage/private/sessions/.htaccess`
- `cdnvault_app/views/.htaccess`
- `cdnvault_app/views/admin/system.php`
- `cdnvault_app/views/admin/user.php`
- `cdnvault_app/views/admin/users.php`
- `cdnvault_app/views/auth/change.php`
- `cdnvault_app/views/auth/forgot.php`
- `cdnvault_app/views/auth/login.php`
- `cdnvault_app/views/auth/register.php`
- `cdnvault_app/views/auth/reset.php`
- `cdnvault_app/views/auth/upgrade.php`
- `cdnvault_app/views/dashboard.php`
- `cdnvault_app/views/errors/error.php`
- `cdnvault_app/views/guest.php`
- `cdnvault_app/views/install/locked.php`
- `cdnvault_app/views/install/wizard.php`
- `cdnvault_app/views/installer.php`
- `cdnvault_app/views/layout.php`
- `cdnvault_app/views/partials/captcha.php`
- `cdnvault_app/views/partials/flashes.php`
- `cdnvault_app/views/partials/head.php`
- `cdnvault_app/views/partials/icons.php`
- `cdnvault_app/views/partials/navigation.php`
- `public_html/.htaccess`
- `public_html/.user.ini`
- `public_html/app-path.php`
- `public_html/assets/css/app.css`
- `public_html/assets/img/favicon.svg`
- `public_html/assets/img/logo.svg`
- `public_html/assets/js/app.js`
- `public_html/assets/js/ui.js`
- `public_html/cdn/.htaccess`
- `public_html/index.php`
- `public_html/install/index.php`
- `public_html/upgrade/index.php`

## Isi setiap berkas

### KEAMANAN.md

````markdown
# Pengamanan CDN Vault v0.3.0

Paket ini memperbaiki navigasi dan pembatasan akun di aplikasi PHP. Pemasangan kode belum mengubah konfigurasi hosting, DNS, Cloudflare, atau akun registrar Anda. Pengaturan pada bagian hosting perlu diterapkan melalui akun masing-masing.

## Akses menu dan data

| Pengunjung | Menu dan izin |
| --- | --- |
| Belum login | Login, daftar, lupa/reset password, CAPTCHA |
| Pendaftaran menunggu/ditolak/nonaktif | Tidak dapat masuk dashboard |
| User disetujui dan aktif | Dashboard pribadi, ubah password sendiri, keluar |
| Admin disetujui dan aktif | Dashboard pribadi, ubah password, kelola pengguna, diagnostik server, keluar |

Menu fitur lanjutan yang belum memiliki halaman telah dikeluarkan dari navigasi. Semua tautan menu yang ditampilkan memiliki route. Mengunggah/galeri gambar, folder, tautan, API key, dan berbagi masih belum tersedia dalam paket tahap ini; menyembunyikan placeholder tidak mengimplementasikan fitur tersebut.

Router memeriksa sesi dan role di server. Mengetik URL `/admin`, detail pengguna, atau endpoint persetujuan secara langsung tetap ditolak untuk user. Kebijakan route secara default memerlukan login; route publik harus ditandai secara eksplisit. Prefix admin tetap memerlukan admin meskipun route baru kelak salah diberi kebijakan publik.

Dashboard mengambil identitas, kuota, statistik, serta lima aktivitas terakhir berdasarkan ID dari sesi terverifikasi. Parameter `user_id`, `owner_id`, dan `id` dari URL/form tidak menentukan pemilik dashboard atau akun yang diganti passwordnya. User tidak memperoleh daftar akun lain. Admin tetap dapat membaca data akun yang diperlukan untuk persetujuan dan administrasi, sesuai permintaan panel admin; dashboard admin sendiri tetap menampilkan statistik pribadinya.

Default visibilitas pada skema/pengaturan akun baru adalah `private`. Direktori CDN masih menolak akses publik. Versi ini tidak membuat endpoint unduh, share publik, atau upload. Perubahan ini tidak menarik kembali file yang pernah Anda taruh secara manual di lokasi publik lain. Bila kelak menambahkan endpoint aset/folder, setiap query wajib menyertakan pemilik dari sesi dan pemeriksaan hubungan objek, termasuk folder induk.

## Kontrol pada aplikasi

| Ancaman | Kontrol yang ada pada paket |
| --- | --- |
| SQL injection | PDO native prepared statements, parameter terikat, multi-statement dimatikan; nama kolom/filter/aksi dibatasi pilihan tetap; ID diperiksa |
| Brute force | CAPTCHA sekali pakai, pembatasan IP + email dan akun lintas IP, penghitung dengan transaksi/penguncian database, respons 429 dan Retry-After |
| XSS | Escaping HTML untuk data pengguna dan pencarian, CSP dengan nonce, tanpa unsafe-inline/eval; JavaScript memakai textContent untuk pesan |
| CSRF | Token acak pada semua form POST/AJAX, verifikasi Origin jika tersedia, penolakan Sec-Fetch-Site cross-site, cookie SameSite=Lax |
| Pencurian/fiksasi sesi | ID sesi berubah saat login, HttpOnly, Secure untuk HTTPS, mode ketat; idle 2 jam dan absolut 12 jam; status/versi sesi diperiksa lagi |
| Akses antar akun | Pemilik dari sesi, pemeriksaan role server, endpoint admin menolak user, tidak ada direktori user umum |
| Permintaan berat | Batas formulir 64 KB, batas request dinamis, batas render/refresh CAPTCHA, format form dan metode HTTP dibatasi |
| Manipulasi domain | Host sesuai base_url, redirect ke domain konfigurasi, penolakan formulir HTTP ketika base_url HTTPS, HSTS |
| Eksposur file | Kode/config/storage di luar webroot, .htaccess privat, directory listing mati; arsip, SQL, log, konfigurasi dan dokumen kode ditolak di webroot |
| Plugin/CMS | Paket tidak menggunakan CMS/plugin/Composer atau library JavaScript dari luar; runtime PHP/server tetap harus diperbarui |

Password disimpan sebagai Argon2id jika tersedia, dengan fallback bcrypt cost 12. Perubahan dan reset password mencabut semua sesi serta token lama. Nonaktivasi atau perubahan email/role oleh admin juga mencabut sesi. Akun dengan password sementara wajib menggantinya dahulu. Tidak ada password admin bawaan. CAPTCHA lokal mengurangi otomasi dasar; untuk bot canggih gunakan challenge/WAF di lapisan depan.

## Batas permintaan

| Jenis | Batas |
| --- | --- |
| Seluruh request PHP aplikasi yang sudah dipasang | 180 request per IP per 60 detik; aset statis tidak dihitung |
| Login | 100 percobaan per IP per 15 menit |
| Login gagal IP + email | Maksimal 5 reservasi gagal dalam jendela berjalan 15 menit |
| Login satu email dari seluruh IP | Maksimal 20 reservasi per 15 menit, termasuk reservasi yang berhasil dan yang ditolak batas pasangan |
| Pendaftaran | 10 per IP per jam |
| Gambar CAPTCHA | 40 render valid per IP per menit |
| Refresh CAPTCHA AJAX | 30 per IP per menit, juga 20 per sesi per menit |
| Lupa password | 5 per IP per 15 menit dan 3 per email per jam |
| Ganti password | 10 per akun per 15 menit |

Batas bersifat sementara dan pulih setelah jendelanya lewat. Pembatasan per akun turut membatasi login sah ketika akun sedang diserang; sesi sah yang sudah ada tidak dicabut hanya karena batas ini. Gunakan WAF/challenge untuk mengurangi serangan sebelum mencapai pembatasan aplikasi. Lupa password tetap tersedia dengan batasnya sendiri. Pengujian lokal mencakup reservasi paralel dan header IP palsu.

Formulir versi ini hanya menerima `application/x-www-form-urlencoded`. Tidak ada upload aktif, sehingga `.htaccess`, `.user.ini`, dan PHP membatasi form ke 64 KB. Jika membuat fitur upload kelak, buat endpoint khusus dengan batas file/piksel/kuota dan pemeriksaan MIME; jangan sekadar menaikkan semua batas tanpa validasi.

## HTTPS dan reverse proxy

1. Aktifkan AutoSSL di cPanel dan gunakan `base_url` HTTPS dengan satu domain utama yang benar. Jika login menghasilkan pesan domain tidak sesuai, periksa domain, port, dan subfolder pada `config/config.php`.
2. Bila memakai Cloudflare, gunakan proxy pada record web dan mode SSL/TLS **Full (strict)** dengan sertifikat origin yang sah. Mode ini memverifikasi sertifikat server asal. Hindari mode Flexible karena origin HTTP dapat menyebabkan penolakan form/redirect berulang pada konfigurasi HTTPS.
3. Pilihan utama untuk IP asli adalah modul server tepercaya yang diatur oleh penyedia hosting. Dengan cara ini, biarkan `security.trusted_proxies` kosong.
4. Jika penyedia mengharuskan pemulihan header di PHP, tambahkan hanya IP/CIDR proxy yang benar-benar berada di depan origin. Contoh struktur berikut memakai alamat dokumentasi, bukan konfigurasi siap pakai:

```php
'security' => [
    'trusted_proxies' => ['192.0.2.10/32', '2001:db8:1234::/48'],
    'client_ip_header' => 'HTTP_CF_CONNECTING_IP',
],
```

Header pengunjung dapat dipilih antara `HTTP_CF_CONNECTING_IP` atau `HTTP_X_REAL_IP`. `X-Forwarded-For` tidak diinterpretasikan sebagai daftar proxy. Aplikasi hanya membaca header IP dan X-Forwarded-Proto jika REMOTE_ADDR termasuk proxy tepercaya; header langsung dari pengunjung diabaikan. CIDR wildcard `/0` ditolak. Jangan mempercayai seluruh internet. Jika menggunakan Cloudflare, minta hosting mengonfirmasi jalur proxy dan gunakan daftar IP resmi terkini; perbarui jika berubah. Jika sudah memakai pemulihan REMOTE_ADDR pada server, jangan sekaligus mengonfigurasikan ulang header secara sembarangan.

## DDoS pada hosting

Rate limit PHP mengurangi beban aplikasi setelah permintaan sampai ke server. Ia tidak menyerap serangan yang memenuhi bandwidth, koneksi, PHP worker, atau database sebelum kode berjalan.

- Gunakan layanan proxy/CDN dengan proteksi DDoS dan WAF di depan domain web. Cloudflare menyediakan mitigasi jaringan dan HTTP pada infrastrukturnya; periksa fitur yang tersedia pada akun Anda.
- Terapkan rate limit/challenge edge pada login, daftar, lupa password, refresh CAPTCHA, dan updater. Uji bahwa form dan gambar CAPTCHA masih bekerja. Sesuaikan ambang menurut trafik sah, termasuk jaringan kantor yang berbagi IP.
- Bypass cache untuk seluruh HTML dinamis, `/app*`, `/admin*`, `/login`, `/register`, `/captcha*`, reset/lupa password, `/install*`, dan `/upgrade*`. Jangan memakai Cache Everything untuk halaman akun. Cache hanya aset statis CSS/JS/gambar logo.
- Minta penyedia menutup akses origin langsung selain dari proxy tepercaya jika konfigurasi hosting mendukungnya; sertifikat origin dan firewall perlu tetap mengizinkan pemeliharaan/AutoSSL sesuai arahan penyedia.
- Aktifkan ModSecurity/WAF hosting dan minta batas koneksi/request/worker yang sesuai paket cPanel. Pantau CPU, memory, disk, dan 429/5xx. Hindari menjalankan uji beban agresif di shared hosting tanpa koordinasi.

Tidak ada jaminan kebal DDoS. Batas dan kapasitas upstream tetap menentukan ketersediaan.

## DNS spoofing dan keamanan domain

PHP tidak memvalidasi jawaban DNS yang sudah diterima browser sebelum koneksi tiba. Untuk melindungi resolusi domain:

1. Aktifkan DNSSEC pada penyedia DNS authoritative. Bila memakai Cloudflare DNS dengan registrar lain, tambahkan **DS record yang persis diberikan** ke registrar, lalu periksa status validasinya. Jangan memakai DS contoh atau DS lama setelah mengganti DNS; delegasi yang tidak cocok dapat membuat domain tidak bisa diakses.
2. Aktifkan MFA pada registrar, penyedia DNS, Cloudflare dan cPanel, serta kunci transfer domain. Batasi akun yang dapat mengubah DNS dan simpan recovery code dengan aman.
3. Gunakan HTTPS dengan sertifikat domain yang sah. HSTS dikirim aplikasi setelah diakses melalui HTTPS. DNSSEC dan TLS melindungi lapisan yang berbeda; keduanya tidak menggantikan keamanan akun registrar atau aplikasi.
4. Tinjau record DNS dan sertifikat secara berkala. Hapus record subdomain yang menunjuk layanan yang sudah ditinggalkan.

## Pembaruan, izin, dan pembersihan

Gunakan cabang PHP yang masih didukung dengan patch keamanan terbaru dari penyedia. Kode diuji di PHP 8.3; kompatibilitas sintaks 8.1 tidak berarti runtime 8.1 yang sudah tidak didukung aman untuk produksi. Perbarui juga Apache/LiteSpeed, MariaDB/MySQL dan panel hosting. Ekstensi PHP adalah bagian runtime hosting, bukan plugin CMS dalam paket ini.

Pada **21 Oktober 2026**, proyek PHP mengumumkan perbaikan dua celah RCE kritis, CVE-2026-109618 dan CVE-2026-109619. Versi upstream yang memperbaikinya adalah **8.2.35, 8.3.36, 8.4.27, dan 8.5.12**. Untuk paket vendor dengan backport, minta penyedia mengonfirmasi patch yang setara. Sumber: [pengumuman keamanan resmi PHP](https://www.php.net/index.php). Jangan menganggap rate limit, CSP, atau .user.ini sebagai pengganti pembaruan runtime: serangan tertentu terjadi sebelum kode aplikasi dijalankan. Installer baru memerlukan PHP 8.2 atau lebih baru; target pengujian aplikasi tetap PHP 8.3.

Simpan app/config/storage di luar public_html. File config 600 dan folder privat 700 dianjurkan; jangan 777. Jangan unggah ZIP, dokumen kode lengkap, backup, atau kredensial pengujian ke webroot. Hapus installer setelah pemasangan; hapus updater setelah migrasi v0.1 selesai. Pertahankan config.php, APP_KEY, storage dan install.lock saat mengganti kode.

Jadwalkan pembersihan catatan kedaluwarsa melalui cPanel Cron Jobs, misalnya tiap jam. Pilih path executable PHP CLI yang benar dari penyedia:

```text
0 * * * * /usr/local/bin/php /home/USER/cdnvault_app/cron/cleanup.php
```

Script hanya berjalan melalui CLI, memiliki lock agar tidak tumpang tindih, dan membatasi batch. Yang dihapus: percobaan login lebih dari 24 jam, bucket throttle lebih dari 24 jam, token reset yang sudah lama kedaluwarsa, token remember kedaluwarsa dan sesi kedaluwarsa/idle. Data akun, aset dan riwayat audit tidak dihapus. File sesi PHP tetap memakai mekanisme garbage collection PHP; minta hosting memastikan session GC berjalan. Pantau ukuran audit/log dan simpan backup sesuai kebutuhan.

## Referensi pengaturan

- [OWASP Authorization](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
- [OWASP Authentication](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- [Cloudflare Full (strict)](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/full-strict/)
- [Cloudflare DDoS](https://developers.cloudflare.com/ddos-protection/about/how-ddos-protection-works/)
- [Cloudflare DNSSEC](https://developers.cloudflare.com/dns/dnssec/)
- [Daftar IP Cloudflare](https://www.cloudflare.com/ips/)
- [PHP supported versions](https://www.php.net/supported-versions.php)
````

### README.md

````markdown
# CDN Vault — Revisi v0.3.0: menu aktif, dashboard pribadi, dan pengamanan akses

Paket aplikasi PHP untuk cPanel, Apache 2.4, atau LiteSpeed, dengan pendaftaran publik yang wajib disetujui admin. Backend memakai PHP native dan PDO MySQL; frontend memakai CSS sendiri dan JavaScript ES modules. Tidak memerlukan Composer, Node, npm, framework, atau dependensi dari CDN. Instalasi belum dilakukan di hosting Anda.

## Pembaruan dari v0.2.0

Penyebab menu lama tidak merespons adalah placeholder fitur lanjutan yang belum memiliki halaman. Navigasi sekarang hanya memuat halaman yang aktif:

| Role | Menu |
| --- | --- |
| User | Dashboard pribadi, ubah password, keluar |
| Admin | Dashboard pribadi, ubah password, kelola pengguna, diagnostik server, keluar |

User tidak menerima tautan/markup menu admin dan tetap ditolak saat mencoba URL admin langsung. Statistik serta aktivitas pribadi tidak dapat diganti dengan parameter ID akun lain. Path dengan garis miring akhir dan instalasi subfolder didukung. Sidebar mobile dan navigasi saat JavaScript nonaktif tersedia.

Untuk instalasi v0.2.0, backup terlebih dahulu lalu ganti file kode menggunakan paket ini. **Pertahankan `config/config.php`, APP_KEY, `install.lock`, isi storage dan `public_html/app-path.php` yang disesuaikan.** Skema tetap versi 2, sehingga tidak ada migrasi ulang dan tidak perlu menjalankan installer. Versi tampilan berasal dari kode baru. Setelah mengunggah `.user.ini`, tunggu sampai pengaturan PHP dimuat ulang sesuai hosting.

Panduan ancaman, batas rate limit, HTTPS/proxy tepercaya, WAF/DDoS, DNSSEC dan cron tersedia di **[KEAMANAN.md](KEAMANAN.md)**. Proteksi DNS/DDoS memerlukan konfigurasi penyedia; paket kode tidak memasang pengaturan tersebut otomatis. Perhatikan pengumuman patch PHP 21 Oktober 2026 yang dijelaskan di panduan tersebut sebelum menjalankan di produksi.

## Arsitektur

`public_html/index.php` memuat bootstrap dari `cdnvault_app`, lalu router menjalankan middleware autentikasi, pemeriksaan role, CSRF, controller, dan view. Koneksi database menggunakan prepared statements. Konfigurasi, sesi, dan log berada di folder aplikasi di luar webroot. Waktu database dan log memakai UTC.

| Lokasi akhir | Isi |
| --- | --- |
| `/home/USER/cdnvault_app/` | Kode aplikasi, konfigurasi, database, penyimpanan privat |
| `/home/USER/public_html/` | Front controller, installer, CSS, JavaScript, logo |
| `/home/USER/cdnvault_app/config/config.php` | Dibuat otomatis oleh installer; tidak disertakan dalam ZIP |
| `/home/USER/cdnvault_app/install.lock` | Dibuat setelah instalasi berhasil |
| `/home/USER/public_html/cdn/` | Masih ditutup sampai pipeline upload Tahap 2 selesai |

`USER` berarti nama akun hosting Anda. Untuk subdomain, lokasi webroot dapat berbeda. Daftar folder dan setiap file lengkap tersedia di `KODE-LENGKAP.md`.

## Fitur revisi ini

- Installer empat langkah: persyaratan server, koneksi database, akun admin, penyelesaian konfigurasi dan skema.
- Kunci pemasangan yang dibaca melalui File Manager untuk memverifikasi kepemilikan hosting.
- Pendaftaran memakai email, password, konfirmasi password, dan CAPTCHA. Akun baru selalu berperan user, nonaktif, dan berstatus pending sampai disetujui admin.
- Login hanya memakai email, password, dan CAPTCHA, tanpa username atau login sosial. Admin masuk ke /admin/users; pengguna masuk ke /app.
- Panel admin untuk menyetujui/menolak pendaftaran, mengaktifkan/menonaktifkan akun, mengubah nama/email/role/kuota, membuat pengguna langsung, serta mereset password sementara.
- CSRF pada formulir dan header AJAX, password Argon2id dengan fallback bcrypt cost 12, dan pembaruan hash saat dibutuhkan.
- Pembatasan lima login gagal per pasangan IP + email selama 15 menit. Reservasi percobaan dikunci di database sehingga permintaan paralel tidak melewati hitungan.
- Checkbox “Ingat saya” dihilangkan dari login sesuai revisi. Login baru tidak menerbitkan token remember walaupun parameter itu dipalsukan. Penanganan token lama tetap memeriksa persetujuan dan status aktif; pembaruan v0.1 mencabut token lama.
- CAPTCHA gambar PNG lokal lima karakter, tidak peka huruf besar/kecil, terikat sesi dan formulir, kedaluwarsa lima menit, sekali pakai, dan dapat diperbarui. Tidak perlu API key CAPTCHA pihak ketiga.
- Sesi HttpOnly, SameSite=Lax, Secure jika HTTPS; ID sesi berubah setelah login. Timeout idle dua jam, batas absolut 12 jam.
- Lupa/reset password sekali pakai, berlaku satu jam. Reset dan perubahan password mencabut seluruh sesi serta token “Ingat saya”.
- Halaman penggantian password, termasuk pengalihan wajib untuk akun dengan password sementara.
- Dashboard pribadi dengan statistik dan aktivitas milik akun sendiri, navigasi aktif berdasarkan role, layout terang/gelap/sistem, sidebar desktop, drawer mobile, serta banner offline.
- Penolakan domain/Origin yang tidak sesuai, path ambigu, form lebih dari 64 KB dan metode yang tidak didukung; rate limit request dan CAPTCHA.
- Batas akun lintas IP untuk percobaan login dan dukungan proxy tepercaya yang harus dikonfigurasi eksplisit.
- Diagnostik server khusus admin dan pengiriman email uji ke admin yang sedang masuk.
- Pemeriksaan status approved dan is_active dilakukan di server pada setiap akses. Nonaktivasi, perubahan identitas/role, dan reset password mencabut sesi yang terkait.
- Admin yang sedang digunakan tidak dapat menonaktifkan atau menurunkan perannya sendiri. Perubahan admin diserialkan lewat penguncian database agar minimal satu admin aktif tetap ada.
- Pembaruan skema dari v0.1 melalui /upgrade/ dengan kredensial admin lama, CSRF, CAPTCHA, serta backup konfigurasi otomatis.
- Halaman 403/404/405/419/500, header keamanan/CSP, escaping view, dan log berotasi dengan referensi error tanpa mencatat password/token.
- Skema lengkap 15 tabel: 13 tabel dalam spesifikasi ditambah `user_sessions` dan `throttle_buckets` untuk pencabutan sesi dan penguncian rate limit.

## Alur pendaftaran dan persetujuan

1. Pengguna membuka `/register`, mengisi email, password, konfirmasi, dan CAPTCHA. Tidak ada kolom username.
2. Server membuat akun dengan `role=user`, `approval_status=pending`, serta `is_active=0`. Mengirim role/status lewat parameter tidak dapat mengubah ketentuan ini.
3. Pengguna memperoleh informasi bahwa pendaftaran menunggu keputusan admin. Belum ada sesi login yang dibuat.
4. Admin login menggunakan email, password, dan CAPTCHA yang sama bentuknya, lalu masuk ke `/admin/users`.
5. Admin memilih filter **Menunggu persetujuan**, membuka detail akun, lalu memilih **Setujui** atau **Tolak**. Catatan peninjauan dapat ditambahkan di halaman detail.
6. Akun yang disetujui menjadi `approved` dan aktif. Pengguna boleh login menggunakan email, password, dan CAPTCHA. Persetujuan berlaku untuk akun; admin tidak perlu menyetujui ulang setiap kali pengguna login.
7. Akun yang ditolak tetap tidak bisa login. Tombol **Aktifkan** hanya berlaku untuk akun yang sudah disetujui; pendaftaran ditolak harus disetujui dahulu jika keputusan ditinjau kembali.
8. **Nonaktifkan akun** memblokir login berikutnya dan mencabut semua sesi serta token akun saat itu.

Admin juga dapat membuat akun langsung dengan password sementara. Akun tersebut langsung disetujui dan harus mengganti password saat masuk pertama. Password sementara tidak dikirim lewat email secara otomatis. Admin dapat mengatur role user/admin dan kuota; kuota tidak dapat diturunkan di bawah ukuran data yang sudah terpakai.

CAPTCHA dibuat sendiri memakai GD. Tombol **Ganti kode** memakai AJAX pada login/pendaftaran; jika JavaScript tidak aktif, formulir memuat kode baru lewat POST. Jawaban gambar tidak dimasukkan ke HTML, URL, log, atau database. Gambar memerlukan sesi/cookie yang sama dengan formulir. Pastikan ekstensi GD aktif.

## Memperbarui instalasi v0.1 yang sudah ada

1. Backup database, `config/config.php`, dan seluruh penyimpanan melalui cPanel. Perubahan tabel MySQL memakai commit implisit, sehingga backup database tetap diperlukan.
2. Unggah kode revisi ini. Pertahankan konfigurasi produksi `cdnvault_app/config/config.php`, `install.lock`, folder storage, dan lokasi CDN Anda. Jangan mengganti APP_KEY. Jika sebelumnya mengubah `public_html/app-path.php`, pertahankan path khusus tersebut.
3. Buka website. Konfigurasi versi lama otomatis mengarah ke `/upgrade/`.
4. Isi **email admin lama**, password admin lama, dan CAPTCHA. Hanya administrator lama yang aktif dapat menjalankan pembaruan; tidak ada akun/default password khusus updater.
5. Updater menambahkan kolom persetujuan dan index yang belum ada. Akun lama dianggap sudah disetujui; akun yang sebelumnya nonaktif tetap nonaktif. Password, data pengguna, pengaturan mail, base URL, dan APP_KEY dipertahankan.
6. Konfigurasi lama disalin otomatis ke `cdnvault_app/config/config.before-0.2.php` dengan izin privat. File ini tidak boleh dibagikan karena berisi kredensial.
7. Setelah selesai, semua sesi, token remember, serta tautan reset lama dicabut. Login kembali melalui formulir baru, lalu buka menu **Kelola pengguna**.
8. Jika pembaruan terhenti, perbaiki penyebab di log dan ulangi `/upgrade/`. Penambahan kolom/index bersifat idempoten. Setelah versi skema 2 tersimpan, updater hanya menampilkan status sudah terbaru dan tidak menjalankan migrasi lagi. Folder `public_html/upgrade` dapat dihapus setelah pembaruan berhasil.

Untuk pemasangan baru, jalankan `/install/` seperti panduan berikut; skema persetujuan langsung dibuat sehingga tidak perlu menjalankan updater.

## Instalasi cPanel

1. Di **MultiPHP Manager**, pilih PHP 8.3 dengan patch keamanan terbaru dari penyedia (upstream 8.3.36 atau lebih baru untuk pengumuman 21 Oktober 2026, atau backport vendor setara). Installer memerlukan minimal PHP 8.2 yang masih didukung dan dipatch; runtime aplikasi diuji dengan PHP 8.3.
2. Di **Select PHP Version**, aktifkan `pdo_mysql`, `mbstring`, `fileinfo`, `gd`, `curl`, `zip`, `exif`, dan `openssl`. Dukungan GD WebP dianjurkan; fallback JPEG akan dipasang pada Tahap 2. `dom` ditampilkan di diagnostik sebagai kemampuan tambahan.
3. Aktifkan SSL melalui **SSL/TLS Status → AutoSSL**. Buka wizard lewat HTTPS agar kredensial database dan akun admin terlindungi saat dikirim.
4. Di **MySQL Databases**, buat database kosong khusus CDN Vault dan pengguna database. Tambahkan pengguna tersebut ke database dengan **ALL PRIVILEGES**. Catat nama lengkap beserta awalan akun cPanel. Installer membuat tabel, bukan membuat database atau pengguna MySQL di akun cPanel.
5. Unggah ZIP melalui **File Manager** ke folder home akun, lalu **Extract**. Pindahkan folder `cdnvault_app` ke lokasi sejajar dengan `public_html`. Pindahkan *isi* folder `public_html` dari paket ke webroot tujuan. Jangan letakkan `README.md`, `VALIDASI.md`, atau `KODE-LENGKAP.md` di webroot.
6. Aktifkan **Settings → Show Hidden Files** di File Manager. Pastikan `.htaccess` dan `.user.ini` ikut tersalin.
7. Struktur default langsung ditemukan oleh `public_html/app-path.php`. Jika lokasi berbeda, edit file itu agar mengembalikan path absolut folder aplikasi yang benar. File tersebut berisi satu pengaturan path; jangan menaruh kredensial database di sana.
8. Mulai dengan izin folder **755** dan file **644**. Folder `config`, `storage/private`, `storage/private/sessions`, `storage/logs`, `storage/cache`, root aplikasi, dan `public_html/cdn` harus dapat ditulis oleh PHP. Jika model izin hosting memerlukannya, gunakan **775** pada folder yang perlu ditulis; jangan memakai **777**. Installer berusaha mengencangkan folder privat ke 700 dan `config.php` ke 600.
9. Buka `https://domain-anda/install/`. Halaman pertama membuat `cdnvault_app/config/setup-key.txt`. Buka file tersebut melalui File Manager, salin kuncinya, lalu masukkan ke formulir. Kunci sengaja tidak ditampilkan melalui browser.
10. Selesaikan empat langkah. Tombol **Uji koneksi** tersedia sebelum melanjutkan ke akun admin. Password akun memakai 12–72 byte; huruf ASCII memakai satu byte per karakter. Password tidak disimpan sebagai teks asli.
11. Pada langkah terakhir, pastikan URL utama sesuai domain/subfolder sebenarnya dan memakai HTTPS. Alamat email pengirim sebaiknya kotak email yang tersedia pada domain Anda.
12. Setelah berhasil, installer membuat APP_KEY acak, konfigurasi, akun admin, tabel, dan `install.lock`, lalu mengarahkan Anda ke login. Hapus folder **`public_html/install`** melalui File Manager. Installer juga menolak berjalan jika konfigurasi atau lock sudah ada.
13. Masuk, buka **Diagnostik server**, periksa kemampuan hosting, dan kirim email uji.

Jangan menyalin `config.example.php` menjadi `config.php` sebelum menjalankan wizard. Keberadaan `config.php` memang mengunci installer. Tidak ada akun atau password bawaan dalam paket. Pendaftaran pengguna publik kini tersedia melalui /register, tetapi akun baru tidak bisa login sebelum disetujui.

Jika tabel sudah diimpor manual melalui phpMyAdmin, installer tetap bisa berjalan selama tabel `users` masih kosong dan skemanya sama. Jangan arahkan installer ke database aplikasi lain. Jika terjadi kegagalan di tengah pembuatan tabel, tabel kosong yang sudah dibuat boleh tersisa; wizard memakai `CREATE TABLE IF NOT EXISTS` dan tidak menghapus data database.

## Subdomain atau subfolder

Untuk subdomain seperti `cdn.domainanda.com`, buat subdomain di menu **Domains**, pilih document root-nya, lalu unggah isi `public_html` paket ke sana. Simpan `cdnvault_app` di luar document root dan sesuaikan `app-path.php` jika perlu. Aktifkan AutoSSL untuk subdomain dan isi URL tersebut pada wizard.

Subfolder seperti `https://domainanda.com/vault` juga didukung. Letakkan isi webroot dalam `public_html/vault`, isi URL utama dengan `/vault`, lalu ubah `app-path.php` ke path absolut folder aplikasi. Router, URL aset, dan path cookie mengikuti URL konfigurasi.

## Alternatif aplikasi di dalam public_html

Gunakan hanya jika hosting benar-benar tidak mengizinkan lokasi di luar webroot:

1. Letakkan `cdnvault_app` di dalam webroot.
2. Ganti isi `public_html/app-path.php` dengan kode lengkap berikut:

```php
<?php
declare(strict_types=1);

return __DIR__ . '/cdnvault_app';
```

3. Pastikan `cdnvault_app/.htaccess` dan seluruh `.htaccess` pelindung folder aplikasi terunggah. Root `.htaccess` juga menolak akses URL ke folder aplikasi.
4. Melalui browser, uji akses langsung ke `/cdnvault_app/config/config.example.php`, `/cdnvault_app/database/schema.sql`, dan `/cdnvault_app/storage/`. Semuanya harus ditolak. Jangan memakai alternatif ini jika server mengabaikan perlindungan `.htaccess`; minta bantuan penyedia hosting.

## Konfigurasi email

Default menggunakan `mail()`. Untuk hosting yang memerlukan SMTP, buka `cdnvault_app/config/config.php`, cari `mail.smtp`, dan isi host, port, username, serta password dari **Email Accounts → Connect Devices**. Gunakan `enabled => true`, `encryption => 'tls'` untuk STARTTLS port 587, atau `encryption => 'ssl'` untuk TLS langsung port 465. Sertifikat server selalu diverifikasi. Jangan menonaktifkan verifikasi untuk mengatasi kesalahan sertifikat.

Konfigurasi SMTP tidak memerlukan perubahan tabel database. Gunakan mailbox pengirim yang sah dari domain Anda. Setelah diatur, masuk sebagai admin lalu gunakan tombol **Kirim email uji**. Status berhasil berarti server pengirim menerima pesan; penerimaan inbox tetap dipengaruhi konfigurasi email penyedia, SPF/DKIM, dan filter spam.

Respons lupa password selalu sama untuk alamat yang terdaftar dan tidak terdaftar, termasuk saat pengiriman gagal. Jika email tidak diterima, admin perlu memeriksa SMTP dan log aplikasi. Token reset tidak ditampilkan pada layar atau log.

## Pengujian manual setelah upload

- [ ] Tanpa login, `/app`, `/admin/system`, serta URL `/app/...` dan `/admin/...` mengarah ke login.
- [ ] Login admin berhasil; logout bekerja lewat tombol/form POST.
- [ ] Lima password salah pada email yang sama menghasilkan error generik; percobaan berikutnya dibatasi dengan sisa waktu.
- [ ] CAPTCHA wajib diisi: kode salah, kode kedaluwarsa, kode sesi lain, atau kode yang sudah dipakai harus ditolak.
- [ ] Buat akun lewat /register. Status awal harus Menunggu dan belum bisa masuk dashboard.
- [ ] Dari /admin/users, setujui akun tersebut; setelah itu pengguna bisa login.
- [ ] Coba menolak atau menonaktifkan akun. Akun tersebut tidak boleh login; sesi yang sudah aktif harus dicabut.
- [ ] Lupa password mengirim email dan tautannya dapat dipakai satu kali, maksimal satu jam.
- [ ] Setelah reset, password lama ditolak dan sesi perangkat sebelumnya tidak dapat membuka dashboard lagi.
- [ ] HTTPS mengirim cookie `Secure`, `HttpOnly`, dan `SameSite=Lax`.
- [ ] Pengguna biasa tidak dapat membuka `/admin/system`. Pengguna biasa juga tidak boleh melihat atau mengubah persetujuan di /admin/users.
- [ ] Konfigurasi, SQL, sesi, log, dan folder aplikasi ditolak jika dicoba melalui URL.
- [ ] Login, menu, formulir, dan tema nyaman dipakai pada layar 360 px.
- [ ] Folder installer sudah dihapus.

Rangkuman uji lokal dan batas verifikasi tersedia di `VALIDASI.md`.

## Pemecahan masalah

| Gejala | Pemeriksaan |
| --- | --- |
| Folder aplikasi belum ditemukan | Periksa path `public_html/app-path.php`, nama folder, serta apakah ZIP diekstrak satu tingkat terlalu dalam. |
| Error 500 | Pilih PHP yang masih didukung dan dipatch (pengujian aplikasi: PHP 8.3), periksa error log hosting dan `storage/logs/app.log`. Cocokkan referensi error dari layar. |
| Error 500 dari `.htaccess` sebelum halaman PHP tampil | Tanyakan dukungan `AllowOverride` kepada penyedia. Sebagian hosting membatasi `Options`, `Header`, atau modul kompresi. Sesuaikan hanya direktif yang ditolak berdasarkan error log; pertahankan perlindungan folder privat. |
| `/login` atau `/app` menghasilkan 404 dari server | Pastikan `mod_rewrite`/rewrite LiteSpeed aktif dan root `.htaccess` ikut tersalin. |
| Kunci pemasangan belum muncul | Buka `/install/` sekali, lalu periksa folder `cdnvault_app/config` dan izin tulis. |
| Koneksi database gagal | Gunakan nama DB/user dengan awalan cPanel, host dari penyedia, port yang benar, dan ALL PRIVILEGES pada database khusus aplikasi. |
| Installer terkunci setelah konfigurasi sudah ada | Ini perilaku normal. Gunakan login. Untuk pemulihan, pulihkan konfigurasi dari backup; jangan menghapus lock pada database yang berisi pengguna. |
| Login berulang atau sesi langsung hilang | Periksa URL HTTPS, path subfolder, izin `storage/private/sessions`, jam server, dan apakah cookie diblokir browser. |
| CAPTCHA tidak terlihat | Pastikan GD aktif, cookie diizinkan, serta gambar /captcha tidak di-cache oleh proxy/CDN. Gunakan Ganti kode jika sudah lewat lima menit. |
| Akun baru belum bisa login | Buka /admin/users sebagai admin dan periksa status pendaftaran. Akun harus approved dan aktif. |
| Website mengarah ke /upgrade/ | Gunakan akun admin lama dan jalankan pembaruan; jangan menghapus atau mengganti config.php. |
| Email tidak terkirim | Isi SMTP, gunakan port/enkripsi yang sesuai, dan uji melalui diagnostik. Periksa mailbox, sertifikat, serta batas pengiriman hosting. |
| Batas upload berbeda dengan `.user.ini` | Nilai efektif ditampilkan di diagnostik. `.user.ini` dapat diabaikan atau memerlukan waktu untuk dimuat ulang; gunakan MultiPHP INI Editor atau hubungi penyedia. |
| Halaman kosong | Pastikan seluruh file PHP terunggah, versi PHP benar, dan lihat log hosting serta log aplikasi. `display_errors` sengaja dimatikan. |
| IP di rate limit selalu IP proxy | Default memakai `REMOTE_ADDR`. Minta penyedia memulihkan IP di server, atau atur hanya CIDR proxy tepercaya sesuai KEAMANAN.md. Header dari pengunjung langsung diabaikan. |

`KeepAlive` tidak ditulis di `.htaccess` karena pengaturannya berada pada konfigurasi server. Kompresi dan cache aset dipasang jika modul tersedia. Aset CSS/JS memakai versi hash pada URL; halaman akun memakai `no-store`.

## Asumsi dan kelanjutan

- PHP berjalan 64-bit, seperti runtime cPanel umum, agar perhitungan kuota 5 GB dan batas byte tidak melampaui kapasitas integer.
- Database sudah dibuat melalui cPanel. Aplikasi memakai satu database khusus.
- Akun admin pertama dibuat installer. Pengguna dapat mengajukan pendaftaran publik, tetapi hanya admin yang dapat menyetujui. Akun yang dibuat langsung oleh admin dianggap sudah disetujui dan wajib mengganti password sementara.
- Tidak ada fitur upload, galeri, API publik, share, atau PWA aktif pada Tahap 1. Placeholder fitur lanjutan telah dihapus dari navigasi; hanya menu yang berfungsi ditampilkan.
- Kolom TOTP sudah tersedia, tetapi fitur pengaktifan/QR/kode cadangan baru dibuat pada Tahap 7. Jangan mengaktifkan `totp_enabled` secara manual; akun bertanda TOTP aktif ditolak login pada versi ini agar tidak melewati faktor kedua.
- `cron/cleanup.php` sudah tersedia, hanya melalui CLI. Jadwalkan tiap jam untuk membersihkan catatan login, bucket throttle, token dan sesi kedaluwarsa; lihat KEAMANAN.md. Script mempertahankan akun, aset, dan riwayat audit.
- Nilai Lighthouse, LCP, CLS, dan performa galeri ribuan aset belum diukur; target itu baru relevan setelah fitur galeri dan optimasi tersedia.
- Backup `config.php` dan APP_KEY bersama database serta seluruh penyimpanan aplikasi. Jangan mengganti APP_KEY secara acak setelah fitur URL bertanda tangan dan enkripsi tersedia.

Revisi v0.3.0 mempertahankan login CAPTCHA dan panel persetujuan pengguna, memperbaiki navigasi serta memperketat akses dan isolasi akun. Fitur upload, galeri, link/organisasi, berbagi, API publik, dan 2FA tetap mengikuti tahap pengembangan berikutnya; panel persetujuan pengguna sudah aktif pada revisi ini.
````

### VALIDASI.md

````markdown
# Validasi CDN Vault v0.3.0

Revisi navigasi, isolasi akun dan pengamanan permintaan. Pengujian dijalankan secara lokal pada PHP 8.3 (paket Ubuntu), MariaDB 10.11, serta Apache 2.4. Ini adalah verifikasi fungsi dan kontrol aplikasi, bukan sertifikasi keamanan menyeluruh atau pengujian hosting produksi.

## Hasil

- 169 pemeriksaan integrasi PHP/CGI dan database: lulus.
- 15 pemeriksaan akses berkas melalui Apache HTTP sungguhan: lulus, termasuk .htaccess dan jalur validasi SSL yang tetap tersedia.
- Pemeriksaan sintaks seluruh 56 file PHP dan 2 file JavaScript: lulus.
- Dashboard user, daftar admin, tombol sidebar desktop, serta drawer mobile pada iframe 360 px diperiksa di browser. Lebar konten mobile sama dengan viewport; tidak ada overflow horizontal.
- Template untuk QA visual berasal dari respons PHP sesungguhnya. Halaman visual disajikan sebagai fixture statis; perilaku login, role, CSRF, CAPTCHA dan perubahan akun diperiksa terpisah melalui CGI/database. Kredensial tidak dimasukkan ke browser.

## Cakupan penting

Installer baru, pendaftaran pending, pemalsuan role/status, persetujuan admin, dua user dengan aset/statistik berbeda, penolakan URL admin dan seluruh aksi admin untuk user, penolakan parameter pemilik lain, escaping XSS tersimpan/refleksi, payload SQL injection, metode dan path ambigu, Host/Origin, HTTPS/header proxy palsu, CAPTCHA PNG/sesi/sekali pakai/tujuan, throttle lintas IP dan reservasi paralel, pencabutan sesi, reset/password sementara, pembaruan konfigurasi v0.2, migrasi skema v0.1, instalasi subfolder, pembersihan kedaluwarsa, serta fail closed penyimpanan sesi.

Uji limiter umum menempatkan bucket pada ambang untuk memeriksa HTTP 429 dan pemulihannya. Uji reservasi login paralel memakai proses PHP terpisah dengan satu database nyata. Tidak dilakukan simulasi DDoS volumetrik atau uji beban hosting.

## Batas verifikasi

- Belum dipasang atau diuji pada akun cPanel/domain pengguna. Konfigurasi produksi, kredensial database/mail, sertifikat, DNSSEC, firewall origin, WAF, DDoS upstream dan kebijakan cache hosting belum diubah.
- Pengiriman email ke inbox/SMTP produksi tidak diuji dalam revisi ini; pengujian lupa password memeriksa respons dan token pada aplikasi. SMTP tetap memerlukan konfigurasi mailbox yang sah.
- MySQL 5.7/8 dan versi PHP lain tidak dijalankan dalam uji ini. Runtime produksi harus berupa cabang yang didukung dan sudah menerima patch keamanan PHP 21 Oktober 2026 atau backport vendor yang setara; lihat KEAMANAN.md. Runtime QA tidak dibundel untuk dipasang di hosting.
- Tidak ada CMS/plugin di dalam paket. Patch PHP, server, database dan cPanel tetap tanggung jawab lapisan hosting.
- Upload/galeri, folder, tautan, berbagi publik, API dan pengaktifan 2FA belum diimplementasikan; placeholder menu telah dikeluarkan, bukan diubah menjadi fitur palsu.

## Daftar pemeriksaan otomatis

| No. | Pemeriksaan | Hasil |
| --- | --- | --- |
| 1 | Installer page | Lulus |
| 2 | Setup secret absent from HTML | Lulus |
| 3 | Installer CSRF required | Lulus |
| 4 | Installer verifies host ownership | Lulus |
| 5 | Installer database step | Lulus |
| 6 | Installer administrator step | Lulus |
| 7 | Fresh install completes | Lulus |
| 8 | Production config private permissions | Lulus |
| 9 | Setup secret removed | Lulus |
| 10 | Version and schema correct | Lulus |
| 11 | 15 database tables | Lulus |
| 12 | New item default private | Lulus |
| 13 | Installer locked after install | Lulus |
| 14 | Guest authentication /app | Lulus |
| 15 | Guest authentication /app/unknown | Lulus |
| 16 | Guest authentication /admin | Lulus |
| 17 | Guest authentication /admin/users/1 | Lulus |
| 18 | Guest authentication /admin/unknown | Lulus |
| 19 | Anonymous internal API rejected | Lulus |
| 20 | Admin login and landing | Lulus |
| 21 | Admin menu route /admin | Lulus |
| 22 | Admin menu route /admin/ | Lulus |
| 23 | Admin menu route /admin/users | Lulus |
| 24 | Admin menu route /admin/users/ | Lulus |
| 25 | Admin menu route /admin/users/1 | Lulus |
| 26 | Admin menu route /admin/system | Lulus |
| 27 | Admin menu route /app | Lulus |
| 28 | Admin menu route /app/ | Lulus |
| 29 | Admin menu route /app/change-password | Lulus |
| 30 | Admin menu present for administrator | Lulus |
| 31 | Public registration submitted | Lulus |
| 32 | Registration cannot forge privileges | Lulus |
| 33 | Registration privacy default | Lulus |
| 34 | Pending account refused | Lulus |
| 35 | Admin approves registration | Lulus |
| 36 | Approved user enters own dashboard | Lulus |
| 37 | Login session regenerated | Lulus |
| 38 | Secure session cookies | Lulus |
| 39 | Second approved user login | Lulus |
| 40 | User HTML contains no admin menus or URLs | Lulus |
| 41 | Only functional user menu labels | Lulus |
| 42 | Only own identity in user HTML | Lulus |
| 43 | User statistics reflect own records | Lulus |
| 44 | Second user has different own statistics | Lulus |
| 45 | Owner query cannot change dashboard user_id=3 | Lulus |
| 46 | Owner query cannot change dashboard id=3 | Lulus |
| 47 | Owner query cannot change dashboard owner_id=3 | Lulus |
| 48 | Owner query cannot change dashboard user_id=1%20OR%201=1 | Lulus |
| 49 | Owner query cannot change dashboard user_id[]=1 | Lulus |
| 50 | Other user activity timestamp absent | Lulus |
| 51 | User denied admin URL /admin | Lulus |
| 52 | User denied admin URL /admin/ | Lulus |
| 53 | User denied admin URL /admin/users | Lulus |
| 54 | User denied admin URL /admin/users/3 | Lulus |
| 55 | User denied admin URL /admin/system | Lulus |
| 56 | User denied admin URL /admin/does-not-exist | Lulus |
| 57 | User denied admin mutation approve | Lulus |
| 58 | User denied admin mutation reject | Lulus |
| 59 | User denied admin mutation activate | Lulus |
| 60 | User denied admin mutation deactivate | Lulus |
| 61 | User denied admin mutation update | Lulus |
| 62 | User denied admin mutation password | Lulus |
| 63 | User denied account creation | Lulus |
| 64 | Unauthorized changes left victim intact | Lulus |
| 65 | No user directory endpoint | Lulus |
| 66 | No user enumeration API | Lulus |
| 67 | Visible user navigation https://vault.test/app | Lulus |
| 68 | Visible user navigation https://vault.test/app/change-password | Lulus |
| 69 | Logout CSRF required | Lulus |
| 70 | Logout GET does not mutate session | Lulus |
| 71 | Session survives rejected logout | Lulus |
| 72 | Stored XSS escaped in user dashboard | Lulus |
| 73 | Stored XSS escaped in administrator view | Lulus |
| 74 | Reflected search XSS escaped | Lulus |
| 75 | SQL injection in search does not enumerate | Lulus |
| 76 | SQL injection cannot bypass login | Lulus |
| 77 | Stacked SQL disabled | Lulus |
| 78 | CSP nonce and frame protection | Lulus |
| 79 | Private pages never cached | Lulus |
| 80 | Security response header strict-transport-security | Lulus |
| 81 | Security response header x-content-type-options | Lulus |
| 82 | Security response header x-frame-options | Lulus |
| 83 | Security response header cross-origin-opener-policy | Lulus |
| 84 | Security response header cross-origin-resource-policy | Lulus |
| 85 | Spoofed Host rejected | Lulus |
| 86 | Host suffix confusion rejected | Lulus |
| 87 | Foreign origin rejected despite valid token | Lulus |
| 88 | Cross-site fetch rejected | Lulus |
| 89 | Origin suffix confusion rejected | Lulus |
| 90 | Origin attacks leave session usable | Lulus |
| 91 | HTTP GET redirected to configured HTTPS | Lulus |
| 92 | Untrusted proxy cannot spoof HTTPS | Lulus |
| 93 | Oversized request rejected | Lulus |
| 94 | Unsupported body type rejected | Lulus |
| 95 | Ambiguous path rejected /admin%2fusers | Lulus |
| 96 | Ambiguous path rejected /%61dmin%252fusers | Lulus |
| 97 | Ambiguous path rejected /app/../admin/users | Lulus |
| 98 | Ambiguous path rejected /admin\users | Lulus |
| 99 | Ambiguous path rejected //admin/users | Lulus |
| 100 | Ambiguous path rejected /admin%00/users | Lulus |
| 101 | Ambiguous path rejected /%GG | Lulus |
| 102 | Unsupported HTTP method rejected | Lulus |
| 103 | CAPTCHA renders actual PNG | Lulus |
| 104 | CAPTCHA response never cached | Lulus |
| 105 | Other session cannot view CAPTCHA | Lulus |
| 106 | Wrong credentials fail with valid CAPTCHA | Lulus |
| 107 | CAPTCHA cannot be reused | Lulus |
| 108 | CAPTCHA purpose cannot be swapped | Lulus |
| 109 | CAPTCHA refresh CSRF required | Lulus |
| 110 | Authorized CAPTCHA refresh works | Lulus |
| 111 | Failed login counted 1 | Lulus |
| 112 | Failed login counted 2 | Lulus |
| 113 | Failed login counted 3 | Lulus |
| 114 | Failed login counted 4 | Lulus |
| 115 | Failed login counted 5 | Lulus |
| 116 | Sixth attempt throttled despite spoofed IP header | Lulus |
| 117 | Distributed account limit allows only 20 parallel reservations | Lulus |
| 118 | Rotating IP does not bypass account cap | Lulus |
| 119 | Pair lock allows only 5 parallel failures | Lulus |
| 120 | General request flood returns 429 | Lulus |
| 121 | Request cap recovers after window | Lulus |
| 122 | Default headers never trusted | Lulus |
| 123 | Configured IPv4 proxy may supply visitor IP | Lulus |
| 124 | Outside proxy CIDR cannot spoof headers | Lulus |
| 125 | Configured IPv6 proxy recognized | Lulus |
| 126 | Wildcard proxy trust rejected | Lulus |
| 127 | Admin deactivates account | Lulus |
| 128 | Disabled account loses existing session | Lulus |
| 129 | Disabled account cannot log in | Lulus |
| 130 | Admin reactivation works | Lulus |
| 131 | Admin resets temporary password | Lulus |
| 132 | Password reset revokes old session | Lulus |
| 133 | Temporary password forces change | Lulus |
| 134 | Forced-password account cannot skip change | Lulus |
| 135 | Own password change completes | Lulus |
| 136 | Forged user ID did not change other password | Lulus |
| 137 | Login after own password change | Lulus |
| 138 | Cannot deactivate current administrator | Lulus |
| 139 | Existing v0.2 configuration works without migration | Lulus |
| 140 | Already-current schema updater is informational | Lulus |
| 141 | Subfolder login redirects correctly | Lulus |
| 142 | Subfolder dashboard and trailing slash work | Lulus |
| 143 | Subfolder admin remains protected | Lulus |
| 144 | Scheduled cleanup runs as CLI | Lulus |
| 145 | Expired login records removed | Lulus |
| 146 | Cleanup retains users and audit history | Lulus |
| 147 | Legacy configuration redirects to updater | Lulus |
| 148 | Legacy updater requires CSRF | Lulus |
| 149 | User cannot execute legacy migration | Lulus |
| 150 | Legacy administrator migration succeeds | Lulus |
| 151 | Migration preserves APP_KEY | Lulus |
| 152 | Migration records current code version | Lulus |
| 153 | Migration creates private configuration backup | Lulus |
| 154 | Migration revokes all prior sessions | Lulus |
| 155 | Pre-migration session rejected | Lulus |
| 156 | User login after migration | Lulus |
| 157 | Administrator login after migration | Lulus |
| 158 | Noncanonical Unicode email cannot log in | Lulus |
| 159 | Valid reset link accepted | Lulus |
| 160 | Token-based reset completes | Lulus |
| 161 | Reset token marked single-use | Lulus |
| 162 | Reset revokes account sessions | Lulus |
| 163 | Reset preserves other account session | Lulus |
| 164 | Reset token reuse denied | Lulus |
| 165 | Reset cannot alter submitted other owner ID | Lulus |
| 166 | Forgot password does not enumerate accounts | Lulus |
| 167 | Recovery remains available during login throttling | Lulus |
| 168 | Missing private session storage fails closed | Lulus |
| 169 | User remains usable after security regressions | Lulus |
| 170 | Apache access /assets/css/app.css | Lulus |
| 171 | Apache access /.well-known/pki-validation/token.txt | Lulus |
| 172 | Apache access /cdnvault_app/config/config.php | Lulus |
| 173 | Apache access /cdnvault_app/ | Lulus |
| 174 | Apache access /cdn/private.webp | Lulus |
| 175 | Apache access /backup.zip | Lulus |
| 176 | Apache access /dump.sql | Lulus |
| 177 | Apache access /config.env | Lulus |
| 178 | Apache access /README.md | Lulus |
| 179 | Apache access /app.log | Lulus |
| 180 | Apache access /settings.json | Lulus |
| 181 | Apache access /config.bak | Lulus |
| 182 | Apache access /app-path.php | Lulus |
| 183 | Apache access /.user.ini | Lulus |
| 184 | Apache access /.git/config | Lulus |
````

### cdnvault_app/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/autoload.php

````php
<?php
declare(strict_types=1);

if (!defined('VAULT_APP_PATH')) {
    define('VAULT_APP_PATH', __DIR__);
}
spl_autoload_register(static function (string $class): void {
    $prefix = 'Vault\\';
    if (!str_starts_with($class, $prefix)) {
        return;
    }
    $relative = substr($class, strlen($prefix));
    if (!preg_match('/^[A-Za-z0-9_\\\\]+$/D', $relative)) {
        return;
    }
    $file = VAULT_APP_PATH . '/src/' . str_replace('\\', '/', $relative) . '.php';
    if (is_file($file)) {
        require $file;
    }
});
require_once __DIR__ . '/src/helpers.php';
````

### cdnvault_app/bootstrap.php

````php
<?php
declare(strict_types=1);

require_once __DIR__ . '/autoload.php';
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);

set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
    if (!(error_reporting() & $severity)) {
        return false;
    }
    throw new ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(static function (Throwable $exception): void {
    while (ob_get_level() > 0) {
        ob_end_clean();
    }
    if ($exception instanceof \Vault\HttpException) {
        \Vault\Response::error($exception->status, $exception->getMessage());
    }
    $reference = \Vault\Logger::exception($exception);
    \Vault\Response::error(500, 'Layanan sedang mengalami kendala. Silakan coba kembali atau hubungi administrator.', $reference);
});
$reserveMemory = str_repeat('x', 65536);
register_shutdown_function(static function () use (&$reserveMemory): void {
    $error = error_get_last();
    if (!$error || !in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
        return;
    }
    $reserveMemory = null;
    // Cadangan untuk fatal error; detail teknis tidak dikirim ke browser.
    \Vault\Logger::write('error', 'fatal_error', ['file' => basename($error['file']), 'line' => $error['line']]);
    while (ob_get_level() > 0) {
        ob_end_clean();
    }
    if (!headers_sent()) {
        http_response_code(500);
        header('Content-Type: text/plain; charset=UTF-8');
    }
    echo 'Layanan sedang mengalami kendala. Periksa log aplikasi melalui cPanel.';
});

\Vault\Config::load(require __DIR__ . '/config/config.php');
ob_start(function_exists('ob_gzhandler') && !ini_get('zlib.output_compression') ? 'ob_gzhandler' : null);
\Vault\Response::securityHeaders();
\Vault\RequestGuard::validate();
if ((int) \Vault\Config::get('schema_version', 1) < 2) {
    \Vault\Response::redirect(url('/upgrade/'), 302);
}
\Vault\RequestGuard::rateLimit();
\Vault\Session::start();
require __DIR__ . '/routes.php';
````

### cdnvault_app/config/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/config/config.example.php

````php
<?php
declare(strict_types=1);

// Contoh saja: config.php dan APP_KEY dibuat oleh installer.
return [
    'name' => 'CDN Vault',
    'version' => '0.3.0',
    'schema_version' => 2,
    'base_url' => 'https://cdn.domainanda.com',
    'app_key' => '',
    'db' => [
        'host' => 'localhost', 'port' => 3306, 'name' => '', 'user' => '', 'password' => '',
    ],
    'paths' => [
        'public' => dirname(__DIR__, 2) . '/public_html',
        'private' => dirname(__DIR__) . '/storage/private',
        'cdn' => dirname(__DIR__, 2) . '/public_html/cdn',
    ],
    // Default: never trust client-supplied IP/protocol headers. See KEAMANAN.md.
    'security' => [
        'trusted_proxies' => [],
        'client_ip_header' => 'HTTP_CF_CONNECTING_IP',
    ],
    'upload' => ['max_bytes' => 20971520, 'max_pixels' => 40000000, 'quality' => 82],
    'mail' => [
        'from_email' => 'noreply@domainanda.com', 'from_name' => 'CDN Vault',
        'smtp' => [
            'enabled' => false, 'host' => '', 'port' => 587, 'encryption' => 'tls',
            'username' => '', 'password' => '',
        ],
    ],
];
````

### cdnvault_app/cron/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/cron/cleanup.php

````php
<?php
declare(strict_types=1);

if (PHP_SAPI !== 'cli') {
    http_response_code(404);
    exit;
}
define('VAULT_APP_PATH', dirname(__DIR__));
define('VAULT_PUBLIC_PATH', dirname(VAULT_APP_PATH) . '/public_html');
require VAULT_APP_PATH . '/autoload.php';
if (!is_file(VAULT_APP_PATH . '/config/config.php')) {
    fwrite(STDERR, "Konfigurasi belum tersedia.\n");
    exit(1);
}
\Vault\Config::load(require VAULT_APP_PATH . '/config/config.php');
$guard = fopen(VAULT_APP_PATH . '/storage/cache/cleanup.lock', 'c');
if (!$guard || !flock($guard, LOCK_EX | LOCK_NB)) {
    exit;
}
try {
    // Only expired operational records; account data and audit history are retained.
    foreach ([
        'login_attempts' => 'attempted_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)',
        'throttle_buckets' => 'window_start < UNIX_TIMESTAMP() - 86400',
        'password_resets' => 'expires_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)',
        'auth_tokens' => 'expires_at < UTC_TIMESTAMP()',
        'user_sessions' => 'expires_at < UTC_TIMESTAMP() OR last_seen_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 2 HOUR)',
    ] as $table => $condition) {
        for ($batch = 0; $batch < 10; $batch++) {
            $count = \Vault\DB::run('DELETE FROM ' . $table . ' WHERE ' . $condition . ' LIMIT 1000')->rowCount();
            if ($count < 1000) {
                break;
            }
        }
    }
    echo "Pembersihan catatan kedaluwarsa selesai.\n";
} catch (Throwable $exception) {
    $reference = \Vault\Logger::exception($exception);
    fwrite(STDERR, 'Pembersihan belum selesai. Referensi: ' . $reference . "\n");
    exit(1);
} finally {
    flock($guard, LOCK_UN);
    fclose($guard);
}
````

### cdnvault_app/database/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/database/schema.sql

````sql
-- CDN Vault, Tahap 1. Waktu disimpan sebagai UTC.
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS users (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  email VARCHAR(191) NOT NULL,
  display_name VARCHAR(120) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role ENUM('admin','user') NOT NULL DEFAULT 'user',
  storage_quota_bytes BIGINT UNSIGNED NOT NULL DEFAULT 5368709120,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  approval_status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'approved',
  approved_by BIGINT UNSIGNED NULL,
  approved_at DATETIME NULL,
  review_note VARCHAR(500) NULL,
  must_change_password TINYINT(1) NOT NULL DEFAULT 0,
  session_version INT UNSIGNED NOT NULL DEFAULT 1,
  totp_secret TEXT NULL,
  totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
  settings JSON NULL,
  last_login_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_users_email (email),
  KEY idx_users_role (role, is_active),
  KEY idx_users_approval (approval_status, is_active, created_at, id),
  CONSTRAINT fk_users_approver FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS auth_tokens (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  selector CHAR(24) NOT NULL,
  token_hash CHAR(64) NOT NULL,
  expires_at DATETIME NOT NULL,
  user_agent VARCHAR(255) NOT NULL,
  ip VARCHAR(45) NOT NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_auth_selector (selector),
  KEY idx_auth_expiry (expires_at), KEY idx_auth_user (user_id),
  CONSTRAINT fk_auth_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS user_sessions (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  session_hash CHAR(64) NOT NULL,
  user_agent VARCHAR(255) NOT NULL,
  ip VARCHAR(45) NOT NULL,
  last_seen_at DATETIME NOT NULL,
  expires_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_sessions_hash (session_hash),
  KEY idx_sessions_user (user_id, expires_at), KEY idx_sessions_expiry (expires_at),
  CONSTRAINT fk_session_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS password_resets (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  token_hash CHAR(64) NOT NULL,
  expires_at DATETIME NOT NULL,
  used_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_reset_hash (token_hash),
  KEY idx_reset_expiry (expires_at), KEY idx_reset_user (user_id, used_at),
  CONSTRAINT fk_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS login_attempts (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  ip VARCHAR(45) NOT NULL,
  email VARCHAR(191) NOT NULL,
  attempted_at DATETIME NOT NULL,
  success TINYINT(1) NOT NULL DEFAULT 0,
  PRIMARY KEY (id), KEY idx_login_ip_time (ip, attempted_at),
  KEY idx_login_pair (ip, email, attempted_at, success)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS throttle_buckets (
  bucket_key CHAR(64) NOT NULL,
  window_start BIGINT UNSIGNED NOT NULL,
  hits INT UNSIGNED NOT NULL DEFAULT 0,
  PRIMARY KEY (bucket_key), KEY idx_throttle_window (window_start)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS folders (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  parent_id BIGINT UNSIGNED NULL,
  name VARCHAR(191) NOT NULL,
  color CHAR(7) NOT NULL DEFAULT '#6366f1',
  deleted_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  PRIMARY KEY (id), KEY idx_folders_parent (user_id, parent_id),
  CONSTRAINT fk_folder_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  CONSTRAINT fk_folder_parent FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS items (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  folder_id BIGINT UNSIGNED NULL,
  type ENUM('image','link') NOT NULL,
  name VARCHAR(255) NOT NULL,
  description TEXT NULL,
  visibility ENUM('public','private') NOT NULL DEFAULT 'private',
  is_favorite TINYINT(1) NOT NULL DEFAULT 0,
  slug CHAR(8) NOT NULL,
  view_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
  file_key CHAR(36) NULL,
  ext VARCHAR(8) NULL,
  mime_type VARCHAR(64) NULL,
  size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
  width INT UNSIGNED NULL,
  height INT UNSIGNED NULL,
  thumb_size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
  medium_size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
  blur_placeholder TEXT NULL,
  content_hash CHAR(64) NULL,
  is_animated TINYINT(1) NOT NULL DEFAULT 0,
  url TEXT NULL,
  domain VARCHAR(253) NULL,
  favicon_url TEXT NULL,
  og_image_url TEXT NULL,
  og_title VARCHAR(255) NULL,
  og_description TEXT NULL,
  deleted_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_item_slug (slug),
  KEY idx_items_user_date (user_id, deleted_at, created_at, id),
  KEY idx_items_folder (user_id, folder_id, deleted_at, created_at),
  KEY idx_items_type (user_id, type, deleted_at, created_at),
  KEY idx_items_hash (user_id, content_hash),
  FULLTEXT KEY ft_items_content (name, description),
  CONSTRAINT fk_item_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  CONSTRAINT fk_item_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS tags (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  name VARCHAR(120) NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_tag_name (user_id, name),
  CONSTRAINT fk_tag_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS item_tags (
  item_id BIGINT UNSIGNED NOT NULL,
  tag_id BIGINT UNSIGNED NOT NULL,
  PRIMARY KEY (item_id, tag_id), KEY idx_item_tags_tag (tag_id),
  CONSTRAINT fk_item_tag_item FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
  CONSTRAINT fk_item_tag_tag FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS api_keys (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  name VARCHAR(120) NOT NULL,
  key_prefix VARCHAR(20) NOT NULL,
  key_hash CHAR(64) NOT NULL,
  last_used_at DATETIME NULL,
  revoked_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_api_hash (key_hash), KEY idx_api_user (user_id, revoked_at),
  CONSTRAINT fk_api_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS api_rate_limits (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  api_key_id BIGINT UNSIGNED NOT NULL,
  window_start DATETIME NOT NULL,
  hits INT UNSIGNED NOT NULL DEFAULT 0,
  PRIMARY KEY (id), UNIQUE KEY uq_api_window (api_key_id, window_start),
  KEY idx_api_window_expiry (window_start),
  CONSTRAINT fk_api_rate_key FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS share_links (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  item_id BIGINT UNSIGNED NULL,
  folder_id BIGINT UNSIGNED NULL,
  token CHAR(64) NOT NULL,
  password_hash VARCHAR(255) NULL,
  expires_at DATETIME NULL,
  view_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
  revoked_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id), UNIQUE KEY uq_share_token (token),
  KEY idx_share_user (user_id, created_at), KEY idx_share_expiry (expires_at),
  CONSTRAINT fk_share_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  CONSTRAINT fk_share_item FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
  CONSTRAINT fk_share_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS activity_logs (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NULL,
  action VARCHAR(80) NOT NULL,
  item_id BIGINT UNSIGNED NULL,
  meta JSON NULL,
  ip VARCHAR(45) NOT NULL,
  created_at DATETIME NOT NULL,
  PRIMARY KEY (id), KEY idx_activity_user (user_id, created_at),
  CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
  CONSTRAINT fk_activity_item FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS settings (
  `key` VARCHAR(191) NOT NULL,
  `value` LONGTEXT NULL,
  PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
````

### cdnvault_app/routes.php

````php
<?php
declare(strict_types=1);

use Vault\Auth;
use Vault\Captcha;
use Vault\Controllers\AdminController;
use Vault\Controllers\AdminUserController;
use Vault\Controllers\AuthController;
use Vault\Controllers\DashboardController;
use Vault\Response;
use Vault\Router;

$router = new Router();
$auth = new AuthController();
$dashboard = new DashboardController();
$admin = new AdminController();
$users = new AdminUserController();
$router->add('GET', '/', static fn () => Response::redirect(url(Auth::landingPath()), 302), 'public');
$router->add('GET', '/login', [$auth, 'loginForm'], 'public');
$router->add('POST', '/login', [$auth, 'login'], 'public');
$router->add('GET', '/register', [$auth, 'registerForm'], 'public');
$router->add('POST', '/register', [$auth, 'register'], 'public');
$router->add('GET', '/captcha', static fn () => Captcha::render($_GET['challenge'] ?? null), 'public');
$router->add('POST', '/captcha/refresh', static fn () => Captcha::refresh(), 'public');
$router->add('POST', '/logout', [$auth, 'logout'], 'auth');
$router->add('GET', '/forgot-password', [$auth, 'forgotForm'], 'public');
$router->add('POST', '/forgot-password', [$auth, 'forgot'], 'public');
$router->add('GET', '/reset-password/{token}', [$auth, 'resetForm'], 'public');
$router->add('POST', '/reset-password/{token}', [$auth, 'reset'], 'public');
$router->add('GET', '/app', [$dashboard, 'index'], 'auth');
$router->add('GET', '/app/change-password', [$auth, 'changeForm'], 'auth');
$router->add('POST', '/app/change-password', [$auth, 'change'], 'auth');
$router->add('GET', '/admin/system', [$admin, 'system'], 'admin');
$router->add('POST', '/admin/system/test-email', [$admin, 'testEmail'], 'admin');
$router->add('GET', '/admin', static fn () => Response::redirect(url('/admin/users'), 302), 'admin');
$router->add('GET', '/admin/users', [$users, 'index'], 'admin');
$router->add('POST', '/admin/users/create', [$users, 'create'], 'admin');
$router->add('GET', '/admin/users/{id}', [$users, 'show'], 'admin');
foreach (['approve', 'reject', 'activate', 'deactivate', 'update', 'password'] as $action) {
    $router->add('POST', '/admin/users/{id}/' . $action,
        static fn (string $id) => $users->action($id, $action), 'admin');
}
$router->dispatch();
````

### cdnvault_app/src/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/src/Auth.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Auth
{
    private static ?array $current = null;
    private static bool $resolved = false;
    private static string $failureMessage = 'Email atau password salah';
    private const DUMMY_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi';

    public static function attempt(string $email, string $password, bool $remember = false): bool
    {
        self::$failureMessage = 'Email atau password salah';
        // Reject alternate Unicode spellings that a case-insensitive SQL collation
        // might otherwise map to an existing account with a different throttle key.
        if (!Validator::validEmail($email)) {
            return false;
        }
        $user = DB::one('SELECT * FROM users WHERE email = ? LIMIT 1', [$email]);
        $valid = password_verify($password, $user['password_hash'] ?? self::DUMMY_HASH);
        if (!$user || !$valid) {
            return false;
        }
        if (!self::approved($user)) {
            self::$failureMessage = ($user['approval_status'] ?? '') === 'pending'
                ? 'Akun Anda sedang menunggu persetujuan admin. Silakan coba kembali setelah disetujui.'
                : 'Akun belum diaktifkan atau tidak disetujui. Hubungi administrator.';
            return false;
        }
        // Fitur TOTP baru diaktifkan pada tahap 7; akun bertanda aktif ditolak aman.
        if ((bool) $user['totp_enabled']) {
            return false;
        }
        $newHash = Validator::needsRehash($user['password_hash']) ? Validator::hashPassword($password) : null;
        return DB::transaction(static function () use ($user, $remember, $newHash): bool {
            $fresh = DB::one('SELECT * FROM users WHERE id = ? FOR UPDATE', [$user['id']]);
            if (!$fresh || !self::approved($fresh) || (bool) $fresh['totp_enabled']
                || !hash_equals($user['password_hash'], $fresh['password_hash'])) {
                return false;
            }
            if ($newHash !== null) {
                DB::run('UPDATE users SET password_hash = ?, updated_at = UTC_TIMESTAMP() WHERE id = ?', [$newHash, $fresh['id']]);
                $fresh['password_hash'] = $newHash;
            }
            self::establish($fresh);
            self::forgetRemember();
            if ($remember && !(bool) $fresh['must_change_password']) {
                self::issueRemember((int) $fresh['id']);
            }
            Logger::activity((int) $fresh['id'], 'login');
            return true;
        });
    }

    public static function approved(array $user): bool
    {
        return (bool) $user['is_active'] && ($user['approval_status'] ?? '') === 'approved';
    }

    public static function failureMessage(): string
    {
        return self::$failureMessage;
    }

    private static function establish(array $user): void
    {
        Session::establish($user);
        $hash = hash('sha256', session_id());
        DB::run('INSERT INTO user_sessions (user_id, session_hash, user_agent, ip, last_seen_at, expires_at, created_at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL 12 HOUR), UTC_TIMESTAMP())',
            [$user['id'], $hash, Validator::text($_SERVER['HTTP_USER_AGENT'] ?? '', 255), \client_ip()]);
        DB::run('UPDATE users SET last_login_at = UTC_TIMESTAMP() WHERE id = ?', [$user['id']]);
        self::$current = $user;
        self::$resolved = true;
    }

    public static function user(): ?array
    {
        if (self::$resolved) {
            return self::$current;
        }
        self::$resolved = true;
        if (!isset($_SESSION['auth_user_id']) && empty($_SESSION['reauth_required'])) {
            self::resumeRemember();
        }
        if (!isset($_SESSION['auth_user_id'])) {
            return null;
        }
        $user = DB::one('SELECT u.* FROM users u INNER JOIN user_sessions s ON s.user_id = u.id WHERE u.id = ? AND s.session_hash = ? AND s.expires_at > UTC_TIMESTAMP() AND s.last_seen_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 2 HOUR) LIMIT 1',
            [$_SESSION['auth_user_id'], hash('sha256', session_id())]);
        if (!$user || !self::approved($user)
            || (int) $user['session_version'] !== (int) ($_SESSION['auth_version'] ?? 0)) {
            self::logout();
            return null;
        }
        if (time() - (int) ($_SESSION['auth_synced'] ?? 0) >= 60) {
            DB::run('UPDATE user_sessions SET last_seen_at = UTC_TIMESTAMP() WHERE session_hash = ? AND user_id = ?',
                [hash('sha256', session_id()), $user['id']]);
            $_SESSION['auth_synced'] = time();
        }
        $_SESSION['auth_last'] = time();
        self::$current = $user;
        return $user;
    }

    private static function issueRemember(int $userId): void
    {
        $selector = bin2hex(random_bytes(12));
        $token = bin2hex(random_bytes(32));
        DB::run('INSERT INTO auth_tokens (user_id, selector, token_hash, expires_at, user_agent, ip, created_at) VALUES (?, ?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 DAY), ?, ?, UTC_TIMESTAMP())',
            [$userId, $selector, hash('sha256', $token), Validator::text($_SERVER['HTTP_USER_AGENT'] ?? '', 255), \client_ip()]);
        Session::cookie('CDVREMEMBER', $selector . ':' . $token, time() + 2592000);
    }

    private static function resumeRemember(): void
    {
        $cookie = $_COOKIE['CDVREMEMBER'] ?? '';
        if (!is_string($cookie) || !preg_match('/^([a-f0-9]{24}):([a-f0-9]{64})$/D', $cookie, $parts)) {
            return;
        }
        $result = DB::transaction(static function () use ($parts): ?array {
            $token = DB::one('SELECT a.id AS token_id, a.token_hash, a.expires_at, u.* FROM auth_tokens a INNER JOIN users u ON u.id = a.user_id WHERE a.selector = ? AND a.expires_at > UTC_TIMESTAMP() FOR UPDATE', [$parts[1]]);
            if (!$token || !hash_equals($token['token_hash'], hash('sha256', $parts[2]))
                || !self::approved($token) || (bool) $token['must_change_password'] || (bool) $token['totp_enabled']) {
                return null;
            }
            $newToken = bin2hex(random_bytes(32));
            DB::run('UPDATE auth_tokens SET token_hash = ?, ip = ? WHERE id = ?',
                [hash('sha256', $newToken), \client_ip(), $token['token_id']]);
            return ['user' => $token, 'token' => $newToken, 'expires' => strtotime($token['expires_at'] . ' UTC')];
        });
        if (!$result) {
            Session::cookie('CDVREMEMBER', '', time() - 3600);
            return;
        }
        self::establish($result['user']);
        Session::cookie('CDVREMEMBER', $parts[1] . ':' . $result['token'], $result['expires']);
        Logger::activity((int) $result['user']['id'], 'login_remembered');
    }

    private static function forgetRemember(): void
    {
        $cookie = $_COOKIE['CDVREMEMBER'] ?? '';
        if (is_string($cookie) && preg_match('/^([a-f0-9]{24}):([a-f0-9]{64})$/D', $cookie, $parts)) {
            DB::run('DELETE FROM auth_tokens WHERE selector = ? AND token_hash = ?', [$parts[1], hash('sha256', $parts[2])]);
        }
        Session::cookie('CDVREMEMBER', '', time() - 3600);
        unset($_COOKIE['CDVREMEMBER']);
    }

    public static function logout(): void
    {
        DB::run('DELETE FROM user_sessions WHERE session_hash = ?', [hash('sha256', session_id())]);
        self::forgetRemember();
        Session::clearAuthentication();
        self::$current = null;
        self::$resolved = true;
    }

    public static function requireLogin(string $path = '/app'): array
    {
        $user = self::user();
        if (!$user) {
            if (Response::wantsJson()) {
                Response::json(['success' => false, 'data' => null,
                    'error' => ['code' => 'UNAUTHENTICATED', 'message' => 'Silakan login terlebih dahulu.']], 401);
            }
            $_SESSION['intended_path'] = $path;
            Response::redirect(\url('/login'), 302);
        }
        if ((bool) $user['must_change_password'] && !in_array($path, ['/app/change-password', '/logout'], true)) {
            Response::redirect(\url('/app/change-password'), 302);
        }
        return $user;
    }

    public static function requireAdmin(): void
    {
        if ((self::user()['role'] ?? '') !== 'admin') {
            throw new HttpException(403, 'Halaman ini hanya dapat diakses administrator.');
        }
    }

    public static function landingPath(mixed $intended = null): string
    {
        $user = self::user();
        if (!$user) {
            return '/login';
        }
        if ((bool) $user['must_change_password']) {
            return '/app/change-password';
        }
        if (in_array($intended, ['/app', '/app/change-password'], true)) {
            return $intended;
        }
        if ($user['role'] === 'admin' && is_string($intended)
            && (in_array($intended, ['/admin', '/admin/users', '/admin/system'], true)
                || preg_match('#^/admin/users/[1-9][0-9]*$#D', $intended))) {
            return $intended;
        }
        return $user['role'] === 'admin' ? '/admin/users' : '/app';
    }
}
````

### cdnvault_app/src/Captcha.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Captcha
{
    private const ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
    private const PURPOSES = ['login', 'register', 'upgrade'];

    public static function issue(string $purpose): array
    {
        if (!in_array($purpose, self::PURPOSES, true)) {
            throw new HttpException(422, 'Jenis CAPTCHA tidak valid.');
        }
        $records = $_SESSION['_captchas'] ?? [];
        foreach ($records as $id => $record) {
            if (($record['expires'] ?? 0) <= time()) {
                unset($records[$id]);
            }
        }
        while (count($records) >= 16) {
            array_shift($records);
        }
        $id = bin2hex(random_bytes(16));
        $records[$id] = ['purpose' => $purpose, 'expires' => time() + 300];
        $_SESSION['_captchas'] = $records;
        return ['id' => $id, 'purpose' => $purpose,
            'image_url' => $purpose === 'upgrade'
                ? \url('/upgrade/?image=' . $id) : \url('/captcha?challenge=' . $id)];
    }

    private static function answer(string $id, string $purpose): string
    {
        $bytes = hash_hmac('sha256', session_id() . ':' . $purpose . ':' . $id,
            (string) Config::get('app_key'), true);
        $answer = '';
        for ($index = 0; $index < 5; $index++) {
            $answer .= self::ALPHABET[ord($bytes[$index]) % strlen(self::ALPHABET)];
        }
        return $answer;
    }

    public static function verify(string $purpose, mixed $id, mixed $input): bool
    {
        if (!is_string($id) || !preg_match('/^[a-f0-9]{32}$/D', $id)) {
            return false;
        }
        $record = $_SESSION['_captchas'][$id] ?? null;
        // Kode hanya berlaku sekali, termasuk saat jawaban salah.
        unset($_SESSION['_captchas'][$id]);
        if (!$record || $record['purpose'] !== $purpose || $record['expires'] <= time()
            || !is_string($input) || strlen($input) > 20) {
            return false;
        }
        return hash_equals(self::answer($id, $purpose), strtoupper(trim($input)));
    }

    public static function render(mixed $id): never
    {
        if (!is_string($id) || !preg_match('/^[a-f0-9]{32}$/D', $id)
            || !isset($_SESSION['_captchas'][$id])
            || $_SESSION['_captchas'][$id]['expires'] <= time()) {
            throw new HttpException(404, 'Kode CAPTCHA kedaluwarsa. Muat kode baru.');
        }
        $wait = RateLimiter::consume('captcha-image:' . \client_ip(), 40, 60);
        if ($wait) {
            header('Retry-After: ' . $wait);
            throw new HttpException(429, 'Tunggu sebentar sebelum memuat gambar CAPTCHA lagi.');
        }
        if (!extension_loaded('gd')) {
            throw new HttpException(503, 'Aktifkan ekstensi GD melalui cPanel untuk menampilkan CAPTCHA.');
        }
        $code = self::answer($id, $_SESSION['_captchas'][$id]['purpose']);
        $canvas = imagecreatetruecolor(190, 64);
        $background = imagecolorallocate($canvas, 241, 243, 255);
        imagefill($canvas, 0, 0, $background);
        for ($index = 0; $index < 7; $index++) {
            $color = imagecolorallocate($canvas, random_int(160, 205), random_int(165, 210), random_int(205, 235));
            imageline($canvas, random_int(0, 190), random_int(0, 64), random_int(0, 190), random_int(0, 64), $color);
        }
        for ($index = 0; $index < 5; $index++) {
            $glyph = imagecreatetruecolor(20, 24);
            imagealphablending($glyph, false);
            $transparent = imagecolorallocatealpha($glyph, 0, 0, 0, 127);
            imagefill($glyph, 0, 0, $transparent);
            imagesavealpha($glyph, true);
            $ink = imagecolorallocate($glyph, random_int(30, 70), random_int(35, 70), random_int(105, 155));
            imagestring($glyph, 5, 5, 4, $code[$index], $ink);
            $rotated = imagerotate($glyph, random_int(-16, 16), $transparent);
            imagesavealpha($rotated, true);
            imagealphablending($canvas, true);
            imagecopyresampled($canvas, $rotated, 10 + $index * 34, random_int(5, 9), 0, 0,
                35, 48, imagesx($rotated), imagesy($rotated));
            imagedestroy($rotated);
            imagedestroy($glyph);
        }
        while (ob_get_level() > 0) {
            ob_end_clean();
        }
        header('Content-Type: image/png');
        header('Cache-Control: private, no-store, max-age=0');
        header('X-Content-Type-Options: nosniff');
        imagepng($canvas, null, 6);
        imagedestroy($canvas);
        exit;
    }

    public static function refresh(): never
    {
        $wait = RateLimiter::consume('captcha-refresh:' . \client_ip(), 30, 60);
        if ($wait) {
            header('Retry-After: ' . $wait);
            throw new HttpException(429, 'Tunggu sebentar sebelum mengganti kode lagi.');
        }
        $times = array_values(array_filter($_SESSION['_captcha_refreshes'] ?? [], static fn ($time) => $time > time() - 60));
        if (count($times) >= 20) {
            header('Retry-After: 60');
            Response::json(['success' => false, 'data' => null,
                'error' => ['code' => 'CAPTCHA_LIMIT', 'message' => 'Tunggu sebentar sebelum mengganti kode lagi.']], 429);
        }
        $times[] = time();
        $_SESSION['_captcha_refreshes'] = $times;
        $purpose = Validator::text($_POST['purpose'] ?? '', 20);
        if (!in_array($purpose, ['login', 'register'], true)) {
            throw new HttpException(422, 'Jenis CAPTCHA tidak valid.');
        }
        Response::json(['success' => true, 'data' => self::issue($purpose), 'error' => null]);
    }
}
````

### cdnvault_app/src/Client.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Client
{
    public static function trustedProxy(): bool
    {
        $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
        foreach ((array) Config::get('security.trusted_proxies', []) as $network) {
            if (is_string($network) && self::inNetwork($remote, $network)) {
                return true;
            }
        }
        return false;
    }

    public static function inNetwork(string $ip, string $network): bool
    {
        $parts = explode('/', $network, 2);
        $address = @inet_pton($ip);
        $base = @inet_pton($parts[0]);
        if ($address === false || $base === false || strlen($address) !== strlen($base)) {
            return false;
        }
        $bits = isset($parts[1]) ? filter_var($parts[1], FILTER_VALIDATE_INT) : strlen($base) * 8;
        // A wildcard trust rule would let arbitrary clients forge their IP and HTTPS state.
        if ($bits === false || $bits < 1 || $bits > strlen($base) * 8) {
            return false;
        }
        $bytes = intdiv($bits, 8);
        $remainder = $bits % 8;
        return substr($address, 0, $bytes) === substr($base, 0, $bytes)
            && ($remainder === 0 || (ord($address[$bytes]) & (255 << (8 - $remainder)))
                === (ord($base[$bytes]) & (255 << (8 - $remainder))));
    }

    public static function ip(): string
    {
        $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
        if (self::trustedProxy()) {
            $header = Config::get('security.client_ip_header', 'HTTP_CF_CONNECTING_IP');
            if (in_array($header, ['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP'], true)) {
                $candidate = $_SERVER[$header] ?? '';
                if (is_string($candidate) && filter_var($candidate, FILTER_VALIDATE_IP)) {
                    return (string) inet_ntop(inet_pton($candidate));
                }
            }
        }
        return filter_var($remote, FILTER_VALIDATE_IP) ? (string) inet_ntop(inet_pton($remote)) : '0.0.0.0';
    }

    public static function https(): bool
    {
        if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
            return true;
        }
        return self::trustedProxy() && ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
    }
}
````

### cdnvault_app/src/Config.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Config
{
    public const VERSION = '0.3.0';
    private static array $values = [];

    public static function load(array $values): void
    {
        self::$values = $values;
        self::$values['version'] = self::VERSION;
        date_default_timezone_set('UTC');
    }

    public static function get(string $key, mixed $default = null): mixed
    {
        $value = self::$values;
        foreach (explode('.', $key) as $part) {
            if (!is_array($value) || !array_key_exists($part, $value)) {
                return $default;
            }
            $value = $value[$part];
        }
        return $value;
    }

    public static function basePath(): string
    {
        return rtrim((string) parse_url((string) self::get('base_url', ''), PHP_URL_PATH), '/');
    }

    public static function secure(): bool
    {
        // Header proxy tidak dipercaya secara otomatis.
        return (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')
            || parse_url((string) self::get('base_url', ''), PHP_URL_SCHEME) === 'https';
    }
}
````

### cdnvault_app/src/Controllers/AdminController.php

````php
<?php
declare(strict_types=1);
namespace Vault\Controllers;

use Vault\{Auth, Config, Logger, Mailer, RateLimiter, Response, Session, Validator, View};

final class AdminController
{
    public function system(): void
    {
        Auth::requireAdmin();
        $extensions = [];
        foreach (['pdo_mysql', 'mbstring', 'fileinfo', 'gd', 'curl', 'zip', 'exif', 'openssl', 'dom'] as $extension) {
            $extensions[$extension] = extension_loaded($extension);
        }
        $gd = function_exists('gd_info') ? gd_info() : [];
        $paths = [
            'Konfigurasi' => VAULT_APP_PATH . '/config',
            'File privat' => (string) Config::get('paths.private'),
            'Sesi' => VAULT_APP_PATH . '/storage/private/sessions',
            'Log aplikasi' => VAULT_APP_PATH . '/storage/logs',
            'Cache' => VAULT_APP_PATH . '/storage/cache',
            'CDN publik' => (string) Config::get('paths.cdn'),
        ];
        $limits = [];
        foreach (['upload_max_filesize', 'post_max_size', 'memory_limit', 'max_execution_time', 'max_file_uploads'] as $key) {
            $limits[$key] = (string) ini_get($key);
        }
        $effectiveUpload = min((int) Config::get('upload.max_bytes', 20971520),
            Validator::iniBytes((string) ini_get('upload_max_filesize')),
            max(0, Validator::iniBytes((string) ini_get('post_max_size')) - 1048576));
        $disk = @disk_free_space((string) Config::get('paths.private'));
        $opcache = function_exists('opcache_get_status') ? @opcache_get_status(false) : false;
        $cdnCount = 0;
        $truncated = false;
        $started = microtime(true);
        if (is_dir((string) Config::get('paths.cdn'))) {
            $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
                (string) Config::get('paths.cdn'), \FilesystemIterator::SKIP_DOTS));
            foreach ($iterator as $file) {
                if ($file->isFile() && preg_match('/\.(webp|jpe?g|png|gif|avif)$/iD', $file->getFilename())) {
                    $cdnCount++;
                }
                if ($cdnCount >= 50000 || microtime(true) - $started > 0.25) {
                    $truncated = true;
                    break;
                }
            }
        }
        View::render('admin/system', compact('extensions', 'gd', 'paths', 'limits', 'effectiveUpload', 'disk', 'opcache', 'cdnCount', 'truncated')
            + ['title' => 'Diagnostik server', 'user' => Auth::user()]);
    }

    public function testEmail(): void
    {
        Auth::requireAdmin();
        $user = Auth::user();
        $retry = RateLimiter::consume('test-mail:' . $user['id'], 3, 600);
        if ($retry) {
            Session::flash('error', 'Batas uji email tercapai. Coba lagi dalam ' . $retry . ' detik.');
        } else {
            try {
                (new Mailer())->send($user['email'], 'Uji email CDN Vault', "Email uji berhasil dikirim dari CDN Vault.\nWaktu UTC: " . gmdate('Y-m-d H:i:s'));
                Logger::activity((int) $user['id'], 'test_email_sent');
                Session::flash('success', 'Email diserahkan ke server pengirim. Periksa inbox dan folder spam Anda.');
            } catch (\Throwable $exception) {
                $reference = Logger::exception($exception);
                Session::flash('error', 'Email gagal dikirim. Periksa konfigurasi SMTP. Referensi: ' . $reference);
            }
        }
        Response::redirect(\url('/admin/system'));
    }
}
````

### cdnvault_app/src/Controllers/AdminUserController.php

````php
<?php
declare(strict_types=1);
namespace Vault\Controllers;

use Vault\{Auth, DB, HttpException, Response, Session, Validator, View};
use Vault\Services\UserAdminService;

final class AdminUserController
{
    private function id(string $id): int
    {
        $value = filter_var($id, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        if ($value === false) {
            throw new HttpException(404, 'Pengguna tidak ditemukan.');
        }
        return $value;
    }

    public function index(): void
    {
        Auth::requireAdmin();
        $query = Validator::text($_GET['q'] ?? '', 120);
        $status = Validator::text($_GET['status'] ?? 'all', 20);
        if (!in_array($status, ['all', 'pending', 'approved', 'rejected', 'disabled'], true)) {
            $status = 'all';
        }
        $cursor = filter_var($_GET['cursor'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        $conditions = ['1 = 1'];
        $parameters = [];
        if ($query !== '') {
            $like = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $query) . '%';
            $conditions[] = "(email LIKE ? ESCAPE '!' OR display_name LIKE ? ESCAPE '!')";
            $parameters[] = $like;
            $parameters[] = $like;
        }
        if ($status === 'disabled') {
            $conditions[] = "approval_status = 'approved' AND is_active = 0";
        } elseif ($status !== 'all') {
            $conditions[] = 'approval_status = ?';
            $parameters[] = $status;
        }
        if ($cursor) {
            $conditions[] = 'id < ?';
            $parameters[] = $cursor;
        }
        $rows = DB::run('SELECT id, email, display_name, role, storage_quota_bytes, is_active, approval_status, last_login_at, created_at FROM users WHERE '
            . implode(' AND ', $conditions) . ' ORDER BY id DESC LIMIT 26', $parameters)->fetchAll();
        $next = count($rows) > 25 ? (int) $rows[24]['id'] : null;
        $rows = array_slice($rows, 0, 25);
        $stats = DB::one("SELECT COUNT(*) AS total, COALESCE(SUM(approval_status = 'pending'), 0) AS pending, COALESCE(SUM(approval_status = 'approved' AND is_active = 1), 0) AS active, COALESCE(SUM(approval_status = 'rejected'), 0) AS rejected FROM users");
        View::render('admin/users', ['title' => 'Pengguna', 'user' => Auth::user(),
            'rows' => $rows, 'stats' => $stats, 'query' => $query, 'status' => $status, 'next' => $next]);
    }

    public function show(string $id): void
    {
        Auth::requireAdmin();
        $member = DB::one('SELECT id, email, display_name, role, storage_quota_bytes, is_active, approval_status, approved_by, approved_at, review_note, must_change_password, last_login_at, created_at, updated_at FROM users WHERE id = ?', [$this->id($id)]);
        if (!$member) {
            throw new HttpException(404, 'Pengguna tidak ditemukan.');
        }
        $usage = DB::one('SELECT COALESCE(SUM(size_bytes + thumb_size_bytes + medium_size_bytes), 0) AS bytes FROM items WHERE user_id = ?', [$member['id']]);
        View::render('admin/user', ['title' => 'Detail pengguna', 'user' => Auth::user(), 'member' => $member, 'usage' => $usage]);
    }

    public function create(): void
    {
        Auth::requireAdmin();
        try {
            UserAdminService::create(Auth::user(), $_POST);
            Session::flash('success', 'Pengguna dibuat dan disetujui. Pengguna wajib mengganti password sementara saat login pertama.');
        } catch (HttpException $exception) {
            if ($exception->status !== 422) {
                throw $exception;
            }
            Session::flash('error', $exception->getMessage());
        } catch (\PDOException $exception) {
            if ((string) $exception->getCode() !== '23000') {
                throw $exception;
            }
            Session::flash('error', 'Email tersebut sudah dipakai oleh akun lain.');
        }
        Response::redirect(\url('/admin/users'));
    }

    public function action(string $id, string $action): void
    {
        Auth::requireAdmin();
        $targetId = $this->id($id);
        try {
            $result = UserAdminService::perform(Auth::user(), $targetId, $action, $_POST);
            if ($result['reauth']) {
                Auth::logout();
                Session::flash('success', 'Data akun diperbarui. Masuk kembali menggunakan email Anda.');
                Response::redirect(\url('/login'));
            }
            $messages = [
                'approve' => 'Pendaftaran disetujui. Pengguna sekarang boleh login.',
                'reject' => 'Pendaftaran ditolak. Pengguna tidak dapat login.',
                'activate' => 'Akun diaktifkan kembali.',
                'deactivate' => 'Akun dinonaktifkan dan seluruh sesinya dicabut.',
                'update' => 'Data pengguna berhasil diperbarui.',
                'password' => 'Password sementara diperbarui. Pengguna wajib menggantinya saat login berikutnya.',
            ];
            Session::flash('success', $messages[$action] ?? 'Perubahan disimpan.');
        } catch (HttpException $exception) {
            if ($exception->status !== 422) {
                throw $exception;
            }
            Session::flash('error', $exception->getMessage());
        } catch (\PDOException $exception) {
            if ((string) $exception->getCode() !== '23000') {
                throw $exception;
            }
            Session::flash('error', 'Email tersebut sudah dipakai oleh akun lain.');
        }
        Response::redirect(\url('/admin/users/' . $targetId));
    }
}
````

### cdnvault_app/src/Controllers/AuthController.php

````php
<?php
declare(strict_types=1);
namespace Vault\Controllers;

use Vault\{Auth, Captcha, DB, HttpException, Logger, Mailer, RateLimiter, Response, Session, Validator, View};

final class AuthController
{
    public function loginForm(): void
    {
        if (Auth::user()) {
            Response::redirect(\url(Auth::landingPath()), 302);
        }
        $this->loginView();
    }

    private function loginView(string $email = '', ?string $error = null, array $extra = []): void
    {
        View::render('auth/login', ['title' => 'Masuk', 'email' => $email, 'error' => $error,
            'captcha' => Captcha::issue('login')] + $extra, 'guest');
    }

    public function login(): void
    {
        $email = Validator::email($_POST['email'] ?? '');
        $password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
        if (isset($_POST['_refresh_captcha'])) {
            $this->loginView($email);
            return;
        }
        $intended = $_SESSION['intended_path'] ?? null;
        $retry = RateLimiter::consume('login-ip:' . \client_ip(), 100, 900);
        $reservation = $retry ? ['allowed' => false, 'retry_after' => $retry]
            : RateLimiter::reserveLogin(\client_ip(), $email);
        if (!$reservation['allowed']) {
            $wait = (int) $reservation['retry_after'];
            header('Retry-After: ' . $wait);
            http_response_code(429);
            $this->loginView($email, 'Terlalu banyak percobaan. Coba lagi dalam ' . $wait . ' detik.', ['retryAfter' => $wait]);
            return;
        }
        if (!Captcha::verify('login', $_POST['_captcha_id'] ?? null, $_POST['captcha'] ?? null)) {
            http_response_code(422);
            $this->loginView($email, 'Kode CAPTCHA salah atau kedaluwarsa. Ketik kode baru yang ditampilkan.');
            return;
        }
        if (strlen($password) <= 4096 && !str_contains($password, "\0")
            && Auth::attempt($email, $password, false)) {
            DB::run('UPDATE login_attempts SET success = 1 WHERE id = ?', [$reservation['attempt_id']]);
            Response::redirect(\url(Auth::landingPath($intended)));
        }
        usleep(200000 + random_int(0, 150000));
        http_response_code(422);
        $this->loginView($email, Auth::failureMessage());
    }

    public function registerForm(): void
    {
        if (Auth::user()) {
            Response::redirect(\url('/app'), 302);
        }
        $this->registerView('', null, ($_GET['submitted'] ?? '') === '1');
    }

    private function registerView(string $email = '', ?string $error = null, bool $submitted = false): void
    {
        View::render('auth/register', ['title' => 'Daftar akun', 'email' => $email, 'error' => $error,
            'submitted' => $submitted, 'captcha' => Captcha::issue('register')], 'guest');
    }

    public function register(): void
    {
        $email = Validator::email($_POST['email'] ?? '');
        if (isset($_POST['_refresh_captcha'])) {
            $this->registerView($email);
            return;
        }
        $wait = RateLimiter::consume('register-ip:' . \client_ip(), 10, 3600);
        if ($wait) {
            http_response_code(429);
            header('Retry-After: ' . $wait);
            $this->registerView($email, 'Batas pendaftaran sementara tercapai. Coba lagi dalam ' . $wait . ' detik.');
            return;
        }
        $validCaptcha = Captcha::verify('register', $_POST['_captcha_id'] ?? null, $_POST['captcha'] ?? null);
        $error = Validator::passwordError($_POST['password'] ?? null, $_POST['password_confirmation'] ?? null);
        if (!$validCaptcha) {
            $error = 'Kode CAPTCHA salah atau kedaluwarsa. Ketik kode baru yang ditampilkan.';
        } elseif (!Validator::validEmail($email)) {
            $error = 'Masukkan alamat email yang valid, maksimal 191 byte.';
        }
        if ($error !== null) {
            http_response_code(422);
            $this->registerView($email, $error);
            return;
        }
        $hash = Validator::hashPassword($_POST['password']);
        try {
            DB::transaction(static function () use ($email, $hash): void {
                $name = mb_substr(strstr($email, '@', true), 0, 120, 'UTF-8');
                DB::run("INSERT INTO users (email, display_name, password_hash, role, is_active, approval_status, settings, created_at, updated_at) VALUES (?, ?, ?, 'user', 0, 'pending', ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())",
                    [$email, $name, $hash, json_encode(['theme' => 'system', 'visibility' => 'private'], JSON_THROW_ON_ERROR)]);
                Logger::activity((int) DB::pdo()->lastInsertId(), 'registration_requested');
            });
        } catch (\PDOException $exception) {
            if ((string) $exception->getCode() !== '23000') {
                throw $exception;
            }
            // Respons duplikat tetap sama; akun lama tidak diubah atau diaktifkan.
        }
        Response::redirect(\url('/register?submitted=1'));
    }

    public function logout(): void
    {
        $user = Auth::user();
        if ($user) {
            Logger::activity((int) $user['id'], 'logout');
        }
        Auth::logout();
        Session::flash('success', 'Anda sudah keluar dengan aman.');
        Response::redirect(\url('/login'));
    }

    public function forgotForm(): void
    {
        View::render('auth/forgot', ['title' => 'Lupa password', 'sent' => false], 'guest');
    }

    public function forgot(): void
    {
        $email = Validator::email($_POST['email'] ?? '');
        $ipLimited = RateLimiter::consume('forgot-ip:' . \client_ip(), 5, 900);
        $emailLimited = RateLimiter::consume('forgot-email:' . $email, 3, 3600);
        if (!$ipLimited && !$emailLimited && Validator::validEmail($email)) {
            $user = DB::one("SELECT id, email, display_name FROM users WHERE email = ? AND is_active = 1 AND approval_status = 'approved'", [$email]);
            if ($user) {
                $token = bin2hex(random_bytes(32));
                DB::transaction(static function () use ($user, $token): void {
                    DB::one('SELECT id FROM users WHERE id = ? FOR UPDATE', [$user['id']]);
                    DB::run('UPDATE password_resets SET used_at = UTC_TIMESTAMP() WHERE user_id = ? AND used_at IS NULL', [$user['id']]);
                    DB::run('INSERT INTO password_resets (user_id, token_hash, expires_at, created_at) VALUES (?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 1 HOUR), UTC_TIMESTAMP())',
                        [$user['id'], hash('sha256', $token)]);
                });
                try {
                    (new Mailer())->send($user['email'], 'Atur ulang password CDN Vault',
                        "Halo " . $user['display_name'] . ",\n\nGunakan tautan berikut untuk membuat password baru:\n"
                        . \url('/reset-password/' . $token)
                        . "\n\nTautan ini berlaku 1 jam dan hanya dapat digunakan sekali. Jika Anda tidak meminta perubahan, abaikan email ini.\n\nCDN Vault");
                } catch (\Throwable $exception) {
                    Logger::exception($exception);
                }
            }
        }
        // Pesan dan kode status selalu sama untuk alamat terdaftar maupun tidak.
        usleep(200000 + random_int(0, 100000));
        View::render('auth/forgot', ['title' => 'Periksa email Anda', 'sent' => true], 'guest');
    }

    private function validReset(string $token): ?array
    {
        if (!preg_match('/^[a-f0-9]{64}$/D', $token)) {
            return null;
        }
        return DB::one("SELECT r.id FROM password_resets r INNER JOIN users u ON u.id = r.user_id WHERE r.token_hash = ? AND r.used_at IS NULL AND r.expires_at > UTC_TIMESTAMP() AND u.is_active = 1 AND u.approval_status = 'approved'", [hash('sha256', $token)]);
    }

    public function resetForm(string $token): void
    {
        View::render('auth/reset', ['title' => 'Password baru', 'token' => $token,
            'valid' => (bool) $this->validReset($token), 'error' => null], 'guest');
    }

    public function reset(string $token): void
    {
        $error = Validator::passwordError($_POST['password'] ?? null, $_POST['password_confirmation'] ?? null);
        if (!$this->validReset($token)) {
            http_response_code(422);
            View::render('auth/reset', ['title' => 'Tautan kedaluwarsa', 'token' => '', 'valid' => false, 'error' => null], 'guest');
            return;
        }
        if ($error) {
            http_response_code(422);
            View::render('auth/reset', ['title' => 'Password baru', 'token' => $token, 'valid' => true, 'error' => $error], 'guest');
            return;
        }
        $hash = Validator::hashPassword($_POST['password']);
        $changed = DB::transaction(static function () use ($token, $hash): bool {
            $row = DB::one("SELECT r.id, r.user_id FROM password_resets r INNER JOIN users u ON u.id = r.user_id WHERE r.token_hash = ? AND r.used_at IS NULL AND r.expires_at > UTC_TIMESTAMP() AND u.is_active = 1 AND u.approval_status = 'approved' FOR UPDATE", [hash('sha256', $token)]);
            if (!$row) {
                return false;
            }
            DB::run('UPDATE users SET password_hash = ?, must_change_password = 0, session_version = session_version + 1, updated_at = UTC_TIMESTAMP() WHERE id = ?', [$hash, $row['user_id']]);
            DB::run('UPDATE password_resets SET used_at = UTC_TIMESTAMP() WHERE user_id = ? AND used_at IS NULL', [$row['user_id']]);
            DB::run('DELETE FROM auth_tokens WHERE user_id = ?', [$row['user_id']]);
            DB::run('DELETE FROM user_sessions WHERE user_id = ?', [$row['user_id']]);
            Logger::activity((int) $row['user_id'], 'password_reset');
            return true;
        });
        if (!$changed) {
            throw new HttpException(422, 'Tautan pengaturan ulang tidak berlaku. Silakan minta tautan baru.');
        }
        Auth::logout();
        Session::flash('success', 'Password berhasil diperbarui. Silakan masuk menggunakan password baru.');
        Response::redirect(\url('/login'));
    }

    public function changeForm(): void
    {
        View::render('auth/change', ['title' => 'Ubah password', 'user' => Auth::user(), 'error' => null]);
    }

    public function change(): void
    {
        $user = Auth::user();
        $old = is_string($_POST['current_password'] ?? null) ? $_POST['current_password'] : '';
        $error = Validator::passwordError($_POST['password'] ?? null, $_POST['password_confirmation'] ?? null);
        $retry = RateLimiter::consume('password-change:' . $user['id'], 10, 900);
        if ($retry) {
            $error = 'Terlalu banyak percobaan. Coba kembali dalam ' . $retry . ' detik.';
        } elseif (strlen($old) > 4096 || str_contains($old, "\0") || !password_verify($old, $user['password_hash'])) {
            $error = 'Password saat ini belum sesuai.';
        }
        if ($error) {
            http_response_code(422);
            View::render('auth/change', ['title' => 'Ubah password', 'user' => $user, 'error' => $error]);
            return;
        }
        $hash = Validator::hashPassword($_POST['password']);
        DB::transaction(static function () use ($user, $hash): void {
            $fresh = DB::one('SELECT password_hash, session_version FROM users WHERE id = ? FOR UPDATE', [$user['id']]);
            if (!$fresh || !hash_equals($user['password_hash'], $fresh['password_hash'])
                || (int) $fresh['session_version'] !== (int) $user['session_version']) {
                throw new HttpException(409, 'Keamanan akun berubah. Silakan masuk kembali sebelum mengganti password.');
            }
            DB::run('UPDATE users SET password_hash = ?, must_change_password = 0, session_version = session_version + 1, updated_at = UTC_TIMESTAMP() WHERE id = ?', [$hash, $user['id']]);
            DB::run('DELETE FROM user_sessions WHERE user_id = ?', [$user['id']]);
            DB::run('DELETE FROM auth_tokens WHERE user_id = ?', [$user['id']]);
            DB::run('UPDATE password_resets SET used_at = UTC_TIMESTAMP() WHERE user_id = ? AND used_at IS NULL', [$user['id']]);
            Logger::activity((int) $user['id'], 'password_changed');
        });
        Auth::logout();
        Session::flash('success', 'Password diperbarui. Silakan masuk kembali.');
        Response::redirect(\url('/login'));
    }
}
````

### cdnvault_app/src/Controllers/DashboardController.php

````php
<?php
declare(strict_types=1);
namespace Vault\Controllers;

use Vault\View;
use Vault\Services\DashboardService;

final class DashboardController
{
    public function index(): void
    {
        View::render('dashboard', DashboardService::current() + ['title' => 'Dashboard']);
    }
}
````

### cdnvault_app/src/Controllers/UpgradeController.php

````php
<?php
declare(strict_types=1);
namespace Vault\Controllers;

use Vault\{Captcha, Config, Csrf, DB, HttpException, RateLimiter, Response, Session, Validator, View};
use Vault\Services\UpgradeService;

final class UpgradeController
{
    public function handle(): void
    {
        if (isset($_GET['image'])) {
            Captcha::render($_GET['image']);
        }
        $complete = (int) Config::get('schema_version', 1) >= 2;
        $error = null;
        if (!$complete && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
            Csrf::verify();
            if (!isset($_POST['_refresh_captcha'])) {
                $email = Validator::email($_POST['email'] ?? '');
                $retry = RateLimiter::consume('upgrade-ip:' . \client_ip(), 30, 900);
                $reservation = $retry ? ['allowed' => false, 'retry_after' => $retry]
                    : RateLimiter::reserveLogin(\client_ip(), $email);
                if (!$reservation['allowed']) {
                    http_response_code(429);
                    header('Retry-After: ' . $reservation['retry_after']);
                    $error = 'Terlalu banyak percobaan. Coba kembali dalam ' . $reservation['retry_after'] . ' detik.';
                } elseif (!Captcha::verify('upgrade', $_POST['_captcha_id'] ?? null, $_POST['captcha'] ?? null)) {
                    http_response_code(422);
                    $error = 'Kode CAPTCHA salah atau kedaluwarsa.';
                } else {
                    $password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
                    $admin = Validator::validEmail($email)
                        ? DB::one('SELECT id, password_hash, role, is_active, totp_enabled FROM users WHERE email = ?', [$email]) : null;
                    $hash = $admin['password_hash'] ?? '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi';
                    $verified = strlen($password) <= 4096 && !str_contains($password, "\0") && password_verify($password, $hash);
                    if (!$verified || !$admin || $admin['role'] !== 'admin' || !(bool) $admin['is_active'] || (bool) $admin['totp_enabled']) {
                        usleep(250000);
                        http_response_code(422);
                        $error = 'Email atau password admin salah.';
                    } else {
                        DB::run('UPDATE login_attempts SET success = 1 WHERE id = ?', [$reservation['attempt_id']]);
                        UpgradeService::run((int) $admin['id']);
                        Session::clearAuthentication();
                        Session::cookie('CDVREMEMBER', '', time() - 3600);
                        Session::flash('success', 'Pembaruan selesai. Masuk kembali dengan email, password, dan CAPTCHA.');
                        Response::redirect(\url('/login'));
                    }
                }
            }
        }
        View::render('auth/upgrade', ['title' => 'Perbarui aplikasi', 'complete' => $complete,
            'error' => $error, 'captcha' => Captcha::issue('upgrade')], 'guest');
    }
}
````

### cdnvault_app/src/Csrf.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Csrf
{
    public static function token(): string
    {
        return $_SESSION['_csrf'] ??= bin2hex(random_bytes(32));
    }

    public static function verify(): void
    {
        $provided = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? $_POST['_csrf'] ?? '';
        if (!is_string($provided) || !hash_equals(self::token(), $provided)) {
            throw new HttpException(419, 'Sesi formulir berakhir. Muat ulang halaman lalu coba kembali.');
        }
    }
}
````

### cdnvault_app/src/DB.php

````php
<?php
declare(strict_types=1);
namespace Vault;

use PDO;

final class DB
{
    private static ?PDO $connection = null;

    public static function connect(array $credentials): PDO
    {
        $host = (string) ($credentials['host'] ?? 'localhost');
        $name = (string) ($credentials['name'] ?? '');
        if (!preg_match('/^[A-Za-z0-9._:-]+$/D', $host)
            || !preg_match('/^[A-Za-z0-9_$-]{1,64}$/D', $name)) {
            throw new \InvalidArgumentException('Konfigurasi database tidak valid.');
        }
        $port = (int) ($credentials['port'] ?? 3306);
        if ($port < 1 || $port > 65535) {
            throw new \InvalidArgumentException('Port database tidak valid.');
        }
        $pdo = new PDO(
            "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4",
            (string) ($credentials['user'] ?? ''),
            (string) ($credentials['password'] ?? ''),
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
             PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
             PDO::ATTR_EMULATE_PREPARES => false,
             PDO::MYSQL_ATTR_MULTI_STATEMENTS => false,
             PDO::ATTR_PERSISTENT => false,
             PDO::ATTR_TIMEOUT => 5]
        );
        $pdo->prepare("SET time_zone = '+00:00'")->execute();
        $pdo->prepare("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'")->execute();
        return $pdo;
    }

    public static function pdo(): PDO
    {
        return self::$connection ??= self::connect((array) Config::get('db'));
    }

    public static function run(string $sql, array $parameters = []): \PDOStatement
    {
        $statement = self::pdo()->prepare($sql);
        $statement->execute($parameters);
        return $statement;
    }

    public static function one(string $sql, array $parameters = []): ?array
    {
        $row = self::run($sql, $parameters)->fetch();
        return $row === false ? null : $row;
    }

    public static function transaction(callable $callback): mixed
    {
        $pdo = self::pdo();
        $pdo->beginTransaction();
        try {
            $result = $callback($pdo);
            $pdo->commit();
            return $result;
        } catch (\Throwable $exception) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }
            throw $exception;
        }
    }
}
````

### cdnvault_app/src/HttpException.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class HttpException extends \RuntimeException
{
    public function __construct(public int $status, string $message)
    {
        parent::__construct($message);
    }
}
````

### cdnvault_app/src/Installer.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Installer
{
    public static function guessBaseUrl(): string
    {
        $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
        if (!preg_match('/^[a-zA-Z0-9.\[\]:-]+$/D', $host)) {
            $host = 'localhost';
        }
        $script = str_replace('\\', '/', (string) ($_SERVER['SCRIPT_NAME'] ?? '/install/index.php'));
        $base = rtrim(dirname(dirname($script)), '/.');
        if (!preg_match('#^(/[A-Za-z0-9_-]+)*$#D', $base)) {
            $base = '';
        }
        $https = !empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off';
        return ($https ? 'https://' : 'http://') . $host . $base;
    }

    public static function requirements(): array
    {
        $checks = [
            ['label' => 'PHP 8.2 atau lebih baru dengan patch keamanan terkini', 'ok' => PHP_VERSION_ID >= 80200, 'required' => true, 'detail' => PHP_VERSION],
        ];
        foreach (['pdo_mysql', 'mbstring', 'fileinfo', 'gd', 'curl', 'zip', 'exif', 'openssl'] as $extension) {
            $checks[] = ['label' => 'Ekstensi ' . $extension, 'ok' => extension_loaded($extension), 'required' => true, 'detail' => extension_loaded($extension) ? 'Aktif' : 'Aktifkan di Select PHP Version'];
        }
        $gd = function_exists('gd_info') ? gd_info() : [];
        $checks[] = ['label' => 'GD dengan WebP', 'ok' => !empty($gd['WebP Support']), 'required' => false, 'detail' => !empty($gd['WebP Support']) ? 'Didukung' : 'Akan memakai fallback JPEG'];
        foreach (['config', 'storage/private', 'storage/private/sessions', 'storage/logs', 'storage/cache'] as $relative) {
            $path = VAULT_APP_PATH . '/' . $relative;
            $checks[] = ['label' => 'Folder ' . $relative, 'ok' => is_dir($path) && is_writable($path), 'required' => true, 'detail' => is_writable($path) ? 'Bisa ditulis' : 'Perbaiki izin melalui File Manager'];
        }
        $checks[] = ['label' => 'Root aplikasi', 'ok' => is_writable(VAULT_APP_PATH), 'required' => true, 'detail' => 'Untuk menulis install.lock'];
        $checks[] = ['label' => 'Folder CDN publik', 'ok' => is_dir(VAULT_PUBLIC_PATH . '/cdn') && is_writable(VAULT_PUBLIC_PATH . '/cdn'), 'required' => true, 'detail' => 'Tujuan gambar publik'];
        $checks[] = ['label' => 'Batas upload minimal 20 MB', 'ok' => Validator::iniBytes((string) ini_get('upload_max_filesize')) >= 20971520, 'required' => false, 'detail' => 'Efektif: ' . ini_get('upload_max_filesize')];
        $checks[] = ['label' => 'Batas POST minimal 25 MB', 'ok' => Validator::iniBytes((string) ini_get('post_max_size')) >= 26214400, 'required' => false, 'detail' => 'Efektif: ' . ini_get('post_max_size')];
        $checks[] = ['label' => 'Memori minimal 128 MB', 'ok' => Validator::iniBytes((string) ini_get('memory_limit')) >= 134217728, 'required' => false, 'detail' => 'Efektif: ' . ini_get('memory_limit')];
        return $checks;
    }

    private static function ready(array $checks): bool
    {
        foreach ($checks as $check) {
            if ($check['required'] && !$check['ok']) {
                return false;
            }
        }
        return true;
    }

    private static function setupKey(): string
    {
        $file = VAULT_APP_PATH . '/config/setup-key.txt';
        if (!is_file($file)) {
            $handle = @fopen($file, 'x');
            if ($handle !== false) {
                try {
                    if (!chmod($file, 0600)) {
                        throw new \RuntimeException('Izin file kunci belum aman.');
                    }
                    $key = bin2hex(random_bytes(32));
                    if (fwrite($handle, $key . PHP_EOL) !== 65) {
                        throw new \RuntimeException('Kunci pemasangan tidak dapat ditulis.');
                    }
                    fflush($handle);
                } finally {
                    fclose($handle);
                }
            }
        }
        if (!is_readable($file)) {
            throw new \RuntimeException('Folder config harus bisa ditulis. Periksa izin folder melalui cPanel.');
        }
        $key = trim((string) file_get_contents($file));
        if (!preg_match('/^[a-f0-9]{64}$/D', $key)) {
            throw new \RuntimeException('Kunci pemasangan belum siap. Muat ulang atau periksa config/setup-key.txt.');
        }
        return $key;
    }

    public function handle(): void
    {
        $locked = is_file(VAULT_APP_PATH . '/install.lock') || is_file(VAULT_APP_PATH . '/config/config.php');
        if ($locked) {
            http_response_code(403);
            View::render('install/locked', ['title' => 'Installer terkunci'], 'installer');
            return;
        }
        if (isset($_SESSION['install']['started']) && time() - (int) $_SESSION['install']['started'] > 1800) {
            unset($_SESSION['install']);
        }
        $_SESSION['install'] ??= ['started' => time(), 'step' => 1];
        $checks = self::requirements();
        $error = null;
        $notice = null;
        try {
            $setupKey = self::setupKey();
            if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
                Csrf::verify();
                $action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
                $state = &$_SESSION['install'];
                if ($action === 'requirements') {
                    $provided = is_string($_POST['setup_key'] ?? null) ? trim($_POST['setup_key']) : '';
                    if (!hash_equals($setupKey, $provided)) {
                        usleep(250000);
                        throw new \RuntimeException('Kunci pemasangan belum sesuai. Salin dari File Manager cPanel.');
                    }
                    if (!self::ready($checks)) {
                        throw new \RuntimeException('Lengkapi semua persyaratan wajib sebelum melanjutkan.');
                    }
                    $state['authorized'] = true;
                    $state['step'] = max(2, (int) $state['step']);
                    Response::redirect(\url('/install/?step=2'));
                }
                if (empty($state['authorized'])) {
                    throw new \RuntimeException('Verifikasi kunci pemasangan terlebih dahulu.');
                }
                if (in_array($action, ['database', 'test_database'], true) && $state['step'] >= 2) {
                    $credentials = [
                        'host' => Validator::text($_POST['host'] ?? 'localhost', 255),
                        'port' => (int) ($_POST['port'] ?? 3306),
                        'name' => Validator::text($_POST['database'] ?? '', 64),
                        'user' => Validator::text($_POST['username'] ?? '', 128),
                        'password' => is_string($_POST['db_password'] ?? null) ? $_POST['db_password'] : '',
                    ];
                    if ($credentials['password'] === '' && isset($state['db']['password'])) {
                        $credentials['password'] = $state['db']['password'];
                    }
                    try {
                        $pdo = DB::connect($credentials);
                        $statement = $pdo->prepare('SELECT VERSION()');
                        $statement->execute();
                        $version = (string) $statement->fetchColumn();
                        $maria = stripos($version, 'MariaDB') !== false;
                        $cleanVersion = preg_replace('/^5\.5\.5-/', '', $version);
                        if (!version_compare($cleanVersion, $maria ? '10.3' : '5.7', '>=')) {
                            throw new \RuntimeException('Versi database terlalu lama.');
                        }
                    } catch (\Throwable $exception) {
                        Logger::exception($exception);
                        throw new \RuntimeException('Koneksi database gagal atau versi belum didukung. Periksa host, port, nama database, pengguna, password, dan hak akses.');
                    }
                    $state['db'] = $credentials;
                    if ($action === 'test_database') {
                        $notice = 'Koneksi database berhasil. Klik Lanjutkan untuk membuat akun admin.';
                    } else {
                        $state['step'] = max(3, (int) $state['step']);
                        Response::redirect(\url('/install/?step=3'));
                    }
                } elseif ($action === 'admin' && $state['step'] >= 3 && isset($state['db'])) {
                    $email = Validator::email($_POST['email'] ?? '');
                    $name = Validator::text($_POST['display_name'] ?? '', 120);
                    $error = Validator::passwordError($_POST['password'] ?? null, $_POST['password_confirmation'] ?? null);
                    if (!Validator::validEmail($email) || $name === '') {
                        throw new \RuntimeException('Isi nama dan alamat email admin yang valid.');
                    }
                    if ($error) {
                        throw new \RuntimeException($error);
                    }
                    $state['admin'] = ['email' => $email, 'display_name' => $name,
                        'password_hash' => Validator::hashPassword($_POST['password'])];
                    $state['step'] = 4;
                    Response::redirect(\url('/install/?step=4'));
                } elseif ($action === 'install' && $state['step'] === 4 && isset($state['admin'], $state['db'])) {
                    $baseUrl = Validator::baseUrl(Validator::text($_POST['base_url'] ?? '', 500));
                    $sender = Validator::email($_POST['from_email'] ?? '');
                    if ($baseUrl === null || !Validator::validEmail($sender)) {
                        throw new \RuntimeException('URL aplikasi atau alamat email pengirim belum valid.');
                    }
                    if (!self::ready(self::requirements())) {
                        throw new \RuntimeException('Persyaratan server berubah. Periksa kembali langkah pertama.');
                    }
                    $this->install($state['db'], $state['admin'], $baseUrl, $sender);
                    $_SESSION = [];
                    session_regenerate_id(true);
                    Config::load(require VAULT_APP_PATH . '/config/config.php');
                    Session::flash('success', 'Instalasi selesai. Hapus folder public_html/install melalui File Manager, lalu masuk dengan akun admin Anda.');
                    Response::redirect(\url('/login'));
                } elseif (!in_array($action, ['database', 'test_database'], true)) {
                    throw new \RuntimeException('Urutan pemasangan belum sesuai. Ulangi langkah yang tersedia.');
                }
            }
        } catch (HttpException $exception) {
            http_response_code($exception->status);
            $error = $exception->getMessage();
        } catch (\Throwable $exception) {
            http_response_code(422);
            $error = $exception instanceof \RuntimeException && !$exception instanceof \PDOException
                ? $exception->getMessage() : 'Pemasangan belum berhasil. Periksa konfigurasi dan log aplikasi.';
            Logger::exception($exception);
        }
        $requested = filter_var($_GET['step'] ?? 1, FILTER_VALIDATE_INT);
        $step = max(1, min((int) $requested, (int) $_SESSION['install']['step']));
        View::render('install/wizard', ['title' => 'Pasang CDN Vault', 'checks' => $checks,
            'ready' => self::ready($checks), 'step' => $step, 'state' => $_SESSION['install'],
            'error' => $error, 'notice' => $notice], 'installer');
    }

    private function install(array $credentials, array $admin, string $baseUrl, string $sender): void
    {
        $guard = fopen(VAULT_APP_PATH . '/config/.install-guard', 'c');
        if (!$guard || !flock($guard, LOCK_EX | LOCK_NB)) {
            throw new \RuntimeException('Pemasangan lain sedang berjalan. Coba kembali sesaat lagi.');
        }
        $configFile = VAULT_APP_PATH . '/config/config.php';
        $lockFile = VAULT_APP_PATH . '/install.lock';
        $temporary = VAULT_APP_PATH . '/config/.config-' . bin2hex(random_bytes(8));
        $wroteConfig = false;
        $wroteLock = false;
        $pdo = null;
        try {
            if (is_file($configFile) || is_file($lockFile)) {
                throw new \RuntimeException('Aplikasi sudah terpasang. Installer terkunci.');
            }
            foreach (['config', 'storage/private', 'storage/private/sessions', 'storage/logs', 'storage/cache'] as $relative) {
                $directory = VAULT_APP_PATH . '/' . $relative;
                if (!is_dir($directory) && !mkdir($directory, 0700, true)) {
                    throw new \RuntimeException('Folder aplikasi tidak dapat dibuat.');
                }
                @chmod($directory, 0700);
                if (!is_file($directory . '/.htaccess')) {
                    file_put_contents($directory . '/.htaccess', "Require all denied\n");
                }
            }
            $pdo = DB::connect($credentials);
            $schema = file_get_contents(VAULT_APP_PATH . '/database/schema.sql');
            if ($schema === false) {
                throw new \RuntimeException('File skema database tidak ditemukan.');
            }
            foreach (explode(';', $schema) as $sql) {
                if (trim($sql) !== '') {
                    $pdo->prepare($sql)->execute();
                }
            }
            $pdo->beginTransaction();
            $statement = $pdo->prepare('SELECT COUNT(*) FROM users');
            $statement->execute();
            if ((int) $statement->fetchColumn() !== 0) {
                throw new \RuntimeException('Database sudah berisi pengguna. Gunakan database kosong atau pulihkan config.php dari backup.');
            }
            $statement = $pdo->prepare("INSERT INTO users (email, display_name, password_hash, role, settings, created_at, updated_at) VALUES (?, ?, ?, 'admin', ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())");
            $statement->execute([$admin['email'], $admin['display_name'], $admin['password_hash'],
                json_encode(['theme' => 'system', 'view' => 'grid', 'quality' => 82, 'client_compression' => true, 'visibility' => 'private'], JSON_THROW_ON_ERROR)]);
            $adminId = (int) $pdo->lastInsertId();
            $values = require VAULT_APP_PATH . '/config/config.example.php';
            $values['base_url'] = $baseUrl;
            $values['app_key'] = bin2hex(random_bytes(32));
            $values['db'] = $credentials;
            $values['paths'] = ['public' => VAULT_PUBLIC_PATH,
                'private' => VAULT_APP_PATH . '/storage/private', 'cdn' => VAULT_PUBLIC_PATH . '/cdn'];
            $values['mail']['from_email'] = $sender;
            $code = "<?php\ndeclare(strict_types=1);\n\n// Dibuat installer. Jangan bagikan file ini.\nreturn "
                . var_export($values, true) . ";\n";
            if (file_put_contents($temporary, $code, LOCK_EX) !== strlen($code) || !chmod($temporary, 0600)) {
                throw new \RuntimeException('Konfigurasi tidak dapat ditulis dengan aman.');
            }
            if (!rename($temporary, $configFile)) {
                throw new \RuntimeException('Konfigurasi tidak dapat disimpan.');
            }
            $wroteConfig = true;
            $lockHandle = fopen($lockFile, 'x');
            if ($lockHandle === false) {
                throw new \RuntimeException('Kunci instalasi tidak dapat dibuat.');
            }
            $wroteLock = true;
            try {
                fwrite($lockHandle, 'Installed ' . gmdate('c') . PHP_EOL);
                chmod($lockFile, 0600);
            } finally {
                fclose($lockHandle);
            }
            $statement = $pdo->prepare('INSERT INTO activity_logs (user_id, action, meta, ip, created_at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP())');
            $statement->execute([$adminId, 'installed', '{}', \client_ip()]);
            $pdo->commit();
            @unlink(VAULT_APP_PATH . '/config/setup-key.txt');
        } catch (\Throwable $exception) {
            if ($pdo && $pdo->inTransaction()) {
                $pdo->rollBack();
            }
            if ($wroteConfig) {
                @unlink($configFile);
            }
            if ($wroteLock) {
                @unlink($lockFile);
            }
            @unlink($temporary);
            throw $exception;
        } finally {
            flock($guard, LOCK_UN);
            fclose($guard);
        }
    }
}
````

### cdnvault_app/src/Logger.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Logger
{
    public static function write(string $level, string $event, array $context = []): void
    {
        $directory = VAULT_APP_PATH . '/storage/logs';
        if (!is_dir($directory) || !is_writable($directory)) {
            error_log('CDN Vault: ' . $level . ' ' . $event);
            return;
        }
        $guard = @fopen($directory . '/.log-lock', 'c');
        if ($guard === false) {
            return;
        }
        try {
            if (!flock($guard, LOCK_EX)) {
                return;
            }
            $file = $directory . '/app.log';
            clearstatcache(true, $file);
            if (is_file($file) && filesize($file) > 5242880) {
                if (is_file($file . '.3')) {
                    unlink($file . '.3');
                }
                for ($index = 2; $index >= 1; $index--) {
                    if (is_file($file . '.' . $index)) {
                        rename($file . '.' . $index, $file . '.' . ($index + 1));
                    }
                }
                rename($file, $file . '.1');
            }
            // Hanya konteks terpilih; jangan pernah memasukkan request atau secret.
            $allowed = array_intersect_key($context, array_flip(['reference', 'type', 'file', 'line', 'code', 'user_id']));
            $line = json_encode(['time' => gmdate('c'), 'level' => $level, 'event' => $event,
                'context' => $allowed], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) . PHP_EOL;
            file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
            @chmod($file, 0600);
        } catch (\Throwable $ignored) {
            error_log('CDN Vault: log storage unavailable');
        } finally {
            @flock($guard, LOCK_UN);
            @fclose($guard);
        }
    }

    public static function exception(\Throwable $exception): string
    {
        $reference = bin2hex(random_bytes(6));
        self::write('error', 'unhandled_exception', [
            'reference' => $reference, 'type' => get_class($exception),
            'file' => basename($exception->getFile()), 'line' => $exception->getLine(),
            'code' => (string) $exception->getCode(),
        ]);
        return $reference;
    }

    public static function activity(int $userId, string $action, array $metadata = []): void
    {
        DB::run('INSERT INTO activity_logs (user_id, action, meta, ip, created_at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP())',
            [$userId, $action, json_encode($metadata, JSON_THROW_ON_ERROR), \client_ip()]);
    }
}
````

### cdnvault_app/src/Mailer.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Mailer
{
    public function send(string $recipient, string $subject, string $body): void
    {
        $from = (string) Config::get('mail.from_email', '');
        if (!Validator::validEmail($recipient) || !Validator::validEmail($from)) {
            throw new \RuntimeException('Alamat email pengirim atau penerima belum valid.');
        }
        $name = str_replace(["\r", "\n"], '', (string) Config::get('mail.from_name', 'CDN Vault'));
        $encodedSubject = '=?UTF-8?B?' . base64_encode(str_replace(["\r", "\n"], '', $subject)) . '?=';
        $encodedName = '=?UTF-8?B?' . base64_encode($name) . '?=';
        $headers = [
            'From: ' . $encodedName . ' <' . $from . '>',
            'MIME-Version: 1.0', 'Content-Type: text/plain; charset=UTF-8',
            'Content-Transfer-Encoding: base64',
            'Date: ' . gmdate('D, d M Y H:i:s') . ' +0000',
            'Message-ID: <' . bin2hex(random_bytes(16)) . '@' . substr(strrchr($from, '@'), 1) . '>',
        ];
        $encodedBody = chunk_split(base64_encode($body), 76, "\r\n");
        if (!(bool) Config::get('mail.smtp.enabled', false)) {
            if (!function_exists('mail') || !@mail($recipient, $encodedSubject, $encodedBody, implode("\r\n", $headers))) {
                throw new \RuntimeException('Pengiriman email gagal.');
            }
            return;
        }
        $smtp = (array) Config::get('mail.smtp');
        $host = (string) ($smtp['host'] ?? '');
        $port = (int) ($smtp['port'] ?? 587);
        $encryption = (string) ($smtp['encryption'] ?? 'tls');
        if (!preg_match('/^[A-Za-z0-9.-]+$/D', $host) || $port < 1 || $port > 65535
            || !in_array($encryption, ['tls', 'ssl'], true)) {
            throw new \RuntimeException('Konfigurasi SMTP tidak valid.');
        }
        $context = stream_context_create(['ssl' => [
            'verify_peer' => true, 'verify_peer_name' => true,
            'peer_name' => $host, 'allow_self_signed' => false, 'SNI_enabled' => true,
        ]]);
        $socket = @stream_socket_client(($encryption === 'ssl' ? 'tls://' : 'tcp://') . $host . ':' . $port,
            $errorNumber, $errorMessage, 10, STREAM_CLIENT_CONNECT, $context);
        if ($socket === false) {
            throw new \RuntimeException('Koneksi SMTP gagal.');
        }
        stream_set_timeout($socket, 10);
        try {
            $this->expect($socket, [220]);
            $this->command($socket, 'EHLO cdnvault.local', [250]);
            if ($encryption === 'tls') {
                $this->command($socket, 'STARTTLS', [220]);
                $method = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
                if (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT')) {
                    $method |= STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
                }
                if (@stream_socket_enable_crypto($socket, true, $method) !== true) {
                    throw new \RuntimeException('Negosiasi TLS SMTP gagal.');
                }
                $this->command($socket, 'EHLO cdnvault.local', [250]);
            }
            if ((string) ($smtp['username'] ?? '') !== '') {
                $this->command($socket, 'AUTH LOGIN', [334]);
                $this->command($socket, base64_encode((string) $smtp['username']), [334]);
                $this->command($socket, base64_encode((string) ($smtp['password'] ?? '')), [235]);
            }
            $this->command($socket, 'MAIL FROM:<' . $from . '>', [250]);
            $this->command($socket, 'RCPT TO:<' . $recipient . '>', [250, 251]);
            $this->command($socket, 'DATA', [354]);
            $message = 'To: <' . $recipient . ">\r\nSubject: " . $encodedSubject . "\r\n"
                . implode("\r\n", $headers) . "\r\n\r\n" . $encodedBody;
            $this->write($socket, $message . ".\r\n");
            $this->expect($socket, [250]);
            // Pesan sudah diterima; kegagalan QUIT tidak membatalkan pengiriman.
            @fwrite($socket, "QUIT\r\n");
        } finally {
            fclose($socket);
        }
    }

    private function write($socket, string $data): void
    {
        $length = strlen($data);
        $offset = 0;
        while ($offset < $length) {
            $written = fwrite($socket, substr($data, $offset));
            if ($written === false || $written === 0) {
                throw new \RuntimeException('Penulisan SMTP gagal.');
            }
            $offset += $written;
        }
    }

    private function command($socket, string $line, array $codes): void
    {
        $this->write($socket, $line . "\r\n");
        $this->expect($socket, $codes);
    }

    private function expect($socket, array $codes): void
    {
        for ($lineNumber = 0; $lineNumber < 100; $lineNumber++) {
            $line = fgets($socket, 1024);
            if ($line === false || !preg_match('/^(\d{3})([ -])/', $line, $matches)) {
                throw new \RuntimeException('Respons SMTP tidak valid.');
            }
            if ($matches[2] === ' ') {
                if (!in_array((int) $matches[1], $codes, true)) {
                    throw new \RuntimeException('Server SMTP menolak permintaan.');
                }
                return;
            }
        }
        throw new \RuntimeException('Respons SMTP terlalu panjang.');
    }
}
````

### cdnvault_app/src/RateLimiter.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class RateLimiter
{
    public static function consume(string $name, int $limit, int $window): int
    {
        return DB::transaction(static function () use ($name, $limit, $window): int {
            $key = hash('sha256', $name);
            DB::run('INSERT INTO throttle_buckets (bucket_key, window_start, hits) VALUES (?, ?, 0) ON DUPLICATE KEY UPDATE bucket_key = VALUES(bucket_key)', [$key, time()]);
            $row = DB::one('SELECT window_start, hits FROM throttle_buckets WHERE bucket_key = ? FOR UPDATE', [$key]);
            $start = (int) $row['window_start'];
            $hits = (int) $row['hits'];
            if ($start + $window <= time()) {
                $start = time();
                $hits = 0;
            }
            if ($hits >= $limit) {
                return max(1, $start + $window - time());
            }
            DB::run('UPDATE throttle_buckets SET window_start = ?, hits = ? WHERE bucket_key = ?', [$start, $hits + 1, $key]);
            return 0;
        });
    }

    public static function reserveLogin(string $ip, string $email): array
    {
        // Shared across IPs to limit distributed password guessing for one account.
        $wait = self::consume('login-account:' . $email, 20, 900);
        if ($wait) {
            return ['allowed' => false, 'retry_after' => $wait];
        }
        return DB::transaction(static function () use ($ip, $email): array {
            $key = hash('sha256', 'login-pair:' . $ip . ':' . $email);
            DB::run('INSERT INTO throttle_buckets (bucket_key, window_start, hits) VALUES (?, ?, 0) ON DUPLICATE KEY UPDATE bucket_key = VALUES(bucket_key)', [$key, time()]);
            DB::one('SELECT bucket_key FROM throttle_buckets WHERE bucket_key = ? FOR UPDATE', [$key]);
            $row = DB::one('SELECT COUNT(*) AS failures, UNIX_TIMESTAMP(MIN(attempted_at)) AS first_failure FROM login_attempts WHERE ip = ? AND email = ? AND success = 0 AND attempted_at > DATE_SUB(UTC_TIMESTAMP(), INTERVAL 15 MINUTE)', [$ip, $email]);
            if ((int) $row['failures'] >= 5) {
                return ['allowed' => false, 'retry_after' => max(1, (int) $row['first_failure'] + 900 - time())];
            }
            // Reservasi dihitung gagal sampai verifikasi selesai untuk menutup balapan paralel.
            DB::run('INSERT INTO login_attempts (ip, email, attempted_at, success) VALUES (?, ?, UTC_TIMESTAMP(), 0)', [$ip, $email]);
            return ['allowed' => true, 'attempt_id' => (int) DB::pdo()->lastInsertId()];
        });
    }
}
````

### cdnvault_app/src/RequestGuard.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class RequestGuard
{
    public const MAX_BODY_BYTES = 65536;

    public static function path(): string
    {
        $request = (string) ($_SERVER['REQUEST_URI'] ?? '/');
        if (strlen($request) > 4096 || !str_starts_with($request, '/')
            || preg_match('/[\x00-\x20\x7f\\\\]/', $request)) {
            throw new HttpException(400, 'Alamat permintaan tidak valid.');
        }
        $raw = explode('?', $request, 2)[0];
        if (preg_match('/%(?![a-fA-F0-9]{2})/', $raw)) {
            throw new HttpException(400, 'Alamat permintaan tidak valid.');
        }
        $path = rawurldecode($raw);
        // Reject encoded separators, traversal, controls and double-encoded paths.
        if (preg_match('/%(?:2f|5c)/i', $raw) || preg_match('/[\x00-\x20\x7f%\\\\]/', $path)
            || str_contains($path, '//') || preg_match('#(?:^|/)\.{1,2}(?:/|$)#', $path)) {
            throw new HttpException(400, 'Alamat permintaan tidak valid.');
        }
        return $path;
    }

    public static function validate(): void
    {
        $path = self::path();
        $method = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
        if (!in_array($method, ['GET', 'HEAD', 'POST'], true)) {
            header('Allow: GET, HEAD, POST');
            throw new HttpException(405, 'Metode permintaan tidak diizinkan.');
        }
        if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > self::MAX_BODY_BYTES
            || (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > Validator::iniBytes((string) ini_get('post_max_size'))) {
            throw new HttpException(413, 'Ukuran formulir melewati batas 64 KB.');
        }
        if ($method === 'POST') {
            $contentType = strtolower(trim(explode(';', (string) ($_SERVER['CONTENT_TYPE'] ?? ''))[0]));
            if ($contentType !== 'application/x-www-form-urlencoded') {
                throw new HttpException(415, 'Gunakan format formulir yang didukung.');
            }
            if (($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '') === 'cross-site') {
                throw new HttpException(403, 'Permintaan lintas situs ditolak.');
            }
        }
        $base = parse_url((string) Config::get('base_url'));
        $host = strtolower((string) ($base['host'] ?? ''));
        $scheme = $base['scheme'] ?? 'https';
        $port = (int) ($base['port'] ?? ($scheme === 'https' ? 443 : 80));
        $received = strtolower((string) ($_SERVER['HTTP_HOST'] ?? ''));
        $authority = $host . ($port === ($scheme === 'https' ? 443 : 80) ? '' : ':' . $port);
        $explicit = $host . ':' . $port;
        if ($host === '' || !in_array($received, [$authority, $explicit], true)) {
            throw new HttpException(400, 'Domain permintaan tidak sesuai konfigurasi website.');
        }
        if ($method === 'POST' && isset($_SERVER['HTTP_ORIGIN'])) {
            $origin = parse_url((string) $_SERVER['HTTP_ORIGIN']);
            if (!$origin || ($origin['scheme'] ?? '') !== $scheme
                || strtolower($origin['host'] ?? '') !== $host
                || (int) ($origin['port'] ?? (($origin['scheme'] ?? '') === 'https' ? 443 : 80)) !== $port
                || isset($origin['user']) || isset($origin['pass']) || isset($origin['query'])
                || isset($origin['fragment']) || !in_array($origin['path'] ?? '', ['', '/'], true)) {
                throw new HttpException(403, 'Asal formulir tidak sesuai website.');
            }
        }
        if ($scheme === 'https' && !Client::https()) {
            if ($method === 'POST') {
                throw new HttpException(400, 'Buka website melalui HTTPS sebelum mengirim formulir.');
            }
            // Use the configured domain, never an untrusted Host or proxy header.
            Response::redirect('https://' . $authority . (string) $_SERVER['REQUEST_URI'], 302);
        }
    }

    public static function rateLimit(): void
    {
        $wait = RateLimiter::consume('request-ip:' . Client::ip(), 180, 60);
        if ($wait) {
            header('Retry-After: ' . $wait);
            throw new HttpException(429, 'Terlalu banyak permintaan. Tunggu sebentar lalu coba kembali.');
        }
    }
}
````

### cdnvault_app/src/Response.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Response
{
    private static ?string $nonce = null;

    public static function nonce(): string
    {
        return self::$nonce ??= base64_encode(random_bytes(18));
    }

    public static function securityHeaders(): void
    {
        header('Content-Type: text/html; charset=UTF-8');
        header('Cache-Control: private, no-store, max-age=0');
        header('Pragma: no-cache');
        header('X-Content-Type-Options: nosniff');
        header('X-Frame-Options: DENY');
        header('Referrer-Policy: no-referrer');
        header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
        header('Cross-Origin-Opener-Policy: same-origin');
        header('Cross-Origin-Resource-Policy: same-origin');
        header('X-Permitted-Cross-Domain-Policies: none');
        $nonce = self::nonce();
        header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'");
        if (Config::secure()) {
            header('Strict-Transport-Security: max-age=31536000');
        }
    }

    public static function redirect(string $location, int $status = 303): never
    {
        if (preg_match('/[\r\n]/', $location)) {
            throw new \InvalidArgumentException('Alamat redirect tidak valid.');
        }
        header('Location: ' . $location, true, $status);
        exit;
    }

    public static function wantsJson(): bool
    {
        return str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'application/json')
            || str_contains((string) ($_SERVER['REQUEST_URI'] ?? ''), '/api/');
    }

    public static function json(array $data, int $status = 200): never
    {
        http_response_code($status);
        header('Content-Type: application/json; charset=UTF-8');
        echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public static function error(int $status, string $message, ?string $reference = null): never
    {
        http_response_code($status);
        if (self::wantsJson()) {
            self::json(['success' => false, 'data' => null,
                'error' => ['code' => 'HTTP_' . $status, 'message' => $message, 'reference' => $reference]], $status);
        }
        View::render('errors/error', compact('status', 'message', 'reference') + ['title' => 'Terjadi kendala'], 'guest');
        exit;
    }
}
````

### cdnvault_app/src/Router.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Router
{
    private array $routes = [];

    public function add(string $method, string $path, callable $handler, string $access = 'auth'): self
    {
        if (!in_array($access, ['public', 'auth', 'admin'], true)) {
            throw new \InvalidArgumentException('Kebijakan akses route tidak valid.');
        }
        $quoted = preg_quote($path, '#');
        $pattern = preg_replace('/\\\\\{[a-zA-Z_]+\\\\\}/', '([A-Za-z0-9_-]+)', $quoted);
        $this->routes[] = [$method, '#^' . $pattern . '$#D', $handler, $access];
        return $this;
    }

    public function dispatch(): void
    {
        $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
        $uri = RequestGuard::path();
        $base = Config::basePath();
        if ($base !== '' && $uri !== $base && !str_starts_with($uri, $base . '/')) {
            throw new HttpException(404, 'Halaman tidak ditemukan.');
        }
        $path = rtrim(substr($uri, strlen($base)), '/') ?: '/';
        // Enforce protected namespaces even if a future route is misconfigured.
        if (preg_match('#^/(?:app|admin)(?:/|$)#', $path)) {
            Auth::requireLogin($path);
            if (preg_match('#^/admin(?:/|$)#', $path)) {
                Auth::requireAdmin();
            }
        }
        if (str_starts_with($path, '/api/internal/')) {
            Auth::requireLogin($path);
            if (!in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
                Csrf::verify();
            }
        }
        $matchingPath = false;
        foreach ($this->routes as [$routeMethod, $pattern, $handler, $access]) {
            if (!preg_match($pattern, $path, $matches)) {
                continue;
            }
            $matchingPath = true;
            if ($routeMethod !== ($method === 'HEAD' ? 'GET' : $method)) {
                continue;
            }
            if ($access === 'auth' || $access === 'admin') {
                Auth::requireLogin($path);
            }
            if ($access === 'admin') {
                Auth::requireAdmin();
            }
            if (!in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
                Csrf::verify();
            }
            array_shift($matches);
            $handler(...$matches);
            return;
        }
        if ($matchingPath) {
            header('Allow: GET, HEAD, POST');
        }
        throw new HttpException($matchingPath ? 405 : 404,
            $matchingPath ? 'Metode permintaan tidak diizinkan.' : 'Halaman tidak ditemukan.');
    }
}
````

### cdnvault_app/src/Services/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/src/Services/DashboardService.php

````php
<?php
declare(strict_types=1);
namespace Vault\Services;

use Vault\{Auth, DB};

final class DashboardService
{
    public static function current(): array
    {
        // Ownership comes only from the verified session, never a URL or form ID.
        $user = Auth::requireLogin('/app');
        $stats = DB::one("SELECT COALESCE(SUM(type = 'image' AND deleted_at IS NULL), 0) AS images,
            COALESCE(SUM(type = 'link' AND deleted_at IS NULL), 0) AS links,
            COALESCE(SUM(size_bytes + thumb_size_bytes + medium_size_bytes), 0) AS bytes
            FROM items WHERE user_id = ?", [$user['id']]);
        $activity = DB::run('SELECT action, created_at FROM activity_logs WHERE user_id = ? ORDER BY id DESC LIMIT 5',
            [$user['id']])->fetchAll();
        return compact('user', 'stats', 'activity');
    }
}
````

### cdnvault_app/src/Services/UpgradeService.php

````php
<?php
declare(strict_types=1);
namespace Vault\Services;

use Vault\{Config, DB, HttpException, Logger};

final class UpgradeService
{
    public static function run(int $adminId): void
    {
        $guard = fopen(VAULT_APP_PATH . '/config/.upgrade-guard', 'c');
        if (!$guard || !flock($guard, LOCK_EX | LOCK_NB)) {
            throw new HttpException(409, 'Pembaruan lain sedang berjalan. Coba kembali sesaat lagi.');
        }
        $temporary = null;
        try {
            $file = VAULT_APP_PATH . '/config/config.php';
            $original = file_get_contents($file);
            if ($original === false) {
                throw new \RuntimeException('Konfigurasi tidak dapat dibaca.');
            }
            $values = require $file;
            if ((int) ($values['schema_version'] ?? 1) >= 2) {
                return;
            }
            $columns = DB::run("SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'")->fetchAll(\PDO::FETCH_COLUMN);
            $definitions = [
                'approval_status' => "ALTER TABLE users ADD COLUMN approval_status ENUM('pending','approved','rejected') NOT NULL DEFAULT 'approved' AFTER is_active",
                'approved_by' => 'ALTER TABLE users ADD COLUMN approved_by BIGINT UNSIGNED NULL AFTER approval_status',
                'approved_at' => 'ALTER TABLE users ADD COLUMN approved_at DATETIME NULL AFTER approved_by',
                'review_note' => 'ALTER TABLE users ADD COLUMN review_note VARCHAR(500) NULL AFTER approved_at',
            ];
            // DDL MySQL melakukan commit implisit; tiap langkah aman untuk diulangi.
            foreach ($definitions as $name => $sql) {
                if (!in_array($name, $columns, true)) {
                    DB::run($sql);
                }
            }
            $index = DB::one("SELECT INDEX_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND INDEX_NAME = 'idx_users_approval' LIMIT 1");
            if (!$index) {
                DB::run('ALTER TABLE users ADD INDEX idx_users_approval (approval_status, is_active, created_at, id)');
            }
            $constraint = DB::one("SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND CONSTRAINT_NAME = 'fk_users_approver'");
            if (!$constraint) {
                DB::run('ALTER TABLE users ADD CONSTRAINT fk_users_approver FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL');
            }
            DB::transaction(static function () use ($adminId): void {
                $admin = DB::one('SELECT role, is_active, approval_status FROM users WHERE id = ? FOR UPDATE', [$adminId]);
                if (!$admin || $admin['role'] !== 'admin' || !(bool) $admin['is_active'] || $admin['approval_status'] !== 'approved') {
                    throw new HttpException(403, 'Akun admin tidak lagi aktif.');
                }
                DB::run("UPDATE users SET approved_by = ?, approved_at = UTC_TIMESTAMP() WHERE approval_status = 'approved' AND approved_at IS NULL", [$adminId]);
                DB::run('UPDATE users SET session_version = session_version + 1');
                DB::run('DELETE FROM user_sessions');
                DB::run('DELETE FROM auth_tokens');
                DB::run('UPDATE password_resets SET used_at = UTC_TIMESTAMP() WHERE used_at IS NULL');
                Logger::activity($adminId, 'schema_upgraded', ['schema_version' => 2]);
            });
            $backup = VAULT_APP_PATH . '/config/config.before-0.2.php';
            if (!is_file($backup)) {
                $handle = fopen($backup, 'x');
                if ($handle === false) {
                    throw new \RuntimeException('Backup konfigurasi belum dapat dibuat.');
                }
                try {
                    chmod($backup, 0600);
                    if (fwrite($handle, $original) !== strlen($original)) {
                        throw new \RuntimeException('Backup konfigurasi belum lengkap.');
                    }
                } finally {
                    fclose($handle);
                }
            }
            if (!hash_equals(hash('sha256', $original), hash_file('sha256', $file))) {
                throw new HttpException(409, 'Konfigurasi berubah saat pembaruan. Jalankan kembali agar perubahan terbaru tetap terjaga.');
            }
            $values['schema_version'] = 2;
            $values['version'] = Config::VERSION;
            $code = "<?php\ndeclare(strict_types=1);\n\n// Konfigurasi diperbarui tanpa mengganti APP_KEY.\nreturn " . var_export($values, true) . ";\n";
            $temporary = VAULT_APP_PATH . '/config/.upgrade-' . bin2hex(random_bytes(8));
            if (file_put_contents($temporary, $code, LOCK_EX) !== strlen($code) || !chmod($temporary, 0600)
                || !rename($temporary, $file)) {
                throw new \RuntimeException('Konfigurasi belum dapat diperbarui. Periksa izin folder config.');
            }
            Config::load($values);
        } finally {
            if ($temporary !== null && is_file($temporary)) {
                @unlink($temporary);
            }
            flock($guard, LOCK_UN);
            fclose($guard);
        }
    }
}
````

### cdnvault_app/src/Services/UserAdminService.php

````php
<?php
declare(strict_types=1);
namespace Vault\Services;

use Vault\{DB, HttpException, Logger, Validator};

final class UserAdminService
{
    private static function guard(array $actor): void
    {
        DB::run("INSERT INTO settings (`key`, `value`) VALUES ('admin_guard', '1') ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)");
        DB::one("SELECT `key` FROM settings WHERE `key` = 'admin_guard' FOR UPDATE");
        $fresh = DB::one('SELECT role, is_active, approval_status, session_version FROM users WHERE id = ? FOR UPDATE', [$actor['id']]);
        if (!$fresh || $fresh['role'] !== 'admin' || !(bool) $fresh['is_active']
            || $fresh['approval_status'] !== 'approved'
            || (int) $fresh['session_version'] !== (int) $actor['session_version']) {
            throw new HttpException(403, 'Akses admin sudah berubah. Silakan masuk kembali.');
        }
    }

    private static function quota(mixed $value): int
    {
        $number = is_scalar($value) ? filter_var($value, FILTER_VALIDATE_FLOAT) : false;
        if ($number === false || !is_finite((float) $number) || $number < 0.1 || $number > 10240) {
            throw new HttpException(422, 'Kuota harus antara 0,1 dan 10.240 GB.');
        }
        return (int) round($number * 1073741824);
    }

    private static function role(mixed $value): string
    {
        if (!is_string($value) || !in_array($value, ['admin', 'user'], true)) {
            throw new HttpException(422, 'Peran pengguna tidak valid.');
        }
        return $value;
    }

    private static function preserveAdmin(array $actor, array $target, string $role, bool $active): void
    {
        $losesAccess = $role !== 'admin' || !$active;
        if ((int) $actor['id'] === (int) $target['id'] && $losesAccess) {
            throw new HttpException(422, 'Anda tidak dapat menonaktifkan atau menurunkan peran akun admin yang sedang digunakan.');
        }
        if ($target['role'] === 'admin' && (bool) $target['is_active']
            && $target['approval_status'] === 'approved' && $losesAccess) {
            $count = DB::one("SELECT COUNT(*) AS total FROM users WHERE role = 'admin' AND is_active = 1 AND approval_status = 'approved'");
            if ((int) $count['total'] <= 1) {
                throw new HttpException(422, 'Minimal satu administrator aktif harus tetap tersedia.');
            }
        }
    }

    private static function revoke(int $id): void
    {
        DB::run('DELETE FROM user_sessions WHERE user_id = ?', [$id]);
        DB::run('DELETE FROM auth_tokens WHERE user_id = ?', [$id]);
        DB::run('UPDATE password_resets SET used_at = UTC_TIMESTAMP() WHERE user_id = ? AND used_at IS NULL', [$id]);
    }

    public static function create(array $actor, array $input): void
    {
        $email = Validator::email($input['email'] ?? '');
        $name = Validator::text($input['display_name'] ?? '', 120);
        if (!Validator::validEmail($email) || $name === '') {
            throw new HttpException(422, 'Masukkan nama dan alamat email yang valid.');
        }
        $error = Validator::passwordError($input['password'] ?? null, $input['password_confirmation'] ?? null);
        if ($error) {
            throw new HttpException(422, $error);
        }
        $hash = Validator::hashPassword($input['password']);
        $role = self::role($input['role'] ?? 'user');
        $quota = self::quota($input['quota_gb'] ?? 5);
        DB::transaction(static function () use ($actor, $email, $name, $hash, $role, $quota): void {
            self::guard($actor);
            DB::run("INSERT INTO users (email, display_name, password_hash, role, storage_quota_bytes, is_active, approval_status, approved_by, approved_at, must_change_password, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 'approved', ?, UTC_TIMESTAMP(), 1, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())",
                [$email, $name, $hash, $role, $quota, $actor['id'], '{}']);
            Logger::activity((int) $actor['id'], 'user_created', ['target_user_id' => (int) DB::pdo()->lastInsertId()]);
        });
    }

    public static function perform(array $actor, int $id, string $action, array $input): array
    {
        $hash = null;
        if ($action === 'password') {
            $error = Validator::passwordError($input['password'] ?? null, $input['password_confirmation'] ?? null);
            if ($error) {
                throw new HttpException(422, $error);
            }
            $hash = Validator::hashPassword($input['password']);
        }
        return DB::transaction(static function () use ($actor, $id, $action, $input, $hash): array {
            self::guard($actor);
            $target = DB::one('SELECT * FROM users WHERE id = ? FOR UPDATE', [$id]);
            if (!$target) {
                throw new HttpException(404, 'Pengguna tidak ditemukan.');
            }
            $reauth = false;
            $note = Validator::text($input['review_note'] ?? '', 500);
            switch ($action) {
                case 'approve':
                    if (!in_array($target['approval_status'], ['pending', 'rejected'], true)) {
                        throw new HttpException(422, 'Akun ini sudah disetujui. Gunakan tombol Aktifkan jika sedang nonaktif.');
                    }
                    DB::run("UPDATE users SET approval_status = 'approved', is_active = 1, approved_by = ?, approved_at = UTC_TIMESTAMP(), review_note = ?, session_version = session_version + 1, updated_at = UTC_TIMESTAMP() WHERE id = ?",
                        [$actor['id'], $note ?: null, $id]);
                    self::revoke($id);
                    break;
                case 'reject':
                    if ($target['approval_status'] === 'approved') {
                        throw new HttpException(422, 'Untuk akun yang sudah disetujui, gunakan Nonaktifkan.');
                    }
                    self::preserveAdmin($actor, $target, $target['role'], false);
                    DB::run("UPDATE users SET approval_status = 'rejected', is_active = 0, approved_by = ?, approved_at = UTC_TIMESTAMP(), review_note = ?, session_version = session_version + 1, updated_at = UTC_TIMESTAMP() WHERE id = ?",
                        [$actor['id'], $note ?: null, $id]);
                    self::revoke($id);
                    break;
                case 'activate':
                case 'deactivate':
                    if ($target['approval_status'] !== 'approved') {
                        throw new HttpException(422, 'Tinjau dan setujui pendaftaran terlebih dahulu.');
                    }
                    $active = $action === 'activate';
                    self::preserveAdmin($actor, $target, $target['role'], $active);
                    DB::run('UPDATE users SET is_active = ?, session_version = session_version + 1, updated_at = UTC_TIMESTAMP() WHERE id = ?', [$active ? 1 : 0, $id]);
                    self::revoke($id);
                    break;
                case 'update':
                    $email = Validator::email($input['email'] ?? '');
                    $name = Validator::text($input['display_name'] ?? '', 120);
                    $role = self::role($input['role'] ?? 'user');
                    $quota = self::quota($input['quota_gb'] ?? 5);
                    if (!Validator::validEmail($email) || $name === '') {
                        throw new HttpException(422, 'Nama dan alamat email harus valid.');
                    }
                    if ($role === 'admin' && $target['approval_status'] !== 'approved') {
                        throw new HttpException(422, 'Setujui akun sebelum memberikan peran administrator.');
                    }
                    self::preserveAdmin($actor, $target, $role, (bool) $target['is_active']);
                    $usage = DB::one('SELECT COALESCE(SUM(size_bytes + thumb_size_bytes + medium_size_bytes), 0) AS bytes FROM items WHERE user_id = ?', [$id]);
                    if ($quota < (int) $usage['bytes']) {
                        throw new HttpException(422, 'Kuota tidak boleh lebih kecil dari penyimpanan yang sudah terpakai.');
                    }
                    $identityChanged = $email !== $target['email'] || $role !== $target['role'];
                    DB::run('UPDATE users SET email = ?, display_name = ?, role = ?, storage_quota_bytes = ?, session_version = session_version + ?, updated_at = UTC_TIMESTAMP() WHERE id = ?',
                        [$email, $name, $role, $quota, $identityChanged ? 1 : 0, $id]);
                    if ($identityChanged) {
                        self::revoke($id);
                        $reauth = (int) $actor['id'] === $id;
                    }
                    break;
                case 'password':
                    if ((int) $actor['id'] === $id) {
                        throw new HttpException(422, 'Untuk akun Anda sendiri, gunakan menu Ubah password.');
                    }
                    DB::run('UPDATE users SET password_hash = ?, must_change_password = 1, session_version = session_version + 1, updated_at = UTC_TIMESTAMP() WHERE id = ?', [$hash, $id]);
                    self::revoke($id);
                    break;
                default:
                    throw new HttpException(404, 'Aksi tidak ditemukan.');
            }
            Logger::activity((int) $actor['id'], 'user_' . $action, ['target_user_id' => $id]);
            return ['reauth' => $reauth];
        });
    }
}
````

### cdnvault_app/src/Session.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Session
{
    public static function start(): void
    {
        if (session_status() === PHP_SESSION_ACTIVE) {
            return;
        }
        ini_set('session.use_strict_mode', '1');
        ini_set('session.use_only_cookies', '1');
        ini_set('session.use_trans_sid', '0');
        ini_set('session.gc_maxlifetime', '43200');
        $directory = VAULT_APP_PATH . '/storage/private/sessions';
        if (!is_dir($directory) || !is_writable($directory)) {
            throw new HttpException(503, 'Penyimpanan sesi belum dapat dipakai. Periksa izin folder sesi melalui cPanel.');
        }
        session_save_path($directory);
        session_name('CDVSESSID');
        session_set_cookie_params([
            'lifetime' => 0,
            'path' => Config::basePath() . '/',
            'secure' => Config::secure(),
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
        session_start();
        if (isset($_SESSION['auth_user_id']) && (
            time() - (int) ($_SESSION['auth_last'] ?? 0) > 7200
            || time() - (int) ($_SESSION['auth_created'] ?? 0) > 43200
        )) {
            self::clearAuthentication();
            $_SESSION['reauth_required'] = true;
            self::cookie('CDVREMEMBER', '', time() - 3600);
        }
    }

    public static function establish(array $user): void
    {
        session_regenerate_id(true);
        $_SESSION = [
            'auth_user_id' => (int) $user['id'],
            'auth_version' => (int) $user['session_version'],
            'auth_created' => time(),
            'auth_last' => time(),
            'auth_synced' => time(),
            '_csrf' => bin2hex(random_bytes(32)),
        ];
    }

    public static function clearAuthentication(): void
    {
        $_SESSION = [];
        session_regenerate_id(true);
    }

    public static function cookie(string $name, string $value, int $expires): void
    {
        setcookie($name, $value, [
            'expires' => $expires,
            'path' => Config::basePath() . '/',
            'secure' => Config::secure(),
            'httponly' => true,
            'samesite' => 'Lax',
        ]);
    }

    public static function flash(string $type, string $message): void
    {
        $_SESSION['_flashes'][] = ['type' => $type, 'message' => $message];
    }

    public static function flashes(): array
    {
        $messages = $_SESSION['_flashes'] ?? [];
        unset($_SESSION['_flashes']);
        return $messages;
    }
}
````

### cdnvault_app/src/Validator.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class Validator
{
    public static function text(mixed $input, int $limit = 191): string
    {
        return is_string($input) ? mb_substr(trim($input), 0, $limit, 'UTF-8') : '';
    }

    public static function email(mixed $input): string
    {
        if (!is_string($input) || strlen(trim($input)) > 191) {
            return '';
        }
        return mb_strtolower(trim($input), 'UTF-8');
    }

    public static function validEmail(string $email): bool
    {
        return strlen($email) <= 191 && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
    }

    public static function passwordError(mixed $password, mixed $confirmation): ?string
    {
        if (!is_string($password) || strlen($password) < 12 || strlen($password) > 72) {
            return 'Password harus berisi 12–72 byte. Gunakan frasa yang panjang dan sulit ditebak.';
        }
        if (str_contains($password, "\0")) {
            return 'Password mengandung karakter yang tidak didukung.';
        }
        if (!is_string($confirmation) || !hash_equals($password, $confirmation)) {
            return 'Konfirmasi password belum cocok.';
        }
        return null;
    }

    public static function hashPassword(string $password): string
    {
        if (defined('PASSWORD_ARGON2ID')) {
            return password_hash($password, PASSWORD_ARGON2ID, ['memory_cost' => 49152, 'time_cost' => 2, 'threads' => 1]);
        }
        return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
    }

    public static function needsRehash(string $hash): bool
    {
        return defined('PASSWORD_ARGON2ID')
            ? password_needs_rehash($hash, PASSWORD_ARGON2ID, ['memory_cost' => 49152, 'time_cost' => 2, 'threads' => 1])
            : password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12]);
    }

    public static function iniBytes(string $value): int
    {
        $value = trim($value);
        if ($value === '-1' || $value === '0') {
            return PHP_INT_MAX;
        }
        $number = (float) $value;
        return (int) ($number * match (strtolower(substr($value, -1))) {
            'g' => 1073741824, 'm' => 1048576, 'k' => 1024, default => 1,
        });
    }

    public static function baseUrl(string $value): ?string
    {
        if (!filter_var($value, FILTER_VALIDATE_URL) || preg_match('/[\s\\\\]/', $value)) {
            return null;
        }
        $parts = parse_url($value);
        if (!$parts || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
            || isset($parts['user'], $parts['pass']) || isset($parts['user'])
            || isset($parts['query']) || isset($parts['fragment'])) {
            return null;
        }
        $path = rawurldecode($parts['path'] ?? '');
        if (!preg_match('#^(/[A-Za-z0-9_-]+)*/?$#D', $path)) {
            return null;
        }
        return rtrim($value, '/');
    }
}
````

### cdnvault_app/src/View.php

````php
<?php
declare(strict_types=1);
namespace Vault;

final class View
{
    public static function render(string $template, array $data = [], string $layout = 'layout'): void
    {
        $templateFile = VAULT_APP_PATH . '/views/' . $template . '.php';
        $layoutFile = VAULT_APP_PATH . '/views/' . $layout . '.php';
        if (!is_file($templateFile) || !is_file($layoutFile)) {
            throw new \RuntimeException('Template tidak tersedia.');
        }
        extract($data, EXTR_SKIP);
        $flashes = Session::flashes();
        ob_start();
        require $templateFile;
        $content = (string) ob_get_clean();
        require $layoutFile;
    }
}
````

### cdnvault_app/src/helpers.php

````php
<?php
declare(strict_types=1);

function e(mixed $value): string
{
    return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function config(string $key, mixed $default = null): mixed
{
    return \Vault\Config::get($key, $default);
}

function url(string $path = ''): string
{
    return rtrim((string) config('base_url', ''), '/') . '/' . ltrim($path, '/');
}

function asset(string $path): string
{
    static $version;
    if ($version === null) {
        $context = hash_init('sha256');
        $files = array_merge(
            glob(VAULT_PUBLIC_PATH . '/assets/css/*.css') ?: [],
            glob(VAULT_PUBLIC_PATH . '/assets/js/*.js') ?: []
        );
        sort($files);
        foreach ($files as $file) {
            hash_update_file($context, $file);
        }
        $version = substr(hash_final($context), 0, 12);
    }
    return url('/assets/' . ltrim($path, '/')) . '?v=' . $version;
}

function csrf_field(): string
{
    return '<input type="hidden" name="_csrf" value="' . e(\Vault\Csrf::token()) . '">';
}

function icon(string $name, string $class = ''): string
{
    if (!preg_match('/^[a-z-]+$/D', $name)) {
        return '';
    }
    return '<svg class="icon ' . e($class) . '" aria-hidden="true"><use href="#i-'
        . e($name) . '"></use></svg>';
}

function client_ip(): string
{
    return \Vault\Client::ip();
}

function format_bytes(int|float $bytes): string
{
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
    $index = 0;
    while ($bytes >= 1024 && $index < 4) {
        $bytes /= 1024;
        $index++;
    }
    return number_format($bytes, $index ? 1 : 0, ',', '.') . ' ' . $units[$index];
}

function account_status(array $user): array
{
    return match ($user['approval_status']) {
        'pending' => ['label' => 'Menunggu', 'class' => 'badge-warning'],
        'rejected' => ['label' => 'Ditolak', 'class' => 'badge-danger'],
        default => (bool) $user['is_active']
            ? ['label' => 'Aktif', 'class' => 'badge-success']
            : ['label' => 'Nonaktif', 'class' => 'badge-neutral'],
    };
}
````

### cdnvault_app/storage/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/storage/cache/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/storage/logs/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/storage/private/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/storage/private/sessions/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/views/.htaccess

````apache
Options -Indexes
Require all denied
````

### cdnvault_app/views/admin/system.php

````php
<div class="page-heading"><div><span class="eyebrow">ADMINISTRATOR</span><h1>Diagnostik server</h1><p>Periksa kemampuan hosting dan konfigurasi efektif aplikasi.</p></div><a class="button button-secondary" href="<?= e(url('/admin/system')) ?>">Muat ulang</a></div>
<div class="notice notice-info"><div><b>Pastikan patch keamanan PHP terpasang.</b><p>Minta penyedia hosting mengonfirmasi pembaruan keamanan PHP 21 Oktober 2026 atau patch vendor yang setara. Nomor versi saja tidak membuktikan status patch vendor. Panduan rinci tersedia di KEAMANAN.md dalam paket instalasi.</p></div></div>
<div class="stats-grid"><article class="stat-card"><div class="stat-top"><span>Versi PHP</span><?= icon('server') ?></div><strong><?= e(PHP_VERSION) ?></strong><small><?= e(PHP_SAPI) ?></small></article><article class="stat-card"><div class="stat-top"><span>Upload efektif per file</span><?= icon('cloud') ?></div><strong><?= e(format_bytes($effectiveUpload)) ?></strong><small>Memperhitungkan batas POST</small></article><article class="stat-card"><div class="stat-top"><span>Ruang disk tersisa</span><?= icon('folder') ?></div><strong><?= $disk !== false ? e(format_bytes($disk)) : 'Tidak tersedia' ?></strong><small>Kuota paket hosting dapat berbeda</small></article><article class="stat-card"><div class="stat-top"><span>File gambar di CDN</span><?= icon('image') ?></div><strong><?= $truncated ? '≥ ' : '' ?><?= e(number_format($cdnCount)) ?></strong><small><?= $truncated ? 'Pemindaian dibatasi agar tetap ringan' : 'File fisik di folder CDN' ?></small></article></div>
<div class="two-columns"><section class="panel"><div class="panel-heading"><h2>Ekstensi PHP</h2><span class="muted">Status saat ini</span></div><dl class="detail-list"><?php foreach ($extensions as $extension => $enabled): ?><div><dt><?= e($extension) ?></dt><dd class="<?= $enabled ? 'text-success' : 'text-danger' ?>"><?= $enabled ? 'Aktif' : 'Belum aktif' ?></dd></div><?php endforeach; ?><div><dt>Dukungan WebP</dt><dd><?= !empty($gd['WebP Support']) ? 'Tersedia' : 'Fallback JPEG' ?></dd></div><div><dt>Dukungan AVIF</dt><dd><?= !empty($gd['AVIF Support']) ? 'Tersedia' : 'Tidak tersedia' ?></dd></div><div><dt>OPcache</dt><dd><?= is_array($opcache) && !empty($opcache['opcache_enabled']) ? 'Aktif' : 'Nonaktif / status dibatasi' ?></dd></div></dl></section><section class="panel"><div class="panel-heading"><h2>Batas hosting</h2><span class="muted">php.ini efektif</span></div><dl class="detail-list"><?php foreach ($limits as $key => $value): ?><div><dt><code><?= e($key) ?></code></dt><dd><?= e($value) ?></dd></div><?php endforeach; ?></dl><div class="notice notice-info">Nilai .user.ini dapat diabaikan atau dibatasi oleh penyedia hosting. Ubah batas melalui MultiPHP INI Editor jika tersedia.</div></section></div>
<section class="panel section-gap"><div class="panel-heading"><h2>Akses folder</h2><span class="muted">Hanya administrator</span></div><div class="table-scroll"><table><thead><tr><th>Folder</th><th>Lokasi</th><th>Status</th></tr></thead><tbody><?php foreach ($paths as $label => $path): ?><tr><td><?= e($label) ?></td><td><code><?= e($path) ?></code></td><td class="<?= is_writable($path) ? 'text-success' : 'text-danger' ?>"><?= is_writable($path) ? 'Bisa ditulis' : 'Tidak bisa ditulis' ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<section class="panel section-gap"><div class="panel-heading"><h2>Pengiriman email</h2><span class="badge"><?= config('mail.smtp.enabled') ? 'SMTP dengan TLS' : 'PHP mail()' ?></span></div><p>Email uji hanya dikirim ke akun admin yang sedang masuk: <strong><?= e($user['email']) ?></strong>.</p><p class="field-hint">Untuk SMTP, atur mail.smtp di cdnvault_app/config/config.php menggunakan data kotak email cPanel. Gunakan tls port 587 atau ssl port 465.</p><form method="post" action="<?= e(url('/admin/system/test-email')) ?>" data-loading-form><?= csrf_field() ?><button class="button button-primary" type="submit" data-loading-label="Mengirim email uji…"><?= icon('mail') ?> Kirim email uji</button></form></section>
<section class="panel section-gap"><h2>Saran kesiapan</h2><ul class="plain-list"><li>Pastikan domain sudah memakai HTTPS dengan AutoSSL.</li><li>Aktifkan OPcache melalui cPanel atau minta bantuan penyedia hosting.</li><li>Jika GD belum mendukung WebP, aplikasi akan memakai JPEG pada tahap upload.</li><li>Hapus folder public_html/install setelah pemasangan selesai.</li><li>Folder CDN baru dibuka pada Tahap 2 setelah validasi gambar dan re-encode tersedia.</li></ul></section>
````

### cdnvault_app/views/admin/user.php

````php
<?php $state = account_status($member); $isSelf = (int) $user['id'] === (int) $member['id']; ?>
<a class="back-link" href="<?= e(url('/admin/users')) ?>">← Kembali ke daftar pengguna</a>
<div class="page-heading"><div><span class="eyebrow">PANEL ADMIN</span><h1><?= e($member['display_name']) ?></h1><p><?= e($member['email']) ?></p></div><span class="badge <?= e($state['class']) ?>"><?= e($state['label']) ?></span></div>
<div class="two-columns">
    <section class="panel"><div class="panel-heading"><h2>Profil dan akses</h2><span class="muted">ID <?= e($member['id']) ?></span></div>
        <form method="post" action="<?= e(url('/admin/users/' . $member['id'] . '/update')) ?>" class="form-stack" data-loading-form>
            <?= csrf_field() ?>
            <div class="field"><label for="display-name">Nama</label><input id="display-name" name="display_name" value="<?= e($member['display_name']) ?>" maxlength="120" required></div>
            <div class="field"><label for="member-email">Email login</label><input type="email" id="member-email" name="email" value="<?= e($member['email']) ?>" maxlength="191" required></div>
            <div class="admin-form-grid"><div class="field"><label for="member-role">Peran</label><select id="member-role" name="role"><option value="user"<?= $member['role'] === 'user' ? ' selected' : '' ?>>Pengguna</option><option value="admin"<?= $member['role'] === 'admin' ? ' selected' : '' ?>>Administrator</option></select></div><div class="field"><label for="member-quota">Kuota (GB)</label><input type="number" id="member-quota" name="quota_gb" min="0.1" max="10240" step="0.1" value="<?= e(round((int) $member['storage_quota_bytes'] / 1073741824, 1)) ?>" required></div></div>
            <p class="field-hint">Perubahan email atau peran mencabut sesi akun. Penyimpanan terpakai: <?= e(format_bytes((int) $usage['bytes'])) ?>.</p>
            <?php if ($isSelf): ?><p class="field-hint">Ini akun admin yang sedang Anda gunakan. Peran sendiri tidak bisa diturunkan.</p><?php endif; ?>
            <button type="submit" class="button button-primary" data-loading-label="Menyimpan…">Simpan perubahan <?= icon('check') ?></button>
        </form>
    </section>
    <section class="panel"><div class="panel-heading"><h2>Persetujuan dan status</h2><span class="badge <?= e($state['class']) ?>"><?= e($state['label']) ?></span></div>
        <dl class="detail-list"><div><dt>Terdaftar (UTC)</dt><dd><?= e($member['created_at']) ?></dd></div><div><dt>Login terakhir (UTC)</dt><dd><?= e($member['last_login_at'] ?: 'Belum pernah') ?></dd></div><div><dt>Ditinjau (UTC)</dt><dd><?= e($member['approved_at'] ?: 'Belum ditinjau') ?></dd></div><div><dt>Wajib ganti password</dt><dd><?= (bool) $member['must_change_password'] ? 'Ya' : 'Tidak' ?></dd></div></dl>
        <?php if ($member['review_note']): ?><div class="notice notice-info admin-review-note"><?= e($member['review_note']) ?></div><?php endif; ?>
        <?php if (in_array($member['approval_status'], ['pending', 'rejected'], true)): ?>
            <form method="post" action="<?= e(url('/admin/users/' . $member['id'] . '/approve')) ?>" class="form-stack section-gap" data-loading-form>
                <?= csrf_field() ?><div class="field"><label for="review-note">Catatan admin (opsional)</label><textarea id="review-note" name="review_note" maxlength="500" rows="3" placeholder="Catatan peninjauan akun"></textarea></div>
                <div class="button-row"><button type="submit" class="button button-primary">Setujui akun <?= icon('check') ?></button><button type="submit" class="button button-danger" formaction="<?= e(url('/admin/users/' . $member['id'] . '/reject')) ?>">Tolak pendaftaran</button></div>
            </form>
        <?php elseif (!$isSelf): ?>
            <form method="post" action="<?= e(url('/admin/users/' . $member['id'] . ((bool) $member['is_active'] ? '/deactivate' : '/activate'))) ?>" class="section-gap" data-loading-form<?= (bool) $member['is_active'] ? ' data-confirm="Nonaktifkan akun ini dan cabut seluruh sesinya?"' : '' ?>><?= csrf_field() ?><button type="submit" class="button <?= (bool) $member['is_active'] ? 'button-danger' : 'button-primary' ?>"><?= (bool) $member['is_active'] ? 'Nonaktifkan akun' : 'Aktifkan kembali' ?></button></form>
        <?php else: ?><p class="field-hint section-gap">Akun admin yang sedang digunakan tidak dapat dinonaktifkan sendiri.</p><?php endif; ?>
    </section>
</div>
<?php if (!$isSelf): ?>
<section class="panel section-gap"><div class="panel-heading"><h2>Reset password pengguna</h2><span class="badge">Password sementara</span></div><p>Seluruh sesi dicabut. Setelah masuk, pengguna wajib mengganti password sementara sebelum membuka dashboard.</p>
    <form method="post" action="<?= e(url('/admin/users/' . $member['id'] . '/password')) ?>" class="form-stack" data-loading-form data-confirm="Reset password akun ini dan cabut seluruh sesinya?">
        <?= csrf_field() ?><div class="admin-form-grid"><div class="field"><label for="temporary-password">Password sementara baru</label><input type="password" id="temporary-password" name="password" autocomplete="new-password" minlength="12" maxlength="72" required></div><div class="field"><label for="temporary-confirm">Ulangi password sementara</label><input type="password" id="temporary-confirm" name="password_confirmation" autocomplete="new-password" minlength="12" maxlength="72" required></div></div><button type="submit" class="button button-secondary admin-submit">Reset password <?= icon('key') ?></button>
    </form>
</section>
<?php endif; ?>
````

### cdnvault_app/views/admin/users.php

````php
<div class="page-heading"><div><span class="eyebrow">PANEL ADMIN</span><h1>Persetujuan pengguna</h1><p>Tinjau pendaftaran dan kelola akun yang boleh mengakses website.</p></div><a class="button button-primary" href="#create-user"><?= icon('users') ?> Tambah pengguna</a></div>
<div class="stats-grid">
<?php foreach ([['users', 'Total akun', $stats['total'], 'Termasuk akun administrator'], ['clock', 'Menunggu persetujuan', $stats['pending'], 'Perlu keputusan administrator'], ['vault', 'Akun aktif', $stats['active'], 'Sudah disetujui dan diaktifkan'], ['lock', 'Pendaftaran ditolak', $stats['rejected'], 'Tidak dapat masuk website']] as [$symbol, $label, $value, $hint]): ?>
    <article class="stat-card"><div class="stat-top"><span><?= e($label) ?></span><span class="stat-icon"><?= icon($symbol) ?></span></div><strong><?= e(number_format((int) $value)) ?></strong><small><?= e($hint) ?></small></article>
<?php endforeach; ?>
</div>
<?php if ((int) $stats['pending'] > 0): ?><div class="notice notice-info"><?= icon('clock') ?><span>Ada <?= e($stats['pending']) ?> pendaftaran menunggu persetujuan. <a href="<?= e(url('/admin/users?status=pending')) ?>">Tinjau sekarang</a>.</span></div><?php endif; ?>
<section class="panel">
    <div class="panel-heading"><h2>Daftar akun</h2><span class="muted">25 akun per halaman</span></div>
    <form class="admin-filters" method="get" action="<?= e(url('/admin/users')) ?>">
        <div class="field"><label for="user-search">Cari nama atau email</label><input type="search" id="user-search" name="q" maxlength="120" value="<?= e($query) ?>" placeholder="Cari pengguna…"></div>
        <div class="field"><label for="user-status">Status</label><select id="user-status" name="status"><?php foreach (['all' => 'Semua akun', 'pending' => 'Menunggu persetujuan', 'approved' => 'Sudah disetujui', 'disabled' => 'Nonaktif', 'rejected' => 'Ditolak'] as $value => $label): ?><option value="<?= e($value) ?>"<?= $status === $value ? ' selected' : '' ?>><?= e($label) ?></option><?php endforeach; ?></select></div>
        <button type="submit" class="button button-secondary">Terapkan filter</button>
    </form>
    <?php if (!$rows): ?><div class="admin-empty"><span class="large-icon"><?= icon('users') ?></span><h3>Belum ada akun pada filter ini.</h3><p>Pendaftaran baru akan muncul di status Menunggu persetujuan.</p></div><?php else: ?>
    <div class="table-scroll"><table class="users-table"><thead><tr><th>Pengguna</th><th>Status</th><th>Peran / kuota</th><th>Terdaftar (UTC)</th><th>Aksi</th></tr></thead><tbody>
    <?php foreach ($rows as $member): $state = account_status($member); ?>
        <tr><td data-label="Pengguna"><div class="user-identity"><b><?= e($member['display_name']) ?></b><span><?= e($member['email']) ?></span></div></td><td data-label="Status"><span class="badge <?= e($state['class']) ?>"><?= e($state['label']) ?></span></td><td data-label="Peran / kuota"><div class="user-identity"><b><?= $member['role'] === 'admin' ? 'Administrator' : 'Pengguna' ?></b><span><?= e(format_bytes((int) $member['storage_quota_bytes'])) ?></span></div></td><td data-label="Terdaftar"><span class="muted"><?= e($member['created_at']) ?></span></td><td data-label="Aksi"><div class="row-actions"><a class="button button-secondary button-small" href="<?= e(url('/admin/users/' . $member['id'])) ?>">Detail</a>
        <?php if ($member['approval_status'] === 'pending'): ?>
            <form method="post" action="<?= e(url('/admin/users/' . $member['id'] . '/approve')) ?>" data-loading-form><?= csrf_field() ?><button type="submit" class="button button-primary button-small">Setujui</button></form>
            <form method="post" action="<?= e(url('/admin/users/' . $member['id'] . '/reject')) ?>" data-loading-form data-confirm="Tolak pendaftaran akun ini?"><?= csrf_field() ?><button type="submit" class="button button-danger button-small">Tolak</button></form>
        <?php endif; ?></div></td></tr>
    <?php endforeach; ?>
    </tbody></table></div>
    <div class="pagination-row"><span class="field-hint">Menampilkan <?= e(count($rows)) ?> akun. Semua waktu menggunakan UTC.</span><?php if ($next): ?><a class="button button-secondary button-small" href="<?= e(url('/admin/users?' . http_build_query(['q' => $query, 'status' => $status, 'cursor' => $next]))) ?>">Halaman berikutnya <?= icon('arrow') ?></a><?php endif; ?></div>
    <?php endif; ?>
</section>
<section class="panel section-gap" id="create-user"><div class="panel-heading"><h2>Tambah pengguna langsung</h2><span class="badge">Disetujui admin</span></div><p>Akun yang dibuat admin langsung disetujui. Pengguna wajib mengganti password sementara saat login pertama.</p>
    <form method="post" action="<?= e(url('/admin/users/create')) ?>" class="form-stack" data-loading-form>
        <?= csrf_field() ?>
        <div class="admin-form-grid"><div class="field"><label for="new-name">Nama pengguna</label><input id="new-name" name="display_name" maxlength="120" required></div><div class="field"><label for="new-email">Alamat email</label><input type="email" id="new-email" name="email" maxlength="191" autocomplete="off" required></div><div class="field"><label for="new-role">Peran</label><select id="new-role" name="role"><option value="user">Pengguna</option><option value="admin">Administrator</option></select></div><div class="field"><label for="new-quota">Kuota (GB)</label><input type="number" id="new-quota" name="quota_gb" min="0.1" max="10240" step="0.1" value="5" required></div><div class="field"><label for="new-password">Password sementara</label><input type="password" id="new-password" name="password" autocomplete="new-password" minlength="12" maxlength="72" required></div><div class="field"><label for="new-confirm">Ulangi password sementara</label><input type="password" id="new-confirm" name="password_confirmation" autocomplete="new-password" minlength="12" maxlength="72" required></div></div>
        <button type="submit" class="button button-primary admin-submit" data-loading-label="Membuat akun…">Buat & setujui akun <?= icon('check') ?></button>
    </form>
</section>
````

### cdnvault_app/views/auth/change.php

````php
<div class="page-heading"><div><span class="eyebrow">KEAMANAN AKUN</span><h1>Ubah password</h1><p><?= !empty($user['must_change_password']) ? 'Ganti password sementara sebelum menggunakan dashboard.' : 'Gunakan password unik untuk melindungi aset Anda.' ?></p></div></div>
<section class="panel narrow-panel">
    <?php if ($error): ?><div class="notice notice-error" role="alert"><?= e($error) ?></div><?php endif; ?>
    <form method="post" action="<?= e(url('/app/change-password')) ?>" class="form-stack" data-loading-form>
        <?= csrf_field() ?>
        <div class="field"><label for="current_password">Password saat ini</label><input type="password" name="current_password" id="current_password" autocomplete="current-password" maxlength="4096" required></div>
        <div class="field"><label for="password">Password baru</label><div class="password-field"><input type="password" name="password" id="password" minlength="12" maxlength="72" autocomplete="new-password" data-strength required><button type="button" class="icon-button" data-password-toggle="password" aria-label="Tampilkan password" aria-pressed="false"><?= icon('eye') ?></button></div><meter class="strength-meter" min="0" max="4" value="0" aria-label="Perkiraan kekuatan password"></meter><span class="field-hint" data-strength-label>12–72 byte. Gunakan frasa yang sulit ditebak.</span></div>
        <div class="field"><label for="confirmation">Ulangi password baru</label><input type="password" name="password_confirmation" id="confirmation" minlength="12" maxlength="72" autocomplete="new-password" required></div>
        <p class="field-hint">Setelah perubahan berhasil, Anda akan keluar dari semua perangkat dan perlu masuk kembali.</p>
        <button type="submit" class="button button-primary" data-loading-label="Menyimpan…">Simpan password <?= icon('check') ?></button>
    </form>
</section>
````

### cdnvault_app/views/auth/forgot.php

````php
<a class="back-link" href="<?= e(url('/login')) ?>">← Kembali ke login</a>
<?php if ($sent): ?>
    <div class="large-icon"><?= icon('mail') ?></div><h1>Periksa email Anda.</h1>
    <p class="subtitle">Jika alamat email terdaftar, kami akan mengirim tautan untuk mengatur ulang password. Tautan berlaku selama 1 jam.</p>
    <div class="notice notice-info">Belum terlihat? Periksa folder spam dan pastikan email pengirim aplikasi sudah dikonfigurasi oleh administrator.</div>
    <a class="button button-primary" href="<?= e(url('/login')) ?>">Kembali ke login <?= icon('arrow') ?></a>
<?php else: ?>
    <div class="large-icon"><?= icon('key') ?></div><h1>Lupa password?</h1>
    <p class="subtitle">Masukkan email akun Anda. Kami akan membantu Anda kembali ke ruang aset Anda.</p>
    <form method="post" action="<?= e(url('/forgot-password')) ?>" class="form-stack" data-loading-form>
        <?= csrf_field() ?><div class="field"><label for="email">Alamat email</label><input id="email" name="email" type="email" autocomplete="email" maxlength="191" placeholder="nama@domain.com" required autofocus></div>
        <button type="submit" class="button button-primary button-wide" data-loading-label="Mengirim permintaan…">Kirim tautan pemulihan <?= icon('mail') ?></button>
    </form>
<?php endif; ?>
````

### cdnvault_app/views/auth/login.php

````php
<span class="eyebrow">SELAMAT DATANG KEMBALI</span>
<h1>Masuk ke ruang Anda.</h1>
<p class="subtitle">Kelola semua aset dari satu tempat yang nyaman.</p>
<?php if ($error): ?><div class="notice notice-error" role="alert"<?= isset($retryAfter) ? ' data-countdown="' . e($retryAfter) . '"' : '' ?>><?= icon('lock') ?><span><?= e($error) ?></span></div><?php endif; ?>
<form method="post" action="<?= e(url('/login')) ?>" class="form-stack" data-loading-form>
    <?= csrf_field() ?>
    <div class="field"><label for="email">Alamat email</label><input id="email" name="email" type="email" autocomplete="username" inputmode="email" maxlength="191" value="<?= e($email) ?>" placeholder="nama@domain.com" required autofocus></div>
    <div class="field"><label for="password">Password</label><div class="password-field"><input id="password" name="password" type="password" autocomplete="current-password" maxlength="4096" placeholder="Masukkan password Anda" required><button type="button" class="icon-button" data-password-toggle="password" aria-label="Tampilkan password" aria-pressed="false"><?= icon('eye') ?></button></div></div>
    <?php require VAULT_APP_PATH . '/views/partials/captcha.php'; ?>
    <div class="form-between"><span class="field-hint">Email + password, tanpa username.</span><a href="<?= e(url('/forgot-password')) ?>">Lupa password?</a></div>
    <button class="button button-primary button-wide" type="submit" data-loading-label="Memeriksa akun…">Masuk ke dashboard <?= icon('arrow') ?></button>
</form>
<div class="auth-footnote"><?= icon('lock') ?><p>Belum punya akun? <a href="<?= e(url('/register')) ?>">Daftar sekarang</a>.<br>Akun baru perlu disetujui admin sebelum bisa masuk.</p></div>
````

### cdnvault_app/views/auth/register.php

````php
<a class="back-link" href="<?= e(url('/login')) ?>">← Kembali ke login</a>
<?php if ($submitted): ?>
    <div class="large-icon"><?= icon('clock') ?></div><span class="eyebrow">PENDAFTARAN DITERIMA</span><h1>Tunggu persetujuan admin.</h1><p class="subtitle">Jika email belum terdaftar, permintaan akun Anda sudah masuk antrean peninjauan. Anda dapat login setelah admin menyetujui pendaftaran.</p><div class="notice notice-info">Jika email sudah memiliki akun, akun tersebut tetap menggunakan password dan status sebelumnya.</div><a class="button button-primary button-wide" href="<?= e(url('/login')) ?>">Kembali ke login <?= icon('arrow') ?></a>
<?php else: ?>
    <span class="eyebrow">RUANG ASET UNTUK ANDA</span><h1>Ajukan akun baru.</h1><p class="subtitle">Daftar dengan email dan password. Administrator akan meninjau akun Anda sebelum akses dibuka.</p>
    <?php if ($error): ?><div class="notice notice-error" role="alert"><?= e($error) ?></div><?php endif; ?>
    <form method="post" action="<?= e(url('/register')) ?>" class="form-stack" data-loading-form>
        <?= csrf_field() ?>
        <div class="field"><label for="email">Alamat email</label><input type="email" id="email" name="email" autocomplete="username" maxlength="191" value="<?= e($email) ?>" placeholder="nama@domain.com" required></div>
        <div class="field"><label for="password">Password</label><div class="password-field"><input type="password" name="password" id="password" autocomplete="new-password" minlength="12" maxlength="72" data-strength required><button type="button" class="icon-button" data-password-toggle="password" aria-label="Tampilkan password" aria-pressed="false"><?= icon('eye') ?></button></div><meter class="strength-meter" min="0" max="4" value="0" aria-label="Perkiraan kekuatan password"></meter><span class="field-hint" data-strength-label>Gunakan password unik sepanjang 12–72 byte.</span></div>
        <div class="field"><label for="password_confirmation">Ulangi password</label><input type="password" id="password_confirmation" name="password_confirmation" autocomplete="new-password" minlength="12" maxlength="72" required></div>
        <?php require VAULT_APP_PATH . '/views/partials/captcha.php'; ?>
        <button type="submit" class="button button-primary button-wide" data-loading-label="Mengajukan pendaftaran…">Daftar & minta persetujuan <?= icon('arrow') ?></button>
    </form>
    <div class="auth-footnote"><?= icon('vault') ?><p>Pendaftaran belum memberikan akses dashboard. Anda perlu menunggu keputusan administrator.</p></div>
<?php endif; ?>
````

### cdnvault_app/views/auth/reset.php

````php
<?php if (!$valid): ?>
    <div class="large-icon"><?= icon('clock') ?></div><h1>Tautan sudah tidak berlaku.</h1><p class="subtitle">Tautan mungkin kedaluwarsa atau sudah pernah dipakai. Minta tautan baru untuk melanjutkan.</p><a class="button button-primary" href="<?= e(url('/forgot-password')) ?>">Minta tautan baru <?= icon('arrow') ?></a>
<?php else: ?>
    <span class="eyebrow">MULAI DENGAN PASSWORD BARU</span><h1>Amankan akun Anda.</h1><p class="subtitle">Pilih frasa password unik, minimal 12 byte dan maksimal 72 byte.</p>
    <?php if ($error): ?><div class="notice notice-error" role="alert"><?= e($error) ?></div><?php endif; ?>
    <form method="post" action="<?= e(url('/reset-password/' . $token)) ?>" class="form-stack" data-loading-form>
        <?= csrf_field() ?>
        <div class="field"><label for="password">Password baru</label><div class="password-field"><input id="password" name="password" type="password" autocomplete="new-password" minlength="12" maxlength="72" data-strength required><button type="button" class="icon-button" data-password-toggle="password" aria-label="Tampilkan password" aria-pressed="false"><?= icon('eye') ?></button></div><meter class="strength-meter" min="0" max="4" value="0" aria-label="Perkiraan kekuatan password"></meter><span class="field-hint" data-strength-label>Gunakan frasa yang panjang dan unik.</span></div>
        <div class="field"><label for="confirmation">Ulangi password baru</label><input id="confirmation" name="password_confirmation" type="password" autocomplete="new-password" minlength="12" maxlength="72" required></div>
        <button type="submit" class="button button-primary button-wide" data-loading-label="Menyimpan password…">Simpan password baru <?= icon('check') ?></button>
    </form>
<?php endif; ?>
````

### cdnvault_app/views/auth/upgrade.php

````php
<span class="eyebrow">PEMBARUAN APLIKASI</span>
<?php if ($complete): ?>
    <div class="large-icon"><?= icon('check') ?></div><h1>Aplikasi sudah terbaru.</h1><p class="subtitle">Pendaftaran, persetujuan admin, dan CAPTCHA sudah tersedia.</p><a class="button button-primary" href="<?= e(url('/login')) ?>">Buka login <?= icon('arrow') ?></a>
<?php else: ?>
    <h1>Aktifkan panel persetujuan.</h1><p class="subtitle">Pemilik website perlu masuk dengan akun admin lama untuk menambahkan kolom persetujuan. Data pengguna dan APP_KEY tetap dipertahankan.</p>
    <?php if ($error): ?><div class="notice notice-error" role="alert"><?= e($error) ?></div><?php endif; ?>
    <form method="post" action="<?= e(url('/upgrade/')) ?>" class="form-stack" data-loading-form>
        <?= csrf_field() ?>
        <div class="field"><label for="email">Email admin lama</label><input type="email" name="email" id="email" autocomplete="username" maxlength="191" required></div>
        <div class="field"><label for="password">Password admin</label><input type="password" name="password" id="password" autocomplete="current-password" maxlength="4096" required></div>
        <?php require VAULT_APP_PATH . '/views/partials/captcha.php'; ?>
        <p class="field-hint">Backup konfigurasi dibuat otomatis. Sesi lama dicabut setelah pembaruan agar pengguna masuk kembali melalui formulir baru.</p>
        <button type="submit" class="button button-primary" data-loading-label="Memperbarui…">Perbarui aplikasi <?= icon('arrow') ?></button>
    </form>
<?php endif; ?>
````

### cdnvault_app/views/dashboard.php

````php
<div class="page-heading"><div><span class="eyebrow">RUANG KERJA ANDA</span><h1>Dashboard pribadi.</h1><p>Halo, <?= e($user['display_name']) ?>. Berikut ringkasan akun Anda.</p></div><span class="badge badge-success"><?= icon('lock') ?> Akun aktif</span></div>
<div class="stats-grid">
    <?php foreach ([['image', 'Total gambar', number_format((int) $stats['images'], 0, ',', '.'), 'Gambar milik akun Anda'], ['link', 'Total tautan', number_format((int) $stats['links'], 0, ',', '.'), 'Tautan milik akun Anda'], ['server', 'Penyimpanan terpakai', format_bytes((int) $stats['bytes']), 'Termasuk semua varian gambar'], ['cloud', 'Sisa kuota', format_bytes(max(0, (int) $user['storage_quota_bytes'] - (int) $stats['bytes'])), 'Dari ' . format_bytes((int) $user['storage_quota_bytes'])]] as [$symbol, $label, $value, $hint]): ?>
        <article class="stat-card"><div class="stat-top"><span><?= e($label) ?></span><span class="stat-icon"><?= icon($symbol) ?></span></div><strong><?= e($value) ?></strong><small><?= e($hint) ?></small></article>
    <?php endforeach; ?>
</div>
<section class="welcome-panel"><div class="welcome-copy"><span class="badge">RUANG KERJA PRIBADI</span><h2>Satu akun.<br>Ruang Anda sendiri.</h2><p>Ringkasan dan aktivitas di halaman ini hanya berasal dari akun Anda. Kelola keamanan akun melalui menu ubah password.</p><a class="button button-primary" href="<?= e(url('/app/change-password')) ?>">Ubah password <?= icon('arrow') ?></a></div><div class="welcome-art" aria-hidden="true"><div class="vault-block"><?= icon('lock') ?></div></div></section>
<div class="two-columns">
    <section class="panel"><div class="panel-heading"><h2>Akun Anda</h2><span class="muted">Pribadi</span></div><dl class="detail-list"><div><dt>Email</dt><dd><?= e($user['email']) ?></dd></div><div><dt>Peran</dt><dd><?= $user['role'] === 'admin' ? 'Administrator' : 'Pengguna' ?></dd></div><div><dt>Kuota penyimpanan</dt><dd><?= e(format_bytes((int) $user['storage_quota_bytes'])) ?></dd></div><div><dt>Status</dt><dd><span class="badge badge-success">Disetujui dan aktif</span></dd></div></dl></section>
    <section class="panel"><div class="panel-heading"><h2>Aktivitas akun Anda</h2><span class="muted">5 terbaru</span></div>
        <?php $labels = ['login' => 'Berhasil masuk', 'login_remembered' => 'Sesi akun dipulihkan', 'logout' => 'Keluar dari akun', 'registration_requested' => 'Pendaftaran diajukan', 'password_changed' => 'Password diperbarui', 'password_reset' => 'Password diatur ulang']; ?>
        <?php if (!$activity): ?><p>Belum ada aktivitas untuk ditampilkan.</p><?php endif; ?>
        <dl class="detail-list"><?php foreach ($activity as $event): ?><div><dt><?= e($labels[$event['action']] ?? 'Aktivitas akun') ?></dt><dd><time><?= e($event['created_at']) ?> UTC</time></dd></div><?php endforeach; ?></dl>
        <p class="field-hint">Selalu keluar setelah memakai perangkat bersama.</p>
    </section>
</div>
````

### cdnvault_app/views/errors/error.php

````php
<span class="eyebrow">HTTP <?= e($status) ?></span><h1><?= $status === 404 ? 'Halaman tidak ditemukan.' : ($status === 403 ? 'Akses terbatas.' : ($status === 419 ? 'Muat ulang halaman.' : 'Ada sedikit kendala.')) ?></h1><p class="subtitle"><?= e($message) ?></p>
<?php if ($reference): ?><p class="field-hint">Referensi untuk administrator: <code><?= e($reference) ?></code></p><?php endif; ?>
<a href="<?= e(url('/app')) ?>" class="button button-primary">Kembali ke dashboard <?= icon('arrow') ?></a>
````

### cdnvault_app/views/guest.php

````php
<?php require __DIR__ . '/partials/head.php'; ?>
<body class="guest-page" data-base-path="<?= e(\Vault\Config::basePath()) ?>">
<?php require __DIR__ . '/partials/icons.php'; ?>
<div class="offline-banner" role="status" hidden>Anda sedang offline. Periksa koneksi sebelum melanjutkan.</div>
<main class="auth-shell">
    <section class="auth-main" aria-label="Akses akun">
        <a class="brand" href="<?= e(url('/')) ?>"><img src="<?= e(asset('img/logo.svg')) ?>" alt="" width="38" height="38"><span>CDN<span class="brand-light">Vault</span></span></a>
        <div class="auth-content">
            <?php require __DIR__ . '/partials/flashes.php'; ?>
            <?= $content ?>
        </div>
        <footer class="auth-footer"><span>© <?= e(gmdate('Y')) ?> CDN Vault</span><button class="text-button" type="button" data-theme-toggle aria-label="Ganti tema"><?= icon('sun') ?> Tema</button></footer>
    </section>
    <aside class="auth-branding" aria-label="Tentang CDN Vault">
        <div class="branding-top"><span class="status-dot"></span> RUANG ASET PRIBADI ANDA <span class="small-pill">v<?= e(config('version', '0.3.0')) ?></span></div>
        <div class="branding-copy"><span class="eyebrow">SIMPAN. ATUR. BAGIKAN.</span><h2>Satu rumah untuk<br>semua aset Anda.</h2><p>Ruang yang rapi untuk gambar dan tautan.<br>Akses mudah, kendali tetap di tangan Anda.</p></div>
        <div class="asset-scene" aria-hidden="true">
            <div class="scene-card scene-back"><div class="art-landscape"><span></span></div><div class="scene-lines"><b>landscape.webp</b><span>2400 × 1600 · WebP</span></div></div>
            <div class="scene-card scene-front"><div class="art-orbit"><span></span></div><div class="scene-lines"><b>creative-assets</b><span>Semua ide, tersimpan rapi.</span></div><span class="scene-badge"><?= icon('lock') ?> Ruang pribadi</span></div>
            <div class="floating-chip"><?= icon('vault') ?><span>Dashboard terlindungi<br><b>Akses hanya untuk Anda</b></span></div>
        </div>
        <div class="branding-bottom"><span><?= icon('lock') ?> Login aman</span><span><?= icon('server') ?> Hosting Anda sendiri</span></div>
    </aside>
</main>
<div class="toast-region" aria-live="polite"></div>
</body></html>
````

### cdnvault_app/views/install/locked.php

````php
<section class="panel install-locked"><div class="large-icon"><?= icon('vault') ?></div><span class="eyebrow">PEMASANGAN SUDAH SELESAI</span><h1>Installer terkunci.</h1><p>Konfigurasi aplikasi atau install.lock sudah ditemukan. Pemasangan ulang melalui browser dinonaktifkan.</p><div class="notice notice-info">Hapus folder <code>public_html/install</code> melalui File Manager cPanel. Jangan hapus config.php atau install.lock untuk memasang ulang database yang sudah berisi pengguna.</div><a href="<?= e(url('/login')) ?>" class="button button-primary">Buka halaman login <?= icon('arrow') ?></a></section>
````

### cdnvault_app/views/install/wizard.php

````php
<div class="install-intro"><span class="eyebrow">MULAI DARI RUANG MILIK ANDA</span><h1>Pasang CDN Vault.</h1><p>Empat langkah untuk menghubungkan hosting dan membuat akun pertama.</p></div>
<ol class="install-steps" aria-label="Langkah pemasangan"><?php foreach ([1 => 'Persyaratan', 2 => 'Database', 3 => 'Akun admin', 4 => 'Selesaikan'] as $number => $label): ?><li class="<?= $step === $number ? 'current' : ($step > $number ? 'complete' : '') ?>"<?= $step === $number ? ' aria-current="step"' : '' ?>><span><?= e($number) ?></span><b><?= e($label) ?></b></li><?php endforeach; ?></ol>
<section class="panel install-panel">
<?php if ($error): ?><div class="notice notice-error" role="alert"><?= e($error) ?></div><?php endif; ?>
<?php if ($notice): ?><div class="notice notice-success" role="status"><?= e($notice) ?></div><?php endif; ?>
<?php if ($step === 1): ?>
    <div class="panel-heading"><h2>Periksa kesiapan hosting</h2><span class="badge">Langkah 1 / 4</span></div>
    <p class="muted">Item wajib harus siap. Dukungan WebP dan batas upload akan menyesuaikan kemampuan hosting.</p>
    <ul class="requirements"><?php foreach ($checks as $check): ?><li><span class="check-circle <?= $check['ok'] ? 'good' : ($check['required'] ? 'bad' : 'advisory') ?>"><?= icon($check['ok'] ? 'check' : 'close') ?></span><span><b><?= e($check['label']) ?></b><small><?= e($check['detail']) ?></small></span><span class="requirement-label"><?= $check['required'] ? 'Wajib' : 'Anjuran' ?></span></li><?php endforeach; ?></ul>
    <div class="notice notice-info"><div><b>Verifikasi kepemilikan hosting</b><p>Buka File Manager cPanel, lalu salin kunci dari file <code><?= e(VAULT_APP_PATH . '/config/setup-key.txt') ?></code>. Kunci tidak ditampilkan melalui browser.</p></div></div>
    <form method="post" action="<?= e(url('/install/?step=1')) ?>" class="form-stack" data-loading-form><?= csrf_field() ?><input type="hidden" name="action" value="requirements"><div class="field"><label for="setup_key">Kunci pemasangan</label><input type="password" id="setup_key" name="setup_key" autocomplete="off" minlength="64" maxlength="64" required><span class="field-hint">Kunci dibuat saat halaman ini pertama kali dibuka.</span></div><button type="submit" class="button button-primary"<?= !$ready ? ' disabled' : '' ?>>Verifikasi & lanjutkan <?= icon('arrow') ?></button></form>
<?php elseif ($step === 2): ?>
    <div class="panel-heading"><h2>Hubungkan database</h2><span class="badge">Langkah 2 / 4</span></div><p class="muted">Buat database dan pengguna MySQL di cPanel, lalu berikan ALL PRIVILEGES pada database tersebut.</p>
    <form method="post" action="<?= e(url('/install/?step=2')) ?>" class="form-stack" data-loading-form><?= csrf_field() ?>
        <div class="form-two"><div class="field"><label for="host">Host database</label><input id="host" name="host" value="<?= e($state['db']['host'] ?? 'localhost') ?>" maxlength="255" required></div><div class="field"><label for="port">Port</label><input type="number" id="port" name="port" value="<?= e($state['db']['port'] ?? 3306) ?>" min="1" max="65535" required></div></div>
        <div class="field"><label for="database">Nama database</label><input id="database" name="database" value="<?= e($state['db']['name'] ?? '') ?>" placeholder="usercpanel_cdnvault" maxlength="64" required></div>
        <div class="field"><label for="username">Pengguna database</label><input id="username" name="username" value="<?= e($state['db']['user'] ?? '') ?>" placeholder="usercpanel_vault" maxlength="128" autocomplete="off" required></div>
        <div class="field"><label for="db_password">Password database</label><div class="password-field"><input id="db_password" name="db_password" type="password" autocomplete="off"<?= !isset($state['db']) ? ' required' : '' ?>><button type="button" class="icon-button" data-password-toggle="db_password" aria-label="Tampilkan password database" aria-pressed="false"><?= icon('eye') ?></button></div><?php if (isset($state['db'])): ?><span class="field-hint">Biarkan kosong untuk memakai password yang sudah diuji.</span><?php endif; ?></div>
        <div class="button-row"><a class="button button-secondary" href="<?= e(url('/install/?step=1')) ?>">Kembali</a><button type="submit" name="action" value="test_database" class="button button-secondary">Uji koneksi</button><button type="submit" name="action" value="database" class="button button-primary">Lanjutkan <?= icon('arrow') ?></button></div>
    </form>
<?php elseif ($step === 3): ?>
    <div class="panel-heading"><h2>Buat akun administrator</h2><span class="badge">Langkah 3 / 4</span></div><p class="muted">Akun ini mengelola ruang kerja Anda. Pendaftaran pengguna baru wajib disetujui administrator.</p>
    <form method="post" action="<?= e(url('/install/?step=3')) ?>" class="form-stack" data-loading-form><?= csrf_field() ?><input type="hidden" name="action" value="admin">
        <div class="field"><label for="display_name">Nama Anda</label><input id="display_name" name="display_name" maxlength="120" autocomplete="name" value="<?= e($state['admin']['display_name'] ?? '') ?>" required></div>
        <div class="field"><label for="email">Email administrator</label><input type="email" id="email" name="email" autocomplete="username" maxlength="191" value="<?= e($state['admin']['email'] ?? '') ?>" required></div>
        <div class="field"><label for="password">Password</label><div class="password-field"><input type="password" id="password" name="password" autocomplete="new-password" minlength="12" maxlength="72" data-strength required><button type="button" class="icon-button" data-password-toggle="password" aria-label="Tampilkan password" aria-pressed="false"><?= icon('eye') ?></button></div><meter class="strength-meter" min="0" max="4" value="0" aria-label="Perkiraan kekuatan password"></meter><span class="field-hint" data-strength-label>Minimal 12 byte, maksimal 72 byte.</span></div>
        <div class="field"><label for="password_confirmation">Ulangi password</label><input type="password" id="password_confirmation" name="password_confirmation" minlength="12" maxlength="72" autocomplete="new-password" required></div>
        <div class="button-row"><a class="button button-secondary" href="<?= e(url('/install/?step=2')) ?>">Kembali</a><button class="button button-primary" type="submit">Lanjutkan <?= icon('arrow') ?></button></div>
    </form>
<?php else: ?>
    <div class="panel-heading"><h2>Siap membuat ruang Anda?</h2><span class="badge">Langkah 4 / 4</span></div><p class="muted">Konfirmasi alamat aplikasi. Struktur tabel, konfigurasi, APP_KEY acak, dan kunci instalasi akan dibuat otomatis.</p>
    <dl class="detail-list install-summary"><div><dt>Database</dt><dd><?= e($state['db']['name']) ?></dd></div><div><dt>Akun pertama</dt><dd><?= e($state['admin']['email']) ?></dd></div><div><dt>Penyimpanan aplikasi</dt><dd><code><?= e(VAULT_APP_PATH) ?></code></dd></div></dl>
    <form method="post" action="<?= e(url('/install/?step=4')) ?>" class="form-stack" data-loading-form><?= csrf_field() ?><input type="hidden" name="action" value="install">
        <div class="field"><label for="base_url">URL utama aplikasi</label><input type="url" id="base_url" name="base_url" value="<?= e(config('base_url')) ?>" maxlength="500" required><span class="field-hint">Gunakan alamat HTTPS yang sudah aktif. Subfolder seperti https://domain.com/vault juga didukung.</span></div>
        <div class="field"><label for="from_email">Email pengirim aplikasi</label><input type="email" id="from_email" name="from_email" value="<?= e($state['admin']['email']) ?>" maxlength="191" required><span class="field-hint">Sebaiknya alamat kotak email dari domain Anda. Konfigurasi SMTP dapat diisi melalui config.php.</span></div>
        <div class="notice notice-info">Installer tidak membuat database MySQL di akun cPanel Anda. Database harus sudah dibuat pada langkah sebelumnya. Gunakan database kosong khusus aplikasi.</div>
        <div class="button-row"><a class="button button-secondary" href="<?= e(url('/install/?step=3')) ?>">Kembali</a><button class="button button-primary" type="submit" data-loading-label="Memasang aplikasi…">Pasang CDN Vault <?= icon('check') ?></button></div>
    </form>
<?php endif; ?>
</section>
````

### cdnvault_app/views/installer.php

````php
<?php require __DIR__ . '/partials/head.php'; ?>
<body class="installer-page" data-base-path="<?= e(\Vault\Config::basePath()) ?>">
<?php require __DIR__ . '/partials/icons.php'; ?>
<div class="offline-banner" role="status" hidden>Anda sedang offline. Sambungkan kembali sebelum melanjutkan.</div>
<header class="installer-header"><a class="brand" href="<?= e(url('/install/')) ?>"><img src="<?= e(asset('img/logo.svg')) ?>" alt="" width="36" height="36"><span>CDN<span class="brand-light">Vault</span></span></a><button type="button" class="text-button" data-theme-toggle aria-label="Ganti tema"><?= icon('sun') ?> Tema</button></header>
<main class="installer-main"><?= $content ?></main><footer class="installer-footer">CDN Vault · Pemasangan mandiri untuk cPanel · Tahap 1</footer>
</body></html>
````

### cdnvault_app/views/layout.php

````php
<?php require __DIR__ . '/partials/head.php'; ?>
<body class="app-page" data-base-path="<?= e(\Vault\Config::basePath()) ?>">
<?php require __DIR__ . '/partials/icons.php'; ?>
<a class="skip-link" href="#main">Lewati navigasi</a>
<div class="offline-banner" role="status" hidden>Anda sedang offline. Sambungkan kembali untuk menyimpan perubahan.</div>
<div class="app-shell">
    <aside class="sidebar" id="sidebar" aria-label="Navigasi utama">
        <a class="brand" href="<?= e(url('/app')) ?>"><img src="<?= e(asset('img/logo.svg')) ?>" alt="" width="36" height="36"><span>CDN<span class="brand-light">Vault</span></span></a>
        <div class="workspace-label"><span class="workspace-avatar"><?= e(mb_strtoupper(mb_substr($user['display_name'], 0, 1))) ?></span><span>Ruang kerja pribadi<small><?= e($user['display_name']) ?></small></span><?= icon('lock') ?></div>
        <nav class="nav-list" aria-label="Menu akun">
            <?php require __DIR__ . '/partials/navigation.php'; ?>
        </nav>
        <div class="sidebar-foot"><div><?= icon('vault') ?><span>Hosting Anda. Kendali Anda.</span></div><small>CDN Vault · v<?= e(config('version', '0.3.0')) ?></small></div>
    </aside>
    <button type="button" class="sidebar-overlay" data-sidebar-close aria-label="Tutup navigasi" hidden></button>
    <div class="app-body">
        <header class="topbar"><div class="topbar-left"><button type="button" class="icon-button" data-sidebar-toggle aria-label="Buka atau lipat navigasi" aria-controls="sidebar" aria-expanded="true"><?= icon('menu') ?></button><span>Ruang kerja <span class="breadcrumb-slash">/</span> <b><?= e($title ?? 'Dashboard') ?></b></span></div><div class="topbar-actions"><button type="button" class="icon-button" data-theme-toggle aria-label="Ganti tema"><?= icon('sun') ?></button><span class="role-pill"><?= $user['role'] === 'admin' ? 'Admin' : 'Pengguna' ?></span><form method="post" action="<?= e(url('/logout')) ?>"><?= csrf_field() ?><button type="submit" class="icon-button" aria-label="Keluar dari akun" title="Keluar"><?= icon('logout') ?></button></form></div></header>
        <main id="main" class="main-content"><noscript><nav class="noscript-nav" aria-label="Menu tanpa JavaScript"><?php require __DIR__ . '/partials/navigation.php'; ?></nav></noscript><?php require __DIR__ . '/partials/flashes.php'; ?><?= $content ?></main>
        <footer class="app-footer">CDN Vault <span>Ruang yang aman untuk ide berikutnya.</span></footer>
    </div>
</div>
<div class="toast-region" aria-live="polite"></div>
</body></html>
````

### cdnvault_app/views/partials/captcha.php

````php
<div class="field captcha-field" data-captcha-field>
    <label for="captcha">Kode keamanan (CAPTCHA)</label>
    <div class="captcha-display">
        <img src="<?= e($captcha['image_url']) ?>" alt="Gambar kode keamanan lima karakter" width="190" height="64" data-captcha-image>
        <button type="submit" name="_refresh_captcha" value="1" formnovalidate class="button button-secondary captcha-refresh" data-captcha-refresh data-purpose="<?= e($captcha['purpose']) ?>" data-refresh-url="<?= e(url('/captcha/refresh')) ?>" aria-label="Muat gambar CAPTCHA baru">Ganti kode</button>
    </div>
    <input type="hidden" name="_captcha_id" value="<?= e($captcha['id']) ?>" data-captcha-id>
    <input type="text" id="captcha" name="captcha" autocomplete="off" autocapitalize="characters" spellcheck="false" minlength="5" maxlength="5" placeholder="Ketik 5 karakter pada gambar" required aria-describedby="captcha-help">
    <span class="field-hint" id="captcha-help" data-captcha-help>Huruf besar dan kecil dianggap sama. Kode berlaku 5 menit dan hanya sekali pakai.</span>
</div>
````

### cdnvault_app/views/partials/flashes.php

````php
<?php foreach ($flashes as $flash): ?>
    <div class="notice <?= ($flash['type'] ?? '') === 'error' ? 'notice-error' : 'notice-success' ?>" role="status">
        <?= icon(($flash['type'] ?? '') === 'error' ? 'close' : 'check') ?>
        <span><?= e($flash['message'] ?? '') ?></span>
        <button type="button" class="icon-button" data-dismiss aria-label="Tutup pemberitahuan"><?= icon('close') ?></button>
    </div>
<?php endforeach; ?>
````

### cdnvault_app/views/partials/head.php

````php
<!doctype html>
<html lang="id">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <meta name="robots" content="noindex, nofollow">
    <meta name="csrf-token" content="<?= e(\Vault\Csrf::token()) ?>">
    <title><?= e($title ?? 'Dasbor') ?> · CDN Vault</title>
    <link rel="icon" type="image/svg+xml" href="<?= e(asset('img/favicon.svg')) ?>">
    <script nonce="<?= e(\Vault\Response::nonce()) ?>">try{var t=localStorage.getItem('cdv-theme')||'system';document.documentElement.dataset.theme=t==='system'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):t}catch(e){}</script>
    <link rel="stylesheet" href="<?= e(asset('css/app.css')) ?>">
    <script type="module" src="<?= e(asset('js/app.js')) ?>"></script>
</head>
````

### cdnvault_app/views/partials/icons.php

````php
<svg class="sprite" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
    <symbol id="i-vault" viewBox="0 0 24 24"><path d="M12 2 3 6v6c0 5 9 10 9 10s9-5 9-10V6L12 2Z"/><path d="m8 12 3 3 5-6"/></symbol>
    <symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></symbol>
    <symbol id="i-image" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="4"/><circle cx="8" cy="8" r="1.5"/><path d="m3 17 6-6 4 4 3-3 5 5"/></symbol>
    <symbol id="i-link" viewBox="0 0 24 24"><path d="m10 13 4-4M8 16l-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m2 1 1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0" transform="translate(1 0)"/></symbol>
    <symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h5l3 4h6a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></symbol>
    <symbol id="i-star" viewBox="0 0 24 24"><path d="m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1 3-6Z"/></symbol>
    <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/></symbol>
    <symbol id="i-key" viewBox="0 0 24 24"><circle cx="8" cy="9" r="5"/><path d="m12 13 8 8m-2-2 3-3m-6 0 3-3"/></symbol>
    <symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
    <symbol id="i-settings" viewBox="0 0 24 24"><path d="m9 3-1 3-3 1-2 3 2 3-1 3 3 3h3l2 2 3-2h3l2-3-1-3 2-3-2-3-3-1-1-3H9Z"/><circle cx="12" cy="12" r="3"/></symbol>
    <symbol id="i-server" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.1M7 17.5h.1M12 7h5m-5 11h5"/></symbol>
    <symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-6-6 6 6-6 6"/></symbol>
    <symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></symbol>
    <symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3"/><path d="M8 10V7a4 4 0 0 1 8 0v3m-4 5v2"/></symbol>
    <symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
    <symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
    <symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
    <symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5"/></symbol>
    <symbol id="i-logout" viewBox="0 0 24 24"><path d="M10 4H4v16h6m5-13 5 5-5 5m-7-5h12"/></symbol>
    <symbol id="i-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="m3 7 9 6 9-6"/></symbol>
    <symbol id="i-cloud" viewBox="0 0 24 24"><path d="M7 19a5 5 0 0 1-1-10 7 7 0 0 1 13-1 5.5 5.5 0 0 1 0 11H7Z"/></symbol>
    <symbol id="i-users" viewBox="0 0 24 24"><circle cx="9" cy="7" r="4"/><path d="M2 21v-2a7 7 0 0 1 14 0v2M17 3a4 4 0 0 1 0 8m2 4a7 7 0 0 1 3 6"/></symbol>
</svg>
````

### cdnvault_app/views/partials/navigation.php

````php
<span class="nav-caption">AKUN PRIBADI</span>
<a href="<?= e(url('/app')) ?>" class="nav-item <?= ($title ?? '') === 'Dashboard' ? 'active' : '' ?>" <?= ($title ?? '') === 'Dashboard' ? 'aria-current="page"' : '' ?>><?= icon('grid') ?><span>Dashboard</span></a>
<a href="<?= e(url('/app/change-password')) ?>" class="nav-item <?= ($title ?? '') === 'Ubah password' ? 'active' : '' ?>" <?= ($title ?? '') === 'Ubah password' ? 'aria-current="page"' : '' ?>><?= icon('key') ?><span>Ubah password</span></a>
<?php if ($user['role'] === 'admin'): ?>
    <span class="nav-caption">ADMINISTRATOR</span>
    <a class="nav-item <?= in_array($title ?? '', ['Pengguna', 'Detail pengguna'], true) ? 'active' : '' ?>" href="<?= e(url('/admin/users')) ?>"><?= icon('users') ?><span>Kelola pengguna</span></a>
    <a class="nav-item <?= ($title ?? '') === 'Diagnostik server' ? 'active' : '' ?>" href="<?= e(url('/admin/system')) ?>"><?= icon('server') ?><span>Diagnostik server</span></a>
<?php endif; ?>
````

### public_html/.htaccess

````apache
Options -Indexes -MultiViews
DirectoryIndex index.php
LimitRequestBody 65536

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_METHOD} !^(GET|HEAD|POST)$
    RewriteRule ^ - [F,END]
    # Jangan layani kode aplikasi, cadangan, atau dotfile.
    RewriteRule (^|/)(cdnvault_app|config|src|views|storage|database|cron)(/|$) - [F,END,NC]
    RewriteRule (^|/)\.(?!well-known(?:/|$)) - [F,END]
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^ index.php [END,QSA]
</IfModule>

<FilesMatch "(^app-path\.php$|^\.user\.ini$|\.(?:env|ini|sql|log|bak|old|orig|dist|map|sh|lock|zip|tar|gz|7z|md|json|ya?ml)$)">
    Require all denied
</FilesMatch>

<IfModule mod_headers.c>
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "DENY"
    Header always set Referrer-Policy "no-referrer"
    Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
    Header always set Cross-Origin-Resource-Policy "same-origin"
    Header always set X-Permitted-Cross-Domain-Policies "none"
    <FilesMatch "\.(?:css|js|svg|woff2)$">
        Header set Cache-Control "public, max-age=31536000, immutable"
    </FilesMatch>
</IfModule>

<IfModule mod_brotli.c>
    AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css application/javascript application/json image/svg+xml
</IfModule>
<IfModule !mod_brotli.c>
    <IfModule mod_deflate.c>
        AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json image/svg+xml
    </IfModule>
</IfModule>
<IfModule mod_expires.c>
    ExpiresActive On
    ExpiresByType text/css "access plus 1 year"
    ExpiresByType application/javascript "access plus 1 year"
    ExpiresByType image/svg+xml "access plus 1 year"
    ExpiresByType font/woff2 "access plus 1 year"
</IfModule>
FileETag MTime Size

# KeepAlive hanya bisa diatur penyedia hosting, bukan melalui .htaccess.
# HTTPS memakai AutoSSL cPanel. Gunakan Full (strict) jika memakai Cloudflare.
````

### public_html/.user.ini

````ini
upload_max_filesize=32K
post_max_size=64K
memory_limit=256M
max_execution_time=30
max_input_time=15
max_input_vars=64
display_errors=Off
log_errors=On
expose_php=Off
session.use_strict_mode=1
session.use_only_cookies=1
session.cookie_httponly=1
session.cookie_samesite=Lax
session.gc_maxlifetime=43200
````

### public_html/app-path.php

````php
<?php
declare(strict_types=1);

// Ubah satu baris ini jika lokasi folder aplikasi berbeda.
return dirname(__DIR__) . '/cdnvault_app';
````

### public_html/assets/css/app.css

````css
:root{color-scheme:light;--bg:#f7f8fc;--surface:#fff;--surface-alt:#f4f5fa;--text:#202437;--muted:#71768b;--border:#e9eaf2;--accent:#4f46e5;--accent-hover:#4338ca;--accent-soft:#eeedff;--success:#168057;--danger:#c02c44;--shadow:0 6px 26px #20204405;--radius:16px;--sidebar:260px;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px;line-height:1.55}
:root[data-theme=dark]{color-scheme:dark;--bg:#11131e;--surface:#1a1d2b;--surface-alt:#222537;--text:#eeeff8;--muted:#a1a5bb;--border:#303448;--accent:#a49dff;--accent-hover:#bdb7ff;--accent-soft:#2c2850;--success:#6cdab0;--danger:#ff91a3;--shadow:0 6px 26px #00000012}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}button,input,select,textarea{font:inherit}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,a,input{touch-action:manipulation}button{cursor:pointer}button:disabled{opacity:.65;cursor:wait}button,input,select,textarea,a{outline-offset:4px}:focus-visible{outline:3px solid var(--accent)}button{color:inherit}h1,h2,h3,p{margin-top:0}h1{font-size:32px;letter-spacing:-1.15px;line-height:1.25;margin-bottom:12px;font-weight:720}h2{font-size:21px;line-height:1.35;letter-spacing:-.5px}h3{font-size:15px;margin-bottom:6px}p{color:var(--muted)}small{font-size:12px}code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.87em;overflow-wrap:anywhere}img{max-width:100%}svg{flex-shrink:0}[hidden]{display:none!important}.sprite{position:absolute;width:0;height:0;overflow:hidden}.icon{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}.brand{display:flex;align-items:center;gap:11px;font-size:23px;font-weight:770;letter-spacing:-.8px;color:var(--text);white-space:nowrap}.brand:hover{text-decoration:none}.brand-light{font-weight:430}.eyebrow{display:block;font-size:10px;font-weight:750;letter-spacing:1.8px;color:var(--accent);margin-bottom:14px}.subtitle{font-size:15px;line-height:1.8;margin-bottom:30px}.button{display:inline-flex;justify-content:center;align-items:center;gap:10px;min-height:46px;padding:11px 20px;font-weight:650;font-size:13px;border-radius:10px;border:1px solid transparent;text-align:center;transition:background .18s,transform .18s}.button:hover{text-decoration:none;transform:translateY(-1px)}.button-primary{background:#4f46e5;color:#fff;box-shadow:0 4px 12px #4f46e51a}.button-primary:hover{background:#4338ca}.button-secondary{background:var(--surface);color:var(--text);border-color:var(--border)}.button-secondary:hover{background:var(--surface-alt)}.button-wide{width:100%;min-height:49px}.icon-button{display:inline-flex;align-items:center;justify-content:center;min-width:40px;min-height:40px;border:0;background:transparent;border-radius:9px;color:var(--muted)}.icon-button:hover{background:var(--surface-alt);color:var(--text)}.text-button{display:inline-flex;align-items:center;gap:7px;border:0;background:none;color:var(--muted);font-size:12px;padding:8px}.form-stack{display:flex;flex-direction:column;gap:20px}.field{display:flex;flex-direction:column;gap:7px}.field label{font-weight:650;font-size:13px}input:not([type=checkbox]),select,textarea{width:100%;height:48px;border:1px solid var(--border);border-radius:9px;background:var(--surface);color:var(--text);padding:11px 14px;min-width:0;transition:border-color .18s,box-shadow .18s}input::placeholder{color:var(--muted);opacity:.8}input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}.password-field{position:relative}.password-field input{padding-right:48px}.password-field .icon-button{position:absolute;right:4px;top:4px}.form-between{display:flex;justify-content:space-between;gap:14px;align-items:center;font-size:12px}.checkbox{display:flex;gap:7px;align-items:center;color:var(--muted)}input[type=checkbox]{accent-color:#4f46e5;width:15px;height:15px;margin:0}.field-hint{color:var(--muted);font-size:12px;line-height:1.7;margin:0}.strength-meter{height:6px;width:100%;border:0;margin-top:3px}.strength-meter::-webkit-meter-bar{background:var(--surface-alt);border:0}.strength-meter::-webkit-meter-optimum-value{background:var(--accent)}.notice{display:flex;align-items:center;gap:10px;padding:14px 16px;border-radius:10px;font-size:13px;margin-bottom:20px;line-height:1.65;overflow-wrap:anywhere}.notice .icon-button{margin-left:auto;min-width:30px;min-height:30px}.notice p:last-child{margin-bottom:0}.notice-error{background:color-mix(in srgb,var(--danger) 9%,var(--surface));color:var(--danger)}.notice-success{background:color-mix(in srgb,var(--success) 9%,var(--surface));color:var(--success)}.notice-info{background:var(--accent-soft);color:var(--text)}.notice-info p{margin-top:7px;color:var(--muted)}.large-icon{display:flex;align-items:center;justify-content:center;width:60px;height:60px;background:var(--accent-soft);color:var(--accent);border-radius:17px;margin-bottom:22px}.large-icon .icon{width:28px;height:28px}.back-link{display:inline-block;margin-bottom:30px;font-size:13px}.offline-banner{background:#a5481b;color:#fff;text-align:center;padding:10px;font-size:13px;position:sticky;top:0;z-index:100}.guest-page{background:var(--surface)}.auth-shell{min-height:100svh;display:grid;grid-template-columns:1fr 1fr}.auth-main{padding:44px 8.6%;display:flex;flex-direction:column;min-width:0}.auth-content{width:100%;max-width:400px;margin:auto;padding:72px 0 70px}.auth-content h1{font-size:33px}.auth-footer{font-size:11px;color:var(--muted);display:flex;align-items:center;justify-content:space-between}.auth-footnote{display:flex;gap:10px;padding-top:28px;margin-top:28px;border-top:1px solid var(--border);font-size:12px;color:var(--muted)}.auth-footnote .icon{width:16px}.auth-footnote p{margin:0}.auth-branding{background:#efeffb;position:relative;overflow:hidden;display:flex;flex-direction:column;min-height:100%;padding:46px 8%;color:#292851}:root[data-theme=dark] .auth-branding{background:#1c1c3a;color:#eeecff}.branding-top{display:flex;align-items:center;gap:9px;font-size:9px;letter-spacing:1px;font-weight:700}.status-dot{width:6px;height:6px;border-radius:50%;background:#6366f1}.small-pill{margin-left:auto;font-size:9px;border:1px solid #9c9ac940;border-radius:20px;padding:3px 8px}.branding-copy{margin-top:auto;padding-top:65px}.branding-copy h2{font-size:40px;letter-spacing:-1.6px;line-height:1.23;margin-bottom:17px}.branding-copy p{font-size:13px;color:#737192;line-height:1.85}:root[data-theme=dark] .branding-copy p{color:#b6b0cf}.asset-scene{position:relative;height:310px;max-width:430px;margin-top:22px}.scene-card{position:absolute;background:#fff;border:1px solid #ffffff88;padding:10px;border-radius:13px;box-shadow:0 22px 55px #4b408e14;width:230px;color:#2e3045}.scene-back{left:13px;top:8px;transform:rotate(-10deg);width:215px}.scene-front{left:125px;top:50px;transform:rotate(8deg)}.art-landscape{height:130px;border-radius:7px;background:linear-gradient(140deg,#d4e5f0 10%,#a2b9e0 60%,#8895c9);position:relative;overflow:hidden}.art-landscape::after{content:"";position:absolute;width:250px;height:120px;background:#697aa0;transform:rotate(-30deg);left:-60px;top:95px;border-radius:40px}.art-landscape span{position:absolute;width:40px;height:40px;border-radius:50%;background:#f3efe0;right:35px;top:20px}.art-orbit{height:146px;border-radius:7px;background:radial-gradient(ellipse at 70% 20%,#b4b9fa,transparent 70%),#6a65b7;display:grid;place-items:center;overflow:hidden}.art-orbit span{width:100px;height:100px;border-radius:50%;border:24px solid #e2dfff;box-shadow:10px 20px 18px #2a256d50;transform:rotate(-30deg) skewY(12deg)}.scene-lines{padding:12px 4px 7px;display:flex;flex-direction:column;gap:3px}.scene-lines b{font-size:10px}.scene-lines>span{color:#8b8aa3;font-size:8px}.scene-badge{position:absolute;right:13px;bottom:20px;font-size:7px;border:1px solid #e8e7f3;padding:4px 5px;border-radius:5px}.scene-badge .icon{width:10px;height:10px}.floating-chip{position:absolute;bottom:27px;left:0;display:flex;gap:10px;padding:13px 17px;background:#fff;border-radius:12px;box-shadow:0 10px 35px #37317e12;color:#4f46e5;transform:rotate(-3deg)}.floating-chip>span{font-size:9px;line-height:1.7}.floating-chip b{font-size:9px;color:#515067;font-weight:500}.branding-bottom{display:flex;justify-content:space-between;margin-top:auto;padding-top:20px;font-size:9px;color:#777499;gap:8px}.branding-bottom .icon{width:13px;height:13px;margin-right:4px}.app-shell{display:flex;min-height:100svh}.sidebar{background:var(--surface);border-right:1px solid var(--border);width:var(--sidebar);padding:28px 18px 20px;display:flex;flex-direction:column;position:fixed;inset:0 auto 0 0;z-index:50;overflow-y:auto;transition:transform .2s}.sidebar>.brand{padding:0 10px}.workspace-label{display:flex;gap:10px;align-items:center;padding:16px 9px;margin:23px 0 17px;background:var(--surface-alt);border:1px solid var(--border);border-radius:11px;font-size:10px;color:var(--muted)}.workspace-label>span:nth-child(2){flex:1;min-width:0}.workspace-label small{display:block;color:var(--text);font-weight:650;font-size:11px;margin-top:2px;overflow:hidden;text-overflow:ellipsis}.workspace-label>.icon{width:13px}.workspace-avatar{width:29px;height:29px;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent);border-radius:8px;font-size:12px;font-weight:700}.nav-list{display:flex;flex-direction:column;gap:5px}.nav-caption{font-size:8px;color:var(--muted);letter-spacing:1.3px;font-weight:750;padding:14px 12px 8px}.nav-item{display:flex;align-items:center;gap:12px;min-height:42px;padding:10px 12px;border-radius:8px;color:var(--muted);font-size:12px;font-weight:550;white-space:nowrap}.nav-item .icon{width:18px;height:18px}.nav-item.active{background:var(--accent-soft);color:var(--accent)}a.nav-item:hover{background:var(--surface-alt);text-decoration:none}.nav-item.pending{color:var(--muted);cursor:default}.nav-item small{font-size:8px;margin-left:auto;background:var(--surface-alt);border:1px solid var(--border);padding:1px 5px;border-radius:4px}.sidebar-foot{margin-top:auto;padding:30px 10px 0;color:var(--muted);font-size:10px}.sidebar-foot>div{display:flex;align-items:center;gap:6px}.sidebar-foot .icon{width:14px;height:14px;color:var(--accent)}.sidebar-foot small{font-size:9px;display:block;margin-top:9px;padding-left:20px}.app-body{margin-left:var(--sidebar);width:calc(100% - var(--sidebar));min-width:0;display:flex;flex-direction:column;transition:margin .2s,width .2s}.topbar{height:76px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 34px;gap:12px}.topbar-left{display:flex;align-items:center;gap:12px;font-size:11px;color:var(--muted)}.topbar-left b{color:var(--text);font-weight:550}.breadcrumb-slash{margin:0 10px;color:var(--border)}.topbar-actions{display:flex;align-items:center;gap:10px}.topbar-actions form{display:flex}.role-pill{font-size:9px;background:var(--accent-soft);color:var(--accent);padding:4px 9px;border-radius:6px}.main-content{padding:38px 38px 48px;max-width:1600px;width:100%;margin:0 auto;flex:1}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-bottom:28px}.page-heading h1{font-size:29px;margin-bottom:8px}.page-heading .eyebrow{font-size:8px;margin-bottom:8px}.page-heading p{font-size:12px;margin:0}.badge{display:inline-flex;align-items:center;justify-content:center;gap:5px;white-space:nowrap;font-size:9px;padding:6px 10px;border-radius:6px;background:var(--accent-soft);color:var(--accent);font-weight:650}.badge .icon{width:12px;height:12px}.badge-success{background:color-mix(in srgb,var(--success) 10%,var(--surface));color:var(--success)}.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;margin-bottom:27px}.stat-card{padding:20px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:13px;min-width:0}.stat-top{display:flex;align-items:center;justify-content:space-between;font-size:10px;color:var(--muted);margin-bottom:18px;gap:6px}.stat-icon{display:grid;place-items:center;background:var(--accent-soft);width:29px;height:29px;border-radius:7px;color:var(--accent)}.stat-icon .icon,.stat-top>.icon{width:16px;height:16px}.stat-card strong{display:block;font-size:27px;font-weight:650;line-height:1.2;letter-spacing:-.7px;margin-bottom:8px}.stat-card small{font-size:9px;color:var(--muted)}.welcome-panel{background:linear-gradient(120deg,var(--surface),var(--accent-soft));border:1px solid var(--border);border-radius:var(--radius);display:grid;grid-template-columns:1.15fr 1fr;padding:35px 38px;margin-bottom:27px;overflow:hidden}.welcome-copy .badge{font-size:8px;letter-spacing:1px;margin-bottom:17px}.welcome-copy h2{font-size:30px;letter-spacing:-1.1px;line-height:1.3;margin-bottom:14px}.welcome-copy p{font-size:12px;line-height:1.8;max-width:355px;margin-bottom:23px}.welcome-copy .button{font-size:11px;min-height:41px;padding:9px 15px}.welcome-art{position:relative;display:grid;place-items:center;min-height:230px}.vault-block{display:grid;place-items:center;width:130px;height:140px;background:linear-gradient(145deg,#8783f9,#4d46be);border:5px solid #ffffff66;transform:rotate(-10deg);border-radius:25px;box-shadow:18px 25px 25px #7167bb25;position:relative}.vault-block::after{content:"";position:absolute;inset:-30px;border:1px dashed #8980cc50;border-radius:50%}.vault-block .icon{width:65px;height:65px;color:#fff;stroke-width:1.2}.orbit-label{position:absolute;display:flex;align-items:center;gap:7px;padding:10px 14px;border-radius:9px;background:var(--surface);box-shadow:0 8px 25px #433b8c0d;font-size:10px;color:var(--muted);border:1px solid var(--border)}.orbit-label .icon{width:16px;height:16px;color:var(--accent)}.orbit-one{top:5px;left:8px;transform:rotate(-7deg)}.orbit-two{right:-5px;top:85px;transform:rotate(7deg)}.orbit-three{bottom:5px;left:25px;transform:rotate(-4deg)}.two-columns{display:grid;grid-template-columns:1fr 1fr;gap:24px}.panel{background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:var(--radius);padding:25px;min-width:0}.panel h2{font-size:15px;margin-bottom:16px}.panel p{font-size:12px;line-height:1.8}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.panel-heading h2{margin:0}.muted{color:var(--muted);font-size:11px}.detail-list{margin:0}.detail-list>div{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 0;font-size:11px;border-bottom:1px solid var(--border)}.detail-list>div:last-child{border-bottom:0}.detail-list dt{color:var(--muted)}.detail-list dd{margin:0;text-align:right;overflow-wrap:anywhere;min-width:0;max-width:70%}.next-step{display:flex;gap:14px;margin-top:24px;margin-bottom:20px}.next-step>.icon{background:var(--accent-soft);color:var(--accent);padding:10px;width:40px;height:40px;border-radius:10px}.next-step h3{font-size:13px;margin-top:2px}.next-step p{font-size:11px;margin:0;line-height:1.9}.text-success{color:var(--success)}.text-danger{color:var(--danger)}.section-gap{margin-top:24px}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:12px;text-align:left}th{color:var(--muted);font-size:10px;font-weight:550}td,th{padding:15px 10px;border-bottom:1px solid var(--border)}tr:last-child td{border-bottom:0}.plain-list{padding-left:20px;color:var(--muted);font-size:12px;line-height:2}.narrow-panel{max-width:590px}.app-footer{display:flex;justify-content:space-between;padding:16px 38px 24px;color:var(--muted);font-size:9px;gap:15px}.sidebar-collapsed .sidebar{transform:translateX(-100%)}.sidebar-collapsed .app-body{margin-left:0;width:100%}.sidebar-overlay{position:fixed;inset:0;border:0;background:#17162a66;z-index:40}.skip-link{position:absolute;left:10px;top:-100px;background:var(--accent);color:#fff;padding:12px;z-index:110}.skip-link:focus{top:10px}.toast-region{position:fixed;right:20px;bottom:20px;z-index:120}.installer-header{max-width:1000px;margin:0 auto;padding:30px 25px;display:flex;align-items:center;justify-content:space-between}.installer-main{max-width:800px;margin:35px auto 45px;padding:0 25px}.install-intro{text-align:center;margin-bottom:35px}.install-intro h1{font-size:35px}.install-intro>p{font-size:13px}.install-steps{list-style:none;display:grid;grid-template-columns:repeat(4,1fr);padding:0;margin:0 0 30px;gap:12px}.install-steps li{display:flex;align-items:center;justify-content:center;gap:8px;color:var(--muted)}.install-steps li>span{width:26px;height:26px;display:grid;place-items:center;border:1px solid var(--border);border-radius:50%;font-size:10px;background:var(--surface)}.install-steps li>b{font-size:11px;font-weight:550}.install-steps .current{color:var(--accent)}.install-steps .current>span{background:#4f46e5;border-color:#4f46e5;color:white}.install-steps .complete>span{background:var(--accent-soft);color:var(--accent)}.install-panel{padding:30px}.install-panel .panel-heading{margin-bottom:18px}.install-panel .panel-heading h2{font-size:18px}.install-panel .muted{font-size:12px}.requirements{padding:0;margin:25px 0;list-style:none}.requirements li{display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--border)}.requirements li>span:nth-child(2){min-width:0;flex:1}.requirements b{font-size:11px;font-weight:600}.requirements small{font-size:10px;color:var(--muted);display:block;margin-top:3px}.check-circle{display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--surface-alt)}.check-circle .icon{width:14px;height:14px}.check-circle.good{color:var(--success)}.check-circle.bad{color:var(--danger)}.check-circle.advisory{color:var(--muted)}.requirement-label{font-size:9px;color:var(--muted)}.button-row{display:flex;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap;margin-top:5px}.button-row>a:first-child{margin-right:auto}.form-two{display:grid;grid-template-columns:3fr 1fr;gap:20px}.install-summary{margin:10px 0 25px}.installer-footer{text-align:center;color:var(--muted);font-size:10px;padding:0 20px 30px}.install-locked h1{margin-top:14px}.install-locked p{font-size:14px}
.nav-item{font-size:14px}.nav-caption{font-size:10px}.topbar-left{font-size:12px}.workspace-label{font-size:11px}.workspace-label small{font-size:12px}.stat-top{font-size:12px}.stat-card small{font-size:11px}.page-heading p{font-size:14px}.panel h2{font-size:17px}.panel p{font-size:14px}.detail-list>div{font-size:13px}.welcome-copy p{font-size:14px}.welcome-copy .button{font-size:13px}.next-step h3{font-size:15px}.next-step p{font-size:13px}.muted{font-size:12px}.topbar-left>span{font-size:12px}.badge{font-size:11px}.plain-list{font-size:13px}.role-pill{font-size:11px}.requirements b{font-size:13px}.requirements small{font-size:12px}.requirement-label{font-size:11px}.install-steps li>b{font-size:12px}.installer-footer{font-size:12px}
@media(min-width:1550px){.auth-main{padding-left:13%;padding-right:13%}.auth-branding{padding-left:13%;padding-right:13%}.branding-copy h2{font-size:46px}.asset-scene{margin-top:35px}}
@media(max-width:1150px){:root{--sidebar:230px}.main-content{padding:28px 25px}.topbar{padding:0 25px}.stats-grid{gap:12px}.stat-card{padding:16px}.stat-card strong{font-size:24px}.welcome-panel{padding:28px}.welcome-copy h2{font-size:26px}.auth-main{padding:35px 8%}.auth-branding{padding:38px 8%}.branding-copy h2{font-size:33px}.scene-front{left:82px}.scene-back{left:-8px}.scene-card{width:205px}.brand{font-size:21px}}
@media(max-width:900px){.sidebar{width:260px;transform:translateX(-100%)}.app-body,.sidebar-collapsed .app-body{margin-left:0;width:100%}.drawer-open .sidebar{transform:translateX(0)}.page-heading h1{font-size:27px}.stats-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.welcome-art{min-height:225px}.auth-shell{grid-template-columns:1fr}.auth-branding{display:none}.auth-main{min-height:100svh;padding:30px 7%}.auth-content{padding-top:65px;padding-bottom:65px}.auth-content h1{font-size:32px}.topbar-left>span{font-size:10px}.stat-top{font-size:12px}.stat-card small{font-size:11px}.nav-item{font-size:14px}.nav-caption{font-size:10px}.topbar{height:68px}.detail-list>div{font-size:12px}}
@media(max-width:600px){.main-content{padding:26px 18px}.topbar{padding:0 12px}.topbar-left{gap:5px}.topbar-left>span{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.topbar-actions{gap:1px}.role-pill{display:none}.page-heading{align-items:flex-start;gap:12px;flex-direction:column;margin-bottom:23px}.page-heading h1{font-size:26px}.page-heading p{font-size:12px}.stats-grid{gap:11px;margin-bottom:20px}.stat-card{padding:16px 14px}.stat-top{font-size:11px;margin-bottom:14px}.stat-card strong{font-size:24px}.stat-icon{width:25px;height:25px}.stat-card small{font-size:11px}.welcome-panel{display:block;padding:27px 24px;margin-bottom:20px}.welcome-copy h2{font-size:29px}.welcome-art{margin-top:25px;min-height:240px;max-width:300px;margin-left:auto;margin-right:auto}.orbit-two{right:0}.two-columns{grid-template-columns:1fr;gap:20px}.panel{padding:21px}.detail-list dd{max-width:65%;font-size:11px}.app-footer{padding:16px 18px;font-size:8px}.app-footer span{max-width:160px;text-align:right}.auth-main{padding:27px 25px}.auth-content{padding:60px 0}.auth-content h1{font-size:30px}.form-between{gap:10px;font-size:11px;flex-wrap:wrap}.auth-footer{font-size:10px}.installer-header{padding:24px 20px}.installer-main{padding:0 18px;margin-top:25px}.install-intro h1{font-size:30px}.install-intro p{font-size:12px}.install-steps{gap:5px}.install-steps li{flex-direction:column;gap:8px}.install-steps li>b{font-size:9px}.install-panel{padding:21px}.install-panel .panel-heading{align-items:flex-start}.install-panel .panel-heading h2{font-size:16px}.form-two{gap:12px}.button-row .button{font-size:12px;padding:10px 13px}.button-row>a:first-child{margin-right:0}.install-summary>div{gap:10px}.install-summary dd{font-size:10px}.requirements b{font-size:11px}.requirements li{gap:9px}}

/* Pendaftaran, CAPTCHA, dan pengelolaan akun. */
.captcha-field{gap:9px}.captcha-display{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.captcha-display img{width:190px;height:64px;border:1px solid var(--border);border-radius:10px;background:#f1f3ff}.captcha-refresh{padding:9px 13px;font-size:12px;min-height:40px}.captcha-field input[name=captcha]{letter-spacing:3px;text-transform:uppercase}.captcha-field input[name=captcha]::placeholder{text-transform:none;letter-spacing:0;font-size:13px}.auth-content:has(.captcha-field){padding-top:36px;padding-bottom:36px}.auth-content:has(.captcha-field) .subtitle{margin-bottom:23px}.badge-warning{background:color-mix(in srgb,#b77912 12%,var(--surface));color:#96600b}:root[data-theme=dark] .badge-warning{color:#f4cf82}.badge-danger{background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger)}.badge-neutral{background:var(--surface-alt);color:var(--muted)}.button-danger{border-color:color-mix(in srgb,var(--danger) 25%,var(--border));color:var(--danger);background:color-mix(in srgb,var(--danger) 5%,var(--surface))}.button-danger:hover{background:color-mix(in srgb,var(--danger) 12%,var(--surface))}.button-small{font-size:12px;min-height:35px;padding:7px 12px}.admin-filters{display:flex;align-items:flex-end;gap:14px;margin:22px 0}.admin-filters>.field:first-child{flex:1}.admin-filters>.field:nth-child(2){width:220px}.admin-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.admin-submit{align-self:flex-start}.users-table{min-width:750px}.users-table td{vertical-align:middle}.user-identity{display:flex;flex-direction:column;gap:5px;overflow-wrap:anywhere}.user-identity b{font-weight:650;font-size:13px}.user-identity span{font-size:12px;color:var(--muted)}.row-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.row-actions form{margin:0}.admin-empty{text-align:center;padding:40px 12px}.admin-empty .large-icon{margin:0 auto 16px}.admin-empty p{margin-bottom:0}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:20px}.admin-review-note{margin-top:20px}.field textarea{height:auto;min-height:95px;resize:vertical}.admin-page-note{max-width:620px}
@media(max-width:700px){.admin-filters{align-items:stretch;flex-direction:column}.admin-filters>.field:nth-child(2){width:auto}.admin-form-grid{grid-template-columns:1fr}.users-table{min-width:0}.users-table thead{display:none}.users-table,.users-table tbody,.users-table tr,.users-table td{display:block;width:100%}.users-table tr{padding:14px 0;border-bottom:1px solid var(--border)}.users-table tr:last-child{border-bottom:0}.users-table td{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:9px 0;border:0;text-align:right}.users-table td::before{content:attr(data-label);color:var(--muted);font-size:11px;text-align:left;flex-shrink:0}.users-table .user-identity{max-width:75%}.row-actions{justify-content:flex-end}.pagination-row{align-items:flex-start;flex-direction:column}.captcha-display{gap:9px}.captcha-refresh{font-size:11px;padding:8px 11px}.admin-submit{align-self:stretch}}

@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
.noscript-nav{display:flex;flex-wrap:wrap;gap:8px;padding:12px;margin-bottom:24px;background:var(--surface);border:1px solid var(--border);border-radius:10px}.noscript-nav .nav-caption{display:none}.detail-list dd{overflow-wrap:anywhere}.detail-list time{white-space:normal}
````

### public_html/assets/img/favicon.svg

````xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="14" fill="#4f46e5"/><path d="m24 9-12 5v9c0 8 12 15 12 15s12-7 12-15v-9L24 9Z" fill="none" stroke="#fff" stroke-width="3"/><path d="m18 23 4 4 8-9" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
````

### public_html/assets/img/logo.svg

````xml
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><rect width="48" height="48" rx="14" fill="#4f46e5"/><path d="m24 9-12 5v9c0 8 12 15 12 15s12-7 12-15v-9L24 9Z" fill="none" stroke="#fff" stroke-width="2.5" stroke-linejoin="round"/><path d="m18 23 4 4 8-9" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
````

### public_html/assets/js/app.js

````javascript
const version = new URL(import.meta.url).searchParams.get('v') || '1';
import(`./ui.js?v=${encodeURIComponent(version)}`).then(({ initUI }) => initUI()).catch(() => {
  document.querySelectorAll('[data-loading-form] button').forEach(button => { button.disabled = false; });
});
````

### public_html/assets/js/ui.js

````javascript
export function initUI() {
  const storage = {
    get(key, fallback) { try { return localStorage.getItem(key) || fallback; } catch { return fallback; } },
    set(key, value) { try { localStorage.setItem(key, value); } catch { /* Penyimpanan dapat dibatasi browser. */ } }
  };
  const media = matchMedia('(prefers-color-scheme: dark)');
  const applyTheme = () => {
    const preference = storage.get('cdv-theme', 'system');
    document.documentElement.dataset.theme = preference === 'system' ? (media.matches ? 'dark' : 'light') : preference;
    document.querySelectorAll('[data-theme-toggle]').forEach(button => {
      button.title = `Tema: ${preference}. Klik untuk mengganti.`;
    });
  };
  applyTheme();
  media.addEventListener('change', applyTheme);
  document.querySelectorAll('[data-theme-toggle]').forEach(button => button.addEventListener('click', () => {
    const modes = ['system', 'light', 'dark'];
    storage.set('cdv-theme', modes[(modes.indexOf(storage.get('cdv-theme', 'system')) + 1) % modes.length]);
    applyTheme();
  }));
  document.querySelectorAll('[data-password-toggle]').forEach(button => button.addEventListener('click', () => {
    const input = document.getElementById(button.dataset.passwordToggle);
    if (!input) return;
    const visible = input.type === 'password';
    input.type = visible ? 'text' : 'password';
    button.setAttribute('aria-pressed', String(visible));
    button.setAttribute('aria-label', visible ? 'Sembunyikan password' : 'Tampilkan password');
  }));
  document.querySelectorAll('[data-dismiss]').forEach(button => button.addEventListener('click', () => button.closest('.notice')?.remove()));
  document.querySelectorAll('[data-loading-form]').forEach(form => form.addEventListener('submit', event => {
    if (event.defaultPrevented) return;
    if (form.dataset.confirm && !window.confirm(form.dataset.confirm)) {
      event.preventDefault();
      return;
    }
    if (!form.checkValidity()) return;
    if (form.dataset.submitting) { event.preventDefault(); return; }
    form.dataset.submitting = 'true';
    const button = event.submitter;
    if (!button) return;
    // Penonaktifan ditunda agar nama/nilai tombol tetap terkirim bersama formulir.
    setTimeout(() => {
      button.disabled = true;
      button.setAttribute('aria-busy', 'true');
      button.dataset.originalLabel = button.textContent;
      button.textContent = button.dataset.loadingLabel || 'Memproses…';
    }, 0);
  }));
  document.querySelectorAll('[data-captcha-refresh]').forEach(button => {
    if (button.dataset.purpose === 'upgrade') return;
    button.addEventListener('click', async event => {
      event.preventDefault();
      const field = button.closest('[data-captcha-field]');
      const help = field.querySelector('[data-captcha-help]');
      button.disabled = true;
      try {
        const response = await fetch(button.dataset.refreshUrl, {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Accept': 'application/json', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content },
          body: new URLSearchParams({ purpose: button.dataset.purpose })
        });
        const result = await response.json();
        if (!response.ok || !result.success) throw new Error(result.error?.message || 'Kode belum dapat dimuat.');
        field.querySelector('[data-captcha-id]').value = result.data.id;
        field.querySelector('[data-captcha-image]').src = result.data.image_url;
        const input = field.querySelector('input[name=captcha]');
        input.value = '';
        input.focus();
        help.textContent = 'Kode baru siap. Ketik lima karakter pada gambar.';
      } catch (error) {
        help.textContent = error.message || 'Kode belum dapat dimuat. Muat ulang halaman untuk mencoba kembali.';
      } finally {
        button.disabled = false;
      }
    });
  });
  window.addEventListener('pageshow', () => {
    document.querySelectorAll('[data-submitting]').forEach(form => {
      delete form.dataset.submitting;
      form.querySelectorAll('[aria-busy]').forEach(button => {
        button.disabled = false;
        button.removeAttribute('aria-busy');
        if (button.dataset.originalLabel) button.textContent = button.dataset.originalLabel;
      });
    });
  });
  document.querySelectorAll('[data-strength]').forEach(input => input.addEventListener('input', () => {
    const bytes = new TextEncoder().encode(input.value).length;
    const variety = [/[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z\d]/].filter(pattern => pattern.test(input.value)).length;
    const score = bytes < 12 ? (bytes ? 1 : 0) : Math.min(4, Math.floor(bytes / 8) + (variety > 2 ? 1 : 0));
    const field = input.closest('.field');
    const meter = field?.querySelector('meter');
    if (meter) meter.value = score;
    const label = field?.querySelector('[data-strength-label]');
    if (label) label.textContent = bytes > 72 ? 'Password melebihi 72 byte.' : ['Gunakan frasa yang panjang dan unik.', 'Tambahkan panjang password.', 'Cukup panjang. Hindari frasa yang umum.', 'Baik. Pastikan tidak dipakai di akun lain.', 'Panjang dan bervariasi. Simpan di pengelola password.'][score];
    input.setCustomValidity(bytes && (bytes < 12 || bytes > 72) ? 'Password harus berisi 12–72 byte.' : '');
  }));
  const shell = document.querySelector('.app-shell');
  const toggle = document.querySelector('[data-sidebar-toggle]');
  const overlay = document.querySelector('.sidebar-overlay');
  const sidebar = document.querySelector('.sidebar');
  const mobile = matchMedia('(max-width: 900px)');
  const syncSidebar = () => {
    if (!sidebar || !shell) return;
    const hidden = mobile.matches ? !shell.classList.contains('drawer-open') : shell.classList.contains('sidebar-collapsed');
    sidebar.inert = hidden;
    sidebar.setAttribute('aria-hidden', String(hidden));
    toggle?.setAttribute('aria-expanded', String(!hidden));
  };
  const closeDrawer = () => {
    shell?.classList.remove('drawer-open');
    if (overlay) overlay.hidden = true;
    syncSidebar();
  };
  if (storage.get('cdv-sidebar', 'open') === 'closed') shell?.classList.add('sidebar-collapsed');
  syncSidebar();
  toggle?.addEventListener('click', () => {
    if (mobile.matches) {
      const open = !shell.classList.contains('drawer-open');
      shell.classList.toggle('drawer-open', open);
      overlay.hidden = !open;
      syncSidebar();
      if (open) document.querySelector('.sidebar a')?.focus();
    } else {
      const collapsed = shell.classList.toggle('sidebar-collapsed');
      storage.set('cdv-sidebar', collapsed ? 'closed' : 'open');
      syncSidebar();
    }
  });
  document.querySelector('[data-sidebar-close]')?.addEventListener('click', () => { closeDrawer(); toggle?.focus(); });
  document.addEventListener('keydown', event => {
    if (!mobile.matches || !shell?.classList.contains('drawer-open')) return;
    if (event.key === 'Escape') { closeDrawer(); toggle?.focus(); }
    if (event.key === 'Tab') {
      const targets = [...sidebar.querySelectorAll('a[href],button:not([disabled])')];
      const first = targets[0], last = targets.at(-1);
      if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
      else if (!event.shiftKey && (document.activeElement === last || !sidebar.contains(document.activeElement))) { event.preventDefault(); first?.focus(); }
    }
  });
  mobile.addEventListener('change', closeDrawer);
  const updateOnline = () => document.querySelectorAll('.offline-banner').forEach(banner => { banner.hidden = navigator.onLine; });
  addEventListener('online', updateOnline);
  addEventListener('offline', updateOnline);
  updateOnline();
  document.querySelectorAll('[data-countdown]').forEach(notice => {
    let remaining = Number(notice.dataset.countdown);
    const timer = setInterval(() => {
      remaining--;
      const text = notice.querySelector('span');
      if (text) text.textContent = remaining > 0 ? `Terlalu banyak percobaan. Coba lagi dalam ${remaining} detik.` : 'Anda dapat mencoba login kembali.';
      if (remaining <= 0) clearInterval(timer);
    }, 1000);
  });
}
````

### public_html/cdn/.htaccess

````apache
# CDN tetap tertutup sampai pipeline upload aman dipasang pada Tahap 2.
Options -Indexes -ExecCGI
Require all denied
````

### public_html/index.php

````php
<?php
declare(strict_types=1);

ini_set('display_errors', '0');
$appPath = require __DIR__ . '/app-path.php';
if (!is_string($appPath) || !is_file($appPath . '/bootstrap.php')) {
    http_response_code(503);
    header('Content-Type: text/plain; charset=UTF-8');
    exit('Folder aplikasi belum ditemukan. Periksa public_html/app-path.php melalui cPanel.');
}
define('VAULT_APP_PATH', rtrim($appPath, '/'));
define('VAULT_PUBLIC_PATH', __DIR__);
if (!is_file(VAULT_APP_PATH . '/config/config.php')) {
    $base = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php'))), '/.');
    header('Location: ' . $base . '/install/', true, 302);
    exit;
}
require VAULT_APP_PATH . '/bootstrap.php';
````

### public_html/install/index.php

````php
<?php
declare(strict_types=1);

ini_set('display_errors', '0');
if (PHP_VERSION_ID < 80200) {
    http_response_code(503);
    header('Content-Type: text/plain; charset=UTF-8');
    exit('Pilih PHP 8.2 atau lebih baru dengan patch keamanan terkini di MultiPHP Manager cPanel sebelum melanjutkan.');
}
$appPath = require dirname(__DIR__) . '/app-path.php';
if (!is_string($appPath) || !is_file($appPath . '/autoload.php')) {
    http_response_code(503);
    header('Content-Type: text/plain; charset=UTF-8');
    exit('Folder cdnvault_app belum ditemukan. Periksa public_html/app-path.php.');
}
define('VAULT_APP_PATH', rtrim($appPath, '/'));
define('VAULT_PUBLIC_PATH', dirname(__DIR__));
require VAULT_APP_PATH . '/autoload.php';
try {
    \Vault\Config::load(['base_url' => \Vault\Installer::guessBaseUrl()]);
    \Vault\Response::securityHeaders();
    \Vault\RequestGuard::validate();
    \Vault\Session::start();
    (new \Vault\Installer())->handle();
} catch (\Vault\HttpException $exception) {
    \Vault\Response::error($exception->status, $exception->getMessage());
} catch (Throwable $exception) {
    $reference = \Vault\Logger::exception($exception);
    \Vault\Response::error(500, 'Installer mengalami kendala. Periksa izin folder dan log aplikasi.', $reference);
}
````

### public_html/upgrade/index.php

````php
<?php
declare(strict_types=1);

ini_set('display_errors', '0');
$appPath = require dirname(__DIR__) . '/app-path.php';
if (!is_string($appPath) || !is_file($appPath . '/autoload.php')) {
    http_response_code(503);
    header('Content-Type: text/plain; charset=UTF-8');
    exit('Periksa lokasi folder aplikasi melalui app-path.php.');
}
define('VAULT_APP_PATH', rtrim($appPath, '/'));
define('VAULT_PUBLIC_PATH', dirname(__DIR__));
require VAULT_APP_PATH . '/autoload.php';
if (!is_file(VAULT_APP_PATH . '/config/config.php')) {
    $base = rtrim(dirname(dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/upgrade/index.php'))), '/.');
    header('Location: ' . $base . '/install/', true, 302);
    exit;
}
try {
    \Vault\Config::load(require VAULT_APP_PATH . '/config/config.php');
    \Vault\Response::securityHeaders();
    \Vault\RequestGuard::validate();
    \Vault\RequestGuard::rateLimit();
    \Vault\Session::start();
    (new \Vault\Controllers\UpgradeController())->handle();
} catch (\Vault\HttpException $exception) {
    \Vault\Response::error($exception->status, $exception->getMessage());
} catch (Throwable $exception) {
    $reference = \Vault\Logger::exception($exception);
    \Vault\Response::error(500, 'Pembaruan belum berhasil. Periksa izin folder, konfigurasi database, dan log aplikasi. Langkah yang sudah selesai aman dijalankan ulang.', $reference);
}
````
