39303-vm/archive_bootstrap.php
2026-03-25 07:58:17 +00:00

680 lines
24 KiB
PHP

<?php
declare(strict_types=1);
@date_default_timezone_set('Africa/Harare');
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/mail/MailService.php';
function h(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function app_env(string $key, string $default = ''): string
{
$serverValue = $_SERVER[$key] ?? null;
if (is_string($serverValue) && $serverValue !== '') {
return $serverValue;
}
$envValue = getenv($key);
return is_string($envValue) && $envValue !== '' ? $envValue : $default;
}
function page_meta(string $title, string $description = ''): array
{
$projectDescription = app_env('PROJECT_DESCRIPTION', $description);
$projectImageUrl = app_env('PROJECT_IMAGE_URL', '');
return [
'title' => $title,
'description' => $projectDescription,
'image' => $projectImageUrl,
];
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(?string $token): bool
{
$sessionToken = $_SESSION['csrf_token'] ?? '';
return is_string($token) && $sessionToken !== '' && hash_equals($sessionToken, $token);
}
function set_flash(string $type, string $message): void
{
$_SESSION['flashes'][] = [
'type' => $type,
'message' => $message,
];
}
function get_flashes(): array
{
$flashes = $_SESSION['flashes'] ?? [];
unset($_SESSION['flashes']);
return is_array($flashes) ? $flashes : [];
}
function demo_users(): array
{
return [
'superadmin@kbriharare.go.id' => [
'email' => 'superadmin@kbriharare.go.id',
'name' => 'Super Admin KBRI',
'role' => 'super_admin',
'role_label' => 'Super Admin',
'department' => 'Semua Departemen',
'seed_password' => 'Harare2026!',
'avatar' => 'SA',
],
'staf.politik@kbriharare.go.id' => [
'email' => 'staf.politik@kbriharare.go.id',
'name' => 'Staf Politik',
'role' => 'staff',
'role_label' => 'Staf',
'department' => 'Politik',
'seed_password' => 'Harare2026!',
'avatar' => 'SP',
],
'staf.konsuler@kbriharare.go.id' => [
'email' => 'staf.konsuler@kbriharare.go.id',
'name' => 'Staf Konsuler',
'role' => 'staff',
'role_label' => 'Staf',
'department' => 'Kekonsuleran',
'seed_password' => 'Harare2026!',
'avatar' => 'SK',
],
];
}
function authenticate_demo_user(string $email, string $password): ?array
{
$key = strtolower(trim($email));
$users = demo_users();
if (!isset($users[$key])) {
return null;
}
$user = $users[$key];
$hash = password_hash($user['seed_password'], PASSWORD_BCRYPT);
if (!password_verify($password, $hash)) {
return null;
}
unset($user['seed_password']);
return $user;
}
function mask_email(string $email): string
{
if (!str_contains($email, '@')) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$prefix = substr($local, 0, 2);
return $prefix . str_repeat('•', max(2, strlen($local) - 2)) . '@' . $domain;
}
function login_locked_until(): int
{
return (int)($_SESSION['login_locked_until'] ?? 0);
}
function current_user(): ?array
{
return isset($_SESSION['auth_user']) && is_array($_SESSION['auth_user']) ? $_SESSION['auth_user'] : null;
}
function is_authenticated(): bool
{
return current_user() !== null;
}
function is_super_admin(): bool
{
return (current_user()['role'] ?? '') === 'super_admin';
}
function process_login_request(): void
{
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
set_flash('danger', 'Permintaan login tidak valid. Silakan muat ulang halaman.');
return;
}
if (time() < login_locked_until()) {
$seconds = login_locked_until() - time();
set_flash('danger', 'Terlalu banyak percobaan login. Coba lagi dalam ' . max(1, $seconds) . ' detik.');
return;
}
$email = trim((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
set_flash('danger', 'Masukkan email dan password yang valid.');
return;
}
$user = authenticate_demo_user($email, $password);
if ($user === null) {
$_SESSION['login_failures'] = (int)($_SESSION['login_failures'] ?? 0) + 1;
if ((int)$_SESSION['login_failures'] >= 5) {
$_SESSION['login_locked_until'] = time() + 120;
$_SESSION['login_failures'] = 0;
}
set_flash('danger', 'Email atau password tidak cocok.');
return;
}
unset($_SESSION['login_failures'], $_SESSION['login_locked_until']);
$otp = (string)random_int(100000, 999999);
$pending = [
'user' => $user,
'otp' => $otp,
'expires_at' => time() + 300,
'masked_email' => mask_email($user['email']),
'mail_sent' => false,
];
$mailSent = false;
try {
$subject = 'Kode OTP Arsip Digital KBRI Harare';
$html = '<p>Kode OTP Anda:</p><p style="font-size:24px;font-weight:700;letter-spacing:4px;">' . h($otp) . '</p><p>Kode berlaku selama 5 menit.</p>';
$result = MailService::sendMail($user['email'], $subject, $html, 'Kode OTP Anda: ' . $otp);
$mailSent = !empty($result['success']);
} catch (Throwable $exception) {
$mailSent = false;
}
$pending['mail_sent'] = $mailSent;
$_SESSION['pending_auth'] = $pending;
if ($mailSent) {
set_flash('success', 'Kode OTP telah dikirim ke ' . $pending['masked_email'] . '.');
} else {
set_flash('warning', 'SMTP belum terpasang untuk pengujian. Gunakan OTP demo ' . $otp . ' untuk melanjutkan.');
}
}
function process_otp_verification(): void
{
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
set_flash('danger', 'Permintaan MFA tidak valid.');
return;
}
$pending = $_SESSION['pending_auth'] ?? null;
if (!is_array($pending)) {
set_flash('danger', 'Sesi OTP tidak ditemukan. Silakan login ulang.');
return;
}
if ((int)($pending['expires_at'] ?? 0) < time()) {
unset($_SESSION['pending_auth']);
set_flash('danger', 'Kode OTP telah kedaluwarsa. Silakan login ulang.');
return;
}
$otp = preg_replace('/\D+/', '', (string)($_POST['otp'] ?? ''));
if ($otp === '' || !hash_equals((string)$pending['otp'], $otp)) {
set_flash('danger', 'Kode OTP tidak valid.');
return;
}
$_SESSION['auth_user'] = $pending['user'];
$_SESSION['login_audit'] = [
'timestamp' => date('Y-m-d H:i:s'),
'mfa_method' => 'Email OTP',
];
unset($_SESSION['pending_auth']);
session_regenerate_id(true);
set_flash('success', 'Autentikasi berhasil. Selamat datang di brankas digital KBRI Harare.');
}
function logout_user(): void
{
$token = $_SESSION['csrf_token'] ?? null;
$_SESSION = [];
if ($token) {
$_SESSION['csrf_token'] = $token;
}
session_regenerate_id(true);
set_flash('info', 'Sesi Anda telah diakhiri.');
}
function require_auth(): void
{
if (!is_authenticated()) {
header('Location: index.php');
exit;
}
}
function archive_folder_tree(): array
{
return [
[
'group' => 'Informasi Negara',
'items' => [
['folder' => 'Informasi Negara / Zimbabwe', 'label' => 'Zimbabwe', 'hint' => 'Keterangan dasar, informasi penting, catatan peristiwa penting.'],
['folder' => 'Informasi Negara / Zambia', 'label' => 'Zambia', 'hint' => 'Keterangan dasar, informasi penting, catatan peristiwa penting.'],
],
],
[
'group' => 'Politik',
'items' => [
['folder' => 'Politik / Nota Diplomatik', 'label' => 'Nota Diplomatik', 'hint' => 'Permintaan dukungan, pernyataan resmi, laporan pertemuan.'],
['folder' => 'Politik / Perjanjian & Kesepakatan', 'label' => 'Perjanjian & Kesepakatan', 'hint' => 'MoU, agreement bilateral, multilateral.'],
['folder' => 'Politik / Hubungan Bilateral', 'label' => 'Hubungan Bilateral', 'hint' => 'Laporan menteri, pertukaran delegasi, pernyataan bersama.'],
['folder' => 'Politik / Kebijakan Strategis', 'label' => 'Kebijakan Strategis', 'hint' => 'Instruksi pusat, analisis geopolitik, rekomendasi.'],
['folder' => 'Politik / Keamanan & Pertahanan', 'label' => 'Keamanan & Pertahanan', 'hint' => 'MoU pertahanan, contingency plan, intelijen terbatas.'],
],
],
[
'group' => 'Pensosbud',
'items' => [
['folder' => 'Pensosbud / Program Kerja Sama', 'label' => 'Program Kerja Sama', 'hint' => 'Dokumen kolaborasi dan agenda kerja.'],
['folder' => 'Pensosbud / Scholarship', 'label' => 'Scholarship', 'hint' => 'Permohonan, persetujuan, monitoring & evaluasi.'],
['folder' => 'Pensosbud / Event Budaya', 'label' => 'Event Budaya', 'hint' => 'Undangan dan laporan kegiatan budaya.'],
],
],
[
'group' => 'Ekonomi & Perdagangan',
'items' => [
['folder' => 'Ekonomi & Perdagangan / Nota Kesepahaman Dagang', 'label' => 'Nota Kesepahaman Dagang', 'hint' => 'Zimbabwe, Zambia, Indonesia.'],
['folder' => 'Ekonomi & Perdagangan / Agreement Investasi', 'label' => 'Agreement Investasi', 'hint' => 'Dokumen investasi lintas negara.'],
['folder' => 'Ekonomi & Perdagangan / Dukungan Perusahaan', 'label' => 'Dukungan Perusahaan', 'hint' => 'Dukungan perusahaan Indonesia, Zimbabwe, Zambia.'],
],
],
[
'group' => 'Kekonsuleran',
'items' => [
['folder' => 'Kekonsuleran / Dokumen Perjalanan & Identitas', 'label' => 'Dokumen Perjalanan & Identitas', 'hint' => 'Visa, izin tinggal, SPLP, legalisasi, apostille, pencatatan sipil.'],
['folder' => 'Kekonsuleran / Legalisasi & Administrasi Dokumen', 'label' => 'Legalisasi & Administrasi Dokumen', 'hint' => 'Administrasi dokumen konsuler.'],
['folder' => 'Kekonsuleran / Perlindungan & Bantuan WNI', 'label' => 'Perlindungan & Bantuan WNI', 'hint' => 'Kasus perlindungan dan bantuan warga negara.'],
['folder' => 'Kekonsuleran / Fasilitas Diplomatik & Transportasi', 'label' => 'Fasilitas Diplomatik & Transportasi', 'hint' => 'Fasilitas diplomatik dan logistik perjalanan.'],
],
],
[
'group' => 'Kanselerai/HOC',
'items' => [
['folder' => 'Kanselerai/HOC / SOP', 'label' => 'SOP', 'hint' => 'Standar operasional prosedur.'],
['folder' => 'Kanselerai/HOC / Kepegawaian', 'label' => 'Kepegawaian', 'hint' => 'Dokumen personalia dan administrasi SDM.'],
['folder' => 'Kanselerai/HOC / Perkantoran', 'label' => 'Perkantoran', 'hint' => 'Operasional perkantoran dan fasilitas kerja.'],
['folder' => 'Kanselerai/HOC / Wisma Dubes', 'label' => 'Wisma Dubes', 'hint' => 'Dokumen rumah dinas dan operasional.'],
],
],
[
'group' => 'PID',
'items' => [
['folder' => 'PID / Contingency Plan', 'label' => 'Contingency Plan', 'hint' => 'Rencana kontingensi dan respons insiden.'],
['folder' => 'PID / Komputerisasi', 'label' => 'Komputerisasi', 'hint' => 'Sistem, inventaris TI, dan konfigurasi.'],
['folder' => 'PID / Pengamanan Terpadu', 'label' => 'Pengamanan Terpadu', 'hint' => 'Pengamanan terpadu dan SOP keamanan.'],
],
],
[
'group' => 'Administrasi & Internal',
'items' => [
['folder' => 'Administrasi & Internal / Surat Edaran', 'label' => 'Surat Edaran', 'hint' => 'Edaran dan komunikasi internal.'],
['folder' => 'Administrasi & Internal / Laporan Internal', 'label' => 'Laporan Internal', 'hint' => 'Laporan manajemen dan operasional.'],
['folder' => 'Administrasi & Internal / Arsip Kepegawaian', 'label' => 'Arsip Kepegawaian', 'hint' => 'Arsip kepegawaian dan administrasi personal.'],
['folder' => 'Administrasi & Internal / Inventaris & Logistik', 'label' => 'Inventaris & Logistik', 'hint' => 'Persediaan dan logistik internal.'],
],
],
[
'group' => 'ARSIP',
'items' => [
['folder' => 'ARSIP / File Penting', 'label' => 'File Penting', 'hint' => 'Dokumen prioritas dan referensi inti.'],
['folder' => 'ARSIP / Photo', 'label' => 'Photo', 'hint' => 'Dokumentasi foto kegiatan dan arsip visual.'],
['folder' => 'ARSIP / Video', 'label' => 'Video', 'hint' => 'Video resmi dan dokumentasi audiovisual.'],
],
],
];
}
function folder_options(): array
{
$options = [];
foreach (archive_folder_tree() as $section) {
foreach ($section['items'] as $item) {
$options[] = $item['folder'];
}
}
return $options;
}
function category_options(): array
{
return [
'Nota Diplomatik',
'Laporan Pertemuan',
'Perjanjian',
'Instruksi',
'Scholarship',
'Event Budaya',
'Dokumen Konsuler',
'SOP',
'Kepegawaian',
'Inventaris',
'File Penting',
'Photo',
'Video',
];
}
function upload_root(): string
{
return __DIR__ . '/uploads/archives';
}
function ensure_upload_root(): void
{
$root = upload_root();
if (!is_dir($root)) {
mkdir($root, 0775, true);
}
}
function ensure_archive_schema(): void
{
ensure_upload_root();
db()->exec(
"CREATE TABLE IF NOT EXISTS archive_documents (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(190) NOT NULL,
document_date DATE NOT NULL,
category VARCHAR(120) NOT NULL,
folder_path VARCHAR(255) NOT NULL,
department VARCHAR(120) NOT NULL,
notes TEXT NULL,
attachment_name VARCHAR(255) NOT NULL,
attachment_path VARCHAR(255) NOT NULL,
attachment_ext VARCHAR(16) NOT NULL,
attachment_size INT UNSIGNED NOT NULL,
status ENUM('pending', 'validated') NOT NULL DEFAULT 'pending',
created_by VARCHAR(190) NOT NULL,
created_role VARCHAR(50) NOT NULL,
validated_by VARCHAR(190) DEFAULT NULL,
validation_notes TEXT DEFAULT NULL,
activity_log LONGTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
}
function department_from_folder(string $folder): string
{
$parts = explode('/', $folder);
return trim($parts[0] ?? 'Umum');
}
function append_activity(array $entries, string $action, string $actor, string $notes = ''): array
{
$entries[] = [
'action' => $action,
'actor' => $actor,
'notes' => $notes,
'timestamp' => date('Y-m-d H:i:s'),
];
return $entries;
}
function create_document(array $post, array $files, array $user): bool
{
if (!verify_csrf($post['csrf_token'] ?? null)) {
set_flash('danger', 'Token formulir unggah tidak valid.');
return false;
}
$title = trim((string)($post['title'] ?? ''));
$documentDate = trim((string)($post['document_date'] ?? ''));
$category = trim((string)($post['category'] ?? ''));
$folderPath = trim((string)($post['folder_path'] ?? ''));
$notes = trim((string)($post['notes'] ?? ''));
if ($title === '' || strlen($title) < 4) {
set_flash('danger', 'Judul dokumen minimal 4 karakter.');
return false;
}
if ($documentDate === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $documentDate)) {
set_flash('danger', 'Tanggal dokumen wajib diisi.');
return false;
}
if ($category === '') {
set_flash('danger', 'Kategori dokumen wajib diisi.');
return false;
}
if (!in_array($folderPath, folder_options(), true)) {
set_flash('danger', 'Folder tujuan tidak tersedia.');
return false;
}
if (!isset($files['attachment']) || !is_array($files['attachment']) || (int)($files['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
set_flash('danger', 'Lampiran wajib diunggah.');
return false;
}
$file = $files['attachment'];
$allowedExtensions = ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'mp4'];
$originalName = (string)($file['name'] ?? '');
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($extension, $allowedExtensions, true)) {
set_flash('danger', 'Format file harus PDF, DOC, DOCX, JPG, PNG, atau MP4.');
return false;
}
$size = (int)($file['size'] ?? 0);
if ($size <= 0 || $size > 10 * 1024 * 1024) {
set_flash('danger', 'Ukuran file maksimum 10 MB.');
return false;
}
ensure_upload_root();
$storedName = bin2hex(random_bytes(16)) . '.' . $extension;
$relativePath = 'uploads/archives/' . $storedName;
$targetPath = __DIR__ . '/' . $relativePath;
if (!move_uploaded_file((string)$file['tmp_name'], $targetPath)) {
set_flash('danger', 'Lampiran gagal disimpan ke server.');
return false;
}
$department = department_from_folder($folderPath);
$activity = append_activity([], 'upload', $user['name'], 'Dokumen ditambahkan ke folder ' . $folderPath);
$sql = 'INSERT INTO archive_documents
(title, document_date, category, folder_path, department, notes, attachment_name, attachment_path, attachment_ext, attachment_size, created_by, created_role, activity_log)
VALUES
(:title, :document_date, :category, :folder_path, :department, :notes, :attachment_name, :attachment_path, :attachment_ext, :attachment_size, :created_by, :created_role, :activity_log)';
$stmt = db()->prepare($sql);
$stmt->bindValue(':title', $title);
$stmt->bindValue(':document_date', $documentDate);
$stmt->bindValue(':category', $category);
$stmt->bindValue(':folder_path', $folderPath);
$stmt->bindValue(':department', $department);
$stmt->bindValue(':notes', $notes !== '' ? $notes : null, $notes !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':attachment_name', $originalName);
$stmt->bindValue(':attachment_path', $relativePath);
$stmt->bindValue(':attachment_ext', $extension);
$stmt->bindValue(':attachment_size', $size, PDO::PARAM_INT);
$stmt->bindValue(':created_by', $user['name']);
$stmt->bindValue(':created_role', $user['role']);
$stmt->bindValue(':activity_log', json_encode($activity, JSON_UNESCAPED_UNICODE));
$stmt->execute();
set_flash('success', 'Dokumen berhasil ditambahkan. Menunggu validasi Super Admin.');
return true;
}
function get_documents(array $filters = []): array
{
$where = [];
$params = [];
if (!empty($filters['status'])) {
$where[] = 'status = :status';
$params[':status'] = $filters['status'];
}
if (!empty($filters['folder'])) {
$where[] = 'folder_path = :folder';
$params[':folder'] = $filters['folder'];
}
if (!empty($filters['department'])) {
$where[] = 'department = :department';
$params[':department'] = $filters['department'];
}
$sql = 'SELECT * FROM archive_documents';
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY updated_at DESC';
$limit = isset($filters['limit']) ? max(1, min(100, (int)$filters['limit'])) : 20;
$sql .= ' LIMIT ' . $limit;
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
return $stmt->fetchAll();
}
function get_document(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM archive_documents WHERE id = :id LIMIT 1');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$row = $stmt->fetch();
return $row ?: null;
}
function validate_document(int $id, array $user, string $notes = ''): bool
{
if (!is_super_admin()) {
set_flash('danger', 'Hanya Super Admin yang dapat melakukan validasi dokumen.');
return false;
}
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
set_flash('danger', 'Token validasi tidak valid.');
return false;
}
$document = get_document($id);
if (!$document) {
set_flash('danger', 'Dokumen tidak ditemukan.');
return false;
}
if ($document['status'] === 'validated') {
set_flash('info', 'Dokumen ini sudah tervalidasi.');
return false;
}
$activity = json_decode((string)$document['activity_log'], true);
if (!is_array($activity)) {
$activity = [];
}
$activity = append_activity($activity, 'validation', $user['name'], $notes !== '' ? $notes : 'Dokumen disetujui untuk akses staf.');
$stmt = db()->prepare('UPDATE archive_documents SET status = :status, validated_by = :validated_by, validation_notes = :validation_notes, activity_log = :activity_log WHERE id = :id');
$stmt->bindValue(':status', 'validated');
$stmt->bindValue(':validated_by', $user['name']);
$stmt->bindValue(':validation_notes', $notes !== '' ? $notes : null, $notes !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
$stmt->bindValue(':activity_log', json_encode($activity, JSON_UNESCAPED_UNICODE));
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
set_flash('success', 'Dokumen berhasil divalidasi dan dapat diakses staf terkait.');
return true;
}
function archive_dashboard_metrics(): array
{
$totals = db()->query("SELECT
COUNT(*) AS total,
SUM(status = 'pending') AS pending,
SUM(status = 'validated') AS validated,
SUM(DATE(created_at) = CURDATE()) AS today_uploads
FROM archive_documents")->fetch() ?: [];
return [
'total' => (int)($totals['total'] ?? 0),
'pending' => (int)($totals['pending'] ?? 0),
'validated' => (int)($totals['validated'] ?? 0),
'today_uploads' => (int)($totals['today_uploads'] ?? 0),
];
}
function format_filesize(int $bytes): string
{
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 1) . ' MB';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 1) . ' KB';
}
return $bytes . ' B';
}
function document_activity(array $document): array
{
$activity = json_decode((string)($document['activity_log'] ?? '[]'), true);
return is_array($activity) ? $activity : [];
}
function can_access_document_file(array $document): bool
{
if (is_super_admin()) {
return true;
}
return ($document['status'] ?? '') === 'validated';
}
function attachment_mime(string $extension): string
{
return match (strtolower($extension)) {
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'mp4' => 'video/mp4',
default => 'application/octet-stream',
};
}
function can_preview_inline(array $document): bool
{
return in_array(strtolower((string)($document['attachment_ext'] ?? '')), ['pdf', 'jpg', 'jpeg', 'png', 'mp4'], true);
}