$part) { if (preg_match('/^\{(.+)\}$/', $part, $matches)) { $params[$matches[1]] = $pathParts[$i]; continue; } if ($part !== $pathParts[$i]) { return false; } } return true; } function dashboardData(): array { $pdo = Database::connection(); $user = current_user(); if (is_admin()) { $counts = [ 'users' => (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), 'listings' => (int) $pdo->query('SELECT COUNT(*) FROM listings')->fetchColumn(), 'active_listings' => (int) $pdo->query("SELECT COUNT(*) FROM listings WHERE status = 'active'")->fetchColumn(), 'locked_accounts' => (int) $pdo->query('SELECT COUNT(*) FROM users WHERE is_account_locked = 1')->fetchColumn(), ]; $recentListings = $pdo->query('SELECT l.*, u.user_id, u.profile_name FROM listings l INNER JOIN users u ON u.id = l.owner_user_id ORDER BY l.updated_at DESC LIMIT 10')->fetchAll(); } else { $stmt = $pdo->prepare('SELECT COUNT(*) FROM listings WHERE owner_user_id = :uid'); $stmt->execute([':uid' => $user['id']]); $listings = (int) $stmt->fetchColumn(); $stmt = $pdo->prepare("SELECT COUNT(*) FROM listings WHERE owner_user_id = :uid AND status = 'active'"); $stmt->execute([':uid' => $user['id']]); $activeListings = (int) $stmt->fetchColumn(); $counts = [ 'users' => 1, 'listings' => $listings, 'active_listings' => $activeListings, 'locked_accounts' => (int) $user['is_account_locked'], ]; $stmt = $pdo->prepare('SELECT l.*, u.user_id, u.profile_name FROM listings l INNER JOIN users u ON u.id = l.owner_user_id WHERE l.owner_user_id = :uid ORDER BY l.updated_at DESC LIMIT 10'); $stmt->execute([':uid' => $user['id']]); $recentListings = $stmt->fetchAll(); } $recentLogs = []; if (is_admin()) { $recentLogs = $pdo->query('SELECT a.*, u.user_id AS actor_user_id_text, t.user_id AS target_user_id_text FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id LEFT JOIN users t ON t.id = a.target_user_id ORDER BY a.id DESC LIMIT 10')->fetchAll(); } return compact('counts', 'recentListings', 'recentLogs'); } function fetchListings(): array { $pdo = Database::connection(); $user = current_user(); if (is_admin()) { return $pdo->query('SELECT l.*, u.user_id, u.profile_name FROM listings l INNER JOIN users u ON u.id = l.owner_user_id ORDER BY l.updated_at DESC')->fetchAll(); } $stmt = $pdo->prepare('SELECT l.*, u.user_id, u.profile_name FROM listings l INNER JOIN users u ON u.id = l.owner_user_id WHERE l.owner_user_id = :uid ORDER BY l.updated_at DESC'); $stmt->execute([':uid' => $user['id']]); return $stmt->fetchAll(); } function findListing(int $id): ?array { $pdo = Database::connection(); $stmt = $pdo->prepare('SELECT l.*, u.user_id, u.profile_name FROM listings l INNER JOIN users u ON u.id = l.owner_user_id WHERE l.id = :id LIMIT 1'); $stmt->execute([':id' => $id]); $row = $stmt->fetch(); return $row ?: null; } function assertListingAccess(array $listing): void { $user = current_user(); if (!is_admin() && (int) $listing['owner_user_id'] !== (int) $user['id']) { http_response_code(403); exit('Anda tidak punya akses ke baris ini.'); } } function fetchUsers(): array { $pdo = Database::connection(); return $pdo->query('SELECT u.*, (SELECT COUNT(*) FROM listings l WHERE l.owner_user_id = u.id) AS listing_count FROM users u ORDER BY u.id ASC')->fetchAll(); } function findUser(int $id): ?array { $pdo = Database::connection(); $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id LIMIT 1'); $stmt->execute([':id' => $id]); $row = $stmt->fetch(); return $row ?: null; } function input_length(string $value): int { if (function_exists('mb_strlen')) { return mb_strlen($value, 'UTF-8'); } $count = preg_match_all('/./us', $value); return $count === false ? strlen($value) : $count; } function user_id_exists(string $userId, int $exceptId = 0): bool { $stmt = Database::connection()->prepare('SELECT id FROM users WHERE user_id = :user_id AND id <> :except_id LIMIT 1'); $stmt->execute([':user_id' => $userId, ':except_id' => $exceptId]); return $stmt->fetchColumn() !== false; } function user_audit_snapshot(array $user): array { unset($user['password'], $user['current_session_id'], $user['remember_token']); return $user; } function validate_listing_input(int $ownerId, string $url, string $anchorText, string $returnPath): void { if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) { flash('error', 'URL harus berupa alamat http:// atau https:// yang valid.'); redirect($returnPath); } if (input_length($anchorText) > 255 || strlen($url) > 65535) { flash('error', 'Anchor maksimal 255 karakter dan URL terlalu panjang tidak dapat disimpan.'); redirect($returnPath); } if ($ownerId <= 0 || !findUser($ownerId)) { flash('error', 'User pemilik backlink tidak ditemukan. Pilih user yang tersedia.'); redirect($returnPath); } } function finish_listing_change(int $actorId, string $reason, string $successMessage, string $returnPath): never { try { ListingExporter::regenerate($actorId, $reason); flash('success', $successMessage); } catch (Throwable $e) { error_log('SERPLIV export error: ' . $e->getMessage()); flash('error', 'Perubahan sudah tersimpan di database, tetapi data.txt gagal diperbarui. Cek permission data.txt, lalu klik Regenerate di Dashboard.'); } redirect($returnPath); } function require_sensitive_reason(): string { $reason = trim((string) ($_POST['reason'] ?? '')); if ($reason === '') { flash('error', 'Alasan wajib diisi untuk aksi ini.'); redirect('users'); } return $reason; } if ($path === '/' && $method === 'GET') { if (!current_user()) { view('auth/login', ['title' => 'Login']); return; } redirect('dashboard'); } if ($path === '/login' && $method === 'POST') { verify_csrf(); $userId = trim((string) ($_POST['user_id'] ?? '')); $password = (string) ($_POST['password'] ?? ''); if ($userId === '' || $password === '') { flash('error', 'User ID dan password wajib diisi.'); redirect(''); } if (Auth::attempt($userId, $password)) { $user = current_user(); if ((int) ($user['must_change_password'] ?? 0) === 1) { redirect('change-password'); } redirect('dashboard'); } redirect(''); } if ($path === '/logout' && $method === 'POST') { verify_csrf(); Auth::logout(); session_start(); flash('success', 'Berhasil logout.'); redirect(''); } if ($path === '/change-password' && $method === 'GET') { require_auth(); view('auth/change-password', ['title' => 'Ganti Password']); return; } if ($path === '/change-password' && $method === 'POST') { verify_csrf(); require_auth(); $user = current_user(); if ((int) $user['is_profile_locked'] === 1 && (int) $user['must_change_password'] !== 1) { flash('error', 'Profil Anda sedang dikunci oleh Master Admin. Anda tidak dapat mengubah data saat ini. Silakan hubungi Master Admin untuk membuka profil Anda dan melakukan reset password jika diperlukan.'); redirect('dashboard'); } $newPassword = (string) ($_POST['new_password'] ?? ''); $confirmPassword = (string) ($_POST['confirm_password'] ?? ''); if (strlen($newPassword) < 8) { flash('error', 'Password baru minimal 8 karakter.'); redirect('change-password'); } if ($newPassword !== $confirmPassword) { flash('error', 'Konfirmasi password tidak cocok.'); redirect('change-password'); } Auth::changePassword((int) $user['id'], $newPassword); Audit::log((int) $user['id'], 'password_changed'); $_SESSION['auth_user']['must_change_password'] = 0; flash('success', 'Password berhasil diganti.'); redirect('dashboard'); } if ($path === '/dashboard' && $method === 'GET') { require_auth(); $user = current_user(); if ((int) $user['must_change_password'] === 1 && empty($_SESSION['impersonator'])) { redirect('change-password'); } view('dashboard', ['title' => 'Dashboard'] + dashboardData()); return; } if ($path === '/listings' && $method === 'GET') { require_auth(); view('listings/index', ['title' => 'Backlinks', 'listings' => fetchListings()]); return; } if ($path === '/bulk-upload' && $method === 'GET') { require_admin(); $users = fetchUsers(); view('bulk-upload', [ 'title' => 'Bulk Upload', 'users' => $users, ]); return; } if ($path === '/bulk-upload' && $method === 'POST') { verify_csrf(); require_admin(); $actor = current_user(); $ownerId = (int) ($_POST['owner_user_id'] ?? 0); $bulkContent = trim((string) ($_POST['bulk_content'] ?? '')); if ($ownerId <= 0) { flash('error', 'Pilih user tujuan terlebih dahulu.'); redirect('bulk-upload'); } if ($bulkContent === '') { flash('error', 'Isi bulk upload masih kosong.'); redirect('bulk-upload'); } try { $result = BulkListingImporter::importForUser($ownerId, (int) $actor['id'], $bulkContent); if ((int) $result['parsed'] === 0) { flash('error', 'Tidak ada format backlink yang valid. Gunakan format

anchor

.'); redirect('bulk-upload'); } $_SESSION['bulk_upload_result'] = $result; $summary = 'Bulk upload selesai: ' . (int) $result['inserted'] . ' backlink ditambahkan ke ' . ($result['owner']['user_id'] ?? 'user') . ', ' . (int) $result['duplicates'] . ' duplikat dilewati.'; if (!empty($result['export_error'])) { flash('error', $summary . ' Data tersimpan, tetapi data.txt gagal diperbarui. Cek permission data.txt, lalu klik Regenerate di Dashboard.'); } else { flash('success', $summary . ' data.txt sudah disinkronkan.'); } } catch (Throwable $e) { flash('error', 'Bulk upload gagal: ' . $e->getMessage()); } redirect('bulk-upload'); } if ($path === '/listings/create' && $method === 'GET') { require_auth(); $users = is_admin() ? fetchUsers() : []; view('listings/form', ['title' => 'Tambah Backlink', 'listing' => null, 'users' => $users]); return; } if ($path === '/listings/create' && $method === 'POST') { verify_csrf(); require_auth(); $user = current_user(); $pdo = Database::connection(); if (!is_admin() && (int) $user['is_profile_locked'] === 1) { flash('error', 'Profil Anda sedang dikunci oleh Master Admin. Anda tidak dapat mengubah data saat ini. Silakan hubungi Master Admin untuk membuka profil Anda dan melakukan reset password jika diperlukan.'); redirect('listings'); } $ownerId = is_admin() ? (int) ($_POST['owner_user_id'] ?? $user['id']) : (int) $user['id']; $url = trim((string) ($_POST['url'] ?? '')); $anchorText = trim((string) ($_POST['anchor_text'] ?? '')); $status = ($_POST['status'] ?? 'active') === 'inactive' ? 'inactive' : 'active'; if ($url === '' || $anchorText === '') { flash('error', 'URL dan anchor wajib diisi.'); redirect('listings/create'); } validate_listing_input($ownerId, $url, $anchorText, 'listings/create'); $stmt = $pdo->prepare('INSERT INTO listings (owner_user_id, url, anchor_text, status, created_by, updated_by, created_at, updated_at) VALUES (:owner_user_id, :url, :anchor_text, :status, :created_by, :updated_by, NOW(), NOW())'); $stmt->execute([ ':owner_user_id' => $ownerId, ':url' => $url, ':anchor_text' => $anchorText, ':status' => $status, ':created_by' => $user['id'], ':updated_by' => $user['id'], ]); $listingId = (int) $pdo->lastInsertId(); Audit::log((int) $user['id'], 'listing_created', [ 'listing_id' => $listingId, 'target_user_id' => $ownerId, 'new_value' => compact('url', 'anchorText', 'status'), ]); finish_listing_change((int) $user['id'], 'listing_created', 'Backlink berhasil ditambahkan dan data.txt diperbarui.', 'listings'); } $params = []; if (match_route('/listings/{id}/edit', $path, $params) && $method === 'GET') { require_auth(); $listing = findListing((int) $params['id']); if (!$listing) { http_response_code(404); exit('Listing tidak ditemukan.'); } assertListingAccess($listing); $users = is_admin() ? fetchUsers() : []; view('listings/form', ['title' => 'Edit Backlink', 'listing' => $listing, 'users' => $users]); return; } $params = []; if (match_route('/listings/{id}/edit', $path, $params) && $method === 'POST') { verify_csrf(); require_auth(); $pdo = Database::connection(); $user = current_user(); $listing = findListing((int) $params['id']); if (!$listing) { http_response_code(404); exit('Listing tidak ditemukan.'); } assertListingAccess($listing); if (!is_admin() && (int) $user['is_profile_locked'] === 1) { flash('error', 'Profil Anda sedang dikunci oleh Master Admin. Anda tidak dapat mengubah data saat ini. Silakan hubungi Master Admin untuk membuka profil Anda dan melakukan reset password jika diperlukan.'); redirect('listings'); } $ownerId = is_admin() ? (int) ($_POST['owner_user_id'] ?? $listing['owner_user_id']) : (int) $listing['owner_user_id']; $url = trim((string) ($_POST['url'] ?? '')); $anchorText = trim((string) ($_POST['anchor_text'] ?? '')); $status = ($_POST['status'] ?? 'active') === 'inactive' ? 'inactive' : 'active'; if ($url === '' || $anchorText === '') { flash('error', 'URL dan anchor wajib diisi.'); redirect('listings/' . $listing['id'] . '/edit'); } validate_listing_input($ownerId, $url, $anchorText, 'listings/' . $listing['id'] . '/edit'); $stmt = $pdo->prepare('UPDATE listings SET owner_user_id = :owner_user_id, url = :url, anchor_text = :anchor_text, status = :status, updated_by = :updated_by, updated_at = NOW() WHERE id = :id'); $stmt->execute([ ':owner_user_id' => $ownerId, ':url' => $url, ':anchor_text' => $anchorText, ':status' => $status, ':updated_by' => $user['id'], ':id' => $listing['id'], ]); Audit::log((int) $user['id'], 'listing_updated', [ 'listing_id' => (int) $listing['id'], 'target_user_id' => $ownerId, 'old_value' => $listing, 'new_value' => compact('url', 'anchorText', 'status', 'ownerId'), ]); finish_listing_change((int) $user['id'], 'listing_updated', 'Backlink berhasil diperbarui dan data.txt disinkronkan.', 'listings'); } $params = []; if (match_route('/listings/{id}/delete', $path, $params) && $method === 'POST') { verify_csrf(); require_auth(); $pdo = Database::connection(); $user = current_user(); $listing = findListing((int) $params['id']); if (!$listing) { http_response_code(404); exit('Listing tidak ditemukan.'); } assertListingAccess($listing); if (!is_admin() && (int) $user['is_profile_locked'] === 1) { flash('error', 'Profil Anda sedang dikunci oleh Master Admin. Anda tidak dapat mengubah data saat ini. Silakan hubungi Master Admin untuk membuka profil Anda dan melakukan reset password jika diperlukan.'); redirect('listings'); } $stmt = $pdo->prepare('DELETE FROM listings WHERE id = :id'); $stmt->execute([':id' => $listing['id']]); Audit::log((int) $user['id'], 'listing_deleted', [ 'listing_id' => (int) $listing['id'], 'target_user_id' => (int) $listing['owner_user_id'], 'old_value' => $listing, ]); finish_listing_change((int) $user['id'], 'listing_deleted', 'Backlink berhasil dihapus dan data.txt disinkronkan.', 'listings'); } if ($path === '/listings/bulk-delete' && $method === 'POST') { verify_csrf(); require_auth(); $pdo = Database::connection(); $user = current_user(); $rawIds = $_POST['listing_ids'] ?? []; if (!is_array($rawIds)) { $rawIds = []; } $ids = []; foreach ($rawIds as $rawId) { $id = filter_var($rawId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); if ($id !== false) { $ids[(int) $id] = (int) $id; } } $ids = array_values($ids); if (empty($ids)) { flash('error', 'Pilih minimal 1 backlink untuk dihapus.'); redirect('listings'); } if (count($ids) > 1000) { flash('error', 'Maksimal 1000 backlink dapat dihapus dalam satu aksi.'); redirect('listings'); } if (!is_admin() && (int) $user['is_profile_locked'] === 1) { flash('error', 'Profil Anda sedang dikunci oleh Master Admin. Anda tidak dapat mengubah data saat ini.'); redirect('listings'); } $placeholders = implode(',', array_fill(0, count($ids), '?')); $sql = 'SELECT id, owner_user_id, url, anchor_text, status FROM listings WHERE id IN (' . $placeholders . ')'; $params = $ids; if (!is_admin()) { $sql .= ' AND owner_user_id = ?'; $params[] = (int) $user['id']; } $stmt = $pdo->prepare($sql); $stmt->execute($params); $rows = $stmt->fetchAll(); if (empty($rows)) { flash('error', 'Tidak ada backlink terpilih yang dapat dihapus.'); redirect('listings'); } $allowedIds = array_map(static fn(array $row): int => (int) $row['id'], $rows); $deletePlaceholders = implode(',', array_fill(0, count($allowedIds), '?')); try { $pdo->beginTransaction(); $delete = $pdo->prepare('DELETE FROM listings WHERE id IN (' . $deletePlaceholders . ')'); $delete->execute($allowedIds); $ownerIds = array_values(array_unique(array_map(static fn(array $row): int => (int) $row['owner_user_id'], $rows))); Audit::log((int) $user['id'], 'listings_bulk_deleted', [ 'reason' => 'bulk_delete_selected', 'old_value' => [ 'count' => count($rows), 'listing_ids' => $allowedIds, 'owner_user_ids' => $ownerIds, 'rows' => $rows, ], 'new_value' => [ 'deleted_count' => count($rows), ], ]); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } flash('error', 'Bulk delete gagal: ' . $e->getMessage()); redirect('listings'); } try { ListingExporter::regenerate((int) $user['id'], 'listings_bulk_deleted'); flash('success', count($rows) . ' backlink terpilih berhasil dihapus dan data.txt sudah disinkronkan.'); } catch (Throwable $e) { flash('error', count($rows) . ' backlink sudah terhapus dari database, tetapi data.txt gagal diperbarui: ' . $e->getMessage()); } redirect('listings'); } if ($path === '/users' && $method === 'GET') { require_admin(); view('users/index', ['title' => 'Users', 'users' => fetchUsers()]); return; } if ($path === '/users/create' && $method === 'GET') { require_admin(); view('users/form', ['title' => 'Tambah User', 'userRow' => null]); return; } if ($path === '/users/create' && $method === 'POST') { verify_csrf(); require_admin(); $pdo = Database::connection(); $actor = current_user(); $userId = trim((string) ($_POST['user_id'] ?? '')); $profileName = trim((string) ($_POST['profile_name'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $role = ($_POST['role'] ?? 'user') === 'master_admin' ? 'master_admin' : 'user'; $status = ($_POST['status'] ?? 'active') === 'inactive' ? 'inactive' : 'active'; if ($userId === '' || $profileName === '' || strlen($password) < 8) { flash('error', 'User ID, Profile Name, dan password minimal 8 karakter wajib diisi.'); redirect('users/create'); } if (input_length($userId) > 50 || input_length($profileName) > 120) { flash('error', 'User ID maksimal 50 karakter dan Profile Name maksimal 120 karakter.'); redirect('users/create'); } if (user_id_exists($userId)) { flash('error', 'User ID sudah dipakai. Gunakan User ID lain.'); redirect('users/create'); } try { $pdo->beginTransaction(); $stmt = $pdo->prepare('INSERT INTO users (user_id, profile_name, password, role, status, must_change_password, is_profile_locked, is_account_locked, failed_login_attempts, created_at, updated_at) VALUES (:user_id, :profile_name, :password, :role, :status, 1, 0, 0, 0, NOW(), NOW())'); $stmt->execute([ ':user_id' => $userId, ':profile_name' => $profileName, ':password' => password_hash($password, PASSWORD_DEFAULT), ':role' => $role, ':status' => $status, ]); $targetId = (int) $pdo->lastInsertId(); Audit::log((int) $actor['id'], 'user_created', [ 'target_user_id' => $targetId, 'new_value' => compact('userId', 'profileName', 'role', 'status'), ]); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } if ($e instanceof PDOException && (int) ($e->errorInfo[1] ?? 0) === 1062) { flash('error', 'User ID sudah dipakai. Gunakan User ID lain.'); } else { error_log('SERPLIV create user error: ' . $e->getMessage()); flash('error', 'User gagal dibuat. Periksa error log terbaru.'); } redirect('users/create'); } flash('success', 'User berhasil dibuat.'); redirect('users'); } $params = []; if (match_route('/users/{id}/edit', $path, $params) && $method === 'GET') { require_admin(); $userRow = findUser((int) $params['id']); if (!$userRow) { http_response_code(404); exit('User tidak ditemukan.'); } view('users/form', ['title' => 'Edit User', 'userRow' => $userRow]); return; } $params = []; if (match_route('/users/{id}/edit', $path, $params) && $method === 'POST') { verify_csrf(); require_admin(); $pdo = Database::connection(); $actor = current_user(); $userRow = findUser((int) $params['id']); if (!$userRow) { http_response_code(404); exit('User tidak ditemukan.'); } $profileName = trim((string) ($_POST['profile_name'] ?? '')); $status = ($_POST['status']?? 'active') === 'inactive' ? 'inactive' : 'active'; $role = ($_POST['role'] ?? 'user') === 'master_admin' ? 'master_admin' : 'user'; $mustChangePassword = isset($_POST['must_change_password']) ? 1 : 0; $newUserId = $userRow['user_id'] === 'admin' ? 'admin' : trim((string) ($_POST['user_id'] ?? $userRow['user_id'])); if ($profileName === '' || $newUserId === '') { flash('error', 'User ID dan Profile Name wajib diisi.'); redirect('users/' . $userRow['id'] . '/edit'); } if (input_length($newUserId) > 50 || input_length($profileName) > 120) { flash('error', 'User ID maksimal 50 karakter dan Profile Name maksimal 120 karakter.'); redirect('users/' . $userRow['id'] . '/edit'); } if (user_id_exists($newUserId, (int) $userRow['id'])) { flash('error', 'User ID sudah dipakai oleh akun lain.'); redirect('users/' . $userRow['id'] . '/edit'); } if ($userRow['user_id'] === 'admin' || (int) $userRow['id'] === (int) $actor['id']) { $role = 'master_admin'; $status = 'active'; } try { $pdo->beginTransaction(); $stmt = $pdo->prepare('UPDATE users SET user_id = :user_id, profile_name = :profile_name, role = :role, status = :status, must_change_password = :must_change_password, current_session_id = CASE WHEN :inactive = 1 THEN NULL ELSE current_session_id END, updated_at = NOW() WHERE id = :id'); $stmt->execute([ ':user_id' => $newUserId, ':profile_name' => $profileName, ':role' => $role, ':status' => $status, ':must_change_password' => $mustChangePassword, ':inactive' => $status === 'inactive' ? 1 : 0, ':id' => $userRow['id'], ]); Audit::log((int) $actor['id'], 'user_updated', [ 'target_user_id' => (int) $userRow['id'], 'old_value' => user_audit_snapshot($userRow), 'new_value' => compact('newUserId', 'profileName', 'role', 'status', 'mustChangePassword'), 'reason' => trim((string) ($_POST['reason'] ?? '')) ?: 'profile_edit_by_master', ]); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } if ($e instanceof PDOException && (int) ($e->errorInfo[1] ?? 0) === 1062) { flash('error', 'User ID sudah dipakai oleh akun lain.'); } else { error_log('SERPLIV edit user error: ' . $e->getMessage()); flash('error', 'User gagal diperbarui. Periksa error log terbaru.'); } redirect('users/' . $userRow['id'] . '/edit'); } flash('success', 'User berhasil diperbarui.'); redirect('users'); } $params = []; if (match_route('/users/{id}/delete', $path, $params) && $method === 'POST') { verify_csrf(); require_admin(); $pdo = Database::connection(); $actor = current_user(); $targetId = (int) $params['id']; try { $pdo->beginTransaction(); $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id LIMIT 1 FOR UPDATE'); $stmt->execute([':id' => $targetId]); $target = $stmt->fetch(); if (!$target || $target['user_id'] === 'admin' || $targetId === (int) $actor['id']) { $pdo->rollBack(); flash('error', !$target ? 'User tidak ditemukan.' : 'Akun admin utama dan akun yang sedang dipakai tidak dapat dihapus.'); redirect('users'); } $countStmt = $pdo->prepare('SELECT COUNT(*) FROM listings WHERE owner_user_id = :id'); $countStmt->execute([':id' => $targetId]); $listingCount = (int) $countStmt->fetchColumn(); Audit::log((int) $actor['id'], 'user_deleted', [ 'target_user_id' => $targetId, 'reason' => 'User ' . $target['user_id'] . ' dihapus oleh Master Admin', 'old_value' => user_audit_snapshot($target), 'new_value' => ['deleted_listings' => $listingCount], ]); // Foreign keys remove the user's listings and login histories; audit logs stay. $delete = $pdo->prepare('DELETE FROM users WHERE id = :id'); $delete->execute([':id' => $targetId]); $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { $pdo->rollBack(); } error_log('SERPLIV delete user error: ' . $e->getMessage()); flash('error', 'User gagal dihapus. Periksa error log terbaru.'); redirect('users'); } try { ListingExporter::regenerate((int) $actor['id'], 'user_deleted_' . $targetId); flash('success', 'User ' . $target['user_id'] . ' dan ' . $listingCount . ' backlink miliknya berhasil dihapus. data.txt sudah disinkronkan.'); } catch (Throwable $e) { error_log('SERPLIV delete user export error: ' . $e->getMessage()); flash('error', 'User sudah dihapus dari database, tetapi data.txt gagal diperbarui. Cek permission data.txt, lalu klik Regenerate di Dashboard.'); } redirect('users'); } $adminActionRoutes = [ '/users/{id}/reset-password' => 'reset_password', '/users/{id}/lock-account' => 'lock_account', '/users/{id}/unlock-account' => 'unlock_account', '/users/{id}/lock-profile' => 'lock_profile', '/users/{id}/unlock-profile' => 'unlock_profile', '/users/{id}/impersonate' => 'impersonate', ]; foreach ($adminActionRoutes as $pattern => $action) { $params = []; if (match_route($pattern, $path, $params) && $method === 'POST') { verify_csrf(); require_admin(); $pdo = Database::connection(); $actor = current_user(); $target = findUser((int) $params['id']); if (!$target) { http_response_code(404); exit('User tidak ditemukan.'); } $reason = require_sensitive_reason(); if ((int) $target['id'] === (int) $actor['id'] && in_array($action, ['lock_account', 'lock_profile', 'impersonate'], true)) { flash('error', 'Aksi ini tidak dapat dilakukan pada akun yang sedang dipakai.'); redirect('users'); } if ($action === 'reset_password') { $temporaryPassword = (string) ($_POST['temporary_password'] ?? ''); if (strlen($temporaryPassword) < 8) { flash('error', 'Password sementara minimal 8 karakter.'); redirect('users'); } $stmt = $pdo->prepare('UPDATE users SET password = :password, must_change_password = 1, failed_login_attempts = 0, is_account_locked = 0, lock_reason = NULL, locked_by = NULL, locked_at = NULL, current_session_id = NULL, updated_at = NOW() WHERE id = :id'); $stmt->execute([ ':password' => password_hash($temporaryPassword, PASSWORD_DEFAULT), ':id' => $target['id'], ]); Audit::log((int) $actor['id'], 'password_reset_by_master', [ 'target_user_id' => (int) $target['id'], 'reason' => $reason, ]); flash('success', 'Password user berhasil di-reset.'); } if ($action === 'lock_account') { if ($target['user_id'] === 'admin') { flash('error', 'Akun master tidak bisa dikunci.'); redirect('users'); } $stmt = $pdo->prepare("UPDATE users SET is_account_locked = 1, lock_reason = 'locked_by_master', locked_by = :locked_by, locked_at = NOW(), current_session_id = NULL, updated_at = NOW() WHERE id = :id"); $stmt->execute([':locked_by' => $actor['id'], ':id' => $target['id']]); Audit::log((int) $actor['id'], 'account_locked_by_master', [ 'target_user_id' => (int) $target['id'], 'reason' => $reason, ]); flash('success', 'Akun user berhasil dikunci.'); } if ($action === 'unlock_account') { $stmt = $pdo->prepare('UPDATE users SET is_account_locked = 0, failed_login_attempts = 0, lock_reason = NULL, locked_by = NULL, locked_at = NULL, must_change_password = 1, current_session_id = NULL, updated_at = NOW() WHERE id = :id'); $stmt->execute([':id' => $target['id']]); Audit::log((int) $actor['id'], 'account_unlocked_by_master', [ 'target_user_id' => (int) $target['id'], 'reason' => $reason, ]); flash('success', 'Akun user berhasil dibuka. User wajib ganti password saat login berikutnya.'); } if ($action === 'lock_profile') { if ($target['user_id'] === 'admin') { flash('error', 'Profil akun master tidak bisa dikunci.'); redirect('users'); } $stmt = $pdo->prepare('UPDATE users SET is_profile_locked = 1, updated_at = NOW() WHERE id = :id'); $stmt->execute([':id' => $target['id']]); Audit::log((int) $actor['id'], 'profile_locked_by_master', [ 'target_user_id' => (int) $target['id'], 'reason' => $reason, ]); flash('success', 'Profil user berhasil dikunci.'); } if ($action === 'unlock_profile') { $stmt = $pdo->prepare('UPDATE users SET is_profile_locked = 0, updated_at = NOW() WHERE id = :id'); $stmt->execute([':id' => $target['id']]); Audit::log((int) $actor['id'], 'profile_unlocked_by_master', [ 'target_user_id' => (int) $target['id'], 'reason' => $reason, ]); flash('success', 'Profil user berhasil dibuka.'); } if ($action === 'impersonate') { if ($target['role'] === 'master_admin') { flash('error', 'Tidak bisa impersonate akun master.'); redirect('users'); } $_SESSION['impersonator'] = $actor; $_SESSION['auth_user'] = $target; $_SESSION['last_activity_at'] = time(); Audit::log((int) $actor['id'], 'impersonate_started', [ 'target_user_id' => (int) $target['id'], 'reason' => $reason, ]); flash('success', 'Mode impersonate aktif.'); redirect('dashboard'); } redirect('users'); } } if ($path === '/stop-impersonation' && $method === 'POST') { verify_csrf(); require_auth(); $impersonator = $_SESSION['impersonator'] ?? null; if (!$impersonator) { redirect('dashboard'); } Audit::log((int) $impersonator['id'], 'impersonate_stopped', [ 'target_user_id' => (int) (current_user()['id'] ?? 0), ]); $_SESSION['auth_user'] = $impersonator; $_SESSION['impersonator'] = null; $_SESSION['last_activity_at'] = time(); flash('success', 'Kembali ke akun master.'); redirect('dashboard'); } if ($path === '/profile' && $method === 'GET') { require_auth(); view('profile', ['title' => 'Profil']); return; } if ($path === '/profile' && $method === 'POST') { verify_csrf(); require_auth(); $pdo = Database::connection(); $user = current_user(); if ((int) $user['is_profile_locked'] === 1 && !is_admin()) { flash('error', 'Profil Anda sedang dikunci oleh Master Admin. Anda tidak dapat mengubah data saat ini. Silakan hubungi Master Admin untuk membuka profil Anda dan melakukan reset password jika diperlukan.'); redirect('profile'); } $profileName = trim((string) ($_POST['profile_name'] ?? '')); if ($profileName === '') { flash('error', 'Profile name wajib diisi.'); redirect('profile'); } if (input_length($profileName) > 120) { flash('error', 'Profile Name maksimal 120 karakter.'); redirect('profile'); } $stmt = $pdo->prepare('UPDATE users SET profile_name = :profile_name, updated_at = NOW() WHERE id = :id'); $stmt->execute([ ':profile_name' => $profileName, ':id' => $user['id'], ]); $_SESSION['auth_user']['profile_name'] = $profileName; Audit::log((int) $user['id'], 'profile_updated', [ 'target_user_id' => (int) $user['id'], 'new_value' => ['profile_name' => $profileName], ]); flash('success', 'Profil berhasil diperbarui.'); redirect('profile'); } if ($path === '/logs' && $method === 'GET') { require_admin(); $pdo = Database::connection(); $logs = $pdo->query('SELECT a.*, u.user_id AS actor_user_id_text, t.user_id AS target_user_id_text FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id LEFT JOIN users t ON t.id = a.target_user_id ORDER BY a.id DESC LIMIT 200')->fetchAll(); view('logs/index', ['title' => 'Audit Logs', 'logs' => $logs]); return; } if ($path === '/system/regenerate-list' && $method === 'POST') { verify_csrf(); require_admin(); try { ListingExporter::regenerate((int) current_user()['id'], trim((string) ($_POST['reason'] ?? '')) ?: 'manual_regenerate'); flash('success', 'data.txt berhasil di-generate ulang.'); } catch (Throwable $e) { error_log('SERPLIV manual export error: ' . $e->getMessage()); flash('error', 'data.txt gagal diperbarui. Cek permission data.txt dan folder public_html.'); } redirect('dashboard'); } http_response_code(404); echo '404 Not Found';