$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 = '
Kode OTP Anda:
' . h($otp) . '
Kode berlaku selama 5 menit.
'; $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); }