Only a Safe rally is a Good Rally
OUR mission – your safety – your live
FIA Safety Rally
tableau officiel d'affichage
$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';