PHP Classes

File: routes/admin-routes.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   routes/admin-routes.php   Download  
File: routes/admin-routes.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 387,782 bytes
 

Contents

Class file image Download
<?php use BinktermPHP\AdminActionLogger; use BinktermPHP\AdminController; use BinktermPHP\Auth; use BinktermPHP\DoorConfig; use BinktermPHP\DoorManager; use BinktermPHP\RouteHelper; use BinktermPHP\Template; use BinktermPHP\UserMeta; use BinktermPHP\WebDoorManifest; use Pecee\SimpleRouter\SimpleRouter; if (!function_exists('extractUploadedLicenseData')) { /** * @param array<string,mixed> $file * @return array<string,mixed> */ function extractUploadedLicenseData(array $file): array { $tmpName = (string)($file['tmp_name'] ?? ''); $originalName = (string)($file['name'] ?? ''); $ext = strtolower((string)pathinfo($originalName, PATHINFO_EXTENSION)); if ($tmpName === '' || !is_uploaded_file($tmpName)) { throw new \RuntimeException('No uploaded file was received.'); } if ($ext === 'json') { $content = @file_get_contents($tmpName); if ($content === false) { throw new \RuntimeException('Failed to read uploaded license file.'); } } elseif ($ext === 'zip') { if (!class_exists('ZipArchive')) { throw new \RuntimeException('ZIP uploads require the PHP zip extension.'); } $zip = new \ZipArchive(); if ($zip->open($tmpName) !== true) { throw new \RuntimeException('Failed to open uploaded ZIP file.'); } $content = false; for ($i = 0; $i < $zip->numFiles; $i++) { $entryName = (string)$zip->getNameIndex($i); $normalized = str_replace('\\', '/', $entryName); if (strcasecmp(basename($normalized), 'license.json') !== 0) { continue; } $content = $zip->getFromIndex($i); if ($content !== false) { break; } } $zip->close(); if ($content === false) { throw new \RuntimeException('ZIP file does not contain a readable license.json.'); } } else { throw new \RuntimeException('Please upload a license.json or a ZIP containing license.json.'); } $licenseData = json_decode((string)$content, true); if (!is_array($licenseData) || !isset($licenseData['payload'], $licenseData['signature'])) { throw new \RuntimeException('Invalid license format. Expected {payload: {...}, signature: "..."}.' ); } return $licenseData; } } if (!function_exists('apiError')) { function apiError(string $errorCode, string $message, ?int $status = null, array $extra = []): void { if ($status !== null) { http_response_code($status); } echo json_encode(array_merge([ 'success' => false, 'error_code' => $errorCode, 'error' => $message, ], $extra)); exit; } } if (!function_exists('apiLocalizedText')) { function apiLocalizedText(string $key, string $fallback, ?array $user = null, array $params = [], string $namespace = 'errors'): string { static $translator = null; static $resolver = null; if ($translator === null || $resolver === null) { $translator = new \BinktermPHP\I18n\Translator(); $resolver = new \BinktermPHP\I18n\LocaleResolver($translator); } if ($user === null) { try { $auth = new Auth(); $resolvedUser = $auth->getCurrentUser(); if (is_array($resolvedUser)) { $user = $resolvedUser; } } catch (\Throwable $e) { // Fall back to default locale when no user context is available. } } $resolvedLocale = $resolver->resolveLocale((string)($user['locale'] ?? ''), $user); $translated = $translator->translate($key, $params, $resolvedLocale, [$namespace]); return $translated === $key ? $fallback : $translated; } } if (!function_exists('apiLocalizeErrorPayload')) { function apiLocalizeErrorPayload(array $payload, ?array $user = null): array { if (!empty($payload['error_code'])) { $payload['error'] = apiLocalizedText((string)$payload['error_code'], (string)($payload['error'] ?? ''), $user); } return $payload; } } SimpleRouter::group(['prefix' => '/admin'], function() { // Admin dashboard SimpleRouter::get('/', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $adminController = new AdminController(); $stats = $adminController->getSystemStats(); $dbVersion = $adminController->getDatabaseVersion(); $config = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $systemAddresses = [$config->getSystemAddress()]; foreach ($config->getUplinks() as $uplink) { if (!empty($uplink['me'])) { $systemAddresses[] = $uplink['me']; } } $systemAddresses = array_values(array_unique(array_filter($systemAddresses))); $dbStats = new \BinktermPHP\DatabaseStats(\BinktermPHP\Database::getInstance()->getPdo()); $template->renderResponse('admin/dashboard.twig', [ 'stats' => $stats, 'db_version' => $dbVersion, 'daemon_status' => \BinktermPHP\SystemStatus::getDaemonStatus(), 'git_commit' => \BinktermPHP\SystemStatus::getGitCommitHash(), 'git_branch' => \BinktermPHP\SystemStatus::getGitBranch(), 'system_addresses' => $systemAddresses, 'db_summary' => $dbStats->getDashboardSummary(), ]); }); // Licensing management page SimpleRouter::get('/licensing', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/licensing.twig'); }); // License registration info ? serves REGISTER.md as HTML SimpleRouter::get('/api/register-info', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $path = __DIR__ . '/../REGISTER.md'; if (!file_exists($path)) { http_response_code(404); echo json_encode(['error' => 'Not found']); return; } $html = \BinktermPHP\MarkdownRenderer::toHtml(file_get_contents($path)); echo json_encode(['html' => $html]); }); SimpleRouter::get('/api/ram-usage', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $adminController = new AdminController(); $output = $adminController->getRamUsageDetails(); if ($output === null) { http_response_code(404); apiError( 'errors.admin.dashboard.ram_usage_unavailable', apiLocalizedText('errors.admin.dashboard.ram_usage_unavailable', 'RAM usage details are not available on this system.', $user) ); return; } echo json_encode([ 'success' => true, 'output' => $output, ]); }); // License API ? GET: current status SimpleRouter::get('/api/license', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); echo json_encode(['status' => \BinktermPHP\License::getStatus()]); }); // License API ? POST: install a new license SimpleRouter::post('/api/license', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { if (!empty($_FILES['license_file'])) { $upload = $_FILES['license_file']; if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { throw new \RuntimeException('Failed to receive uploaded license file.'); } $licenseData = extractUploadedLicenseData($upload); } else { $input = json_decode(file_get_contents('php://input'), true); $licenseData = $input['license'] ?? null; if (!is_array($licenseData) || !isset($licenseData['payload'], $licenseData['signature'])) { throw new \RuntimeException('Invalid license format. Expected {payload: {...}, signature: "..."}.' ); } } } catch (\RuntimeException $e) { http_response_code(400); echo json_encode(['success' => false, 'error' => $e->getMessage()]); return; } // Verify signature before passing to the daemon for installation. // Write to a temp file so License::getStatus() can parse and verify it. $tmpPath = tempnam(sys_get_temp_dir(), 'binklic_'); file_put_contents($tmpPath, json_encode($licenseData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); \BinktermPHP\License::clearCache(); $originalEnv = $_ENV['LICENSE_FILE'] ?? null; $_ENV['LICENSE_FILE'] = $tmpPath; putenv('LICENSE_FILE=' . $tmpPath); $status = \BinktermPHP\License::getStatus(); if ($originalEnv !== null) { $_ENV['LICENSE_FILE'] = $originalEnv; putenv('LICENSE_FILE=' . $originalEnv); } else { unset($_ENV['LICENSE_FILE']); putenv('LICENSE_FILE'); } \BinktermPHP\License::clearCache(); @unlink($tmpPath); if (!$status['valid']) { $reason = $status['reason'] ?? 'unknown'; $reasonMessages = [ 'invalid_signature' => 'Signature verification failed. This license was not issued by the BinktermPHP project.', 'expired' => 'This license has expired.', 'malformed' => 'License file is malformed or missing required fields.', 'invalid_key' => 'License key data is invalid.', ]; $msg = $reasonMessages[$reason] ?? "License is not valid (reason: {$reason})."; http_response_code(400); echo json_encode(['success' => false, 'error' => $msg]); return; } // Delegate the actual file write to the admin daemon (web process has no write access). try { $daemon = new \BinktermPHP\Admin\AdminDaemonClient(); $daemon->setLicense($licenseData); $daemon->close(); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Admin daemon error: ' . $e->getMessage()]); return; } \BinktermPHP\License::clearCache(); echo json_encode([ 'success' => true, 'message' => 'License installed successfully. ' . ucfirst($status['tier']) . ' edition activated.', 'status' => \BinktermPHP\License::getStatus(), ]); }); // License API ? DELETE: remove license file SimpleRouter::delete('/api/license', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $daemon = new \BinktermPHP\Admin\AdminDaemonClient(); $daemon->deleteLicense(); $daemon->close(); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'Admin daemon error: ' . $e->getMessage()]); return; } \BinktermPHP\License::clearCache(); echo json_encode(['success' => true, 'message' => 'License removed. Running Community Edition.']); }); // Database statistics page SimpleRouter::get('/database-stats', function() { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); $dbStats = new \BinktermPHP\DatabaseStats($db); $template = new Template(); $template->renderResponse('admin/database_stats.twig', [ 'size' => $dbStats->getSizeAndGrowth(), 'activity' => $dbStats->getActivity(), 'queries' => $dbStats->getQueryPerformance(), 'replication' => $dbStats->getReplication(), 'maintenance' => $dbStats->getMaintenanceHealth(), 'indexes' => $dbStats->getIndexHealth(), 'i18n_catalogs' => $dbStats->getI18nCatalogStats(), ]); }); // Users management page SimpleRouter::get('/users', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin_users.twig'); }); // AI bots management page SimpleRouter::get('/ai-bots', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/ai_bots.twig'); }); // Chat rooms management page SimpleRouter::get('/chat-rooms', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/chat_rooms.twig'); }); // Polls management page SimpleRouter::get('/polls', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/polls.twig'); }); // Bulletins management page SimpleRouter::get('/bulletins', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/bulletins.twig'); }); // Shoutbox moderation page SimpleRouter::get('/shoutbox', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/shoutbox.twig'); }); // Binkp configuration page SimpleRouter::get('/binkp-config', function() { $user = RouteHelper::requireAdmin(); $networkManager = new \BinktermPHP\NetworkManager(); $template = new Template(); $template->renderResponse('admin/binkp_config.twig', [ 'timezone_list' => \DateTimeZone::listIdentifiers(), 'supported_charsets' => \BinktermPHP\Binkp\Config\BinkpConfig::getSupportedCharsets(), 'networks' => $networkManager->getAll(), ]); }); SimpleRouter::get('/networks', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/networks.twig', [ 'supported_charsets' => \BinktermPHP\Binkp\Config\BinkpConfig::getSupportedCharsets(), ]); }); // Webdoors config page SimpleRouter::get('/webdoors', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/webdoors_config.twig'); }); // DOSDoors config page SimpleRouter::get('/dosdoors', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/dosdoors_config.twig'); }); // Native Doors config page SimpleRouter::get('/native-doors', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/nativedoors_config.twig'); }); // JS-DOS Doors config page SimpleRouter::get('/jsdosdoors', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/jsdosdoors_config.twig'); }); // File area rules page SimpleRouter::get('/filearea-rules', function() { $user = RouteHelper::requireAdmin(); $fileAreaManager = new \BinktermPHP\FileAreaManager(); $fileAreas = $fileAreaManager->getFileAreas('all', (int)($user['user_id'] ?? $user['id'] ?? 0), true); $fileAreaOptions = []; foreach ($fileAreas as $area) { $tag = strtoupper(trim((string)($area['tag'] ?? ''))); $domain = trim((string)($area['domain'] ?? '')); if ($tag === '') { continue; } $value = $domain !== '' ? $tag . '@' . $domain : $tag; $fileAreaOptions[$value] = [ 'value' => $value, 'label' => $value, ]; } ksort($fileAreaOptions, SORT_NATURAL | SORT_FLAG_CASE); $template = new Template(); $template->renderResponse('admin/filearea_rules.twig', [ 'filearea_rule_options' => array_values($fileAreaOptions), ]); }); // File upload approval queue SimpleRouter::get('/file-approvals', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/file_approvals.twig', [ 'virus_scan_disabled' => \BinktermPHP\Config::env('VIRUS_SCAN_DISABLED', 'false') === 'true', ]); }); // Advertisements management page SimpleRouter::get('/ads', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/ads.twig', [ 'weather_configured' => file_exists(__DIR__ . '/../config/weather.json'), ]); }); SimpleRouter::get('/ad-campaigns', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/ad_campaigns.twig', [ 'weather_configured' => file_exists(__DIR__ . '/../config/weather.json'), ]); }); // BBS settings page SimpleRouter::get('/bbs-settings', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/bbs_settings.twig', [ 'timezone_list' => \DateTimeZone::listIdentifiers(), 'allowed_terminal_shells' => \BinktermPHP\TerminalShellRegistry::getShellDefinitions( \BinktermPHP\BbsConfig::getAllowedTerminalShells() ), ]); }); // AI settings page SimpleRouter::get('/ai-settings', function() { RouteHelper::requireAdmin(); $aiService = \BinktermPHP\AI\AiService::create(); $template = new Template(); $systemDefault = $aiService->getSystemDefault(); $template->renderResponse('admin/ai_settings.twig', [ 'ai_available' => !empty($aiService->getConfiguredProviders()), 'share_summary_default_prompt' => \BinktermPHP\AI\ShareSummaryGenerator::DEFAULT_SYSTEM_PROMPT, 'provider_status' => $aiService->getProviderStatusList(), 'system_default_provider' => $systemDefault ? $systemDefault['provider'] : null, ]); }); // Appearance & Content settings page SimpleRouter::get('/appearance', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/appearance.twig', [ 'available_themes' => \BinktermPHP\Config::getThemes(), 'dashboard_cards' => \BinktermPHP\DashboardCardRegistry::getAllCards(), ]); }); // MRC Chat settings page SimpleRouter::get('/mrc-settings', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/mrc_settings.twig'); }); // Custom template editor page SimpleRouter::get('/template-editor', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/template_editor.twig'); }); // Language overlay editor page SimpleRouter::get('/i18n-overrides', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/i18n_overrides.twig'); }); // Upgrade notes viewer SimpleRouter::get('/register', function() { RouteHelper::requireAdmin(); $docPath = __DIR__ . '/../REGISTER.md'; $raw = file_exists($docPath) ? file_get_contents($docPath) : null; $html = $raw !== null ? \BinktermPHP\MarkdownRenderer::toHtml($raw) : null; $template = new Template(); $template->renderResponse('admin/register_info.twig', [ 'content' => $html, ]); }); SimpleRouter::get('/upgrade-notes', function() { RouteHelper::requireAdmin(); $version = \BinktermPHP\Version::getVersion(); $docPath = __DIR__ . '/../docs/UPGRADING_' . $version . '.md'; if (!file_exists($docPath)) { http_response_code(404); $template = new Template(); $template->renderResponse('admin/upgrade_notes.twig', [ 'version' => $version, 'content' => null, ]); return; } $raw = \BinktermPHP\Web\DocsController::rewriteLinks(file_get_contents($docPath)); $html = \BinktermPHP\MarkdownRenderer::toHtml($raw); $template = new Template(); $template->renderResponse('admin/upgrade_notes.twig', [ 'version' => $version, 'content' => $html, ]); }); // Documentation browser SimpleRouter::get('/docs', function() { RouteHelper::requireAdmin(); $controller = new \BinktermPHP\Web\DocsController(); $controller->index(); }); SimpleRouter::get('/docs/view/{name}', function(string $name) { RouteHelper::requireAdmin(); $controller = new \BinktermPHP\Web\DocsController(); $controller->view($name); })->where(['name' => '[A-Za-z0-9_.\-]+']); SimpleRouter::get('/docs/asset/{path}', function(string $path) { RouteHelper::requireAdmin(); $controller = new \BinktermPHP\Web\DocsController(); $controller->asset($path); })->where(['path' => '[A-Za-z0-9_.\-\/]+']); SimpleRouter::get('/docs/txt/{path}', function(string $path) { RouteHelper::requireAdmin(); $controller = new \BinktermPHP\Web\DocsController(); $controller->viewTxt($path); })->where(['path' => '[A-Za-z0-9 _.%\-\/]+']); // Ad analytics page (license required) SimpleRouter::get('/ad-analytics', function() { RouteHelper::requireAdmin(); if (!\BinktermPHP\License::isValid()) { http_response_code(403); $template = new Template(); $template->renderResponse('errors/403.twig'); return; } $template = new Template(); $template->renderResponse('admin/ad_analytics.twig'); }); // SSE diagnostics page SimpleRouter::get('/sse-test', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/sse_test.twig'); }); // SSE diagnostics stream ? emits events at a configurable interval SimpleRouter::get('/sse-test/stream', function() { RouteHelper::requireAdmin(); $intervalMs = max(50, min(5000, (int)($_GET['interval'] ?? 500))); $durationSec = max(5, min(120, (int)($_GET['duration'] ?? 30))); $payloadSize = max(0, min(65536, (int)($_GET['payload'] ?? 0))); // Disable all output buffering while (ob_get_level()) { ob_end_clean(); } ob_implicit_flush(true); header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); // tell nginx not to buffer header('Connection: keep-alive'); set_time_limit(0); ignore_user_abort(true); $padding = $payloadSize > 0 ? str_repeat('x', $payloadSize) : ''; $seq = 0; $start = microtime(true); $intervalSec = $intervalMs / 1000.0; // Send an initial event so the client knows the stream is open $initTs = round(microtime(true) * 1000); echo "event: open\n"; echo "data: " . json_encode(['server_ts' => $initTs, 'interval_ms' => $intervalMs, 'duration_sec' => $durationSec, 'payload_size' => $payloadSize]) . "\n\n"; flush(); while (!connection_aborted() && (microtime(true) - $start) < $durationSec) { $serverTs = round(microtime(true) * 1000); $data = json_encode(['seq' => $seq, 'server_ts' => $serverTs, 'padding' => $padding]); echo "id: {$seq}\n"; echo "data: {$data}\n\n"; flush(); $seq++; // Sleep until the next scheduled event time, accounting for drift $nextEvent = $start + ($seq * $intervalSec); $sleepSec = $nextEvent - microtime(true); if ($sleepSec > 0) { usleep((int)($sleepSec * 1_000_000)); } } $finalTs = round(microtime(true) * 1000); echo "event: done\n"; echo "data: " . json_encode(['seq' => $seq, 'total' => $seq, 'server_ts' => $finalTs]) . "\n\n"; flush(); }); // SSE real-path inject ? inserts a single sse_test event into sse_events // so the real /api/stream polling path can be benchmarked end-to-end. SimpleRouter::post('/sse-test/inject', function() { $user = RouteHelper::requireAdmin(); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $seq = (int)($input['seq'] ?? 0); $serverTs = (int)round(microtime(true) * 1000); $db = \BinktermPHP\Database::getInstance()->getPdo(); $eventId = \BinktermPHP\Realtime\BinkStream::emit($db, 'sse_test', [ 'seq' => $seq, 'server_ts' => $serverTs, ], $userId); echo json_encode(['ok' => true, 'seq' => $seq, 'server_ts' => $serverTs, 'sse_id' => (int)$eventId]); }); // Buffering diagnostics page SimpleRouter::get('/buffer-test', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/buffer_test.twig'); }); // Buffering diagnostics stream ? sends one event per second for N seconds. // No SharedWorker, no sse_events ? raw EventSource to the browser. // If events arrive one-by-one the chain is not buffering; if they all arrive // at the end something upstream is holding the response body. SimpleRouter::get('/buffer-test/stream', function() { RouteHelper::requireAdmin(); if (ob_get_level()) { ob_end_clean(); } ob_implicit_flush(true); // Do NOT set ignore_user_abort ? we want the loop to exit when the // client closes the connection so Stop actually stops the stream. header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); header('Connection: keep-alive'); $count = max(3, min(20, (int)($_GET['count'] ?? 8))); // Padding comment: force upstream proxy buffers to flush immediately. // Some proxies hold the first chunk until they accumulate enough bytes // to decide on Transfer-Encoding; 2 KB of SSE comment fills those buffers. echo ':' . str_repeat(' ', 2048) . "\n\n"; flush(); for ($i = 0; $i < $count; $i++) { if (connection_aborted()) { break; } $ts = (int)round(microtime(true) * 1000); echo "event: tick\n"; echo "data: " . json_encode(['seq' => $i, 'total' => $count, 'server_ts' => $ts]) . "\n\n"; flush(); if ($i < $count - 1) { sleep(1); if (connection_aborted()) { break; } } } if (!connection_aborted()) { echo "event: done\ndata: {}\n\n"; flush(); } }); // Activity statistics page SimpleRouter::get('/activity-stats', function() { $user = RouteHelper::requireAdmin(); $userId = $user['user_id'] ?? $user['id'] ?? null; $timezone = 'UTC'; if ($userId) { $handler = new \BinktermPHP\MessageHandler(); $settings = $handler->getUserSettings((int)$userId); $timezone = $settings['timezone'] ?? 'UTC'; } $template = new Template(); $template->renderResponse('admin/activity_stats.twig', ['user_timezone' => $timezone]); }); SimpleRouter::get('/ai-usage', function() { $user = RouteHelper::requireAdmin(); $userId = $user['user_id'] ?? $user['id'] ?? null; $timezone = 'UTC'; if ($userId) { $handler = new \BinktermPHP\MessageHandler(); $settings = $handler->getUserSettings((int)$userId); $timezone = $settings['timezone'] ?? 'UTC'; } $period = (string)($_GET['period'] ?? '7d'); $report = (new \BinktermPHP\AI\AiUsageReport())->getReport($period, $timezone); $template = new Template(); $template->renderResponse('admin/ai_usage.twig', [ 'report' => $report, 'user_timezone' => $timezone, ]); }); SimpleRouter::get('/sharing', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/sharing.twig'); }); SimpleRouter::get('/referrals', function() { RouteHelper::requireAdmin(); if (!\BinktermPHP\License::isValid()) { http_response_code(403); $template = new Template(); $template->renderResponse('errors/403.twig'); return; } $template = new Template(); $template->renderResponse('admin/referrals.twig'); }); SimpleRouter::get('/economy', function() { RouteHelper::requireAdmin(); if (!\BinktermPHP\License::isValid()) { http_response_code(403); $template = new Template(); $template->renderResponse('errors/403.twig'); return; } $template = new Template(); $template->renderResponse('admin/economy.twig'); }); // API routes for admin SimpleRouter::group(['prefix' => '/api'], function() { // Get all users with pagination and search SimpleRouter::get('/users', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); $page = intval($_GET['page'] ?? 1); $limit = intval($_GET['limit'] ?? 25); $search = $_GET['search'] ?? ''; header('Content-Type: application/json'); $result = $adminController->getAllUsers($page, $limit, $search); $result = apiLocalizeErrorPayload($result, $user); echo json_encode($result); }); SimpleRouter::get('/admin-users', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare("SELECT real_name FROM users WHERE is_admin = TRUE AND real_name IS NOT NULL ORDER BY real_name"); $stmt->execute(); $admins = $stmt->fetchAll(\PDO::FETCH_COLUMN); echo json_encode(['admins' => $admins]); }); // Get specific user SimpleRouter::get('/users/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $userData = $adminController->getUser($id); if ($userData) { $stats = $adminController->getUserStats($id); $userData['stats'] = $stats; echo json_encode(['user' => $userData]); } else { http_response_code(404); apiError('errors.admin.users.not_found', apiLocalizedText('errors.admin.users.not_found', 'User not found')); } }); // Grant credits to a user SimpleRouter::post('/users/{id}/credits', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $targetUser = $adminController->getUser($id); if (!$targetUser) { http_response_code(404); apiError('errors.admin.users.not_found', apiLocalizedText('errors.admin.users.not_found', 'User not found')); return; } if (!\BinktermPHP\UserCredit::isEnabled()) { http_response_code(400); apiError( 'errors.admin.users.credits_disabled', apiLocalizedText('errors.admin.users.credits_disabled', 'The credits system is disabled', $user) ); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $amount = (int)($input['amount'] ?? 0); $note = trim((string)($input['note'] ?? '')); if ($amount <= 0) { http_response_code(400); apiError( 'errors.admin.users.invalid_credit_amount', apiLocalizedText('errors.admin.users.invalid_credit_amount', 'Credit amount must be a positive integer', $user) ); return; } if ($note === '') { http_response_code(400); apiError( 'errors.admin.users.credit_note_required', apiLocalizedText('errors.admin.users.credit_note_required', 'A note is required for manual credit grants', $user) ); return; } $adminUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); $description = 'Admin credit grant: ' . $note; $granted = \BinktermPHP\UserCredit::credit( (int)$id, $amount, $description, $adminUserId, \BinktermPHP\UserCredit::TYPE_ADMIN_ADJUSTMENT ); if (!$granted) { http_response_code(500); apiError( 'errors.admin.users.credit_grant_failed', apiLocalizedText('errors.admin.users.credit_grant_failed', 'Failed to grant credits', $user) ); return; } echo json_encode([ 'success' => true, 'balance' => \BinktermPHP\UserCredit::getBalance((int)$id), 'message_code' => 'ui.admin.users.credit_grant_success' ]); }); // Finger a user by username ? used by the admin terminal SimpleRouter::get('/finger', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $username = trim($_GET['username'] ?? ''); if ($username === '') { http_response_code(400); echo json_encode(['error' => 'username parameter required']); return; } $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare(" SELECT id, username, real_name, location, fidonet_address, is_active, is_admin, created_at, last_login FROM users WHERE LOWER(username) = LOWER(?) LIMIT 1 "); $stmt->execute([$username]); $target = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$target) { http_response_code(404); echo json_encode(['error' => 'User not found']); return; } // Fetch any active sessions for this user $sessions = $auth->getOnlineSessions(15); $userSessions = array_values(array_filter($sessions, function($s) use ($target) { return (int)$s['user_id'] === (int)$target['id']; })); $online = array_map(function($s) { return [ 'service' => $s['service'] ?? 'web', 'activity' => $s['activity'] ?? '', 'last_activity' => $s['last_activity'] ?? null, 'ip_address' => $s['ip_address'] ?? null, ]; }, $userSessions); echo json_encode([ 'username' => $target['username'], 'real_name' => $target['real_name'], 'location' => $target['location'], 'fidonet_address'=> $target['fidonet_address'], 'is_active' => (bool)$target['is_active'], 'is_admin' => (bool)$target['is_admin'], 'created_at' => $target['created_at'], 'last_login' => $target['last_login'], 'online' => $online, ]); }); // Create new user SimpleRouter::post('/users', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $userId = $adminController->createUser($input); echo json_encode([ 'success' => true, 'user_id' => $userId, 'message_code' => 'ui.admin.users.created_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.users.create_failed', apiLocalizedText('errors.admin.users.create_failed', 'Failed to create user')); } }); // Update user SimpleRouter::put('/users/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $result = $adminController->updateUser($id, $input); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.users.updated_success' ]); } else { echo json_encode(['success' => false]); } } catch (Exception $e) { http_response_code(400); apiError('errors.admin.users.update_failed', apiLocalizedText('errors.admin.users.update_failed', 'Failed to update user')); } }); // Delete user SimpleRouter::delete('/users/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $result = $adminController->deleteUser($id); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.users.deleted_success' ]); } else { echo json_encode(['success' => false]); } } catch (Exception $e) { http_response_code(400); apiError('errors.admin.users.delete_failed', apiLocalizedText('errors.admin.users.delete_failed', 'Failed to delete user')); } }); // Referral analytics (premium) SimpleRouter::get('/referrals', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); if (!\BinktermPHP\License::isValid()) { http_response_code(403); header('Content-Type: application/json'); apiError('errors.referrals.not_licensed', apiLocalizedText('errors.referrals.not_licensed', 'Referral analytics require a registered license', $user)); return; } header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); // Top referrers: users who have referred others $referrersStmt = $db->query(" SELECT u.id, u.username, u.real_name, u.referral_code, COUNT(r.id) AS referral_count, COALESCE(SUM(ct.amount), 0) AS bonus_earned FROM users u LEFT JOIN users r ON r.referred_by = u.id LEFT JOIN user_transactions ct ON ct.user_id = u.id AND ct.transaction_type = 'referral_bonus' GROUP BY u.id, u.username, u.real_name, u.referral_code HAVING COUNT(r.id) > 0 ORDER BY referral_count DESC, bonus_earned DESC LIMIT 100 "); $referrers = $referrersStmt->fetchAll(\PDO::FETCH_ASSOC); // Recent referral signups $recentStmt = $db->query(" SELECT u.id, u.username, u.real_name, u.created_at, ref.username AS referred_by_username, ref.real_name AS referred_by_real_name FROM users u JOIN users ref ON ref.id = u.referred_by ORDER BY u.created_at DESC LIMIT 50 "); $recent = $recentStmt->fetchAll(\PDO::FETCH_ASSOC); // Summary totals $totalsStmt = $db->query(" SELECT COUNT(*) AS total_referred_users, COUNT(DISTINCT referred_by) AS total_referrers FROM users WHERE referred_by IS NOT NULL "); $totals = $totalsStmt->fetch(\PDO::FETCH_ASSOC); echo json_encode([ 'referrers' => $referrers, 'recent' => $recent, 'totals' => $totals, ]); }); // Get system stats SimpleRouter::get('/stats', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $stats = $adminController->getSystemStats(); echo json_encode($stats); }); SimpleRouter::get('/economy', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); if (!\BinktermPHP\License::isValid()) { http_response_code(403); header('Content-Type: application/json'); apiError('errors.economy.not_licensed', apiLocalizedText('errors.economy.not_licensed', 'Economy viewer requires a registered license', $user)); return; } $period = $_GET['period'] ?? '30d'; header('Content-Type: application/json'); echo json_encode($adminController->getEconomyStats($period)); }); // Chat rooms SimpleRouter::get('/chat-rooms', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare(" SELECT id, name, description, is_active, created_at, matterbridge_enabled, matterbridge_gateway, matterbridge_options::text AS matterbridge_options FROM chat_rooms ORDER BY name "); $stmt->execute(); $rooms = $stmt->fetchAll(); foreach ($rooms as &$room) { $room['matterbridge_enabled'] = !empty($room['matterbridge_enabled']); $decoded = json_decode((string)($room['matterbridge_options'] ?? '{}'), true); $room['matterbridge_options'] = is_array($decoded) ? $decoded : []; } unset($room); echo json_encode(['rooms' => $rooms]); }); // Polls SimpleRouter::get('/polls', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $pollStmt = $db->prepare(" SELECT p.id, p.question, p.is_active, p.created_at, p.updated_at, u.username as created_by_username, COUNT(v.id) as vote_count FROM polls p LEFT JOIN users u ON u.id = p.created_by LEFT JOIN poll_votes v ON v.poll_id = p.id GROUP BY p.id, u.username ORDER BY p.created_at DESC "); $pollStmt->execute(); $polls = $pollStmt->fetchAll(); $optionsStmt = $db->prepare(" SELECT id, poll_id, option_text, sort_order FROM poll_options ORDER BY sort_order, id "); $optionsStmt->execute(); $options = $optionsStmt->fetchAll(); $optionsByPoll = []; foreach ($options as $opt) { $optionsByPoll[$opt['poll_id']][] = [ 'id' => (int)$opt['id'], 'option_text' => $opt['option_text'], 'sort_order' => (int)$opt['sort_order'] ]; } $payload = []; foreach ($polls as $poll) { $payload[] = [ 'id' => (int)$poll['id'], 'question' => $poll['question'], 'is_active' => (bool)$poll['is_active'], 'created_at' => $poll['created_at'], 'updated_at' => $poll['updated_at'], 'created_by_username' => $poll['created_by_username'] ?? 'Unknown', 'vote_count' => (int)$poll['vote_count'], 'options' => $optionsByPoll[$poll['id']] ?? [] ]; } echo json_encode(['polls' => $payload]); }); SimpleRouter::post('/polls', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $question = trim($input['question'] ?? ''); $options = $input['options'] ?? []; $isActive = !empty($input['is_active']); if ($question === '') { throw new Exception('Question is required'); } if (!is_array($options) || count($options) < 2) { throw new Exception('At least two options are required'); } $db = \BinktermPHP\Database::getInstance()->getPdo(); $db->beginTransaction(); $pollStmt = $db->prepare(" INSERT INTO polls (question, is_active, created_by) VALUES (?, ?, ?) RETURNING id "); $pollStmt->execute([$question, $isActive ? 1 : 0, $user['id'] ?? $user['user_id']]); $pollId = $pollStmt->fetchColumn(); $optStmt = $db->prepare(" INSERT INTO poll_options (poll_id, option_text, sort_order) VALUES (?, ?, ?) "); $order = 0; foreach ($options as $optionText) { $optionText = trim($optionText); if ($optionText === '') { continue; } $optStmt->execute([$pollId, $optionText, $order++]); } if ($order < 2) { throw new Exception('At least two valid options are required'); } $db->commit(); echo json_encode([ 'success' => true, 'id' => (int)$pollId, 'message_code' => 'ui.admin.polls.created_success' ]); } catch (Exception $e) { if (isset($db) && $db->inTransaction()) { $db->rollBack(); } http_response_code(400); $message = $e->getMessage(); if ($message === 'Question is required') { apiError('errors.admin.polls.question_required', apiLocalizedText('errors.admin.polls.question_required', 'Question is required')); } elseif ($message === 'At least two options are required' || $message === 'At least two valid options are required') { apiError('errors.admin.polls.options_required', apiLocalizedText('errors.admin.polls.options_required', 'At least two options are required')); } else { apiError('errors.admin.polls.create_failed', apiLocalizedText('errors.admin.polls.create_failed', 'Failed to create poll')); } } }); SimpleRouter::put('/polls/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $question = trim($input['question'] ?? ''); $options = $input['options'] ?? []; $isActive = !empty($input['is_active']); if ($question === '') { throw new Exception('Question is required'); } if (!is_array($options) || count($options) < 2) { throw new Exception('At least two options are required'); } $db = \BinktermPHP\Database::getInstance()->getPdo(); $db->beginTransaction(); $existingStmt = $db->prepare("SELECT id FROM polls WHERE id = ?"); $existingStmt->execute([$id]); if (!$existingStmt->fetch()) { throw new Exception('Poll not found'); } $updateStmt = $db->prepare(" UPDATE polls SET question = ?, is_active = ?, updated_at = NOW() WHERE id = ? "); $updateStmt->execute([$question, $isActive ? 1 : 0, $id]); $db->prepare("DELETE FROM poll_votes WHERE poll_id = ?")->execute([$id]); $db->prepare("DELETE FROM poll_options WHERE poll_id = ?")->execute([$id]); $optStmt = $db->prepare(" INSERT INTO poll_options (poll_id, option_text, sort_order) VALUES (?, ?, ?) "); $order = 0; foreach ($options as $optionText) { $optionText = trim($optionText); if ($optionText === '') { continue; } $optStmt->execute([$id, $optionText, $order++]); } if ($order < 2) { throw new Exception('At least two valid options are required'); } $db->commit(); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.polls.updated_success' ]); } catch (Exception $e) { if (isset($db) && $db->inTransaction()) { $db->rollBack(); } http_response_code(400); $message = $e->getMessage(); if ($message === 'Question is required') { apiError('errors.admin.polls.question_required', apiLocalizedText('errors.admin.polls.question_required', 'Question is required')); } elseif ($message === 'At least two options are required' || $message === 'At least two valid options are required') { apiError('errors.admin.polls.options_required', apiLocalizedText('errors.admin.polls.options_required', 'At least two options are required')); } elseif ($message === 'Poll not found') { apiError('errors.admin.polls.not_found', apiLocalizedText('errors.admin.polls.not_found', 'Poll not found')); } else { apiError('errors.admin.polls.update_failed', apiLocalizedText('errors.admin.polls.update_failed', 'Failed to update poll')); } } }); SimpleRouter::delete('/polls/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare("DELETE FROM polls WHERE id = ?"); $stmt->execute([$id]); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.polls.deleted_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.polls.delete_failed', apiLocalizedText('errors.admin.polls.delete_failed', 'Failed to delete poll')); } }); // Bulletins management SimpleRouter::get('/bulletins', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); echo json_encode([ 'success' => true, 'bulletins' => (new \BinktermPHP\BulletinManager())->getAllBulletins(), ]); }); SimpleRouter::post('/bulletins', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $payload = json_decode(file_get_contents('php://input'), true); if (!is_array($payload)) { apiError('errors.admin.bulletins.invalid_payload', apiLocalizedText('errors.admin.bulletins.invalid_payload', 'Invalid bulletin data.'), 400); return; } try { $id = (new \BinktermPHP\BulletinManager())->create($payload, (int)($user['user_id'] ?? $user['id'] ?? 0)); echo json_encode([ 'success' => true, 'id' => $id, 'message_code' => 'ui.admin.bulletins.saved_success', ]); } catch (\Throwable $e) { apiError('errors.admin.bulletins.save_failed', $e->getMessage(), 400); } }); SimpleRouter::put('/bulletins/{id}', function($id) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $payload = json_decode(file_get_contents('php://input'), true); if (!is_array($payload)) { apiError('errors.admin.bulletins.invalid_payload', apiLocalizedText('errors.admin.bulletins.invalid_payload', 'Invalid bulletin data.'), 400); return; } try { (new \BinktermPHP\BulletinManager())->update((int)$id, $payload); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.bulletins.saved_success', ]); } catch (\Throwable $e) { apiError('errors.admin.bulletins.save_failed', $e->getMessage(), 400); } }); SimpleRouter::delete('/bulletins/{id}', function($id) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); (new \BinktermPHP\BulletinManager())->delete((int)$id); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.bulletins.deleted_success', ]); }); SimpleRouter::post('/bulletins/reorder', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $payload = json_decode(file_get_contents('php://input'), true); $ids = is_array($payload) ? ($payload['ids'] ?? []) : []; if (!is_array($ids)) { apiError('errors.admin.bulletins.invalid_payload', apiLocalizedText('errors.admin.bulletins.invalid_payload', 'Invalid bulletin data.'), 400); return; } (new \BinktermPHP\BulletinManager())->reorder($ids); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.bulletins.reordered_success', ]); }); // Shoutbox moderation SimpleRouter::get('/shoutbox', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $limit = intval($_GET['limit'] ?? 100); $messages = $adminController->getShoutboxMessages($limit); echo json_encode(['messages' => $messages]); }); SimpleRouter::post('/shoutbox/{id}/hide', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $result = $adminController->setShoutboxHidden((int)$id, true); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.shoutbox.hidden_success' ]); } else { echo json_encode(['success' => false]); } }); SimpleRouter::post('/shoutbox/{id}/unhide', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $result = $adminController->setShoutboxHidden((int)$id, false); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.shoutbox.unhidden_success' ]); } else { echo json_encode(['success' => false]); } }); SimpleRouter::delete('/shoutbox/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $result = $adminController->deleteShoutboxMessage((int)$id); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.shoutbox.deleted_success' ]); } else { echo json_encode(['success' => false]); } }); // BBS settings SimpleRouter::get('/bbs-settings', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getBbsConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.bbs_settings.load_failed', apiLocalizedText('errors.admin.bbs_settings.load_failed', 'Failed to load BBS settings')); } }); SimpleRouter::post('/bbs-settings', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); $payload = json_decode(file_get_contents('php://input'), true); $config = $payload['config'] ?? []; if (!is_array($config)) { throw new Exception('Invalid configuration payload'); } if (array_key_exists('credits', $config)) { if (!is_array($config['credits'])) { throw new Exception('Invalid credits configuration'); } $credits = $config['credits']; $symbol = trim((string)($credits['symbol'] ?? '')); if (mb_strlen($symbol) > 5) { throw new Exception('Currency symbol must be 0-5 characters'); } if (!is_numeric($credits['daily_amount'] ?? null) || (int)$credits['daily_amount'] < 0) { throw new Exception('Daily login amount must be a non-negative integer'); } if (!is_numeric($credits['daily_login_delay_minutes'] ?? null) || (int)$credits['daily_login_delay_minutes'] < 0) { throw new Exception('Daily login delay must be a non-negative integer'); } if (!is_numeric($credits['approval_bonus'] ?? null) || (int)$credits['approval_bonus'] < 0) { throw new Exception('Approval bonus must be a non-negative integer'); } if (!is_numeric($credits['netmail_cost'] ?? null) || (int)$credits['netmail_cost'] < 0) { throw new Exception('Netmail cost must be a non-negative integer'); } if (!is_numeric($credits['echomail_reward'] ?? null) || (int)$credits['echomail_reward'] < 0) { throw new Exception('Echomail reward must be a non-negative integer'); } if (!is_numeric($credits['crashmail_cost'] ?? null) || (int)$credits['crashmail_cost'] < 0) { throw new Exception('Crashmail cost must be a non-negative integer'); } if (!is_numeric($credits['poll_creation_cost'] ?? null) || (int)$credits['poll_creation_cost'] < 0) { throw new Exception('Poll creation cost must be a non-negative integer'); } if (!is_numeric($credits['file_upload_cost'] ?? 0) || (int)($credits['file_upload_cost'] ?? 0) < 0) { throw new Exception('File upload cost must be a non-negative integer'); } if (!is_numeric($credits['file_upload_reward'] ?? 0) || (int)($credits['file_upload_reward'] ?? 0) < 0) { throw new Exception('File upload reward must be a non-negative integer'); } if (!is_numeric($credits['file_download_cost'] ?? 0) || (int)($credits['file_download_cost'] ?? 0) < 0) { throw new Exception('File download cost must be a non-negative integer'); } if (!is_numeric($credits['file_download_reward'] ?? 0) || (int)($credits['file_download_reward'] ?? 0) < 0) { throw new Exception('File download reward must be a non-negative integer'); } if (!is_numeric($credits['ai_credits_per_milli_usd'] ?? 0) || (int)($credits['ai_credits_per_milli_usd'] ?? 0) < 0) { throw new Exception('AI credits per $0.001 must be a non-negative integer'); } if (!is_numeric($credits['return_14days'] ?? null) || (int)$credits['return_14days'] < 0) { throw new Exception('14-day return bonus must be a non-negative integer'); } if (!is_numeric($credits['transfer_fee_percent'] ?? null) || (float)$credits['transfer_fee_percent'] < 0 || (float)$credits['transfer_fee_percent'] > 1) { throw new Exception('Transfer fee must be between 0 and 1 (0% to 100%)'); } if (isset($credits['referral_bonus']) && (!is_numeric($credits['referral_bonus']) || (int)$credits['referral_bonus'] < 0)) { throw new Exception('Referral bonus must be a non-negative integer'); } $config['credits'] = [ 'enabled' => !empty($credits['enabled']), 'symbol' => $symbol, 'daily_amount' => (int)$credits['daily_amount'], 'daily_login_delay_minutes' => (int)$credits['daily_login_delay_minutes'], 'approval_bonus' => (int)$credits['approval_bonus'], 'netmail_cost' => (int)$credits['netmail_cost'], 'echomail_reward' => (int)$credits['echomail_reward'], 'crashmail_cost' => (int)$credits['crashmail_cost'], 'poll_creation_cost' => (int)$credits['poll_creation_cost'], 'file_upload_cost' => (int)($credits['file_upload_cost'] ?? 0), 'file_upload_reward' => (int)($credits['file_upload_reward'] ?? 0), 'file_download_cost' => (int)($credits['file_download_cost'] ?? 0), 'file_download_reward' => (int)($credits['file_download_reward'] ?? 0), 'ai_credits_per_milli_usd' => (int)($credits['ai_credits_per_milli_usd'] ?? 0), 'return_14days' => (int)$credits['return_14days'], 'transfer_fee_percent' => (float)$credits['transfer_fee_percent'], 'referral_enabled' => !empty($credits['referral_enabled']), 'referral_bonus' => isset($credits['referral_bonus']) ? (int)$credits['referral_bonus'] : 25 ]; } // Validate echomail_moderation_threshold if provided if (array_key_exists('echomail_moderation_threshold', $config)) { $threshold = (int)$config['echomail_moderation_threshold']; if ($threshold < 0) { throw new Exception('Echomail moderation threshold must be a non-negative integer'); } $config['echomail_moderation_threshold'] = $threshold; } // Validate max_cross_post_areas if provided if (array_key_exists('max_cross_post_areas', $config)) { $maxCrossPost = (int)$config['max_cross_post_areas']; if ($maxCrossPost < 2 || $maxCrossPost > 20) { throw new Exception('Max cross-post areas must be between 2 and 20'); } $config['max_cross_post_areas'] = $maxCrossPost; } if (array_key_exists('dashboard_ad_rotate_interval_seconds', $config)) { $dashboardAdRotateInterval = (int)$config['dashboard_ad_rotate_interval_seconds']; if ($dashboardAdRotateInterval < 5 || $dashboardAdRotateInterval > 300) { throw new Exception('Dashboard ad rotation interval must be between 5 and 300 seconds'); } $config['dashboard_ad_rotate_interval_seconds'] = $dashboardAdRotateInterval; } if (array_key_exists('bulletin_display_mode', $config)) { $bulletinDisplayMode = strtolower(trim((string)$config['bulletin_display_mode'])); if (!in_array($bulletinDisplayMode, ['once', 'always'], true)) { throw new Exception('Invalid bulletin display mode'); } $config['bulletin_display_mode'] = $bulletinDisplayMode; } if (isset($config['qwk']['bbs_id'])) { $bbsId = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$config['qwk']['bbs_id'])); $bbsId = substr($bbsId, 0, 8); $config['qwk']['bbs_id'] = $bbsId; } if (array_key_exists('outgoing_charset', $config)) { $allowedCharsets = array_column(\BinktermPHP\Binkp\Config\BinkpConfig::getSupportedCharsets(), 'value'); $charset = strtoupper(trim((string)$config['outgoing_charset'])); if (!in_array($charset, $allowedCharsets, true)) { throw new Exception('Invalid outgoing charset'); } $config['outgoing_charset'] = $charset; } if (array_key_exists('ai_assistant', $config)) { $config['ai_assistant'] = [ 'enabled' => !empty($config['ai_assistant']['enabled']), 'share_summary_enabled' => !empty($config['ai_assistant']['share_summary_enabled']), ]; } if (array_key_exists('terminal_idle', $config)) { $ti = $config['terminal_idle']; if (!is_array($ti)) { throw new Exception('Invalid terminal_idle configuration'); } $warnMinutes = (int)($ti['warn_minutes'] ?? 5); $disconnectMinutes = (int)($ti['disconnect_minutes'] ?? 7); if ($warnMinutes < 1) { throw new Exception('Idle warning timeout must be a positive integer'); } if ($disconnectMinutes < 1) { throw new Exception('Idle disconnect timeout must be a positive integer'); } if ($disconnectMinutes <= $warnMinutes) { throw new Exception('Idle disconnect timeout must be greater than the warning timeout'); } $config['terminal_idle'] = [ 'warn_minutes' => $warnMinutes, 'disconnect_minutes' => $disconnectMinutes, ]; } if (array_key_exists('packet_bbs', $config)) { $pbbs = $config['packet_bbs']; if (!is_array($pbbs)) { throw new Exception('Invalid packet_bbs configuration'); } $sessionTimeout = (int)($pbbs['session_timeout_minutes'] ?? 15); if ($sessionTimeout < 1) { throw new Exception('Session timeout must be a positive integer'); } $config['packet_bbs'] = [ 'session_timeout_minutes' => $sessionTimeout, 'allow_guest_who' => !empty($pbbs['allow_guest_who']), ]; } if (array_key_exists('terminal_server', $config)) { $ts = $config['terminal_server']; if (!is_array($ts)) { throw new Exception('Invalid terminal_server configuration'); } $defaultShell = strtolower(trim((string)($ts['default_shell'] ?? 'tui'))); if (!in_array($defaultShell, \BinktermPHP\BbsConfig::getAllowedTerminalShells(), true)) { throw new Exception('Invalid terminal default shell'); } $config['terminal_server'] = [ 'default_shell' => $defaultShell, 'force_shell' => !empty($ts['force_shell']), ]; } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->setBbsConfig($config); if ($userId) { AdminActionLogger::logAction($userId, 'bbs_settings_updated', [ 'credits' => $config['credits'] ?? null ]); } echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.bbs_settings.saved_success' ]); } catch (Exception $e) { http_response_code(500); $message = $e->getMessage(); if ($message === 'Invalid configuration payload') { apiError('errors.admin.bbs_settings.invalid_payload', apiLocalizedText('errors.admin.bbs_settings.invalid_payload', 'Invalid configuration payload')); } elseif ($message === 'Invalid credits configuration') { apiError('errors.admin.bbs_settings.invalid_credits_config', apiLocalizedText('errors.admin.bbs_settings.invalid_credits_config', 'Invalid credits configuration')); } else { apiError('errors.admin.bbs_settings.save_failed', apiLocalizedText('errors.admin.bbs_settings.save_failed', 'Failed to save BBS settings')); } } }); // --------------------------------------------------------------- // AI Settings API // --------------------------------------------------------------- SimpleRouter::post('/ai-settings', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); if (!is_array($input)) { throw new Exception('Invalid payload'); } $aiAssistant = $input['ai_assistant'] ?? []; if (!is_array($aiAssistant)) { throw new Exception('Invalid payload'); } $prompt = isset($aiAssistant['share_summary_prompt']) ? trim((string)$aiAssistant['share_summary_prompt']) : null; $config = [ 'ai_assistant' => [ 'enabled' => !empty($aiAssistant['enabled']), 'share_summary_enabled' => !empty($aiAssistant['share_summary_enabled']), 'share_summary_prompt' => $prompt !== null ? $prompt : '', ], ]; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->setBbsConfig($config); echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.bbs_settings.ai.saved_success', ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ai_settings.save_failed', apiLocalizedText('errors.admin.ai_settings.save_failed', 'Failed to save AI settings')); } }); // --------------------------------------------------------------- // Appearance API // --------------------------------------------------------------- SimpleRouter::get('/appearance', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $data = $client->getAppearanceConfig(); echo json_encode(['success' => true, 'data' => $data]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.load_failed', apiLocalizedText('errors.admin.appearance.load_failed', 'Failed to load appearance settings')); } }); SimpleRouter::post('/appearance/branding', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $branding = $payload['branding'] ?? []; $accentColor = trim((string)($branding['accent_color'] ?? '')); if ($accentColor !== '' && !preg_match('/^#[0-9A-Fa-f]{6}$/', $accentColor)) { throw new Exception('Invalid accent color format'); } $logoUrl = trim((string)($branding['logo_url'] ?? '')); $footerText = trim((string)($branding['footer_text'] ?? '')); if (mb_strlen($footerText) > 500) { throw new Exception('Footer text must be 500 characters or less'); } $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['branding']['accent_color'] = $accentColor; $config['branding']['default_theme'] = trim((string)($branding['default_theme'] ?? '')); $config['branding']['lock_theme'] = !empty($branding['lock_theme']); $config['branding']['logo_url'] = $logoUrl; $config['branding']['footer_text'] = $footerText; $config['branding']['hide_powered_by'] = !empty($branding['hide_powered_by']); $config['branding']['show_registration_badge'] = isset($branding['show_registration_badge']) ? (bool)$branding['show_registration_badge'] : true; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved' ]); } catch (Exception $e) { http_response_code(500); $message = $e->getMessage(); if ($message === 'Invalid accent color format') { apiError('errors.admin.appearance.branding.invalid_accent_color', apiLocalizedText('errors.admin.appearance.branding.invalid_accent_color', 'Invalid accent color format')); } elseif ($message === 'Footer text must be 500 characters or less') { apiError('errors.admin.appearance.branding.footer_too_long', apiLocalizedText('errors.admin.appearance.branding.footer_too_long', 'Footer text must be 500 characters or less')); } else { apiError('errors.admin.appearance.branding.save_failed', apiLocalizedText('errors.admin.appearance.branding.save_failed', 'Failed to save branding settings')); } } }); SimpleRouter::post('/appearance/content', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $client = new \BinktermPHP\Admin\AdminDaemonClient(); // Save system news markdown if (array_key_exists('system_news', $payload)) { $client->setSystemNews((string)$payload['system_news']); } // Save house rules markdown if (array_key_exists('house_rules', $payload)) { $client->setHouseRules((string)$payload['house_rules']); } if (array_key_exists('register_splash', $payload) && \BinktermPHP\License::isValid()) { $text = (string)$payload['register_splash']; if (mb_strlen($text) > 10000) { throw new Exception('Splash content must be 10,000 characters or less'); } $client->setRegisterSplash($text); } // Save announcement config if (array_key_exists('announcement', $payload)) { $ann = $payload['announcement']; $allowedTypes = ['info', 'warning', 'danger', 'success', 'primary']; $annType = in_array($ann['type'] ?? '', $allowedTypes, true) ? $ann['type'] : 'info'; $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['content']['announcement'] = [ 'enabled' => !empty($ann['enabled']), 'text' => substr(strip_tags((string)($ann['text'] ?? '')), 0, 1000), 'type' => $annType, 'expires_at' => ($ann['expires_at'] ?? '') !== '' ? (string)$ann['expires_at'] : null, 'dismissible' => !empty($ann['dismissible']), ]; $client->setAppearanceConfig($config); } \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved' ]); } catch (Exception $e) { http_response_code(500); if ($e->getMessage() === 'Splash content must be 10,000 characters or less') { apiError('errors.admin.appearance.splash.save_failed', apiLocalizedText('errors.admin.appearance.splash.save_failed', 'Failed to save splash settings')); } else { apiError('errors.admin.appearance.content.save_failed', apiLocalizedText('errors.admin.appearance.content.save_failed', 'Failed to save content settings')); } } }); SimpleRouter::post('/appearance/splash', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); if (!\BinktermPHP\License::isValid()) { http_response_code(403); apiError('errors.admin.appearance.splash.license_required', apiLocalizedText('errors.admin.appearance.splash.license_required', 'A valid license is required to configure splash pages')); return; } try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $client = new \BinktermPHP\Admin\AdminDaemonClient(); if (array_key_exists('register_splash', $payload)) { $text = (string)$payload['register_splash']; if (mb_strlen($text) > 10000) { throw new Exception('Splash content must be 10,000 characters or less'); } $client->setRegisterSplash($text); } echo json_encode(['success' => true, 'message_code' => 'ui.common.saved']); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.splash.save_failed', apiLocalizedText('errors.admin.appearance.splash.save_failed', 'Failed to save splash settings')); } }); SimpleRouter::post('/appearance/login', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $login = $payload['login'] ?? []; $ansiContent = (string)($payload['login_ansi'] ?? ''); $loginSplash = array_key_exists('login_splash', $payload) ? (string)$payload['login_splash'] : null; $displayMode = (string)($login['display_mode'] ?? 'standard'); if (!in_array($displayMode, ['standard', 'ansi_prompt'], true)) { $displayMode = 'standard'; } $ansiSize = (string)($login['ansi_size'] ?? '80x25'); if (!in_array($ansiSize, ['80x25', '132x24', '132x43', '132x50', 'full'], true)) { $ansiSize = '80x25'; } if (mb_strlen($ansiContent) > 200000) { throw new Exception('Login ANSI content must be 200,000 characters or less'); } if ($loginSplash !== null && mb_strlen($loginSplash) > 10000) { throw new Exception('Splash content must be 10,000 characters or less'); } $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['login'] = [ 'display_mode' => $displayMode, 'ansi_size' => $ansiSize, ]; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); $client->setLoginAnsi($ansiContent); if ($loginSplash !== null && \BinktermPHP\License::isValid()) { $client->setLoginSplash($loginSplash); } \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved' ]); } catch (Exception $e) { http_response_code(500); if ($e->getMessage() === 'Login ANSI content must be 200,000 characters or less') { apiError('errors.admin.appearance.login.ansi_too_large', apiLocalizedText('errors.admin.appearance.login.ansi_too_large', 'Login ANSI content must be 200,000 characters or less')); } elseif ($e->getMessage() === 'Splash content must be 10,000 characters or less') { apiError('errors.admin.appearance.splash.save_failed', apiLocalizedText('errors.admin.appearance.splash.save_failed', 'Failed to save splash settings')); } else { apiError('errors.admin.appearance.login.save_failed', apiLocalizedText('errors.admin.appearance.login.save_failed', 'Failed to save login appearance settings')); } } }); SimpleRouter::post('/appearance/navigation', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $links = $payload['custom_links'] ?? []; if (!is_array($links)) { throw new Exception('Invalid custom_links payload'); } $sanitized = []; foreach ($links as $link) { $label = trim((string)($link['label'] ?? '')); $url = trim((string)($link['url'] ?? '')); if ($label === '' || $url === '') { continue; } if (!preg_match('#^https?://#i', $url) && strpos($url, '/') !== 0) { continue; // Only relative paths starting with / or absolute https? URLs } $sanitized[] = [ 'label' => substr($label, 0, 100), 'url' => substr($url, 0, 500), 'new_tab' => !empty($link['new_tab']), ]; } $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['navigation']['custom_links'] = $sanitized; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.navigation.save_failed', apiLocalizedText('errors.admin.appearance.navigation.save_failed', 'Failed to save navigation settings')); } }); SimpleRouter::post('/appearance/seo', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $seo = $payload['seo'] ?? []; $description = substr(trim((string)($seo['description'] ?? '')), 0, 300); $ogImage = trim((string)($seo['og_image_url'] ?? '')); $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['seo']['description'] = $description; $config['seo']['og_image_url'] = $ogImage; $config['seo']['about_page_enabled'] = !empty($seo['about_page_enabled']); $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.seo.save_failed', apiLocalizedText('errors.admin.appearance.seo.save_failed', 'Failed to save SEO settings')); } }); SimpleRouter::post('/appearance/shell', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $shell = $payload['shell'] ?? []; $activeShell = (string)($shell['active'] ?? 'web'); if (!in_array($activeShell, ['web', 'bbs-menu'], true)) { $activeShell = 'web'; } $bbsMenu = $shell['bbs_menu'] ?? []; $variant = (string)($bbsMenu['variant'] ?? 'cards'); if (!in_array($variant, ['cards', 'ansi', 'text'], true)) { $variant = 'cards'; } $ansiSize = (string)($bbsMenu['ansi_size'] ?? '80x25'); if (!in_array($ansiSize, ['80x25', '132x24', '132x43', '132x50', 'full'], true)) { $ansiSize = '80x25'; } $menuItems = $bbsMenu['menu_items'] ?? []; $sanitizedItems = []; if (is_array($menuItems)) { foreach ($menuItems as $item) { $key = strtoupper(trim((string)($item['key'] ?? ''))); $label = trim((string)($item['label'] ?? '')); $url = trim((string)($item['url'] ?? '')); $icon = trim((string)($item['icon'] ?? 'circle')); if (strlen($key) !== 1 || $label === '' || $url === '') { continue; } $sanitizedItems[] = [ 'key' => $key, 'label' => substr($label, 0, 100), 'icon' => preg_replace('/[^a-z0-9-]/', '', strtolower($icon)), 'url' => substr($url, 0, 500), ]; } } $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['shell']['active'] = $activeShell; $config['shell']['lock_shell'] = !empty($shell['lock_shell']); $config['shell']['bbs_menu']['variant'] = $variant; $config['shell']['bbs_menu']['ansi_file'] = basename(trim((string)($bbsMenu['ansi_file'] ?? ''))); $config['shell']['bbs_menu']['ansi_size'] = $ansiSize; if (!empty($sanitizedItems)) { $config['shell']['bbs_menu']['menu_items'] = $sanitizedItems; } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.appearance.shell_saved_reload' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.shell.save_failed', apiLocalizedText('errors.admin.appearance.shell.save_failed', 'Failed to save shell settings')); } }); SimpleRouter::post('/appearance/dashboard', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; // Support explicit reset to built-in defaults if (!empty($payload['reset'])) { $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['dashboard']['default_layout'] = null; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode(['success' => true, 'message_code' => 'ui.common.saved_short']); return; } $layout = $payload['layout'] ?? null; if (!is_array($layout) || !isset($layout['main'], $layout['sidebar'], $layout['hidden'])) { http_response_code(400); apiError('errors.admin.appearance.dashboard.save_failed', apiLocalizedText('errors.admin.appearance.dashboard.save_failed', 'Failed to save dashboard layout')); return; } // Validate card IDs against the full card catalogue $allCards = \BinktermPHP\DashboardCardRegistry::getAllCards(); $allIds = array_keys($allCards); $main = array_values(array_filter((array)$layout['main'], fn($id) => is_string($id) && in_array($id, $allIds, true))); $sidebar = array_values(array_filter((array)$layout['sidebar'], fn($id) => is_string($id) && in_array($id, $allIds, true))); $hidden = array_values(array_filter((array)$layout['hidden'], fn($id) => is_string($id) && in_array($id, $allIds, true))); // Required cards cannot be hidden foreach ($allCards as $id => $card) { if (!empty($card['required'])) { $hidden = array_values(array_filter($hidden, fn($h) => $h !== $id)); } } // Every card must appear in exactly one of main/sidebar $placed = array_merge($main, $sidebar); foreach ($allIds as $id) { if (!in_array($id, $placed, true)) { if ($allCards[$id]['default_zone'] === 'main') { $main[] = $id; } else { $sidebar[] = $id; } } } $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['dashboard']['default_layout'] = [ 'main' => $main, 'sidebar' => $sidebar, 'hidden' => $hidden, ]; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode(['success' => true, 'message_code' => 'ui.common.saved_short']); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.dashboard.save_failed', apiLocalizedText('errors.admin.appearance.dashboard.save_failed', 'Failed to save dashboard layout')); } }); SimpleRouter::post('/appearance/message-reader', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $mr = $payload['message_reader'] ?? []; $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['message_reader']['scrollable_body'] = !empty($mr['scrollable_body']); $config['message_reader']['email_link_url'] = trim((string)($mr['email_link_url'] ?? '')); $config['message_reader']['discord_url'] = trim((string)($mr['discord_url'] ?? '')); if (array_key_exists('media_player', $mr)) { $mp = $mr['media_player']; $validProviders = [ 'youtube', 'odysee', 'rumble', 'bitchute', 'brighteon', 'peertube', 'soundcloud', 'twitter', 'tiktok', 'minds', 'bastyon', 'reverbnation', 'raw_media', ]; $providers = []; foreach ($validProviders as $providerName) { $providers[$providerName] = !empty($mp['providers'][$providerName] ?? true); } $apiKeys = []; foreach (['soundcloud', 'twitter'] as $keyName) { $apiKeys[$keyName] = trim((string)($mp['api_keys'][$keyName] ?? '')); } $config['message_reader']['media_player'] = [ 'enabled' => !empty($mp['enabled'] ?? true), 'providers' => $providers, 'api_keys' => $apiKeys, ]; } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved_short' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.message_reader.save_failed', apiLocalizedText('errors.admin.appearance.message_reader.save_failed', 'Failed to save message reader settings')); } }); SimpleRouter::post('/appearance/file-areas', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $fa = $payload['file_areas'] ?? []; $sidebarTitle = substr(trim((string)($fa['sidebar_info_title'] ?? '')), 0, 200); $sidebarMarkdown = substr((string)($fa['sidebar_info_markdown'] ?? ''), 0, 10000); $footerMarkdown = substr((string)($fa['footer_markdown'] ?? ''), 0, 10000); $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['file_areas']['sidebar_info_title'] = $sidebarTitle; $config['file_areas']['sidebar_info_markdown'] = $sidebarMarkdown; $config['file_areas']['footer_markdown'] = $footerMarkdown; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved_short' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.file_areas.save_failed', apiLocalizedText('errors.admin.appearance.file_areas.save_failed', 'Failed to save file areas settings')); } }); SimpleRouter::post('/appearance/term-menu-keys', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $raw = $payload['term_menu_keys'] ?? []; if (!is_array($raw)) { http_response_code(400); apiError('errors.admin.appearance.term_menu_keys.invalid_key', apiLocalizedText('errors.admin.appearance.term_menu_keys.invalid_key', 'Invalid menu key data')); return; } $knownActions = array_keys(\BinktermPHP\AppearanceConfig::DEFAULT_TERM_MENU_KEYS); $sanitized = []; foreach ($knownActions as $action) { if (!isset($raw[$action])) { continue; } $val = strtolower(trim((string)$raw[$action])); if (!preg_match('/^[a-z0-9]$/', $val)) { http_response_code(400); apiError('errors.admin.appearance.term_menu_keys.invalid_key', apiLocalizedText('errors.admin.appearance.term_menu_keys.invalid_key', 'Menu keys must be a single letter or digit')); return; } $sanitized[$action] = $val; } // quit is mandatory if (!isset($sanitized['quit'])) { http_response_code(400); apiError('errors.admin.appearance.term_menu_keys.quit_required', apiLocalizedText('errors.admin.appearance.term_menu_keys.quit_required', 'A key must be assigned to Quit')); return; } // no duplicate keys $used = []; foreach ($sanitized as $action => $key) { if (in_array($key, $used, true)) { http_response_code(400); apiError('errors.admin.appearance.term_menu_keys.duplicate_key', apiLocalizedText('errors.admin.appearance.term_menu_keys.duplicate_key', 'Each menu key must be unique')); return; } $used[] = $key; } $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['shell']['term_menu_keys'] = $sanitized; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); \BinktermPHP\AppearanceConfig::reload(); echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.term_menu_keys.save_failed', apiLocalizedText('errors.admin.appearance.term_menu_keys.save_failed', 'Failed to save menu key settings')); } }); SimpleRouter::post('/appearance/term-border-style', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $style = strtolower(trim((string)($payload['term_border_style'] ?? ''))); if (!in_array($style, \BinktermPHP\AppearanceConfig::VALID_BORDER_STYLES, true)) { http_response_code(400); apiError('errors.admin.appearance.term_border.invalid_style', apiLocalizedText('errors.admin.appearance.term_border.invalid_style', 'Invalid border style')); return; } $config = \BinktermPHP\AppearanceConfig::getConfig(); $config['shell']['term_border_style'] = $style; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->setAppearanceConfig($config); echo json_encode(['success' => true]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.term_border.save_failed', apiLocalizedText('errors.admin.appearance.term_border.save_failed', 'Failed to save border style')); } }); SimpleRouter::post('/appearance/preview-markdown', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $markdown = (string)($payload['markdown'] ?? ''); $html = \BinktermPHP\MarkdownRenderer::toHtml($markdown); echo json_encode(['success' => true, 'html' => $html]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.markdown_preview.failed', apiLocalizedText('errors.admin.appearance.markdown_preview.failed', 'Failed to render markdown preview')); } }); // Shell art management SimpleRouter::get('/shell-art', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $files = $client->listShellArt(); echo json_encode(['success' => true, 'files' => $files]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.shell_art.list_failed', apiLocalizedText('errors.admin.shell_art.list_failed', 'Failed to list shell art files')); } }); SimpleRouter::post('/shell-art/upload', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { if (empty($_FILES['file'])) { http_response_code(400); apiError('errors.admin.shell_art.upload.no_file', apiLocalizedText('errors.admin.shell_art.upload.no_file', 'No shell art file uploaded')); return; } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); apiError('errors.admin.shell_art.upload.upload_error', apiLocalizedText('errors.admin.shell_art.upload.upload_error', 'Shell art upload failed')); return; } // Max 512 KB for ANSI art if ($file['size'] > 524288) { http_response_code(400); apiError('errors.admin.shell_art.upload.file_too_large', apiLocalizedText('errors.admin.shell_art.upload.file_too_large', 'Shell art file exceeds size limit')); return; } $originalName = basename($file['name']); $contentBase64 = base64_encode(file_get_contents($file['tmp_name'])); $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->uploadShellArt($contentBase64, '', $originalName); $savedName = $result['name'] ?? $originalName; echo json_encode([ 'success' => true, 'name' => $savedName, 'message_code' => 'ui.admin.appearance.shell.uploaded_with_name', 'message_params' => [ 'name' => $savedName ] ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.shell_art.upload.failed', apiLocalizedText('errors.admin.shell_art.upload.failed', 'Failed to upload shell art')); } }); SimpleRouter::delete('/shell-art/{name}', function(string $name) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $name = basename($name); if (!preg_match('/^[a-zA-Z0-9_\-]+\.(ans|asc|txt)$/i', $name)) { http_response_code(400); apiError('errors.admin.shell_art.delete.invalid_name', apiLocalizedText('errors.admin.shell_art.delete.invalid_name', 'Invalid shell art filename')); return; } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->deleteShellArt($name); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.appearance.shell.deleted_with_name', 'message_params' => [ 'name' => $name ] ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.shell_art.delete.failed', apiLocalizedText('errors.admin.shell_art.delete.failed', 'Failed to delete shell art')); } }); SimpleRouter::get('/appearance/terminal-screens', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $screens = $client->listTerminalScreens(); echo json_encode(['success' => true, 'screens' => $screens]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.term_server.list_failed', apiLocalizedText('errors.admin.appearance.term_server.list_failed', 'Failed to load terminal screens')); } }); SimpleRouter::get('/appearance/terminal-screens/{key}', function(string $key) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $screen = $client->getTerminalScreen($key); echo json_encode(['success' => true, 'screen' => $screen]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.term_server.load_failed', apiLocalizedText('errors.admin.appearance.term_server.load_failed', 'Failed to load terminal screen')); } }); SimpleRouter::post('/appearance/terminal-screens/{key}', function(string $key) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?? []; $content = (string)($payload['content'] ?? ''); $client = new \BinktermPHP\Admin\AdminDaemonClient(); $screen = $client->saveTerminalScreen($key, $content); echo json_encode([ 'success' => true, 'screen' => $screen, 'message_code' => 'ui.common.saved', ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.term_server.save_failed', apiLocalizedText('errors.admin.appearance.term_server.save_failed', 'Failed to save terminal screen')); } }); SimpleRouter::post('/appearance/terminal-screens/{key}/upload', function(string $key) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { if (empty($_FILES['file'])) { http_response_code(400); apiError('errors.admin.appearance.term_server.upload.no_file', apiLocalizedText('errors.admin.appearance.term_server.upload.no_file', 'No terminal screen file uploaded')); return; } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); apiError('errors.admin.appearance.term_server.upload.failed', apiLocalizedText('errors.admin.appearance.term_server.upload.failed', 'Terminal screen upload failed')); return; } if ($file['size'] > 1048576) { http_response_code(400); apiError('errors.admin.appearance.term_server.upload.file_too_large', apiLocalizedText('errors.admin.appearance.term_server.upload.file_too_large', 'Terminal screen file exceeds size limit')); return; } $contentBase64 = base64_encode(file_get_contents($file['tmp_name'])); $client = new \BinktermPHP\Admin\AdminDaemonClient(); $screen = $client->uploadTerminalScreen($key, $contentBase64, basename((string)$file['name'])); echo json_encode([ 'success' => true, 'screen' => $screen, 'message_code' => 'ui.common.saved', ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.term_server.upload.failed', apiLocalizedText('errors.admin.appearance.term_server.upload.failed', 'Failed to upload terminal screen')); } }); SimpleRouter::delete('/appearance/terminal-screens/{key}', function(string $key) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->deleteTerminalScreen($key); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved', ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.term_server.delete.failed', apiLocalizedText('errors.admin.appearance.term_server.delete.failed', 'Failed to delete terminal screen')); } }); SimpleRouter::get('/appearance/sixel-screens', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $screens = $client->listSixelScreens(); echo json_encode(['success' => true, 'screens' => $screens]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.sixel.list_failed', apiLocalizedText('errors.admin.appearance.sixel.list_failed', 'Failed to load sixel screens')); } }); SimpleRouter::post('/appearance/sixel-screens/{key}/upload', function(string $key) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { if (empty($_FILES['file'])) { http_response_code(400); apiError('errors.admin.appearance.sixel.upload.no_file', apiLocalizedText('errors.admin.appearance.sixel.upload.no_file', 'No sixel file uploaded')); return; } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); apiError('errors.admin.appearance.sixel.upload.failed', apiLocalizedText('errors.admin.appearance.sixel.upload.failed', 'Sixel upload failed')); return; } if ($file['size'] > 5 * 1048576) { http_response_code(400); apiError('errors.admin.appearance.sixel.upload.file_too_large', apiLocalizedText('errors.admin.appearance.sixel.upload.file_too_large', 'Sixel file exceeds size limit (5MB)')); return; } $contentBase64 = base64_encode(file_get_contents($file['tmp_name'])); $client = new \BinktermPHP\Admin\AdminDaemonClient(); $screen = $client->uploadSixelScreen($key, $contentBase64, basename((string)$file['name'])); echo json_encode([ 'success' => true, 'screen' => $screen, 'message_code' => 'ui.common.saved', ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.sixel.upload.failed', apiLocalizedText('errors.admin.appearance.sixel.upload.failed', 'Failed to upload sixel screen')); } }); SimpleRouter::delete('/appearance/sixel-screens/{key}', function(string $key) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->deleteSixelScreen($key); echo json_encode([ 'success' => true, 'message_code' => 'ui.common.saved', ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.appearance.sixel.delete.failed', apiLocalizedText('errors.admin.appearance.sixel.delete.failed', 'Failed to delete sixel screen')); } }); SimpleRouter::get('/taglines', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->getTaglines(); echo json_encode(['success' => true, 'taglines' => $result['text'] ?? '']); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.taglines.load_failed', apiLocalizedText('errors.admin.taglines.load_failed', 'Failed to load taglines')); } }); SimpleRouter::post('/taglines', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $text = (string)($payload['taglines'] ?? ''); $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->saveTaglines($text); echo json_encode([ 'success' => true, 'taglines' => $result['text'] ?? '', 'message_code' => 'ui.admin.bbs_settings.taglines_saved_success' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.taglines.save_failed', apiLocalizedText('errors.admin.taglines.save_failed', 'Failed to save taglines')); } }); // MRC settings SimpleRouter::get('/matterbridge-settings', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getMatterbridgeConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.matterbridge_settings.load_failed', apiLocalizedText('errors.admin.matterbridge_settings.load_failed', 'Failed to load Matterbridge settings')); } }); SimpleRouter::post('/matterbridge-settings', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $config = $payload['config'] ?? []; if (!is_array($config)) { throw new Exception('Invalid configuration payload'); } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $savedConfig = $client->setMatterbridgeConfig($config); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); if ($userId) { AdminActionLogger::logAction($userId, 'matterbridge_settings_updated', [ 'enabled' => $config['enabled'] ?? null ]); } echo json_encode([ 'success' => true, 'config' => $savedConfig, 'message_code' => 'ui.admin.matterbridge_settings.saved_success' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.matterbridge_settings.save_failed', apiLocalizedText('errors.admin.matterbridge_settings.save_failed', 'Failed to save Matterbridge settings')); } }); SimpleRouter::get('/mrc-settings', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getMrcConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.mrc_settings.load_failed', apiLocalizedText('errors.admin.mrc_settings.load_failed', 'Failed to load MRC settings')); } }); SimpleRouter::post('/mrc-settings', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $config = $payload['config'] ?? []; if (!is_array($config)) { throw new Exception('Invalid configuration payload'); } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $savedConfig = $client->setMrcConfig($config); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); if ($userId) { AdminActionLogger::logAction($userId, 'mrc_settings_updated', [ 'enabled' => $config['enabled'] ?? null ]); } echo json_encode([ 'success' => true, 'config' => $savedConfig, 'message_code' => 'ui.admin.mrc_settings.saved_success' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.mrc_settings.save_failed', apiLocalizedText('errors.admin.mrc_settings.save_failed', 'Failed to save MRC settings')); } }); SimpleRouter::post('/mrc-restart', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->restartMrcDaemon(); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); if ($userId) { AdminActionLogger::logAction($userId, 'mrc_daemon_restarted'); } echo json_encode([ 'success' => true, 'message_code' => 'ui.api.admin.mrc_restart_initiated', 'message' => 'MRC daemon restart initiated' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.mrc_settings.restart_failed', apiLocalizedText('errors.admin.mrc_settings.restart_failed', 'Failed to restart MRC daemon')); } }); SimpleRouter::get('/bbs-system', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getSystemConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.bbs_system.load_failed', apiLocalizedText('errors.admin.bbs_system.load_failed', 'Failed to load system settings')); } }); SimpleRouter::post('/bbs-system', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $config = $payload['config'] ?? []; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->setSystemConfig($config); echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.bbs_settings.system_saved_success' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.bbs_system.save_failed', apiLocalizedText('errors.admin.bbs_system.save_failed', 'Failed to save system settings')); } }); SimpleRouter::get('/networks', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $manager = new \BinktermPHP\NetworkManager(); $networks = $manager->getAll(); $uplinksByDomain = []; $config = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); foreach ($config->getUplinks() as $uplink) { $domain = strtolower(trim((string)($uplink['domain'] ?? ''))); $address = trim((string)($uplink['address'] ?? '')); if ($domain === '' || $address === '') { continue; } $uplinksByDomain[$domain][] = $address; } foreach ($networks as &$network) { $domain = strtolower(trim((string)($network['domain'] ?? ''))); $network['uplinks'] = array_values(array_unique($uplinksByDomain[$domain] ?? [])); } unset($network); echo json_encode(['success' => true, 'networks' => $networks]); } catch (Exception $e) { apiError('errors.admin.networks.load_failed', apiLocalizedText('errors.admin.networks.load_failed', 'Failed to load networks'), 500); } }); SimpleRouter::post('/networks', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $manager = new \BinktermPHP\NetworkManager(); $network = $manager->create(is_array($payload) ? $payload : []); echo json_encode(['success' => true, 'network' => $network, 'message_code' => 'ui.admin.networks.saved']); } catch (Throwable $e) { apiError('errors.admin.networks.save_failed', $e->getMessage(), 400); } }); SimpleRouter::put('/networks/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $manager = new \BinktermPHP\NetworkManager(); $network = $manager->update((int)$id, is_array($payload) ? $payload : []); echo json_encode(['success' => true, 'network' => $network, 'message_code' => 'ui.admin.networks.saved']); } catch (Throwable $e) { apiError('errors.admin.networks.save_failed', $e->getMessage(), 400); } }); SimpleRouter::post('/networks/{id}/change-domain', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $service = new \BinktermPHP\NetworkDomainChangeService(); $result = $service->changeDomain((int)$id, (string)($payload['domain'] ?? '')); echo json_encode(['success' => true, 'result' => $result, 'message_code' => 'ui.admin.networks.domain_changed']); } catch (Throwable $e) { apiError('errors.admin.networks.change_domain_failed', $e->getMessage(), 400); } }); SimpleRouter::delete('/networks/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $manager = new \BinktermPHP\NetworkManager(); $network = $manager->getById((int)$id); if (!$network) { throw new InvalidArgumentException('Network not found'); } $config = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $dependentUplinks = []; foreach ($config->getUplinks() as $uplink) { if (strcasecmp((string)($uplink['domain'] ?? ''), (string)$network['domain']) === 0) { $dependentUplinks[] = (string)($uplink['address'] ?? ''); } } if ($dependentUplinks !== []) { apiError('errors.admin.networks.delete_in_use', apiLocalizedText('errors.admin.networks.delete_in_use', 'Network is in use'), 409, [ 'uplinks' => array_values(array_filter($dependentUplinks)), ]); return; } $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare("SELECT COUNT(*) FROM echoareas WHERE LOWER(domain) = LOWER(?)"); $stmt->execute([(string)$network['domain']]); if ((int)$stmt->fetchColumn() > 0) { apiError('errors.admin.networks.delete_in_use', apiLocalizedText('errors.admin.networks.delete_in_use', 'Network is in use'), 409); return; } $stmt = $db->prepare("SELECT COUNT(*) FROM file_areas WHERE LOWER(domain) = LOWER(?)"); $stmt->execute([(string)$network['domain']]); if ((int)$stmt->fetchColumn() > 0) { apiError('errors.admin.networks.delete_in_use', apiLocalizedText('errors.admin.networks.delete_in_use', 'Network is in use'), 409); return; } $manager->delete((int)$id); echo json_encode(['success' => true, 'message_code' => 'ui.admin.networks.deleted']); } catch (Throwable $e) { apiError('errors.admin.networks.delete_failed', $e->getMessage(), 400); } }); SimpleRouter::get('/binkp-config', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getFullBinkpConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.binkp_config.load_failed', apiLocalizedText('errors.admin.binkp_config.load_failed', 'Failed to load BinkP configuration')); } }); SimpleRouter::post('/binkp-config', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $config = $payload['config'] ?? []; $networkManager = new \BinktermPHP\NetworkManager(); foreach (($config['uplinks'] ?? []) as &$uplink) { if (!is_array($uplink)) { continue; } unset( $uplink['allow_markup'], $uplink['allow_markdown'], $uplink['allow_media'], $uplink['default_charset'], $uplink['posting_name_policy'] ); $domain = trim((string)($uplink['domain'] ?? '')); if ($domain !== '' && !$networkManager->exists($domain)) { throw new InvalidArgumentException("Unknown network domain: {$domain}"); } } unset($uplink); $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->setFullBinkpConfig($config); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.binkp_config.configuration_saved', 'config' => $updated ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.binkp_config.save_failed', apiLocalizedText('errors.admin.binkp_config.save_failed', 'Failed to save BinkP configuration')); } }); SimpleRouter::post('/binkp-reload', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->reloadBinkpConfig(); echo json_encode([ 'success' => true, 'message_code' => 'ui.api.admin.binkp_config_reloaded', 'message' => 'BinkP configuration reload requested', 'result' => $result ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.binkp_config.reload_failed', apiLocalizedText('errors.admin.binkp_config.reload_failed', 'Failed to reload BinkP configuration'), 500); } }); SimpleRouter::get('/webdoors-config', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getWebdoorsConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.webdoors_config.load_failed', apiLocalizedText('errors.admin.webdoors_config.load_failed', 'Failed to load webdoors configuration')); } }); SimpleRouter::get('/webdoors-available', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $doors = []; foreach (WebDoorManifest::listManifests() as $entry) { $manifest = $entry['manifest']; $game = $manifest['game'] ?? []; $gameId = $entry['id']; $doors[] = [ 'id' => $gameId, 'name' => $game['name'] ?? $gameId, 'path' => $entry['path'], 'config' => is_array($manifest['config'] ?? null) ? $manifest['config'] : null ]; } echo json_encode(['doors' => $doors]); }); SimpleRouter::post('/webdoors-config', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $json = $payload['json'] ?? ''; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->saveWebdoorsConfig((string)$json); echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.webdoors_config.saved_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.webdoors_config.save_failed', apiLocalizedText('errors.admin.webdoors_config.save_failed', 'Failed to save webdoors configuration')); } }); SimpleRouter::post('/webdoors-activate', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->activateWebdoorsConfig(); echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.webdoors_config.activated_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.webdoors_config.activate_failed', apiLocalizedText('errors.admin.webdoors_config.activate_failed', 'Failed to activate webdoors configuration')); } }); // JS-DOS Doors API endpoints SimpleRouter::get('/jsdosdoors-config', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getJsdosdoorsConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.jsdosdoors_config.load_failed', apiLocalizedText('errors.admin.jsdosdoors_config.load_failed', 'Failed to load JS-DOS doors configuration')); } }); SimpleRouter::get('/jsdosdoors-available', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $doors = []; foreach (\BinktermPHP\JsdosDoorManifest::listManifests() as $entry) { $manifest = $entry['manifest']; $doors[] = [ 'id' => $entry['id'], 'name' => $manifest['name'] ?? $entry['id'], 'path' => $entry['path'], ]; } echo json_encode(['doors' => $doors]); }); SimpleRouter::post('/jsdosdoors-config', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $json = $payload['json'] ?? ''; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->saveJsdosdoorsConfig((string)$json); echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.jsdosdoors_config.saved_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.jsdosdoors_config.save_failed', apiLocalizedText('errors.admin.jsdosdoors_config.save_failed', 'Failed to save JS-DOS doors configuration')); } }); SimpleRouter::post('/jsdosdoors-activate', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->activateJsdosdoorsConfig(); echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.jsdosdoors_config.activated_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.jsdosdoors_config.activate_failed', apiLocalizedText('errors.admin.jsdosdoors_config.activate_failed', 'Failed to activate JS-DOS doors configuration')); } }); // DOSDoors API endpoints SimpleRouter::get('/dosdoors-config', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->getDosdoorsConfig(); $configData = null; if (!empty($result['config_json'])) { $configData = json_decode($result['config_json'], true); } echo json_encode([ 'success' => true, 'config' => $configData ?? [], 'exists' => $result['active'] ?? false ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.dosdoors_config.load_failed', apiLocalizedText('errors.admin.dosdoors_config.load_failed', 'Failed to load DOS doors configuration'), 500); } }); SimpleRouter::get('/dosdoors-available', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $doorManager = new DoorManager(); $allDoors = $doorManager->getAllDoors(); $doors = []; foreach ($allDoors as $doorId => $door) { $doors[] = [ 'id' => $doorId, 'name' => $door['name'], 'short_name' => $door['short_name'] ?? $door['name'], 'author' => $door['author'] ?? 'Unknown', 'description' => $door['description'] ?? '', 'config' => $door['config'] ?? [] ]; } echo json_encode(['success' => true, 'doors' => $doors]); }); SimpleRouter::post('/dosdoors-config', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $config = $payload['config'] ?? null; if (!is_array($config)) { throw new Exception('Invalid config data'); } $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new Exception('Failed to encode config as JSON'); } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->saveDosdoorsConfig($json); // Reload config class cache DoorConfig::reload(); // Sync enabled doors to database $doorManager = new DoorManager(); $syncResult = $doorManager->syncDoorsToDatabase(); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.dosdoors_config.saved_success', 'config' => $config, 'synced' => $syncResult['synced'], 'sync_errors' => $syncResult['errors'] ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.dosdoors_config.save_failed', apiLocalizedText('errors.admin.dosdoors_config.save_failed', 'Failed to save DOS doors configuration'), 400); } }); // Native Doors API endpoints SimpleRouter::get('/native-doors', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $nativeDoorManager = new \BinktermPHP\NativeDoorManager(); $allDoors = $nativeDoorManager->getAllDoors(); $doors = []; foreach ($allDoors as $doorId => $door) { $doors[] = [ 'id' => $doorId, 'name' => $door['name'], 'short_name' => $door['short_name'] ?? $door['name'], 'author' => $door['author'] ?? 'Unknown', 'description' => $door['description'] ?? '', 'platform' => $door['platform'] ?? [], 'config' => $door['config'] ?? [] ]; } echo json_encode([ 'success' => true, 'doors' => $doors, 'server_platform' => strtolower(PHP_OS_FAMILY), ]); }); SimpleRouter::get('/native-doors/config', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->getNativeDoorsConfig(); $configData = null; if (!empty($result['config_json'])) { $configData = json_decode($result['config_json'], true); } echo json_encode([ 'success' => true, 'config' => $configData ?? [], 'exists' => $result['active'] ?? false ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.native_doors.load_failed', apiLocalizedText('errors.admin.native_doors.load_failed', 'Failed to load native doors configuration'), 500); } }); SimpleRouter::post('/native-doors/config', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $config = $payload['config'] ?? null; if (!is_array($config)) { throw new Exception('Invalid config data'); } $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new Exception('Failed to encode config as JSON'); } $client = new \BinktermPHP\Admin\AdminDaemonClient(); $client->saveNativeDoorsConfig($json); // Reload config class cache and sync enabled doors to database \BinktermPHP\NativeDoorConfig::reload(); $nativeDoorManager = new \BinktermPHP\NativeDoorManager(); $syncResult = $nativeDoorManager->syncDoorsToDatabase(); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.nativedoors_config.saved_success', 'config' => $config, 'synced' => $syncResult['synced'], 'sync_errors' => $syncResult['errors'] ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.native_doors.save_failed', apiLocalizedText('errors.admin.native_doors.save_failed', 'Failed to save native doors configuration'), 400); } }); SimpleRouter::post('/native-doors/sync', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $nativeDoorManager = new \BinktermPHP\NativeDoorManager(); $syncResult = $nativeDoorManager->syncDoorsToDatabase(); echo json_encode([ 'success' => true, 'synced' => $syncResult['synced'], 'errors' => $syncResult['errors'] ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.native_doors.sync_failed', apiLocalizedText('errors.admin.native_doors.sync_failed', 'Failed to sync native doors'), 500); } }); SimpleRouter::get('/filearea-rules', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $config = $client->getFileAreaRulesConfig(); echo json_encode(['success' => true, 'config' => $config]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.filearea_rules.load_failed', apiLocalizedText('errors.admin.filearea_rules.load_failed', 'Failed to load file area rules')); } }); SimpleRouter::post('/filearea-rules', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); $json = $payload['json'] ?? ''; $client = new \BinktermPHP\Admin\AdminDaemonClient(); $updated = $client->saveFileAreaRulesConfig((string)$json); echo json_encode([ 'success' => true, 'config' => $updated, 'message_code' => 'ui.admin.filearea_rules.saved_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.filearea_rules.save_failed', apiLocalizedText('errors.admin.filearea_rules.save_failed', 'Failed to save file area rules')); } }); // File area rules: filenames for pattern tester SimpleRouter::get('/filearea-rules/filenames', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $tag = strtoupper(trim((string)($_GET['tag'] ?? ''))); $domain = trim((string)($_GET['domain'] ?? '')); if ($tag === '') { echo json_encode(['success' => true, 'filenames' => []]); return; } try { $manager = new \BinktermPHP\FileAreaManager(); $area = $manager->getFileAreaByTag($tag, $domain); if (!$area) { echo json_encode(['success' => true, 'filenames' => [], 'area_found' => false]); return; } $files = $manager->getFiles((int)$area['id'], null, true); $filenames = array_values(array_map(fn($f) => $f['filename'], $files)); echo json_encode(['success' => true, 'filenames' => $filenames, 'area_found' => true]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.filearea_rules.load_failed', 'Failed to load filenames'); } }); SimpleRouter::get('/files/pending', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $manager = new \BinktermPHP\FileAreaManager(); echo json_encode([ 'success' => true, 'files' => $manager->listPendingUploads(), 'count' => $manager->countPendingUploads(), ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.file_approvals.load_failed', apiLocalizedText('errors.admin.file_approvals.load_failed', 'Failed to load pending file approvals')); } }); SimpleRouter::post('/files/{id}/approve', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $manager = new \BinktermPHP\FileAreaManager(); $manager->approveFileUpload((int)$id, (int)($user['user_id'] ?? $user['id'] ?? 0)); AdminActionLogger::logAction( (int)($user['user_id'] ?? $user['id'] ?? 0), 'file_upload_approved', ['file_id' => (int)$id] ); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.file_approvals.approved' ]); } catch (Exception $e) { $message = $e->getMessage(); if ($message === 'File not found') { http_response_code(404); apiError('errors.admin.file_approvals.not_found', apiLocalizedText('errors.admin.file_approvals.not_found', 'Pending file not found'), 404); return; } if ($message === 'File is not awaiting approval') { http_response_code(400); apiError('errors.admin.file_approvals.not_pending', apiLocalizedText('errors.admin.file_approvals.not_pending', 'File is not awaiting approval')); return; } getServerLogger()->error('File approval failed: ' . $message); http_response_code(500); apiError('errors.admin.file_approvals.approve_failed', apiLocalizedText('errors.admin.file_approvals.approve_failed', 'Failed to approve file upload')); } })->where(['id' => '[0-9]+']); SimpleRouter::post('/files/{id}/reject', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true) ?: []; $reason = trim((string)($payload['reason'] ?? '')); $manager = new \BinktermPHP\FileAreaManager(); $manager->rejectFileUpload( (int)$id, (int)($user['user_id'] ?? $user['id'] ?? 0), $reason !== '' ? $reason : null ); AdminActionLogger::logAction( (int)($user['user_id'] ?? $user['id'] ?? 0), 'file_upload_rejected', ['file_id' => (int)$id, 'reason' => $reason !== '' ? $reason : null] ); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.file_approvals.rejected' ]); } catch (Exception $e) { $message = $e->getMessage(); if ($message === 'File not found') { http_response_code(404); apiError('errors.admin.file_approvals.not_found', apiLocalizedText('errors.admin.file_approvals.not_found', 'Pending file not found'), 404); return; } if ($message === 'File is not awaiting approval') { http_response_code(400); apiError('errors.admin.file_approvals.not_pending', apiLocalizedText('errors.admin.file_approvals.not_pending', 'File is not awaiting approval')); return; } getServerLogger()->error('File rejection failed: ' . $message); http_response_code(500); apiError('errors.admin.file_approvals.reject_failed', apiLocalizedText('errors.admin.file_approvals.reject_failed', 'Failed to reject file upload')); } })->where(['id' => '[0-9]+']); SimpleRouter::get('/files/{id}/download', function($id) { RouteHelper::requireAdmin(); try { $manager = new \BinktermPHP\FileAreaManager(); $file = $manager->getFileById((int)$id); if (!$file || ($file['source_type'] ?? '') !== 'user_upload' || !in_array(($file['status'] ?? ''), ['pending', 'rejected', 'approved'], true)) { http_response_code(404); echo 'File not found'; return; } $storagePath = $manager->resolveFilePath($file); if (!file_exists($storagePath)) { http_response_code(404); echo 'File not found on disk'; return; } $filename = basename((string)$file['filename']); $encodedFilename = rawurlencode($filename); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . addslashes($filename) . '"; filename*=UTF-8\'\'' . $encodedFilename); header('Content-Length: ' . filesize($storagePath)); header('Cache-Control: no-cache, must-revalidate'); header('Pragma: public'); readfile($storagePath); exit; } catch (Exception $e) { http_response_code(500); echo 'Failed to download file'; } })->where(['id' => '[0-9]+']); // Advertisements SimpleRouter::get('/ads', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $items = $ads->listAds(); echo json_encode(['ads' => $items]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ads.list_failed', apiLocalizedText('errors.admin.ads.list_failed', 'Failed to load advertisements')); } }); SimpleRouter::get('/ads/content-commands', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); echo json_encode(['commands' => \BinktermPHP\Advertising::getAvailableContentCommands()]); }); SimpleRouter::get('/ads/{id}', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $ad = $ads->getAdById((int)$id); if (!$ad) { http_response_code(404); apiError('errors.admin.ads.not_found', apiLocalizedText('errors.admin.ads.not_found', 'Advertisement not found'), 404); return; } echo json_encode(['ad' => $ad]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ads.load_one_failed', apiLocalizedText('errors.admin.ads.load_one_failed', 'Failed to load advertisement')); } })->where(['id' => '[0-9]+']); SimpleRouter::get('/ads/{id}/preview', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $ad = $ads->previewAdById((int)$id); if (!$ad) { http_response_code(404); apiError('errors.admin.ads.not_found', apiLocalizedText('errors.admin.ads.not_found', 'Advertisement not found'), 404); return; } echo json_encode(['ad' => $ad]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ads.load_one_failed', apiLocalizedText('errors.admin.ads.load_one_failed', 'Failed to load advertisement')); } })->where(['id' => '[0-9]+']); SimpleRouter::post('/ads/upload', function() { $user = RouteHelper::requireAdmin(); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); header('Content-Type: application/json'); $contentCommand = trim((string)($_POST['content_command'] ?? '')); $hasFile = isset($_FILES['ad_file']) && ($_FILES['ad_file']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK; if (!$hasFile && $contentCommand === '') { http_response_code(400); apiError('errors.admin.ads.upload.no_file', apiLocalizedText('errors.admin.ads.upload.no_file', 'No advertisement file uploaded')); return; } if ($contentCommand !== '' && !\BinktermPHP\Advertising::validateContentCommand($contentCommand)) { http_response_code(400); apiError('errors.admin.ads.invalid_content_command', apiLocalizedText('errors.admin.ads.invalid_content_command', 'The selected content command is not allowed')); return; } $content = ''; $legacyFilename = trim((string)($_POST['legacy_filename'] ?? '')); $defaultTitle = 'Advertisement'; if ($hasFile) { $file = $_FILES['ad_file']; $maxSize = 5 * 1024 * 1024; if (!empty($file['size']) && $file['size'] > $maxSize) { http_response_code(400); apiError('errors.admin.ads.upload.file_too_large', apiLocalizedText('errors.admin.ads.upload.file_too_large', 'Advertisement file exceeds size limit')); return; } $content = @file_get_contents($file['tmp_name']); if ($content === false) { http_response_code(400); apiError('errors.admin.ads.upload.read_failed', apiLocalizedText('errors.admin.ads.upload.read_failed', 'Failed to read uploaded advertisement file')); return; } if ($legacyFilename === '') { $legacyFilename = (string)($file['name'] ?? ''); } $filename = (string)($file['name'] ?? 'Advertisement'); $defaultTitle = pathinfo($filename, PATHINFO_FILENAME); } $adFilePrefixes = ['ans' => '[ANSI]', 'rip' => '[RIP]', 'six' => '[SIXEL]', 'sixel' => '[SIXEL]']; $getAdFilePrefix = static function(string $fname) use ($adFilePrefixes): string { return $adFilePrefixes[strtolower(pathinfo($fname, PATHINFO_EXTENSION))] ?? ''; }; if ($hasFile && ($prefix = $getAdFilePrefix($filename)) !== '') { $defaultTitle = $prefix . ' ' . $defaultTitle; } try { $ads = new \BinktermPHP\Advertising(); $duplicates = $content !== '' ? $ads->findDuplicatesByContent($content) : []; $resolvedTitle = trim((string)($_POST['title'] ?? $defaultTitle)); if ($hasFile) { $prefix = $getAdFilePrefix((string)($_FILES['ad_file']['name'] ?? '')); if ($prefix !== '' && !str_starts_with($resolvedTitle, $prefix)) { $resolvedTitle = $prefix . ' ' . $resolvedTitle; } } $created = $ads->createAd([ 'title' => $resolvedTitle, 'slug' => trim((string)($_POST['slug'] ?? '')), 'description' => trim((string)($_POST['description'] ?? '')), 'tags' => trim((string)($_POST['tags'] ?? '')), 'content' => $content, 'content_command' => $contentCommand, 'legacy_filename' => $legacyFilename, 'source_type' => 'upload', 'is_active' => !isset($_POST['is_active']) || $_POST['is_active'] !== '0', 'show_on_dashboard' => !empty($_POST['show_on_dashboard']), 'allow_auto_post' => !empty($_POST['allow_auto_post']), 'dashboard_weight' => max(1, (int)($_POST['dashboard_weight'] ?? 1)), 'dashboard_priority' => (int)($_POST['dashboard_priority'] ?? 0), 'click_url' => trim((string)($_POST['click_url'] ?? '')), ], $userId > 0 ? $userId : null); echo json_encode([ 'success' => true, 'ad' => $created, 'duplicates' => $duplicates, 'message_code' => 'ui.admin.ads.uploaded' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ads.upload.failed', apiLocalizedText('errors.admin.ads.upload.failed', 'Failed to upload advertisement')); } }); SimpleRouter::post('/ads/{id}', function($id) { $user = RouteHelper::requireAdmin(); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); if (!is_array($payload)) { http_response_code(400); apiError('errors.admin.ads.invalid_payload', apiLocalizedText('errors.admin.ads.invalid_payload', 'Invalid advertisement payload'), 400); return; } $ads = new \BinktermPHP\Advertising(); $adId = (int)$id; $existing = $ads->getAdById($adId); if (!$existing) { http_response_code(404); apiError('errors.admin.ads.not_found', apiLocalizedText('errors.admin.ads.not_found', 'Advertisement not found'), 404); return; } if (array_key_exists('content_command', $payload)) { $cmd = trim((string)($payload['content_command'] ?? '')); if ($cmd !== '' && !\BinktermPHP\Advertising::validateContentCommand($cmd)) { http_response_code(400); apiError('errors.admin.ads.invalid_content_command', apiLocalizedText('errors.admin.ads.invalid_content_command', 'The selected content command is not allowed'), 400); return; } } $duplicates = []; if (array_key_exists('content', $payload)) { $duplicates = $ads->findDuplicatesByContent((string)$payload['content'], $adId); } $updated = $ads->updateAd($adId, $payload, $userId > 0 ? $userId : null); echo json_encode([ 'success' => true, 'ad' => $updated, 'duplicates' => $duplicates, 'message_code' => 'ui.admin.ads.saved' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ads.save_failed', apiLocalizedText('errors.admin.ads.save_failed', 'Failed to save advertisement')); } })->where(['id' => '[0-9]+']); SimpleRouter::delete('/ads/{id}', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $ad = $ads->getAdById((int)$id); if (!$ad) { http_response_code(404); apiError('errors.admin.ads.not_found', apiLocalizedText('errors.admin.ads.not_found', 'Advertisement not found'), 404); return; } $ads->deleteAd((int)$id); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.ads.deleted' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ads.delete_failed', apiLocalizedText('errors.admin.ads.delete_failed', 'Failed to delete advertisement')); } })->where(['id' => '[0-9]+']); SimpleRouter::get('/ad-campaigns', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); echo json_encode(['campaigns' => $ads->listCampaigns()]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.list_failed', apiLocalizedText('errors.admin.ad_campaigns.list_failed', 'Failed to load ad campaigns')); } }); SimpleRouter::get('/ad-campaigns/log', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $campaignId = isset($_GET['campaign_id']) ? (int)$_GET['campaign_id'] : null; $limit = isset($_GET['limit']) ? max(1, min(500, (int)$_GET['limit'])) : 50; $status = isset($_GET['status']) ? trim((string)$_GET['status']) : null; $ads = new \BinktermPHP\Advertising(); echo json_encode(['log' => $ads->listPostLog($campaignId ?: null, $limit, $status ?: null)]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.log_failed', apiLocalizedText('errors.admin.ad_campaigns.log_failed', 'Failed to load ad campaign log')); } }); SimpleRouter::get('/ad-campaigns/meta', function() { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $users = $db->query("SELECT id, username, real_name, is_system FROM users ORDER BY is_system DESC, LOWER(username)")->fetchAll(PDO::FETCH_ASSOC); $echoareas = $db->query("SELECT id, tag, domain, is_local FROM echoareas WHERE is_active = TRUE ORDER BY LOWER(tag), LOWER(domain)")->fetchAll(PDO::FETCH_ASSOC); echo json_encode([ 'ads' => $ads->listAds(false), 'tags' => $ads->listTags(), 'users' => $users, 'echoareas' => $echoareas, 'timezones' => \DateTimeZone::listIdentifiers() ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.meta_failed', apiLocalizedText('errors.admin.ad_campaigns.meta_failed', 'Failed to load ad campaign metadata')); } }); SimpleRouter::get('/ad-campaigns/{id}', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $campaign = $ads->getCampaignById((int)$id); if (!$campaign) { http_response_code(404); apiError('errors.admin.ad_campaigns.not_found', apiLocalizedText('errors.admin.ad_campaigns.not_found', 'Ad campaign not found'), 404); return; } echo json_encode(['campaign' => $campaign]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.load_one_failed', apiLocalizedText('errors.admin.ad_campaigns.load_one_failed', 'Failed to load ad campaign')); } })->where(['id' => '[0-9]+']); SimpleRouter::post('/ad-campaigns', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); if (!is_array($payload)) { http_response_code(400); apiError('errors.admin.ad_campaigns.invalid_payload', apiLocalizedText('errors.admin.ad_campaigns.invalid_payload', 'Invalid ad campaign payload'), 400); return; } $ads = new \BinktermPHP\Advertising(); $campaign = $ads->createCampaign($payload); echo json_encode([ 'success' => true, 'campaign' => $campaign, 'message_code' => 'ui.admin.ad_campaigns.created' ]); } catch (\InvalidArgumentException $e) { http_response_code(400); apiError('errors.admin.ad_campaigns.invalid_payload', $e->getMessage(), 400); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.create_failed', apiLocalizedText('errors.admin.ad_campaigns.create_failed', 'Failed to create ad campaign')); } }); SimpleRouter::post('/ad-campaigns/{id}', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $payload = json_decode(file_get_contents('php://input'), true); if (!is_array($payload)) { http_response_code(400); apiError('errors.admin.ad_campaigns.invalid_payload', apiLocalizedText('errors.admin.ad_campaigns.invalid_payload', 'Invalid ad campaign payload'), 400); return; } $ads = new \BinktermPHP\Advertising(); $campaign = $ads->updateCampaign((int)$id, $payload); echo json_encode([ 'success' => true, 'campaign' => $campaign, 'message_code' => 'ui.admin.ad_campaigns.saved' ]); } catch (\RuntimeException $e) { http_response_code(404); apiError('errors.admin.ad_campaigns.not_found', $e->getMessage(), 404); } catch (\InvalidArgumentException $e) { http_response_code(400); apiError('errors.admin.ad_campaigns.invalid_payload', $e->getMessage(), 400); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.save_failed', apiLocalizedText('errors.admin.ad_campaigns.save_failed', 'Failed to save ad campaign')); } })->where(['id' => '[0-9]+']); SimpleRouter::delete('/ad-campaigns/{id}', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $campaign = $ads->getCampaignById((int)$id); if (!$campaign) { http_response_code(404); apiError('errors.admin.ad_campaigns.not_found', apiLocalizedText('errors.admin.ad_campaigns.not_found', 'Ad campaign not found'), 404); return; } $ads->deleteCampaign((int)$id); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.ad_campaigns.deleted' ]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.delete_failed', apiLocalizedText('errors.admin.ad_campaigns.delete_failed', 'Failed to delete ad campaign')); } })->where(['id' => '[0-9]+']); SimpleRouter::post('/ad-campaigns/run/{id}', function($id) { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); try { $ads = new \BinktermPHP\Advertising(); $campaign = $ads->getCampaignById((int)$id); if (!$campaign) { http_response_code(404); apiError('errors.admin.ad_campaigns.not_found', apiLocalizedText('errors.admin.ad_campaigns.not_found', 'Ad campaign not found'), 404); return; } $results = $ads->processDueCampaigns((int)$id, false, true); echo json_encode([ 'success' => true, 'results' => $results, 'message_code' => 'ui.admin.ad_campaigns.run_complete' ]); } catch (\Throwable $e) { http_response_code(500); apiError('errors.admin.ad_campaigns.run_failed', apiLocalizedText('errors.admin.ad_campaigns.run_failed', 'Failed to run ad campaign')); } })->where(['id' => '[0-9]+']); // ------------------------------------------------------- // AI Bots // ------------------------------------------------------- SimpleRouter::get('/ai-bots', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $repo = new \BinktermPHP\AiBot\AiBotRepository($db); $bots = $repo->getAllBotsWithSpend(); $result = []; foreach ($bots as $bot) { $activities = $repo->getActivitiesForBot((int)$bot['id']); $activityMap = []; foreach ($activities as $act) { $activityMap[$act['activity_type']] = [ 'is_enabled' => (bool)$act['is_enabled'], 'config_json' => $act['config_json'] ? json_decode($act['config_json'], true) : (object)[], ]; } $result[] = [ 'id' => (int)$bot['id'], 'user_id' => (int)$bot['user_id'], 'username' => $bot['username'], 'name' => $bot['name'], 'description' => $bot['description'], 'system_prompt' => $bot['system_prompt'], 'provider' => $bot['provider'], 'model' => $bot['model'], 'weekly_budget_usd' => (float)$bot['weekly_budget_usd'], 'weekly_spend_usd' => (float)$bot['weekly_spend_usd'], 'context_messages' => (int)$bot['context_messages'], 'is_active' => (bool)$bot['is_active'], 'created_at' => $bot['created_at'], 'activities' => $activityMap, ]; } echo json_encode(['bots' => $result]); }); SimpleRouter::get('/ai-bots/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $repo = new \BinktermPHP\AiBot\AiBotRepository($db); $bot = $repo->findById((int)$id); if (!$bot) { apiError('errors.admin.ai_bots.not_found', apiLocalizedText('errors.admin.ai_bots.not_found', 'Bot not found'), 404); return; } $activities = $repo->getActivitiesForBot($bot->id); $activityMap = []; foreach ($activities as $act) { $activityMap[$act['activity_type']] = [ 'is_enabled' => (bool)$act['is_enabled'], 'config_json' => $act['config_json'] ? json_decode($act['config_json'], true) : (object)[], ]; } // Fetch username from users table $uStmt = $db->prepare("SELECT username FROM users WHERE id = ?"); $uStmt->execute([$bot->userId]); $username = (string)($uStmt->fetchColumn() ?: ''); echo json_encode([ 'id' => $bot->id, 'user_id' => $bot->userId, 'username' => $username, 'name' => $bot->name, 'description' => $bot->description, 'system_prompt' => $bot->systemPrompt, 'provider' => $bot->provider, 'model' => $bot->model, 'weekly_budget_usd' => $bot->weeklyBudgetUsd, 'context_messages' => $bot->contextMessages, 'is_active' => $bot->isActive, 'activities' => $activityMap, ]); })->where(['id' => '[0-9]+']); SimpleRouter::post('/ai-bots', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim((string)($input['name'] ?? '')); $username = trim((string)($input['username'] ?? '')); if ($name === '' || strlen($name) > 100) { throw new \InvalidArgumentException('name_invalid'); } if ($username === '' || strlen($username) > 50 || !preg_match('/^[A-Za-z0-9_]+$/', $username)) { throw new \InvalidArgumentException('username_invalid'); } // Block only if a non-system user already has this username. // A system user with the same username is mapped over by createBot(). $db = \BinktermPHP\Database::getInstance()->getPdo(); $chk = $db->prepare("SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_system = FALSE"); $chk->execute([$username]); if ($chk->fetchColumn()) { throw new \InvalidArgumentException('username_taken'); } $repo = new \BinktermPHP\AiBot\AiBotRepository($db); $botId = $repo->createBot(array_merge($input, [ 'name' => $name, 'username' => $username, ])); echo json_encode([ 'success' => true, 'id' => $botId, 'message_code' => 'ui.admin.ai_bots.created_success', ]); } catch (\InvalidArgumentException $e) { http_response_code(400); $code = $e->getMessage(); $map = [ 'name_invalid' => ['errors.admin.ai_bots.name_invalid', 'Bot name must be 1?100 characters'], 'username_invalid'=> ['errors.admin.ai_bots.username_invalid', 'Username must be 1?50 alphanumeric/underscore characters'], 'username_taken' => ['errors.admin.ai_bots.username_taken', 'Username is already taken'], ]; [$errCode, $fallback] = $map[$code] ?? ['errors.admin.ai_bots.create_failed', 'Failed to create bot']; apiError($errCode, apiLocalizedText($errCode, $fallback)); } catch (\Throwable $e) { http_response_code(500); apiError('errors.admin.ai_bots.create_failed', apiLocalizedText('errors.admin.ai_bots.create_failed', 'Failed to create bot')); } }); SimpleRouter::put('/ai-bots/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $db = \BinktermPHP\Database::getInstance()->getPdo(); $repo = new \BinktermPHP\AiBot\AiBotRepository($db); $bot = $repo->findById((int)$id); if (!$bot) { apiError('errors.admin.ai_bots.not_found', apiLocalizedText('errors.admin.ai_bots.not_found', 'Bot not found'), 404); return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = trim((string)($input['name'] ?? $bot->name)); if ($name === '' || strlen($name) > 100) { throw new \InvalidArgumentException('name_invalid'); } $input['name'] = $name; $repo->updateBot((int)$id, $input); // Update activity settings if provided if (isset($input['activities']) && is_array($input['activities'])) { foreach ($input['activities'] as $activityType => $actData) { $repo->upsertActivity( (int)$id, (string)$activityType, (bool)($actData['is_enabled'] ?? true), is_array($actData['config_json'] ?? null) ? $actData['config_json'] : [] ); } } echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.ai_bots.updated_success', ]); } catch (\InvalidArgumentException $e) { http_response_code(400); apiError('errors.admin.ai_bots.name_invalid', apiLocalizedText('errors.admin.ai_bots.name_invalid', 'Bot name must be 1?100 characters')); } catch (\Throwable $e) { http_response_code(500); apiError('errors.admin.ai_bots.update_failed', apiLocalizedText('errors.admin.ai_bots.update_failed', 'Failed to update bot')); } })->where(['id' => '[0-9]+']); SimpleRouter::delete('/ai-bots/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $db = \BinktermPHP\Database::getInstance()->getPdo(); $repo = new \BinktermPHP\AiBot\AiBotRepository($db); $bot = $repo->findById((int)$id); if (!$bot) { apiError('errors.admin.ai_bots.not_found', apiLocalizedText('errors.admin.ai_bots.not_found', 'Bot not found'), 404); return; } $repo->deleteBot((int)$id); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.ai_bots.deleted_success', ]); } catch (\Throwable $e) { http_response_code(500); apiError('errors.admin.ai_bots.delete_failed', apiLocalizedText('errors.admin.ai_bots.delete_failed', 'Failed to delete bot')); } })->where(['id' => '[0-9]+']); // ------------------------------------------------------- // Chat Rooms // ------------------------------------------------------- SimpleRouter::post('/chat-rooms', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $name = trim($input['name'] ?? ''); $description = trim($input['description'] ?? ''); $isActive = !empty($input['is_active']); $matterbridgeEnabled = !empty($input['matterbridge_enabled']); $matterbridgeGateway = trim((string)($input['matterbridge_gateway'] ?? '')); $matterbridgeOptions = $input['matterbridge_options'] ?? []; if ($name === '' || strlen($name) > 64) { throw new Exception('Room name must be 1-64 characters'); } if ($matterbridgeEnabled && $matterbridgeGateway === '') { throw new Exception('Matterbridge gateway is required'); } if (!is_array($matterbridgeOptions)) { throw new Exception('Matterbridge options must be an object'); } $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare(" INSERT INTO chat_rooms (name, description, is_active, matterbridge_enabled, matterbridge_gateway, matterbridge_options) VALUES (?, ?, ?, ?, ?, ?::jsonb) RETURNING id "); $stmt->execute([ $name, $description ?: null, $isActive ? 'true' : 'false', $matterbridgeEnabled ? 'true' : 'false', $matterbridgeGateway !== '' ? $matterbridgeGateway : null, json_encode($matterbridgeOptions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]); $roomId = $stmt->fetchColumn(); echo json_encode([ 'success' => true, 'id' => (int)$roomId, 'message_code' => 'ui.admin.chat_rooms.created_success' ]); } catch (Exception $e) { http_response_code(400); $message = $e->getMessage(); if ($message === 'Room name must be 1-64 characters') { apiError('errors.admin.chat_rooms.invalid_name_length', apiLocalizedText('errors.admin.chat_rooms.invalid_name_length', 'Room name must be 1-64 characters')); } elseif ($message === 'Matterbridge gateway is required') { apiError('errors.admin.chat_rooms.matterbridge_gateway_required', apiLocalizedText('errors.admin.chat_rooms.matterbridge_gateway_required', 'Matterbridge gateway is required when bridging is enabled')); } elseif ($message === 'Matterbridge options must be an object') { apiError('errors.admin.chat_rooms.matterbridge_options_invalid', apiLocalizedText('errors.admin.chat_rooms.matterbridge_options_invalid', 'Matterbridge channel options must be valid')); } else { apiError('errors.admin.chat_rooms.create_failed', apiLocalizedText('errors.admin.chat_rooms.create_failed', 'Failed to create chat room')); } } }); SimpleRouter::put('/chat-rooms/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $name = trim($input['name'] ?? ''); $description = trim($input['description'] ?? ''); $isActive = !empty($input['is_active']); $matterbridgeEnabled = !empty($input['matterbridge_enabled']); $matterbridgeGateway = trim((string)($input['matterbridge_gateway'] ?? '')); $matterbridgeOptions = $input['matterbridge_options'] ?? []; $db = \BinktermPHP\Database::getInstance()->getPdo(); $existingStmt = $db->prepare("SELECT name FROM chat_rooms WHERE id = ?"); $existingStmt->execute([$id]); $existingName = $existingStmt->fetchColumn(); if (!$existingName) { throw new Exception('Chat room not found'); } if ($existingName === 'Lobby' && $name !== '' && $name !== 'Lobby') { throw new Exception('Lobby name cannot be changed'); } $finalName = $name !== '' ? $name : $existingName; if (strlen($finalName) > 64) { throw new Exception('Room name must be 1-64 characters'); } if ($matterbridgeEnabled && $matterbridgeGateway === '') { throw new Exception('Matterbridge gateway is required'); } if (!is_array($matterbridgeOptions)) { throw new Exception('Matterbridge options must be an object'); } $stmt = $db->prepare(" UPDATE chat_rooms SET name = ?, description = ?, is_active = ?, matterbridge_enabled = ?, matterbridge_gateway = ?, matterbridge_options = ?::jsonb WHERE id = ? "); $stmt->execute([ $finalName, $description ?: null, $isActive ? 'true' : 'false', $matterbridgeEnabled ? 'true' : 'false', $matterbridgeGateway !== '' ? $matterbridgeGateway : null, json_encode($matterbridgeOptions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $id ]); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.chat_rooms.updated_success' ]); } catch (Exception $e) { http_response_code(400); $message = $e->getMessage(); if ($message === 'Chat room not found') { apiError('errors.admin.chat_rooms.not_found', apiLocalizedText('errors.admin.chat_rooms.not_found', 'Chat room not found')); } elseif ($message === 'Lobby name cannot be changed') { apiError('errors.admin.chat_rooms.lobby_name_locked', apiLocalizedText('errors.admin.chat_rooms.lobby_name_locked', 'Lobby name cannot be changed')); } elseif ($message === 'Room name must be 1-64 characters') { apiError('errors.admin.chat_rooms.invalid_name_length', apiLocalizedText('errors.admin.chat_rooms.invalid_name_length', 'Room name must be 1-64 characters')); } elseif ($message === 'Matterbridge gateway is required') { apiError('errors.admin.chat_rooms.matterbridge_gateway_required', apiLocalizedText('errors.admin.chat_rooms.matterbridge_gateway_required', 'Matterbridge gateway is required when bridging is enabled')); } elseif ($message === 'Matterbridge options must be an object') { apiError('errors.admin.chat_rooms.matterbridge_options_invalid', apiLocalizedText('errors.admin.chat_rooms.matterbridge_options_invalid', 'Matterbridge channel options must be valid')); } else { apiError('errors.admin.chat_rooms.update_failed', apiLocalizedText('errors.admin.chat_rooms.update_failed', 'Failed to update chat room')); } } }); SimpleRouter::delete('/chat-rooms/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $db = \BinktermPHP\Database::getInstance()->getPdo(); $existingStmt = $db->prepare("SELECT name FROM chat_rooms WHERE id = ?"); $existingStmt->execute([$id]); $existingName = $existingStmt->fetchColumn(); if (!$existingName) { throw new Exception('Chat room not found'); } if ($existingName === 'Lobby') { throw new Exception('Lobby cannot be deleted'); } $stmt = $db->prepare("DELETE FROM chat_rooms WHERE id = ?"); $stmt->execute([$id]); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.chat_rooms.deleted_success' ]); } catch (Exception $e) { http_response_code(400); $message = $e->getMessage(); if ($message === 'Chat room not found') { apiError('errors.admin.chat_rooms.not_found', apiLocalizedText('errors.admin.chat_rooms.not_found', 'Chat room not found')); } elseif ($message === 'Lobby cannot be deleted') { apiError('errors.admin.chat_rooms.lobby_delete_forbidden', apiLocalizedText('errors.admin.chat_rooms.lobby_delete_forbidden', 'Lobby cannot be deleted')); } else { apiError('errors.admin.chat_rooms.delete_failed', apiLocalizedText('errors.admin.chat_rooms.delete_failed', 'Failed to delete chat room')); } } }); // ======================================== // Insecure Nodes Management // ======================================== // List insecure nodes // ---- Packet BBS node management ---- SimpleRouter::get('/packet-bbs/nodes', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->query( "SELECT n.id, n.node_id, n.handle, n.interface_type, n.user_id, n.lat, n.lon, n.location, n.description, n.last_seen_at, n.created_at, n.autoadd_config, u.username, (n.api_key_hash IS NOT NULL) AS has_api_key, EXISTS ( SELECT 1 FROM meshcore_device_commands c WHERE c.bridge_node_id = n.id AND c.command_type = 'set_autoadd_config' AND c.executed_at IS NULL ) AS autoadd_pending_sync FROM packet_bbs_nodes n LEFT JOIN users u ON u.id = n.user_id ORDER BY n.created_at DESC" ); $nodes = $stmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($nodes as &$node) { $node['autoadd_pending_sync'] = (bool)$node['autoadd_pending_sync']; } unset($node); echo json_encode(['nodes' => $nodes]); }); SimpleRouter::post('/packet-bbs/nodes', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $nodeId = trim($input['node_id'] ?? ''); $handle = trim($input['handle'] ?? ''); $ifaceType = trim($input['interface_type'] ?? 'meshcore'); if (!in_array($ifaceType, ['meshcore', 'meshtastic', 'tnc'], true)) { $ifaceType = 'meshcore'; } if ($nodeId === '') { http_response_code(400); apiError('errors.admin.packet_bbs.node_id_required', 'node_id is required'); return; } try { $db = \BinktermPHP\Database::getInstance()->getPdo(); $lat = isset($input['lat']) && $input['lat'] !== null && $input['lat'] !== '' ? (float)$input['lat'] : null; $lon = isset($input['lon']) && $input['lon'] !== null && $input['lon'] !== '' ? (float)$input['lon'] : null; $location = trim($input['location'] ?? '') ?: null; $description = trim($input['description'] ?? '') ?: null; $stmt = $db->prepare( 'INSERT INTO packet_bbs_nodes (node_id, handle, interface_type, lat, lon, location, description) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id' ); $stmt->execute([$nodeId, $handle ?: null, $ifaceType, $lat, $lon, $location, $description]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); echo json_encode(['success' => true, 'id' => (int)$row['id']]); } catch (\Exception $e) { http_response_code(409); apiError('errors.admin.packet_bbs.node_exists', 'Node already exists or database error'); } }); SimpleRouter::put('/packet-bbs/nodes/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $handle = trim($input['handle'] ?? ''); $ifaceType = trim($input['interface_type'] ?? 'meshcore'); if (!in_array($ifaceType, ['meshcore', 'meshtastic', 'tnc'], true)) { $ifaceType = 'meshcore'; } $userId = !empty($input['user_id']) ? (int)$input['user_id'] : null; $lat = isset($input['lat']) && $input['lat'] !== null && $input['lat'] !== '' ? (float)$input['lat'] : null; $lon = isset($input['lon']) && $input['lon'] !== null && $input['lon'] !== '' ? (float)$input['lon'] : null; $location = trim($input['location'] ?? '') ?: null; $description = trim($input['description'] ?? '') ?: null; $autoaddConfig = array_key_exists('autoadd_config', $input) ? (is_numeric($input['autoadd_config']) ? (int)$input['autoadd_config'] : null) : false; // false = not provided $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare( 'UPDATE packet_bbs_nodes SET handle = ?, interface_type = ?, user_id = ?, lat = ?, lon = ?, location = ?, description = ? WHERE id = ?' ); $stmt->execute([$handle ?: null, $ifaceType, $userId, $lat, $lon, $location, $description, (int)$id]); if ($autoaddConfig !== false) { $colStmt = $db->prepare( 'UPDATE packet_bbs_nodes SET autoadd_config = ? WHERE id = ?' ); $colStmt->execute([$autoaddConfig, (int)$id]); // Queue the device command for this bridge node. if ($autoaddConfig !== null) { $bridgeRow = $db->prepare('SELECT id FROM packet_bbs_nodes WHERE id = ?'); $bridgeRow->execute([(int)$id]); if ($bridgeRow->rowCount() > 0) { $cmdStmt = $db->prepare( 'INSERT INTO meshcore_device_commands (bridge_node_id, command_type, payload) VALUES (?, ?, ?)' ); $cmdStmt->execute([ (int)$id, 'set_autoadd_config', json_encode(['config_byte' => $autoaddConfig]), ]); } } } echo json_encode(['success' => true]); }); SimpleRouter::post('/packet-bbs/nodes/{id}/read-autoadd-config', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); // Verify node exists. $nodeRow = $db->prepare('SELECT id FROM packet_bbs_nodes WHERE id = ?'); $nodeRow->execute([(int)$id]); if (!$nodeRow->fetch(\PDO::FETCH_ASSOC)) { http_response_code(404); echo json_encode(['success' => false, 'error' => 'Node not found']); return; } // Clear the cached value so the UI poll waits for a genuinely fresh read. $db->prepare('UPDATE packet_bbs_nodes SET autoadd_config = NULL WHERE id = ?') ->execute([(int)$id]); $cmdStmt = $db->prepare( 'INSERT INTO meshcore_device_commands (bridge_node_id, command_type, payload) VALUES (?, ?, ?)' ); $cmdStmt->execute([(int)$id, 'get_autoadd_config', '{}']); echo json_encode(['success' => true, 'queued' => true]); }); SimpleRouter::delete('/packet-bbs/nodes/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); // Cascades to packet_bbs_sessions via FK $stmt = $db->prepare('DELETE FROM packet_bbs_nodes WHERE id = ?'); $stmt->execute([(int)$id]); echo json_encode(['success' => $stmt->rowCount() > 0]); }); SimpleRouter::get('/packet-bbs/sessions', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->query( "SELECT s.*, u.username FROM packet_bbs_sessions s LEFT JOIN users u ON u.id = s.user_id ORDER BY s.last_activity_at DESC" ); echo json_encode(['sessions' => $stmt->fetchAll(\PDO::FETCH_ASSOC)]); }); SimpleRouter::get('/packet-bbs/sessions/{nodeId}', function($nodeId) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare( 'SELECT s.*, u.username FROM packet_bbs_sessions s LEFT JOIN users u ON u.id = s.user_id WHERE s.node_id = ?' ); $stmt->execute([$nodeId]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$row) { http_response_code(404); echo json_encode(['error' => 'Session not found']); return; } echo json_encode(['session' => $row]); }); SimpleRouter::delete('/packet-bbs/sessions/{nodeId}', function($nodeId) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare('DELETE FROM packet_bbs_sessions WHERE node_id = ?'); $stmt->execute([$nodeId]); echo json_encode(['success' => true]); }); SimpleRouter::get('/packet-bbs/queue', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->query( "SELECT * FROM packet_bbs_outbound_queue WHERE sent_at IS NULL ORDER BY created_at DESC LIMIT 100" ); echo json_encode(['queue' => $stmt->fetchAll(\PDO::FETCH_ASSOC)]); }); SimpleRouter::delete('/packet-bbs/queue', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); \BinktermPHP\Database::getInstance()->getPdo() ->exec('DELETE FROM packet_bbs_outbound_queue WHERE sent_at IS NULL'); echo json_encode(['success' => true]); }); SimpleRouter::post('/packet-bbs/nodes/{id}/regenerate-key', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare('SELECT id FROM packet_bbs_nodes WHERE id = ?'); $stmt->execute([(int)$id]); if (!$stmt->fetch()) { http_response_code(404); apiError('errors.admin.packet_bbs.node_not_found', 'Node not found'); return; } $token = bin2hex(random_bytes(32)); $hash = hash('sha256', $token); $db->prepare('UPDATE packet_bbs_nodes SET api_key_hash = ? WHERE id = ?') ->execute([$hash, (int)$id]); echo json_encode(['success' => true, 'key' => $token]); }); // ---- end Packet BBS ---- // ---- MeshCore Contact management ---- SimpleRouter::get('/packet-bbs/nodes/{nodeId}/contacts', function($nodeId) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare( "SELECT c.id, c.pub_key_full, c.pub_key_prefix, c.name, c.adv_type, c.user_id, u.username, c.lat, c.lon, c.last_seen_at, c.notes, c.created_at FROM meshcore_contacts c LEFT JOIN users u ON u.id = c.user_id WHERE c.bridge_node_id = ? ORDER BY c.last_seen_at DESC NULLS LAST, c.created_at DESC" ); $stmt->execute([(int)$nodeId]); echo json_encode(['contacts' => $stmt->fetchAll(\PDO::FETCH_ASSOC)]); }); SimpleRouter::put('/packet-bbs/contacts/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $name = isset($input['name']) ? trim((string)$input['name']) : null; $userId = !empty($input['user_id']) ? (int)$input['user_id'] : null; $notes = isset($input['notes']) ? trim((string)$input['notes']) : null; $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare( 'UPDATE meshcore_contacts SET name = ?, user_id = ?, notes = ?, updated_at = NOW() WHERE id = ?' ); $stmt->execute([$name !== '' ? $name : null, $userId, $notes !== '' ? $notes : null, (int)$id]); echo json_encode(['success' => $stmt->rowCount() > 0]); }); SimpleRouter::delete('/packet-bbs/contacts/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $contact = $db->prepare( 'SELECT bridge_node_id, pub_key_full FROM meshcore_contacts WHERE id = ?' ); $contact->execute([(int)$id]); $row = $contact->fetch(\PDO::FETCH_ASSOC); $stmt = $db->prepare('DELETE FROM meshcore_contacts WHERE id = ?'); $stmt->execute([(int)$id]); if ($stmt->rowCount() > 0 && $row && $row['bridge_node_id'] !== null && $row['pub_key_full'] !== null) { $cmd = $db->prepare( 'INSERT INTO meshcore_device_commands (bridge_node_id, command_type, payload) VALUES (?, ?, ?)' ); $cmd->execute([ (int)$row['bridge_node_id'], 'remove_contact', json_encode(['pub_key_full' => $row['pub_key_full']]), ]); } echo json_encode(['success' => $stmt->rowCount() > 0]); }); SimpleRouter::post('/packet-bbs/contacts/bulk-delete', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $body = json_decode(file_get_contents('php://input'), true); $ids = array_values(array_filter( array_map('intval', (array)($body['ids'] ?? [])), fn($id) => $id > 0 )); if (empty($ids)) { echo json_encode(['success' => true, 'deleted' => 0]); return; } $db = \BinktermPHP\Database::getInstance()->getPdo(); $placeholders = implode(',', array_fill(0, count($ids), '?')); // Fetch bridge-linked contacts before deletion so we can queue device commands. $fetch = $db->prepare( "SELECT id, bridge_node_id, pub_key_full FROM meshcore_contacts WHERE id IN ({$placeholders}) AND bridge_node_id IS NOT NULL AND pub_key_full IS NOT NULL" ); $fetch->execute($ids); $bridged = $fetch->fetchAll(\PDO::FETCH_ASSOC); $del = $db->prepare("DELETE FROM meshcore_contacts WHERE id IN ({$placeholders})"); $del->execute($ids); $deleted = $del->rowCount(); if ($deleted > 0 && !empty($bridged)) { $cmdStmt = $db->prepare( 'INSERT INTO meshcore_device_commands (bridge_node_id, command_type, payload) VALUES (?, ?, ?)' ); foreach ($bridged as $row) { $cmdStmt->execute([ (int)$row['bridge_node_id'], 'remove_contact', json_encode(['pub_key_full' => $row['pub_key_full']]), ]); } } echo json_encode(['success' => true, 'deleted' => $deleted]); }); // ---- end MeshCore Contact management ---- SimpleRouter::get('/insecure-nodes', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $nodes = $adminController->getInsecureNodes(); echo json_encode(['nodes' => $nodes]); }); // Add insecure node SimpleRouter::post('/insecure-nodes', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $nodeId = $adminController->addInsecureNode($input); echo json_encode([ 'success' => true, 'id' => $nodeId, 'message_code' => 'ui.admin.insecure_nodes.added_success' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.insecure_nodes.create_failed', apiLocalizedText('errors.admin.insecure_nodes.create_failed', 'Failed to add insecure node')); } }); // Update insecure node SimpleRouter::put('/insecure-nodes/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $input = json_decode(file_get_contents('php://input'), true); $result = $adminController->updateInsecureNode($id, $input); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.insecure_nodes.updated_success' ]); } else { echo json_encode(['success' => false]); } } catch (Exception $e) { http_response_code(400); apiError('errors.admin.insecure_nodes.update_failed', apiLocalizedText('errors.admin.insecure_nodes.update_failed', 'Failed to update insecure node')); } }); // Delete insecure node SimpleRouter::delete('/insecure-nodes/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $result = $adminController->deleteInsecureNode($id); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.insecure_nodes.deleted_success' ]); } else { echo json_encode(['success' => false]); } } catch (Exception $e) { http_response_code(400); apiError('errors.admin.insecure_nodes.delete_failed', apiLocalizedText('errors.admin.insecure_nodes.delete_failed', 'Failed to delete insecure node')); } }); // ======================================== // Crashmail Queue Management // ======================================== // Get crashmail queue stats SimpleRouter::get('/crashmail/stats', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $stats = $adminController->getCrashmailStats(); echo json_encode($stats); }); // Get crashmail queue items SimpleRouter::get('/crashmail/queue', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $status = $_GET['status'] ?? null; $limit = intval($_GET['limit'] ?? 50); $items = $adminController->getCrashmailQueue($status, $limit); echo json_encode(['items' => $items]); }); // Retry failed crashmail SimpleRouter::post('/crashmail/{id}/retry', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $result = $adminController->retryCrashmail($id); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.crashmail_queue.retry_success' ]); } else { echo json_encode(['success' => false]); } } catch (Exception $e) { http_response_code(400); apiError('errors.admin.crashmail.retry_failed', apiLocalizedText('errors.admin.crashmail.retry_failed', 'Failed to retry crashmail item')); } }); // Cancel queued crashmail SimpleRouter::delete('/crashmail/{id}', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $result = $adminController->cancelCrashmail($id); if ($result) { echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.crashmail_queue.cancel_success' ]); } else { echo json_encode(['success' => false]); } } catch (Exception $e) { http_response_code(400); apiError('errors.admin.crashmail.cancel_failed', apiLocalizedText('errors.admin.crashmail.cancel_failed', 'Failed to cancel crashmail item')); } }); // Attempt crashmail delivery (runs crashmail_poll) SimpleRouter::post('/crashmail/poll', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->crashmailPoll(); echo json_encode([ 'success' => true, 'result' => $result, 'message_code' => 'ui.admin.crashmail_queue.delivery_attempt_started' ]); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.crashmail.poll_failed', apiLocalizedText('errors.admin.crashmail.poll_failed', 'Failed to run crashmail poll'), 400); } }); // ======================================== // Binkp Session Log // ======================================== // Get session log SimpleRouter::get('/binkp-sessions', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $filters = [ 'session_type' => $_GET['session_type'] ?? null, 'status' => $_GET['status'] ?? null, 'remote_address' => $_GET['remote_address'] ?? null, 'is_inbound' => $_GET['is_inbound'] ?? null, 'process_id' => $_GET['process_id'] ?? null, ]; $limit = intval($_GET['limit'] ?? 50); $sessions = $adminController->getBinkpSessions($filters, $limit); echo json_encode(['sessions' => $sessions]); }); // Get session stats SimpleRouter::get('/binkp-sessions/stats', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $period = $_GET['period'] ?? 'day'; $stats = $adminController->getBinkpSessionStats($period); echo json_encode($stats); }); SimpleRouter::get('/binkp-sessions/{id}/logs', function($id) { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $session = $adminController->getBinkpSession((int)$id); if (!$session) { http_response_code(404); echo json_encode(['success' => false, 'error' => 'Session not found']); return; } $processId = (int)($session['process_id'] ?? 0); if ($processId <= 0) { echo json_encode([ 'success' => true, 'session' => $session, 'logs' => ['lines' => [], 'line_count' => 0], ]); return; } $controller = new \BinktermPHP\Binkp\Web\BinkpController(); $logs = $controller->getLogsForPid($processId, !empty($session['log_file']) ? (string)$session['log_file'] : null); echo json_encode([ 'success' => true, 'session' => $session, 'logs' => $logs, ], JSON_INVALID_UTF8_SUBSTITUTE); }); // ======================================== // Custom Template Editor // ======================================== SimpleRouter::get('/custom-templates', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $templates = $client->listCustomTemplates(); echo json_encode(['templates' => $templates]); } catch (Exception $e) { http_response_code(500); apiError('errors.admin.custom_templates.list_failed', apiLocalizedText('errors.admin.custom_templates.list_failed', 'Failed to list custom templates')); } }); SimpleRouter::get('/custom-templates/file', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $path = $_GET['path'] ?? ''; try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $template = $client->getCustomTemplate($path); echo json_encode($template); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.custom_templates.get_failed', apiLocalizedText('errors.admin.custom_templates.get_failed', 'Failed to load custom template')); } }); SimpleRouter::post('/custom-templates/file', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $path = trim($input['path'] ?? ''); $content = (string)($input['content'] ?? ''); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->saveCustomTemplate($path, $content); if (is_array($result) && !isset($result['message_code']) && !isset($result['error']) && (($result['success'] ?? true) === true)) { $result['message_code'] = 'ui.admin.template_editor.template_saved_success'; } if (is_array($result)) { $result = apiLocalizeErrorPayload($result, $user); } echo json_encode($result); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.custom_templates.save_failed', apiLocalizedText('errors.admin.custom_templates.save_failed', 'Failed to save custom template')); } }); SimpleRouter::delete('/custom-templates/file', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $path = $_GET['path'] ?? ''; try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->deleteCustomTemplate($path); if (is_array($result) && !isset($result['message_code']) && !isset($result['error']) && (($result['success'] ?? true) === true)) { $result['message_code'] = 'ui.admin.template_editor.template_deleted_success'; } if (is_array($result)) { $result = apiLocalizeErrorPayload($result, $user); } echo json_encode($result); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.custom_templates.delete_failed', apiLocalizedText('errors.admin.custom_templates.delete_failed', 'Failed to delete custom template')); } }); SimpleRouter::post('/custom-templates/install', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $source = trim($input['source'] ?? ''); $overwrite = !empty($input['overwrite']); try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->installCustomTemplate($source, $overwrite); if (is_array($result) && !isset($result['message_code']) && !isset($result['error']) && (($result['success'] ?? true) === true)) { $result['message_code'] = 'ui.admin.template_editor.template_installed_success'; } if (is_array($result)) { $result = apiLocalizeErrorPayload($result, $user); } echo json_encode($result); } catch (Exception $e) { http_response_code(400); apiError('errors.admin.custom_templates.install_failed', apiLocalizedText('errors.admin.custom_templates.install_failed', 'Failed to install custom template')); } }); }); // ======================================== // Language Overlay Editor // ======================================== // GET /admin/api/i18n-overrides/namespaces?locale=en SimpleRouter::get('/api/i18n-overrides/namespaces', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $locale = trim($_GET['locale'] ?? ''); if ($locale === '') { apiError('errors.admin.i18n_overrides.invalid_locale', 'Locale is required', 400); } $translator = new \BinktermPHP\I18n\Translator(); if (!$translator->isSupportedLocale($locale)) { apiError('errors.admin.i18n_overrides.invalid_locale', 'Unsupported locale', 400); } echo json_encode([ 'success' => true, 'locale' => $locale, 'namespaces' => $translator->getAvailableNamespaces($locale), ]); }); // GET /admin/api/i18n-overrides?locale=en&ns=common SimpleRouter::get('/api/i18n-overrides', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $locale = trim($_GET['locale'] ?? ''); $ns = trim($_GET['ns'] ?? ''); if ($locale === '' || $ns === '') { apiError('errors.admin.i18n_overrides.missing_params', 'locale and ns are required', 400); } try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->getI18nOverlay($locale, $ns); echo json_encode(array_merge(['success' => true, 'locale' => $locale, 'ns' => $ns], $result)); } catch (Exception $e) { apiError('errors.admin.i18n_overrides.load_failed', 'Failed to load overlay: ' . $e->getMessage(), 500); } }); // POST /admin/api/i18n-overrides SimpleRouter::post('/api/i18n-overrides', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $locale = trim((string)($input['locale'] ?? '')); $ns = trim((string)($input['ns'] ?? '')); $overrides = is_array($input['overrides'] ?? null) ? $input['overrides'] : []; if ($locale === '' || $ns === '') { apiError('errors.admin.i18n_overrides.missing_params', 'locale and ns are required', 400); } try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->saveI18nOverlay($locale, $ns, $overrides); echo json_encode(['success' => true, 'saved' => count(array_filter($overrides, fn($v) => $v !== ''))]); } catch (Exception $e) { apiError('errors.admin.i18n_overrides.save_failed', 'Failed to save overlay: ' . $e->getMessage(), 500); } }); // POST /admin/api/i18n-overrides/translate ? AI-powered single-string translation SimpleRouter::post('/api/i18n-overrides/translate', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true) ?? []; $locale = trim((string)($input['locale'] ?? '')); $text = trim((string)($input['text'] ?? '')); if ($locale === '' || $text === '') { apiError('errors.admin.i18n_overrides.missing_params', 'locale and text are required', 400); } $apiKey = \BinktermPHP\Config::env('ANTHROPIC_API_KEY', ''); $apiBase = rtrim(\BinktermPHP\Config::env('ANTHROPIC_API_BASE', 'https://api.anthropic.com/v1'), '/'); if ($apiKey === '') { http_response_code(503); echo json_encode(['success' => false, 'error' => 'ANTHROPIC_API_KEY is not configured']); return; } $prompt = "Translate the following UI string from English to the language with locale code \"{$locale}\". " . "Preserve any {placeholder} tokens exactly as written. " . "Return only the translated text with no explanation, no quotation marks, and no commentary.\n\n" . $text; try { $result = \BinktermPHP\AI\HttpClient::postJson( $apiBase . '/messages', [ 'model' => 'claude-haiku-4-5-20251001', 'max_tokens' => 512, 'messages' => [['role' => 'user', 'content' => $prompt]], ], [ 'Content-Type: application/json', 'x-api-key: ' . $apiKey, 'anthropic-version: 2023-06-01', ], 30 ); $translated = trim((string)($result['body']['content'][0]['text'] ?? '')); if ($translated === '') { getServerLogger()->error('i18n translate: empty response for locale=' . $locale); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Translation returned empty result']); return; } echo json_encode(['success' => true, 'translation' => $translated]); } catch (Exception $e) { getServerLogger()->error('i18n translate error: ' . $e->getMessage()); http_response_code(500); echo json_encode(['success' => false, 'error' => 'Translation failed']); } }); // Auto Feed page SimpleRouter::get('/auto-feed', function() { $user = RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/auto_feed.twig'); }); // Auto Feed API - Get all feeds SimpleRouter::get('/api/auto-feed/feeds', function() { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $stmt = $db->query(" SELECT f.*, u.username, e.tag as echoarea_tag, e.domain as echoarea_domain FROM auto_feed_sources f LEFT JOIN users u ON u.id = f.post_as_user_id LEFT JOIN echoareas e ON e.id = f.echoarea_id ORDER BY f.id DESC "); $feeds = $stmt->fetchAll(PDO::FETCH_ASSOC); echo json_encode(['feeds' => $feeds]); }); // Auto Feed API - Get single feed SimpleRouter::get('/api/auto-feed/feeds/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $stmt = $db->prepare(" SELECT f.*, u.username, u.real_name FROM auto_feed_sources f LEFT JOIN users u ON u.id = f.post_as_user_id WHERE f.id = ? "); $stmt->execute([$id]); $feed = $stmt->fetch(PDO::FETCH_ASSOC); if (!$feed) { http_response_code(404); apiError('errors.admin.auto_feed.not_found', apiLocalizedText('errors.admin.auto_feed.not_found', 'Feed source not found')); return; } echo json_encode(['feed' => $feed]); }); // Auto Feed API - Create feed SimpleRouter::post('/api/auto-feed/feeds', function() { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true) ?: []; $sourceType = normalizeAutoFeedSourceType($input['source_type'] ?? null, $input['feed_url'] ?? ''); // Validate required fields if (empty($input['feed_url']) || empty($input['echoarea_id']) || empty($input['post_as_user_id'])) { http_response_code(400); apiError('errors.admin.auto_feed.required_fields', apiLocalizedText('errors.admin.auto_feed.required_fields', 'Feed URL, echo area, and posting user are required')); return; } // Validate URL if (!filter_var($input['feed_url'], FILTER_VALIDATE_URL)) { http_response_code(400); apiError('errors.admin.auto_feed.invalid_url', apiLocalizedText('errors.admin.auto_feed.invalid_url', 'Feed URL is invalid')); return; } // Validate echoarea exists $stmt = $db->prepare("SELECT id FROM echoareas WHERE id = ?"); $stmt->execute([$input['echoarea_id']]); if (!$stmt->fetch()) { http_response_code(400); apiError('errors.admin.auto_feed.echoarea_not_found', apiLocalizedText('errors.admin.auto_feed.echoarea_not_found', 'Echo area not found')); return; } // Validate user exists $stmt = $db->prepare("SELECT id FROM users WHERE id = ?"); $stmt->execute([$input['post_as_user_id']]); if (!$stmt->fetch()) { http_response_code(400); apiError('errors.admin.auto_feed.user_not_found', apiLocalizedText('errors.admin.auto_feed.user_not_found', 'Posting user not found')); return; } try { $stmt = $db->prepare(" INSERT INTO auto_feed_sources (feed_url, feed_name, source_type, echoarea_id, post_as_user_id, max_articles_per_check, active, thread_replies, thread_lookup_limit, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) RETURNING id "); $stmt->execute([ $input['feed_url'], $input['feed_name'] ?? null, $sourceType, $input['echoarea_id'], $input['post_as_user_id'], $input['max_articles_per_check'] ?? 10, $input['active'] ?? true ? 'true' : 'false', isset($input['thread_replies']) && $input['thread_replies'] ? 'true' : 'false', max(100, min(10000, (int)($input['thread_lookup_limit'] ?? 1000))), ]); $feedId = (int)$stmt->fetchColumn(); // Log action $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'auto_feed_created', [ 'feed_id' => $feedId, 'feed_url' => $input['feed_url'], 'echoarea_id' => $input['echoarea_id'] ]); echo json_encode([ 'success' => true, 'id' => $feedId, 'message_code' => 'ui.admin.auto_feed.created_success' ]); } catch (PDOException $e) { http_response_code(400); if (strpos($e->getMessage(), 'duplicate key') !== false) { apiError('errors.admin.auto_feed.duplicate_source', apiLocalizedText('errors.admin.auto_feed.duplicate_source', 'Feed source already exists')); } else { apiError('errors.admin.auto_feed.create_failed', apiLocalizedText('errors.admin.auto_feed.create_failed', 'Failed to create feed source')); } } }); // Auto Feed API - Update feed SimpleRouter::put('/api/auto-feed/feeds/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true) ?: []; $sourceType = normalizeAutoFeedSourceType($input['source_type'] ?? null, $input['feed_url'] ?? ''); // Validate feed exists $stmt = $db->prepare("SELECT * FROM auto_feed_sources WHERE id = ?"); $stmt->execute([$id]); $existingFeed = $stmt->fetch(PDO::FETCH_ASSOC); if (!$existingFeed) { http_response_code(404); apiError('errors.admin.auto_feed.not_found', apiLocalizedText('errors.admin.auto_feed.not_found', 'Feed source not found')); return; } // Validate required fields if (empty($input['feed_url']) || empty($input['echoarea_id']) || empty($input['post_as_user_id'])) { http_response_code(400); apiError('errors.admin.auto_feed.required_fields', apiLocalizedText('errors.admin.auto_feed.required_fields', 'Feed URL, echo area, and posting user are required')); return; } if (!filter_var($input['feed_url'], FILTER_VALIDATE_URL)) { http_response_code(400); apiError('errors.admin.auto_feed.invalid_url', apiLocalizedText('errors.admin.auto_feed.invalid_url', 'Feed URL is invalid')); return; } $stmt = $db->prepare("SELECT id FROM echoareas WHERE id = ?"); $stmt->execute([$input['echoarea_id']]); if (!$stmt->fetch()) { http_response_code(400); apiError('errors.admin.auto_feed.echoarea_not_found', apiLocalizedText('errors.admin.auto_feed.echoarea_not_found', 'Echo area not found')); return; } $stmt = $db->prepare("SELECT id FROM users WHERE id = ?"); $stmt->execute([$input['post_as_user_id']]); if (!$stmt->fetch()) { http_response_code(400); apiError('errors.admin.auto_feed.user_not_found', apiLocalizedText('errors.admin.auto_feed.user_not_found', 'Posting user not found')); return; } try { $stmt = $db->prepare(" UPDATE auto_feed_sources SET feed_url = ?, feed_name = ?, source_type = ?, echoarea_id = ?, post_as_user_id = ?, max_articles_per_check = ?, active = ?, thread_replies = ?, thread_lookup_limit = ?, updated_at = NOW() WHERE id = ? "); $stmt->execute([ $input['feed_url'], $input['feed_name'] ?? null, $sourceType, $input['echoarea_id'], $input['post_as_user_id'], $input['max_articles_per_check'] ?? 10, isset($input['active']) && $input['active'] ? 'true' : 'false', isset($input['thread_replies']) && $input['thread_replies'] ? 'true' : 'false', max(100, min(10000, (int)($input['thread_lookup_limit'] ?? 1000))), $id ]); // Log action $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'auto_feed_updated', [ 'feed_id' => $id, 'feed_url' => $input['feed_url'], 'echoarea_id' => $input['echoarea_id'] ]); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.auto_feed.updated_success' ]); } catch (PDOException $e) { http_response_code(400); apiError('errors.admin.auto_feed.update_failed', apiLocalizedText('errors.admin.auto_feed.update_failed', 'Failed to update feed source')); } }); // Auto Feed API - Delete feed SimpleRouter::delete('/api/auto-feed/feeds/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); // Get feed info for logging $stmt = $db->prepare("SELECT feed_url FROM auto_feed_sources WHERE id = ?"); $stmt->execute([$id]); $feed = $stmt->fetch(PDO::FETCH_ASSOC); if (!$feed) { http_response_code(404); apiError('errors.admin.auto_feed.not_found', apiLocalizedText('errors.admin.auto_feed.not_found', 'Feed source not found')); return; } $stmt = $db->prepare("DELETE FROM auto_feed_sources WHERE id = ?"); $stmt->execute([$id]); // Log action $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'auto_feed_deleted', [ 'feed_id' => $id, 'feed_url' => $feed['feed_url'] ]); echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.auto_feed.deleted_success' ]); }); // Auto Feed API - Check feed now SimpleRouter::post('/api/auto-feed/check/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); // Verify feed exists and is active $stmt = $db->prepare("SELECT * FROM auto_feed_sources WHERE id = ?"); $stmt->execute([$id]); $feed = $stmt->fetch(PDO::FETCH_ASSOC); if (!$feed) { http_response_code(404); apiError('errors.admin.auto_feed.not_found', apiLocalizedText('errors.admin.auto_feed.not_found', 'Feed source not found')); return; } // Execute rss_poster.php script for this feed $scriptPath = __DIR__ . '/../scripts/rss_poster.php'; $command = PHP_BINARY . ' ' . escapeshellarg($scriptPath) . ' --feed-id=' . (int)$id . ' --verbose 2>&1'; $output = []; $returnCode = 0; exec($command, $output, $returnCode); if ($returnCode !== 0) { http_response_code(500); apiError('errors.admin.auto_feed.check_failed', apiLocalizedText('errors.admin.auto_feed.check_failed', 'Feed check failed')); return; } // Reload feed to get updated stats $stmt->execute([$id]); $updatedFeed = $stmt->fetch(PDO::FETCH_ASSOC); // Count articles posted $articlesPosted = $updatedFeed['articles_posted'] - $feed['articles_posted']; echo json_encode([ 'success' => true, 'message_code' => 'ui.admin.auto_feed.checked_articles_posted', 'message_params' => ['count' => max(0, $articlesPosted)], 'articles_posted' => max(0, $articlesPosted), 'output' => implode("\n", $output) ]); }); // Auto Feed API - Get statistics SimpleRouter::get('/api/auto-feed/stats', function() { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $stmt = $db->query(" SELECT COUNT(*) as total_feeds, COUNT(CASE WHEN active THEN 1 END) as active_feeds, COALESCE(SUM(articles_posted), 0) as total_articles FROM auto_feed_sources "); $stats = $stmt->fetch(PDO::FETCH_ASSOC); echo json_encode($stats); }); /** * Normalize an auto feed source type from form input and URL. */ function normalizeAutoFeedSourceType($inputType, $feedUrl) { $sourceType = strtolower(trim((string)($inputType ?? ''))); $host = strtolower((string)(parse_url((string)$feedUrl, PHP_URL_HOST) ?: '')); if ($host === 'bsky.app' || $host === 'www.bsky.app') { $sourceType = 'bluesky'; } elseif ($sourceType === '') { $sourceType = 'rss'; } return in_array($sourceType, ['rss', 'bluesky'], true) ? $sourceType : 'rss'; } // Ad analytics API (license required) SimpleRouter::get('/api/ad-analytics', function() { RouteHelper::requireAdmin(); if (!\BinktermPHP\License::isValid()) { http_response_code(403); apiError('errors.admin.ad_analytics.license_required', apiLocalizedText('errors.admin.ad_analytics.license_required', 'License required')); return; } header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $period = $_GET['period'] ?? '30d'; switch ($period) { case '7d': $interval = "7 days"; break; case '90d': $interval = "90 days"; break; case 'all': $interval = null; break; default: $interval = "30 days"; break; } $dateFilterImp = $interval ? "AND ai.shown_at >= NOW() - INTERVAL '{$interval}'" : ''; $dateFilterClk = $interval ? "AND ac.clicked_at >= NOW() - INTERVAL '{$interval}'" : ''; $dateFilterDay = $interval ? "WHERE ts >= NOW() - INTERVAL '{$interval}'" : ''; try { // Summary totals $totals = $db->query(" SELECT (SELECT COUNT(*) FROM advertisement_impressions " . ($interval ? "WHERE shown_at >= NOW() - INTERVAL '{$interval}'" : '') . ") AS total_impressions, (SELECT COUNT(*) FROM advertisement_clicks " . ($interval ? "WHERE clicked_at >= NOW() - INTERVAL '{$interval}'" : '') . ") AS total_clicks, (SELECT COUNT(*) FROM advertisements WHERE is_active = TRUE) AS active_ads, (SELECT COUNT(*) FROM advertisements) AS total_ads ")->fetch(\PDO::FETCH_ASSOC); // Per-ad stats $perAdStmt = $db->prepare(" SELECT a.id, a.title, a.slug, a.click_url, a.is_active, COUNT(DISTINCT ai.id) AS impressions, COUNT(DISTINCT ac.id) AS clicks, MAX(ai.shown_at) AS last_impression, MAX(ac.clicked_at) AS last_click FROM advertisements a LEFT JOIN advertisement_impressions ai ON ai.advertisement_id = a.id {$dateFilterImp} LEFT JOIN advertisement_clicks ac ON ac.advertisement_id = a.id {$dateFilterClk} GROUP BY a.id ORDER BY impressions DESC, clicks DESC, LOWER(a.title) "); $perAdStmt->execute(); $perAd = $perAdStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($perAd as &$row) { $row['id'] = (int)$row['id']; $row['impressions'] = (int)$row['impressions']; $row['clicks'] = (int)$row['clicks']; $row['is_active'] = $row['is_active'] === 't' || $row['is_active'] === true || $row['is_active'] === '1'; $row['ctr'] = $row['impressions'] > 0 ? round(($row['clicks'] / $row['impressions']) * 100, 1) : 0.0; } unset($row); // Daily time-series (impressions + clicks per day) $dailyImpStmt = $db->query(" SELECT DATE(shown_at AT TIME ZONE 'UTC') AS day, COUNT(*) AS cnt FROM advertisement_impressions " . ($interval ? "WHERE shown_at >= NOW() - INTERVAL '{$interval}'" : '') . " GROUP BY day ORDER BY day "); $dailyClkStmt = $db->query(" SELECT DATE(clicked_at AT TIME ZONE 'UTC') AS day, COUNT(*) AS cnt FROM advertisement_clicks " . ($interval ? "WHERE clicked_at >= NOW() - INTERVAL '{$interval}'" : '') . " GROUP BY day ORDER BY day "); $impByDay = []; foreach ($dailyImpStmt->fetchAll(\PDO::FETCH_ASSOC) as $r) { $impByDay[$r['day']] = (int)$r['cnt']; } $clkByDay = []; foreach ($dailyClkStmt->fetchAll(\PDO::FETCH_ASSOC) as $r) { $clkByDay[$r['day']] = (int)$r['cnt']; } $allDays = array_unique(array_merge(array_keys($impByDay), array_keys($clkByDay))); sort($allDays); $daily = array_map(fn($d) => [ 'day' => $d, 'impressions' => $impByDay[$d] ?? 0, 'clicks' => $clkByDay[$d] ?? 0, ], $allDays); echo json_encode([ 'summary' => [ 'total_impressions' => (int)$totals['total_impressions'], 'total_clicks' => (int)$totals['total_clicks'], 'active_ads' => (int)$totals['active_ads'], 'total_ads' => (int)$totals['total_ads'], 'overall_ctr' => (int)$totals['total_impressions'] > 0 ? round(((int)$totals['total_clicks'] / (int)$totals['total_impressions']) * 100, 1) : 0.0, ], 'per_ad' => $perAd, 'daily' => $daily, ]); } catch (\Exception $e) { http_response_code(500); apiError('errors.admin.ad_analytics.load_failed', apiLocalizedText('errors.admin.ad_analytics.load_failed', 'Failed to load analytics')); } }); // Activity statistics API SimpleRouter::get('/api/activity-stats', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $period = $_GET['period'] ?? '30d'; $excludeAdmins = !empty($_GET['exclude_admins']); // Validate and sanitize user timezone $requestedTz = $_GET['timezone'] ?? 'UTC'; try { new \DateTimeZone($requestedTz); $timezone = $requestedTz; } catch (\Exception $e) { $timezone = 'UTC'; } // Build date filter condition switch ($period) { case '7d': $dateFilter = "AND ual.created_at >= NOW() - INTERVAL '7 days'"; break; case '90d': $dateFilter = "AND ual.created_at >= NOW() - INTERVAL '90 days'"; break; case 'all': $dateFilter = ''; break; case '30d': default: $dateFilter = "AND ual.created_at >= NOW() - INTERVAL '30 days'"; break; } // Optionally exclude activity from admin users $adminFilter = $excludeAdmins ? "AND (ual.user_id IS NULL OR ual.user_id NOT IN (SELECT id FROM users WHERE is_admin = TRUE))" : ''; // Check that user_activity_log table exists try { $db->query("SELECT 1 FROM user_activity_log LIMIT 1"); } catch (\Exception $e) { apiError('errors.admin.activity_stats.table_missing', apiLocalizedText('errors.admin.activity_stats.table_missing', 'Activity log table is not available')); return; } // Summary: total + by category $summaryStmt = $db->query(" SELECT ac.name AS category, COUNT(*) AS cnt FROM user_activity_log ual JOIN activity_types at2 ON ual.activity_type_id = at2.id JOIN activity_categories ac ON at2.category_id = ac.id WHERE 1=1 {$dateFilter}{$adminFilter} GROUP BY ac.name "); $categoryRows = $summaryStmt->fetchAll(\PDO::FETCH_ASSOC); $byCategory = []; $totalEvents = 0; foreach ($categoryRows as $row) { $byCategory[$row['category']] = (int)$row['cnt']; $totalEvents += (int)$row['cnt']; } // Summary: per activity type (for netmail/echomail breakdown) $typeStmt = $db->query(" SELECT activity_type_id, COUNT(*) AS cnt FROM user_activity_log ual WHERE 1=1 {$dateFilter}{$adminFilter} GROUP BY activity_type_id "); $byType = []; foreach ($typeStmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { $byType[(int)$row['activity_type_id']] = (int)$row['cnt']; } // Login breakdown by source (object_name: 'web', 'telnet', 'ssh', etc.) $loginSourceStmt = $db->query(" SELECT COALESCE(object_name, 'web') AS source, COUNT(*) AS cnt FROM user_activity_log ual WHERE activity_type_id = 13 {$dateFilter}{$adminFilter} GROUP BY COALESCE(object_name, 'web') ORDER BY cnt DESC "); $loginBySource = []; foreach ($loginSourceStmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { $loginBySource[$row['source']] = (int)$row['cnt']; } // Popular echoareas (views and posts) $echoAreasStmt = $db->query(" SELECT object_name AS name, SUM(CASE WHEN activity_type_id = 1 THEN 1 ELSE 0 END) AS views, SUM(CASE WHEN activity_type_id = 2 THEN 1 ELSE 0 END) AS posts FROM user_activity_log ual WHERE activity_type_id IN (1, 2) {$dateFilter}{$adminFilter} AND object_name IS NOT NULL GROUP BY object_name ORDER BY views DESC LIMIT 20 "); $popularEchoareas = $echoAreasStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($popularEchoareas as &$row) { $row['views'] = (int)$row['views']; $row['posts'] = (int)$row['posts']; } unset($row); // Popular WebDoors $webdoorsStmt = $db->query(" SELECT object_name AS name, COUNT(*) AS count FROM user_activity_log ual WHERE activity_type_id = 8 {$dateFilter}{$adminFilter} AND object_name IS NOT NULL GROUP BY object_name ORDER BY count DESC LIMIT 10 "); $popularWebdoors = $webdoorsStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($popularWebdoors as &$row) { $row['count'] = (int)$row['count']; } unset($row); // Popular DOS Doors $dosdoorsStmt = $db->query(" SELECT object_name AS name, COUNT(*) AS count FROM user_activity_log ual WHERE activity_type_id = 9 {$dateFilter}{$adminFilter} AND object_name IS NOT NULL GROUP BY object_name ORDER BY count DESC LIMIT 10 "); $popularDosdoors = $dosdoorsStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($popularDosdoors as &$row) { $row['count'] = (int)$row['count']; } unset($row); // Top downloaded files $topFilesStmt = $db->query(" SELECT COALESCE(f.filename, ual.object_name) AS name, COUNT(*) AS count FROM user_activity_log ual JOIN files f ON f.id = ual.object_id JOIN file_areas fa ON fa.id = f.file_area_id WHERE activity_type_id = 6 {$dateFilter}{$adminFilter} AND COALESCE(fa.is_private, FALSE) = FALSE AND COALESCE(f.filename, ual.object_name) IS NOT NULL GROUP BY COALESCE(f.filename, ual.object_name) ORDER BY count DESC LIMIT 15 "); $topFiles = $topFilesStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($topFiles as &$row) { $row['count'] = (int)$row['count']; } unset($row); // Most browsed file areas $fileAreasStmt = $db->query(" SELECT ual.object_id AS area_id, fa.tag AS area_name, COUNT(*) AS count FROM user_activity_log ual JOIN file_areas fa ON fa.id = ual.object_id WHERE ual.activity_type_id = 5 {$dateFilter}{$adminFilter} AND ual.object_id IS NOT NULL AND COALESCE(fa.is_private, FALSE) = FALSE GROUP BY ual.object_id, fa.tag ORDER BY count DESC LIMIT 10 "); $topFileareas = $fileAreasStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($topFileareas as &$row) { $row['area_id'] = (int)$row['area_id']; $row['area_name'] = $row['area_name'] ?? 'Area #' . $row['area_id']; // deleted area fallback $row['count'] = (int)$row['count']; } unset($row); // Nodelist searches $nodelistSearchStmt = $db->query(" SELECT object_name AS name, COUNT(*) AS count FROM user_activity_log ual WHERE activity_type_id = 10 {$dateFilter}{$adminFilter} AND object_name IS NOT NULL GROUP BY object_name ORDER BY count DESC LIMIT 10 "); $topNodelistSearches = $nodelistSearchStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($topNodelistSearches as &$row) { $row['count'] = (int)$row['count']; } unset($row); // Most viewed nodes $topNodesStmt = $db->query(" SELECT object_name AS name, COUNT(*) AS count FROM user_activity_log ual WHERE activity_type_id = 11 {$dateFilter}{$adminFilter} AND object_name IS NOT NULL GROUP BY object_name ORDER BY count DESC LIMIT 10 "); $topNodes = $topNodesStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($topNodes as &$row) { $row['count'] = (int)$row['count']; } unset($row); // Top users $topUsersStmt = $db->query(" SELECT u.username, COUNT(*) AS count FROM user_activity_log ual LEFT JOIN users u ON ual.user_id = u.id WHERE 1=1 {$dateFilter}{$adminFilter} AND ual.user_id IS NOT NULL GROUP BY ual.user_id, u.username ORDER BY count DESC LIMIT 15 "); $topUsers = $topUsersStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($topUsers as &$row) { $row['count'] = (int)$row['count']; } unset($row); // Hourly distribution in user's timezone $hourlyStmt = $db->prepare(" SELECT EXTRACT(HOUR FROM created_at AT TIME ZONE :tz)::int AS hour, COUNT(*) AS count FROM user_activity_log ual WHERE 1=1 {$dateFilter}{$adminFilter} GROUP BY hour ORDER BY hour "); $hourlyStmt->execute([':tz' => $timezone]); $hourlyRaw = $hourlyStmt->fetchAll(\PDO::FETCH_ASSOC); // Fill all 24 hours even if no data $hourly = []; $hourlyByHour = []; foreach ($hourlyRaw as $row) { $hourlyByHour[(int)$row['hour']] = (int)$row['count']; } for ($h = 0; $h < 24; $h++) { $hourly[] = ['hour' => $h, 'count' => $hourlyByHour[$h] ?? 0]; } // Popular interests by subscriber count $popularInterests = []; $interestsEnabled = \BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') === 'true'; if ($interestsEnabled) { try { $popularInterestsStmt = $db->query(" SELECT i.name, i.icon, i.color, COUNT(uis.user_id) AS subscribers FROM interests i LEFT JOIN user_interest_subscriptions uis ON uis.interest_id = i.id WHERE i.is_active = TRUE GROUP BY i.id, i.name, i.icon, i.color ORDER BY subscribers DESC LIMIT 15 "); $popularInterests = $popularInterestsStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($popularInterests as &$row) { $row['subscribers'] = (int)$row['subscribers']; } unset($row); } catch (\Exception $e) { $popularInterests = []; } } // Daily activity (last 30 days always, regardless of period for the overview chart) $dailyAdminFilter = $excludeAdmins ? "AND (user_id IS NULL OR user_id NOT IN (SELECT id FROM users WHERE is_admin = TRUE))" : ''; $dailyStmt = $db->prepare(" SELECT DATE(created_at AT TIME ZONE :tz) AS date, COUNT(*) AS count FROM user_activity_log WHERE created_at >= NOW() - INTERVAL '30 days' {$dailyAdminFilter} GROUP BY date ORDER BY date "); $dailyStmt->execute([':tz' => $timezone]); $daily = $dailyStmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($daily as &$row) { $row['count'] = (int)$row['count']; } unset($row); echo json_encode([ 'period' => $period, 'timezone' => $timezone, 'summary' => [ 'total' => $totalEvents, 'by_category' => $byCategory, 'by_type' => [ 'echomail_views' => $byType[1] ?? 0, 'echomail_sends' => $byType[2] ?? 0, 'netmail_reads' => $byType[3] ?? 0, 'netmail_sends' => $byType[4] ?? 0, ], 'login_by_source' => $loginBySource, ], 'popular_echoareas' => $popularEchoareas, 'popular_webdoors' => $popularWebdoors, 'popular_dosdoors' => $popularDosdoors, 'top_files' => $topFiles, 'top_fileareas' => $topFileareas, 'top_nodelist_searches' => $topNodelistSearches, 'top_nodes' => $topNodes, 'top_users' => $topUsers, 'popular_interests' => $popularInterests, 'hourly' => $hourly, 'daily' => $daily, ]); }); SimpleRouter::get('/api/sharing', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $baseUrl = \BinktermPHP\Config::getSiteUrl(); $messageStmt = $db->query(" SELECT sm.id AS share_id, sm.message_type, sm.share_key, sm.area_identifier, sm.slug, sm.created_at, sm.expires_at, sm.access_count, sm.last_accessed_at, sm.is_public, u.username AS shared_by_username, u.real_name AS shared_by_real_name, COALESCE(em.subject, nm.subject, '') AS subject, CASE WHEN sm.message_type = 'echomail' THEN ea.tag ELSE 'netmail' END AS area_tag FROM shared_messages sm LEFT JOIN echomail em ON (sm.message_type = 'echomail' AND sm.message_id = em.id) LEFT JOIN echoareas ea ON (sm.message_type = 'echomail' AND em.echoarea_id = ea.id) LEFT JOIN netmail nm ON (sm.message_type = 'netmail' AND sm.message_id = nm.id) JOIN users u ON sm.shared_by_user_id = u.id WHERE sm.is_active = TRUE AND (sm.expires_at IS NULL OR sm.expires_at > NOW()) ORDER BY sm.access_count DESC, sm.created_at DESC "); $messageRows = $messageStmt->fetchAll(\PDO::FETCH_ASSOC); $tracker = new \BinktermPHP\ShareReferralTracker($db); $messageReferrers = $tracker->getTopReferrersForMessageShares(array_map(static function ($row) { return (int)$row['share_id']; }, $messageRows), 10); $messages = []; foreach ($messageRows as $row) { $shareId = (int)$row['share_id']; $areaIdentifier = $row['area_identifier'] ?? null; $slug = $row['slug'] ?? null; $shareUrl = (!empty($areaIdentifier) && !empty($slug)) ? $baseUrl . '/shared/' . rawurlencode($areaIdentifier) . '/' . rawurlencode($slug) : $baseUrl . '/shared/' . $row['share_key']; $messages[] = [ 'message_type' => $row['message_type'], 'subject' => $row['subject'], 'area_tag' => $row['area_tag'] ?? '', 'shared_by' => $row['shared_by_real_name'] ?: $row['shared_by_username'], 'created_at' => $row['created_at'], 'expires_at' => $row['expires_at'], 'access_count' => (int)$row['access_count'], 'last_accessed_at' => $row['last_accessed_at'], 'is_public' => filter_var($row['is_public'], FILTER_VALIDATE_BOOLEAN), 'share_url' => $shareUrl, 'top_referrers' => $messageReferrers[$shareId] ?? [], ]; } $fileStmt = $db->query(" SELECT sf.id AS share_id, sf.created_at, sf.expires_at, sf.access_count, sf.last_accessed_at, sf.freq_accessible, u.username AS shared_by_username, u.real_name AS shared_by_real_name, f.filename, fa.tag AS area_tag FROM shared_files sf JOIN files f ON sf.file_id = f.id JOIN file_areas fa ON f.file_area_id = fa.id JOIN users u ON sf.shared_by_user_id = u.id WHERE sf.is_active = TRUE AND (sf.expires_at IS NULL OR sf.expires_at > NOW()) AND f.status = 'approved' ORDER BY sf.access_count DESC, sf.created_at DESC "); $fileRows = $fileStmt->fetchAll(\PDO::FETCH_ASSOC); $fileReferrers = $tracker->getTopReferrersForFileShares(array_map(static function ($row) { return (int)$row['share_id']; }, $fileRows), 10); $files = []; foreach ($fileRows as $row) { $shareId = (int)$row['share_id']; $files[] = [ 'filename' => $row['filename'], 'area_tag' => $row['area_tag'], 'shared_by' => $row['shared_by_real_name'] ?: $row['shared_by_username'], 'created_at' => $row['created_at'], 'expires_at' => $row['expires_at'], 'access_count' => (int)$row['access_count'], 'last_accessed_at' => $row['last_accessed_at'], 'freq_accessible' => filter_var($row['freq_accessible'], FILTER_VALIDATE_BOOLEAN), 'share_url' => $baseUrl . '/shared/file/' . rawurlencode($row['area_tag']) . '/' . rawurlencode($row['filename']), 'top_referrers' => $fileReferrers[$shareId] ?? [], ]; } echo json_encode([ 'messages' => $messages, 'files' => $files, ]); }); }); // FREQ Log admin page SimpleRouter::get('/admin/freq-log', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); $template = new Template(); $template->renderResponse('admin/freq_log.twig'); }); // Crashmail Queue page SimpleRouter::get('/admin/crashmail', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); $template = new Template(); $template->renderResponse('admin/crashmail_queue.twig'); }); // Packet BBS nodes management page SimpleRouter::get('/admin/packet-bbs', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); $template = new Template(); $template->renderResponse('admin/packet_bbs.twig', [ 'current_admin_user' => [ 'id' => (int)($user['user_id'] ?? $user['id'] ?? 0), 'username' => (string)($user['username'] ?? ''), ], ]); }); // Insecure Nodes page SimpleRouter::get('/admin/insecure-nodes', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); $template = new Template(); $template->renderResponse('admin/insecure_nodes.twig'); }); // Binkp Sessions page SimpleRouter::get('/admin/binkp-sessions', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); $template = new Template(); $template->renderResponse('admin/binkp_sessions.twig'); }); // Admin subscription management page SimpleRouter::get('/admin/subscriptions', function() { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); $controller = new BinktermPHP\SubscriptionController(); $data = $controller->renderAdminSubscriptionPage(); // Only render template if we got data back (not redirected) if ($data !== null) { $template = new Template(); $template->renderResponse('admin_subscriptions.twig', $data); } }); // ??? Weather Config Admin ???????????????????????????????????????????????????? // Weather configuration page SimpleRouter::get('/admin/weather-config', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/weather_config.twig'); }); if (!function_exists('buildWeatherConfigFromRequestBody')) { /** * @return array{0: ?array, 1: ?string} */ function buildWeatherConfigFromRequestBody($body): array { if (!is_array($body)) { return [null, 'invalid_json']; } $title = trim((string)($body['title'] ?? '')); $coverageArea = trim((string)($body['coverage_area'] ?? '')); $apiKey = trim((string)($body['api_key'] ?? '')); $locations = $body['locations'] ?? []; $settings = $body['settings'] ?? []; if ($title === '') { return [null, 'errors.admin.weather.title_required']; } if ($apiKey === '') { return [null, 'errors.admin.weather.api_key_required']; } if (!is_array($locations) || count($locations) === 0) { return [null, 'errors.admin.weather.locations_required']; } return [[ 'title' => $title, 'coverage_area' => $coverageArea, 'api_key' => $apiKey, 'locations' => array_values(array_map(function($loc) { return [ 'name' => trim((string)($loc['name'] ?? '')), 'lat' => (float)($loc['lat'] ?? 0), 'lon' => (float)($loc['lon'] ?? 0), ]; }, $locations)), 'settings' => [ 'api_timeout' => max(1, (int)($settings['api_timeout'] ?? 10)), 'max_locations' => max(1, (int)($settings['max_locations'] ?? 10)), 'units' => in_array($settings['units'] ?? '', ['metric', 'imperial', 'standard'], true) ? $settings['units'] : 'metric', ], ], null]; } } // GET current weather config SimpleRouter::get('/admin/api/weather-config', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $daemon = new \BinktermPHP\Admin\AdminDaemonClient(); try { $result = $daemon->getWeatherConfig(); echo json_encode(['success' => true, 'data' => $result]); } catch (\Exception $e) { http_response_code(503); echo json_encode(['success' => false, 'error' => 'daemon_unreachable', 'daemon_error' => true]); } }); // POST weather config preview SimpleRouter::post('/admin/api/weather-config/preview', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); [$config, $error] = buildWeatherConfigFromRequestBody(json_decode(file_get_contents('php://input'), true)); if ($config === null) { http_response_code(400); echo json_encode(['success' => false, 'error' => $error]); return; } ob_start(); require_once __DIR__ . '/../scripts/weather_report.php'; ob_end_clean(); $tmpPath = tempnam(sys_get_temp_dir(), 'binkweather_'); if ($tmpPath === false) { http_response_code(500); echo json_encode(['success' => false, 'error' => 'errors.admin.weather.preview_failed']); return; } try { $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false || file_put_contents($tmpPath, $json) === false) { throw new \RuntimeException('Failed to write temporary weather configuration'); } $generator = new \WeatherReportGenerator($tmpPath); $report = $generator->generateReport(false); if (strpos($report, 'ERROR:') === 0) { throw new \RuntimeException(trim($report)); } echo json_encode([ 'success' => true, 'report' => $report, ]); } catch (\Throwable $e) { http_response_code(500); echo json_encode([ 'success' => false, 'error' => 'errors.admin.weather.preview_failed', 'message' => $e->getMessage(), ]); } finally { @unlink($tmpPath); } }); // POST save weather config SimpleRouter::post('/admin/api/weather-config', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); [$config, $error] = buildWeatherConfigFromRequestBody(json_decode(file_get_contents('php://input'), true)); if ($config === null) { http_response_code(400); echo json_encode(['success' => false, 'error' => $error]); return; } $daemon = new \BinktermPHP\Admin\AdminDaemonClient(); try { $daemon->saveWeatherConfig(json_encode($config)); echo json_encode(['success' => true]); } catch (\Exception $e) { http_response_code(503); echo json_encode(['success' => false, 'error' => 'daemon_unreachable', 'daemon_error' => true]); } }); // ??? BBS Directory Admin ????????????????????????????????????????????????????? // BBS Directory admin page SimpleRouter::get('/admin/bbs-directory', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/bbs_directory.twig'); }); // Echomail Robots admin page SimpleRouter::get('/admin/echomail-robots', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/echomail_robots.twig'); }); SimpleRouter::get('/admin/echomail-moderation', function() { RouteHelper::requireAdmin(); $template = new Template(); $template->renderResponse('admin/echomail_moderation.twig'); }); // BBS Directory API - list entries (paged + search) SimpleRouter::get('/admin/api/bbs-directory/entries', function() { RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = max(1, min(100, (int)($_GET['per_page'] ?? 25))); $search = trim($_GET['search'] ?? ''); $directory = new \BinktermPHP\BbsDirectory($db); $entries = $directory->getAllEntries($page, $perPage, $search); $total = $directory->getTotalCount($search); echo json_encode([ 'entries' => $entries, 'total' => $total, 'page' => $page, 'per_page' => $perPage, ]); }); // BBS Directory API - create manual entry SimpleRouter::post('/admin/api/bbs-directory/entries', function() { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); if (empty($input['name'])) { http_response_code(400); apiError('errors.admin.bbs_directory.name_required', apiLocalizedText('errors.admin.bbs_directory.name_required', 'BBS name is required')); return; } $directory = new \BinktermPHP\BbsDirectory($db); try { $id = $directory->createEntry($input); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'bbs_directory_entry_created', ['entry_id' => $id, 'name' => $input['name']]); echo json_encode(['success' => true, 'id' => $id]); } catch (\PDOException $e) { http_response_code(400); if (strpos($e->getMessage(), 'duplicate key') !== false || strpos($e->getMessage(), 'unique') !== false) { apiError('errors.admin.bbs_directory.duplicate_name', apiLocalizedText('errors.admin.bbs_directory.duplicate_name', 'A BBS with that name already exists')); } else { apiError('errors.admin.bbs_directory.not_found', $e->getMessage()); } } }); // BBS Directory API - update entry SimpleRouter::put('/admin/api/bbs-directory/entries/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); if (empty($input['name'])) { http_response_code(400); apiError('errors.admin.bbs_directory.name_required', apiLocalizedText('errors.admin.bbs_directory.name_required', 'BBS name is required')); return; } $directory = new \BinktermPHP\BbsDirectory($db); if (!$directory->getEntry((int)$id)) { http_response_code(404); apiError('errors.admin.bbs_directory.not_found', apiLocalizedText('errors.admin.bbs_directory.not_found', 'BBS directory entry not found')); return; } try { $directory->updateEntry((int)$id, $input); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'bbs_directory_entry_updated', ['entry_id' => $id, 'name' => $input['name']]); echo json_encode(['success' => true]); } catch (\PDOException $e) { http_response_code(400); if (strpos($e->getMessage(), 'duplicate key') !== false || strpos($e->getMessage(), 'unique') !== false) { apiError('errors.admin.bbs_directory.duplicate_name', apiLocalizedText('errors.admin.bbs_directory.duplicate_name', 'A BBS with that name already exists')); } else { apiError('errors.admin.bbs_directory.not_found', $e->getMessage()); } } }); // BBS Directory API - delete entry SimpleRouter::delete('/admin/api/bbs-directory/entries/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $directory = new \BinktermPHP\BbsDirectory($db); if (!$directory->getEntry((int)$id)) { http_response_code(404); apiError('errors.admin.bbs_directory.not_found', apiLocalizedText('errors.admin.bbs_directory.not_found', 'BBS directory entry not found')); return; } $directory->deleteEntry((int)$id); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'bbs_directory_entry_deleted', ['entry_id' => $id]); echo json_encode(['success' => true]); }); // BBS Directory API - merge duplicate into keep entry SimpleRouter::post('/admin/api/bbs-directory/entries/{id}/merge', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $discardId = (int)($input['discard_id'] ?? 0); if ($discardId === 0) { http_response_code(400); apiError('errors.admin.bbs_directory.merge_missing_discard', apiLocalizedText('errors.admin.bbs_directory.merge_missing_discard', 'discard_id is required')); return; } $directory = new \BinktermPHP\BbsDirectory($db); if (!$directory->getEntry((int)$id)) { http_response_code(404); apiError('errors.admin.bbs_directory.not_found', apiLocalizedText('errors.admin.bbs_directory.not_found', 'BBS directory entry not found')); return; } if (!$directory->getEntry($discardId)) { http_response_code(404); apiError('errors.admin.bbs_directory.not_found', apiLocalizedText('errors.admin.bbs_directory.not_found', 'BBS directory entry not found')); return; } $ok = $directory->mergeEntries((int)$id, $discardId); if (!$ok) { http_response_code(400); apiError('errors.admin.bbs_directory.merge_failed', apiLocalizedText('errors.admin.bbs_directory.merge_failed', 'Merge failed')); return; } $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'bbs_directory_entry_merged', ['keep_id' => (int)$id, 'discard_id' => $discardId]); echo json_encode(['success' => true]); }); // BBS Directory API - list pending entries SimpleRouter::get('/admin/api/bbs-directory/entries/pending', function() { RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $directory = new \BinktermPHP\BbsDirectory($db); echo json_encode(['entries' => $directory->getPendingEntries()]); }); // BBS Directory API - get single entry SimpleRouter::get('/admin/api/bbs-directory/entries/{id}', function($id) { RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $directory = new \BinktermPHP\BbsDirectory($db); $entry = $directory->getEntry((int)$id); if (!$entry) { http_response_code(404); apiError('errors.admin.bbs_directory.not_found', apiLocalizedText('errors.admin.bbs_directory.not_found', 'BBS directory entry not found')); return; } echo json_encode(['entry' => $entry]); }); // BBS Directory API - approve pending entry SimpleRouter::post('/admin/api/bbs-directory/entries/{id}/approve', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $directory = new \BinktermPHP\BbsDirectory($db); if (!$directory->getEntry((int)$id)) { http_response_code(404); apiError('errors.admin.bbs_directory.not_found', apiLocalizedText('errors.admin.bbs_directory.not_found', 'BBS directory entry not found')); return; } $directory->approveEntry((int)$id); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'bbs_directory_entry_approved', ['entry_id' => $id]); echo json_encode(['success' => true]); }); // BBS Directory API - reject pending entry SimpleRouter::post('/admin/api/bbs-directory/entries/{id}/reject', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $directory = new \BinktermPHP\BbsDirectory($db); if (!$directory->getEntry((int)$id)) { http_response_code(404); apiError('errors.admin.bbs_directory.not_found', apiLocalizedText('errors.admin.bbs_directory.not_found', 'BBS directory entry not found')); return; } $directory->rejectEntry((int)$id); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'bbs_directory_entry_rejected', ['entry_id' => $id]); echo json_encode(['success' => true]); }); // BBS Directory API - list robot rules SimpleRouter::get('/admin/api/bbs-directory/robots', function() { RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $stmt = $db->query(" SELECT r.*, e.tag AS echoarea_tag, e.domain AS echoarea_domain FROM echomail_robots r LEFT JOIN echoareas e ON e.id = r.echoarea_id ORDER BY r.id ASC "); $robots = $stmt->fetchAll(\PDO::FETCH_ASSOC); echo json_encode(['robots' => $robots]); }); // BBS Directory API - create robot rule SimpleRouter::post('/admin/api/bbs-directory/robots', function() { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); if (empty($input['name']) || empty($input['echoarea_id']) || empty($input['processor_type'])) { http_response_code(400); apiError('errors.admin.bbs_directory.robot_required_fields', apiLocalizedText('errors.admin.bbs_directory.robot_required_fields', 'Name, echo area, and processor type are required')); return; } $runner = new \BinktermPHP\Robots\EchomailRobotRunner($db); $processors = $runner->getRegisteredProcessors(); if (!isset($processors[$input['processor_type']])) { http_response_code(400); apiError('errors.admin.bbs_directory.invalid_processor_type', apiLocalizedText('errors.admin.bbs_directory.invalid_processor_type', 'Unknown or unsupported processor type')); return; } $stmt = $db->prepare(" INSERT INTO echomail_robots (name, echoarea_id, subject_pattern, processor_type, processor_config, enabled, created_at, updated_at) VALUES (:name, :echoarea_id, :subject_pattern, :processor_type, :processor_config, :enabled, NOW(), NOW()) RETURNING id "); $stmt->execute([ ':name' => $input['name'], ':echoarea_id' => (int)$input['echoarea_id'], ':subject_pattern' => $input['subject_pattern'] ?? null, ':processor_type' => $input['processor_type'], ':processor_config' => json_encode($input['processor_config'] ?? []), ':enabled' => isset($input['enabled']) ? ($input['enabled'] ? 'true' : 'false') : 'true', ]); $row = $stmt->fetch(\PDO::FETCH_ASSOC); $robotId = (int)$row['id']; $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'echomail_robot_created', ['robot_id' => $robotId, 'name' => $input['name']]); echo json_encode(['success' => true, 'id' => $robotId]); }); // BBS Directory API - update robot rule SimpleRouter::put('/admin/api/bbs-directory/robots/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $checkStmt = $db->prepare("SELECT id FROM echomail_robots WHERE id = ?"); $checkStmt->execute([$id]); if (!$checkStmt->fetch()) { http_response_code(404); apiError('errors.admin.bbs_directory.robot_not_found', apiLocalizedText('errors.admin.bbs_directory.robot_not_found', 'Robot rule not found')); return; } if (empty($input['name']) || empty($input['echoarea_id']) || empty($input['processor_type'])) { http_response_code(400); apiError('errors.admin.bbs_directory.robot_required_fields', apiLocalizedText('errors.admin.bbs_directory.robot_required_fields', 'Name, echo area, and processor type are required')); return; } $runner = new \BinktermPHP\Robots\EchomailRobotRunner($db); $processors = $runner->getRegisteredProcessors(); if (!isset($processors[$input['processor_type']])) { http_response_code(400); apiError('errors.admin.bbs_directory.invalid_processor_type', apiLocalizedText('errors.admin.bbs_directory.invalid_processor_type', 'Unknown or unsupported processor type')); return; } $stmt = $db->prepare(" UPDATE echomail_robots SET name = :name, echoarea_id = :echoarea_id, subject_pattern = :subject_pattern, processor_type = :processor_type, processor_config = :processor_config, enabled = :enabled, updated_at = NOW() WHERE id = :id "); $stmt->execute([ ':name' => $input['name'], ':echoarea_id' => (int)$input['echoarea_id'], ':subject_pattern' => $input['subject_pattern'] ?? null, ':processor_type' => $input['processor_type'], ':processor_config' => json_encode($input['processor_config'] ?? []), ':enabled' => isset($input['enabled']) ? ($input['enabled'] ? 'true' : 'false') : 'true', ':id' => (int)$id, ]); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'echomail_robot_updated', ['robot_id' => $id, 'name' => $input['name']]); echo json_encode(['success' => true]); }); // BBS Directory API - delete robot rule SimpleRouter::delete('/admin/api/bbs-directory/robots/{id}', function($id) { $user = RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $checkStmt = $db->prepare("SELECT name FROM echomail_robots WHERE id = ?"); $checkStmt->execute([$id]); $robot = $checkStmt->fetch(\PDO::FETCH_ASSOC); if (!$robot) { http_response_code(404); apiError('errors.admin.bbs_directory.robot_not_found', apiLocalizedText('errors.admin.bbs_directory.robot_not_found', 'Robot rule not found')); return; } $stmt = $db->prepare("DELETE FROM echomail_robots WHERE id = ?"); $stmt->execute([$id]); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); AdminActionLogger::logAction($userId, 'echomail_robot_deleted', ['robot_id' => $id, 'name' => $robot['name']]); echo json_encode(['success' => true]); }); // BBS Directory API - run robot now (via admin daemon) SimpleRouter::post('/admin/api/bbs-directory/robots/{id}/run', function($id) { RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $checkStmt = $db->prepare("SELECT id, name FROM echomail_robots WHERE id = ?"); $checkStmt->execute([$id]); if (!$checkStmt->fetch()) { http_response_code(404); apiError('errors.admin.bbs_directory.robot_not_found', apiLocalizedText('errors.admin.bbs_directory.robot_not_found', 'Robot rule not found')); return; } try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->runEchomailRobot((int)$id); echo json_encode([ 'success' => true, 'exit_code' => $result['exit_code'] ?? 0, 'output' => trim($result['stdout'] ?? ''), 'stderr' => trim($result['stderr'] ?? ''), ]); } catch (\Throwable $e) { http_response_code(500); apiError('errors.admin.bbs_directory.run_failed', $e->getMessage()); } }); // ----------------------------------------------------------------------- // Echomail Moderation Queue API // ----------------------------------------------------------------------- SimpleRouter::get('/admin/api/echomail-moderation', function() { RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $stmt = $db->prepare(" SELECT em.id, em.subject, em.from_name, em.date_written, em.date_received, em.message_text, ea.tag AS echoarea_tag, u.username AS author_username FROM echomail em JOIN echoareas ea ON em.echoarea_id = ea.id LEFT JOIN users u ON em.user_id = u.id WHERE em.moderation_status = 'pending' ORDER BY em.date_received ASC "); $stmt->execute(); $messages = $stmt->fetchAll(\PDO::FETCH_ASSOC); echo json_encode(['success' => true, 'messages' => $messages]); }); SimpleRouter::post('/admin/api/echomail/{id}/approve', function($id) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $handler = new \BinktermPHP\MessageHandler(); $result = $handler->approveEchomail((int)$id); if (!$result) { http_response_code(404); apiError('errors.admin.echomail_moderation.not_found', apiLocalizedText('errors.admin.echomail_moderation.not_found', 'Message not found or not pending')); return; } echo json_encode(['success' => true]); }); SimpleRouter::post('/admin/api/echomail/{id}/reject', function($id) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $handler = new \BinktermPHP\MessageHandler(); $result = $handler->rejectEchomail((int)$id); if (!$result) { http_response_code(404); apiError('errors.admin.echomail_moderation.not_found', apiLocalizedText('errors.admin.echomail_moderation.not_found', 'Message not found or not pending')); return; } echo json_encode(['success' => true]); }); // BBS Directory API - get registered processor types SimpleRouter::get('/admin/api/bbs-directory/processor-types', function() { RouteHelper::requireAdmin(); $db = \BinktermPHP\Database::getInstance()->getPdo(); header('Content-Type: application/json'); $runner = new \BinktermPHP\Robots\EchomailRobotRunner($db); $processors = array_values($runner->getRegisteredProcessors()); echo json_encode(['processors' => $processors]); }); // ============================================================================ // LovlyNet subscription management // ============================================================================ if (!function_exists('annotateLovlyNetAreasWithMetadataIssues')) { /** * @param array<int, array<string, mixed>> $areas * @param string $areaType * @return array<int, array<string, mixed>> */ function annotateLovlyNetAreasWithMetadataIssues(array $areas, string $areaType): array { foreach ($areas as &$area) { $metadata = isset($area['metadata']) && is_array($area['metadata']) ? $area['metadata'] : []; $issues = []; if ($areaType === 'echo' && array_key_exists('sysop_only', $metadata) && !empty($area['local_exists'])) { $recommendedSysopOnly = filter_var($metadata['sysop_only'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $actualSysopOnly = !empty($area['local_is_sysop_only']); if ($recommendedSysopOnly !== null && $recommendedSysopOnly !== $actualSysopOnly) { $issues[] = [ 'setting' => 'sysop_only', 'recommended' => $recommendedSysopOnly, 'actual' => $actualSysopOnly, ]; } } if ($areaType === 'echo' && array_key_exists('allow_media', $metadata) && !empty($area['local_exists'])) { $recommendedAllowMedia = filter_var($metadata['allow_media'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $actualAllowMedia = $area['local_allow_media'] ?? null; // null = use global default if ($recommendedAllowMedia !== null && $recommendedAllowMedia !== $actualAllowMedia) { $issues[] = [ 'setting' => 'allow_media', 'recommended' => $recommendedAllowMedia, 'actual' => $actualAllowMedia, ]; } } if ($areaType === 'file' && array_key_exists('readonly', $metadata) && !empty($area['local_exists'])) { $recommendedReadonly = filter_var($metadata['readonly'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $actualReadonly = ((int)($area['local_upload_permission'] ?? -1)) === \BinktermPHP\FileAreaManager::UPLOAD_READ_ONLY; if ($recommendedReadonly !== null && $recommendedReadonly !== $actualReadonly) { $issues[] = [ 'setting' => 'readonly', 'recommended' => $recommendedReadonly, 'actual' => $actualReadonly, ]; } } if ($areaType === 'file' && array_key_exists('replace', $metadata) && !empty($area['local_exists'])) { $recommendedReplace = filter_var($metadata['replace'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $actualReplace = !empty($area['local_replace_existing']); if ($recommendedReplace !== null && $recommendedReplace !== $actualReplace) { $issues[] = [ 'setting' => 'replace', 'recommended' => $recommendedReplace, 'actual' => $actualReplace, ]; } } $area['setting_issues'] = $issues; $area['has_setting_issues'] = $issues !== []; } unset($area); return $areas; } } /** * GET /admin/lovlynet * Admin page for managing LovlyNet echo/file area subscriptions. */ SimpleRouter::get('/admin/lovlynet', function() { RouteHelper::requireAdmin(); $client = new \BinktermPHP\LovlyNetClient(); $template = new Template(); $template->renderResponse('admin/lovlynet.twig', [ 'lovlynet_configured' => $client->isConfigured(), 'lovlynet_node_number' => $client->getFtnAddress(), 'lovlynet_base_url' => $client->getBaseUrl(), 'lovlynet_hub_address' => $client->getHubAddress(), ]); }); /** * GET /admin/api/lovlynet/areas * Proxy: fetch echo and file areas with subscription status from LovlyNet. */ SimpleRouter::get('/admin/api/lovlynet/areas', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $client = new \BinktermPHP\LovlyNetClient(); $result = $client->getAreas(); if (!$result['success']) { http_response_code(502); echo json_encode(['error' => $result['error']]); return; } $echoareaManager = new \BinktermPHP\EchoareaManager(); $fileAreaManager = new \BinktermPHP\FileAreaManager(); $echoareas = $echoareaManager->annotateAreasWithLocalStatus($result['echoareas'], ['', 'lovlynet']); $fileareas = $fileAreaManager->annotateAreasWithLocalStatus($result['fileareas'], ['', 'lovlynet']); $echoareas = annotateLovlyNetAreasWithMetadataIssues($echoareas, 'echo'); $fileareas = annotateLovlyNetAreasWithMetadataIssues($fileareas, 'file'); echo json_encode([ 'echoareas' => $echoareas, 'fileareas' => $fileareas, 'ftn_address' => $result['ftn_address'] ?? '', ]); }); /** * POST /admin/api/lovlynet/subscription * Proxy: subscribe or unsubscribe from a LovlyNet area. * * Body: { "action": "subscribe"|"unsubscribe", "area_type": "echo"|"file", "area_tag": "TAG" } */ SimpleRouter::post('/admin/api/lovlynet/subscription', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { http_response_code(400); echo json_encode(['error' => 'Invalid JSON']); return; } $action = trim($body['action'] ?? ''); $areaType = trim($body['area_type'] ?? ''); $areaTag = trim($body['area_tag'] ?? ''); if (!in_array($action, ['subscribe', 'unsubscribe'], true)) { http_response_code(400); echo json_encode(['error' => 'Invalid action']); return; } if (!in_array($areaType, ['echo', 'file'], true)) { http_response_code(400); echo json_encode(['error' => 'Invalid area_type']); return; } if ($areaTag === '') { http_response_code(400); echo json_encode(['error' => 'area_tag is required']); return; } $client = new \BinktermPHP\LovlyNetClient(); if ($action === 'subscribe') { $areasResult = $client->getAreas(); if (!$areasResult['success']) { http_response_code(502); echo json_encode(['error' => $areasResult['error']]); return; } } if ($action === 'subscribe' && $areaType === 'echo') { $remoteArea = null; foreach (($areasResult['echoareas'] ?? []) as $candidate) { if (strcasecmp(trim((string)($candidate['tag'] ?? '')), $areaTag) === 0) { $remoteArea = $candidate; break; } } if ($remoteArea === null) { http_response_code(400); echo json_encode(['error' => 'Unknown LovlyNet echo area']); return; } $metadata = isset($remoteArea['metadata']) && is_array($remoteArea['metadata']) ? $remoteArea['metadata'] : []; $isSysopOnly = false; if (isset($metadata['sysop_only'])) { $v = filter_var($metadata['sysop_only'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($v !== null) { $isSysopOnly = $v; } } $echoareaManager = new \BinktermPHP\EchoareaManager(); $localEchoareaId = $echoareaManager->createIfMissing([ 'tag' => $remoteArea['tag'] ?? $areaTag, 'description' => $remoteArea['description'] ?? '', 'domain' => 'lovlynet', 'uplink_address' => $client->getHubAddress(), 'is_local' => false, 'is_active' => true, 'is_sysop_only' => $isSysopOnly, 'gemini_public' => false, ], ['', 'lovlynet']); $client->applyRecommendedSettings('echo', array_merge($remoteArea, [ 'local_echoarea_id' => $localEchoareaId, ])); } if ($action === 'subscribe' && $areaType === 'file') { $remoteArea = null; foreach (($areasResult['fileareas'] ?? []) as $candidate) { if (strcasecmp(trim((string)($candidate['tag'] ?? '')), $areaTag) === 0) { $remoteArea = $candidate; break; } } if ($remoteArea !== null) { $metadata = isset($remoteArea['metadata']) && is_array($remoteArea['metadata']) ? $remoteArea['metadata'] : []; $uploadPermission = \BinktermPHP\FileAreaManager::getDefaultUploadPermissionForArea( $remoteArea['tag'] ?? $areaTag, 'lovlynet' ); if (isset($metadata['readonly'])) { $v = filter_var($metadata['readonly'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($v !== null) { $uploadPermission = $v ? \BinktermPHP\FileAreaManager::UPLOAD_READ_ONLY : \BinktermPHP\FileAreaManager::UPLOAD_USERS_ALLOWED; } } $replaceExisting = true; if (isset($metadata['replace'])) { $v = filter_var($metadata['replace'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($v !== null) { $replaceExisting = $v; } } $fileAreaManager = new \BinktermPHP\FileAreaManager(); $localFileareaId = $fileAreaManager->createIfMissing([ 'tag' => $remoteArea['tag'] ?? $areaTag, 'description' => $remoteArea['description'] ?? '', 'domain' => 'lovlynet', 'is_local' => false, 'is_active' => true, 'upload_permission' => $uploadPermission, 'replace_existing' => $replaceExisting, ]); $client->applyRecommendedSettings('file', array_merge($remoteArea, [ 'local_filearea_id' => $localFileareaId, ])); } } $result = $client->setSubscription($action, $areaType, $areaTag); if (!$result['success']) { http_response_code(502); echo json_encode(['error' => $result['error']]); return; } $echoareas = $result['echoareas'] ?? []; $fileareas = $result['fileareas'] ?? []; if ($areaType === 'echo') { $echoareaManager = new \BinktermPHP\EchoareaManager(); $echoareas = $echoareaManager->annotateAreasWithLocalStatus($echoareas, ['', 'lovlynet']); $echoareas = annotateLovlyNetAreasWithMetadataIssues($echoareas, 'echo'); } elseif ($areaType === 'file') { $fileAreaManager = new \BinktermPHP\FileAreaManager(); $fileareas = $fileAreaManager->annotateAreasWithLocalStatus($fileareas, ['', 'lovlynet']); $fileareas = annotateLovlyNetAreasWithMetadataIssues($fileareas, 'file'); } echo json_encode([ 'success' => true, 'echoareas' => $echoareas, 'fileareas' => $fileareas, ]); }); /** * POST /admin/api/lovlynet/request * Send an AreaFix or FileFix netmail request to the configured LovlyNet hub. * * Body: { "area_type": "echo"|"file", "message_text": "..." } */ SimpleRouter::post('/admin/api/lovlynet/request', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { apiError( 'errors.admin.lovlynet.invalid_json', apiLocalizedText('errors.admin.lovlynet.invalid_json', 'Invalid request payload'), 400, ['success' => false] ); } $areaType = trim((string)($body['area_type'] ?? '')); $messageText = trim((string)($body['message_text'] ?? '')); if (!in_array($areaType, ['echo', 'file'], true)) { apiError( 'errors.admin.lovlynet.invalid_area_type', apiLocalizedText('errors.admin.lovlynet.invalid_area_type', 'Invalid area type'), 400, ['success' => false] ); } if ($messageText === '') { apiError( 'errors.admin.lovlynet.request_message_required', apiLocalizedText('errors.admin.lovlynet.request_message_required', 'Request message is required'), 400, ['success' => false] ); } $client = new \BinktermPHP\LovlyNetClient(); if (!$client->isConfigured()) { apiError( 'errors.admin.lovlynet.not_configured', apiLocalizedText('errors.admin.lovlynet.not_configured', 'LovlyNet is not configured'), 400, ['success' => false] ); } $hubAddress = trim($client->getHubAddress()); $areafixPassword = trim($client->getAreafixPassword()); if ($hubAddress === '' || $areafixPassword === '') { apiError( 'errors.admin.lovlynet.request_config_missing', apiLocalizedText('errors.admin.lovlynet.request_config_missing', 'LovlyNet request settings are incomplete'), 400, ['success' => false] ); } $toName = $areaType === 'file' ? 'FileFix' : 'AreaFix'; $fromUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); $fromName = trim((string)($user['real_name'] ?? $user['username'] ?? '')); try { $messageHandler = new \BinktermPHP\MessageHandler(); $messageHandler->sendNetmail( $fromUserId, $hubAddress, $toName, $areafixPassword, $messageText, $fromName !== '' ? $fromName : null ); } catch (\Throwable $e) { apiError( 'errors.admin.lovlynet.request_send_failed', apiLocalizedText('errors.admin.lovlynet.request_send_failed', 'Failed to send request netmail', $user), 500, ['success' => false] ); } echo json_encode(['success' => true]); }); /** * GET /admin/api/lovlynet/help?type=echo|file * Proxy: fetch AreaFix/FileFix help text from LovlyNet. */ SimpleRouter::get('/admin/api/lovlynet/help', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $type = trim((string)($_GET['type'] ?? '')); if (!in_array($type, ['echo', 'file'], true)) { apiError( 'errors.admin.lovlynet.invalid_area_type', apiLocalizedText('errors.admin.lovlynet.invalid_area_type', 'Invalid area type', $user), 400, ['success' => false] ); } $client = new \BinktermPHP\LovlyNetClient(); $result = $type === 'file' ? $client->getFileFixHelp() : $client->getAreaFixHelp(); if (!$result['success']) { apiError( 'errors.admin.lovlynet.help_fetch_failed', apiLocalizedText('errors.admin.lovlynet.help_fetch_failed', 'Failed to load help text', $user), 502, ['success' => false] ); } echo json_encode([ 'success' => true, 'help' => $result['help'] ?? '', ]); }); /** * POST /admin/api/lovlynet/echoarea-sync * Fetch the current LovlyNet description for a local LovlyNet echoarea. */ SimpleRouter::post('/admin/api/lovlynet/echoarea-sync', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { apiError( 'errors.admin.lovlynet.invalid_json', apiLocalizedText('errors.admin.lovlynet.invalid_json', 'Invalid request payload', $user), 400, ['success' => false] ); } $echoareaId = (int)($body['echoarea_id'] ?? 0); if ($echoareaId <= 0) { apiError( 'errors.echoareas.not_found', apiLocalizedText('errors.echoareas.not_found', 'Echo area not found', $user), 404, ['success' => false] ); } $echoareaManager = new \BinktermPHP\EchoareaManager(); $echoarea = $echoareaManager->getById($echoareaId); if (!$echoarea) { apiError( 'errors.echoareas.not_found', apiLocalizedText('errors.echoareas.not_found', 'Echo area not found', $user), 404, ['success' => false] ); } if (strcasecmp((string)($echoarea['domain'] ?? ''), 'lovlynet') !== 0) { apiError( 'errors.admin.lovlynet.invalid_area_type', apiLocalizedText('errors.admin.lovlynet.invalid_area_type', 'Invalid area type', $user), 400, ['success' => false] ); } $client = new \BinktermPHP\LovlyNetClient(); $areasResult = $client->getAreas(); if (!$areasResult['success']) { apiError( 'errors.admin.lovlynet.help_fetch_failed', apiLocalizedText('errors.admin.lovlynet.help_fetch_failed', 'Failed to load help text', $user), 502, ['success' => false] ); } $remoteArea = null; foreach (($areasResult['echoareas'] ?? []) as $candidate) { if (strcasecmp(trim((string)($candidate['tag'] ?? '')), trim((string)($echoarea['tag'] ?? ''))) === 0) { $remoteArea = $candidate; break; } } if ($remoteArea === null) { apiError( 'errors.echoareas.not_found', apiLocalizedText('errors.echoareas.not_found', 'Echo area not found', $user), 404, ['success' => false] ); } $description = trim((string)($remoteArea['description'] ?? '')); if ($description === '') { apiError( 'errors.admin.lovlynet.request_send_failed', apiLocalizedText('errors.admin.lovlynet.request_send_failed', 'Failed to send request netmail', $user), 502, ['success' => false] ); } echo json_encode([ 'success' => true, 'description' => $description, 'message_code' => 'ui.echoareas.lovlynet_sync_success', ]); }); /** * POST /admin/api/lovlynet/area-sync * Ensure a subscribed LovlyNet area exists locally and has the current description. */ SimpleRouter::post('/admin/api/lovlynet/area-sync', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { apiError( 'errors.admin.lovlynet.invalid_json', apiLocalizedText('errors.admin.lovlynet.invalid_json', 'Invalid request payload', $user), 400, ['success' => false] ); } $areaType = trim((string)($body['area_type'] ?? 'echo')); if (!in_array($areaType, ['echo', 'file'], true)) { apiError( 'errors.admin.lovlynet.invalid_area_type', apiLocalizedText('errors.admin.lovlynet.invalid_area_type', 'Invalid area type', $user), 400, ['success' => false] ); } $areaTag = strtoupper(trim((string)($body['area_tag'] ?? ''))); if ($areaTag === '') { apiError( $areaType === 'file' ? 'errors.fileareas.not_found' : 'errors.echoareas.not_found', apiLocalizedText($areaType === 'file' ? 'errors.fileareas.not_found' : 'errors.echoareas.not_found', $areaType === 'file' ? 'File area not found' : 'Echo area not found', $user), 404, ['success' => false] ); } $client = new \BinktermPHP\LovlyNetClient(); $areasResult = $client->getAreas(); if (!$areasResult['success']) { apiError( 'errors.admin.lovlynet.help_fetch_failed', apiLocalizedText('errors.admin.lovlynet.help_fetch_failed', 'Failed to load help text', $user), 502, ['success' => false] ); } $remoteArea = null; $remoteAreas = $areaType === 'file' ? ($areasResult['fileareas'] ?? []) : ($areasResult['echoareas'] ?? []); foreach ($remoteAreas as $candidate) { if (strcasecmp(trim((string)($candidate['tag'] ?? '')), $areaTag) === 0) { $remoteArea = $candidate; break; } } if ($remoteArea === null) { apiError( $areaType === 'file' ? 'errors.fileareas.not_found' : 'errors.echoareas.not_found', apiLocalizedText($areaType === 'file' ? 'errors.fileareas.not_found' : 'errors.echoareas.not_found', $areaType === 'file' ? 'File area not found' : 'Echo area not found', $user), 404, ['success' => false] ); } $description = trim((string)($remoteArea['description'] ?? '')); if ($description === '') { apiError( $areaType === 'file' ? 'errors.fileareas.update_failed' : 'errors.echoareas.update_failed', apiLocalizedText($areaType === 'file' ? 'errors.fileareas.update_failed' : 'errors.echoareas.update_failed', $areaType === 'file' ? 'Failed to update file area' : 'Failed to update echo area', $user), 500, ['success' => false] ); } if ($areaType === 'file') { $fileAreaManager = new \BinktermPHP\FileAreaManager(); $existingFileArea = $fileAreaManager->getFileAreaByTag((string)($remoteArea['tag'] ?? $areaTag), 'lovlynet'); if ($existingFileArea) { $fileAreaId = (int)$existingFileArea['id']; } else { $metadata = isset($remoteArea['metadata']) && is_array($remoteArea['metadata']) ? $remoteArea['metadata'] : []; $uploadPermission = \BinktermPHP\FileAreaManager::getDefaultUploadPermissionForArea( $remoteArea['tag'] ?? $areaTag, 'lovlynet' ); if (isset($metadata['readonly'])) { $v = filter_var($metadata['readonly'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($v !== null) { $uploadPermission = $v ? \BinktermPHP\FileAreaManager::UPLOAD_READ_ONLY : \BinktermPHP\FileAreaManager::UPLOAD_USERS_ALLOWED; } } $replaceExisting = true; if (isset($metadata['replace'])) { $v = filter_var($metadata['replace'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($v !== null) { $replaceExisting = $v; } } $fileAreaId = $fileAreaManager->createIfMissing([ 'tag' => $remoteArea['tag'] ?? $areaTag, 'description' => $description, 'domain' => 'lovlynet', 'is_local' => false, 'is_active' => true, 'upload_permission' => $uploadPermission, 'replace_existing' => $replaceExisting, ]); } if (!$fileAreaManager->updateDescription($fileAreaId, $description)) { apiError( 'errors.fileareas.update_failed', apiLocalizedText('errors.fileareas.update_failed', 'Failed to update file area', $user), 500, ['success' => false] ); } $client->applyRecommendedSettings('file', array_merge($remoteArea, [ 'local_filearea_id' => $fileAreaId, ])); } else { $echoareaManager = new \BinktermPHP\EchoareaManager(); $existingEchoarea = $echoareaManager->findByTagAndDomains((string)($remoteArea['tag'] ?? $areaTag), ['', 'lovlynet']); if ($existingEchoarea) { $echoareaId = (int)$existingEchoarea['id']; } else { $syncMetadata = isset($remoteArea['metadata']) && is_array($remoteArea['metadata']) ? $remoteArea['metadata'] : []; $isSysopOnly = false; if (isset($syncMetadata['sysop_only'])) { $v = filter_var($syncMetadata['sysop_only'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($v !== null) { $isSysopOnly = $v; } } $echoareaId = $echoareaManager->createIfMissing([ 'tag' => $remoteArea['tag'] ?? $areaTag, 'description' => $description, 'domain' => 'lovlynet', 'uplink_address' => $client->getHubAddress(), 'is_local' => false, 'is_active' => true, 'is_sysop_only' => $isSysopOnly, 'gemini_public' => false, ], ['', 'lovlynet']); } if (!$echoareaManager->updateDescription($echoareaId, $description)) { apiError( 'errors.echoareas.update_failed', apiLocalizedText('errors.echoareas.update_failed', 'Failed to update echo area', $user), 500, ['success' => false] ); } $client->applyRecommendedSettings('echo', array_merge($remoteArea, [ 'local_echoarea_id' => $echoareaId, ])); } echo json_encode([ 'success' => true, 'description' => $description, 'message_code' => 'ui.echoareas.lovlynet_sync_success', ]); }); /** * GET /admin/api/lovlynet/requests * Return outbound AreaFix/FileFix requests and inbound responses for the admin. */ SimpleRouter::get('/admin/api/lovlynet/requests', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $client = new \BinktermPHP\LovlyNetClient(); if (!$client->isConfigured()) { apiError( 'errors.admin.lovlynet.not_configured', apiLocalizedText('errors.admin.lovlynet.not_configured', 'LovlyNet is not configured', $user), 400, ['success' => false] ); } $hubAddress = trim($client->getHubAddress()); if ($hubAddress === '') { apiError( 'errors.admin.lovlynet.request_config_missing', apiLocalizedText('errors.admin.lovlynet.request_config_missing', 'LovlyNet request settings are incomplete', $user), 400, ['success' => false] ); } $messageHandler = new \BinktermPHP\MessageHandler(); $requests = $messageHandler->getLovlyNetRequests((int)($user['user_id'] ?? $user['id'] ?? 0), $hubAddress); echo json_encode([ 'success' => true, 'requests' => $requests, ]); }); /** * GET /admin/api/lovlynet/filearea-files?tag=TAG * Fetch the list of files available in a LovlyNet file area (parsed from files.bbs). */ SimpleRouter::get('/admin/api/lovlynet/filearea-files', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $areaTag = trim((string)($_GET['tag'] ?? '')); if ($areaTag === '') { apiError( 'errors.admin.lovlynet.invalid_area_type', apiLocalizedText('errors.admin.lovlynet.invalid_area_type', 'Area tag is required', $user), 400, ['success' => false] ); } $client = new \BinktermPHP\LovlyNetClient(); if (!$client->isConfigured()) { apiError( 'errors.admin.lovlynet.not_configured', apiLocalizedText('errors.admin.lovlynet.not_configured', 'LovlyNet is not configured', $user), 400, ['success' => false] ); } $result = $client->getFileAreaFiles($areaTag); if (!$result['success']) { apiError( 'errors.admin.lovlynet.filearea_files_failed', apiLocalizedText('errors.admin.lovlynet.filearea_files_failed', 'Failed to load file area files', $user), 502, ['success' => false] ); } // Build a set of locally held filenames (case-insensitive) and collect full local file records. $localFilenames = []; // lowercase filename => true, for local_exists check $allLocalFiles = []; // full records: id, filename, description try { $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare(" SELECT f.id, f.filename, f.short_description FROM files f JOIN file_areas fa ON f.file_area_id = fa.id WHERE LOWER(fa.tag) = LOWER(?) AND f.status = 'approved' ORDER BY f.filename "); $stmt->execute([$areaTag]); foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { $localFilenames[strtolower($row['filename'])] = true; $allLocalFiles[] = [ 'id' => (int)$row['id'], 'filename' => $row['filename'], 'description' => $row['short_description'] ?? '', ]; } } catch (\Throwable $e) { // Non-fatal ? local_exists will default to false } $files = array_map(function (array $file) use ($localFilenames): array { $file['local_exists'] = isset($localFilenames[strtolower($file['filename'] ?? '')]); return $file; }, $result['files']); // Local-only files: present locally but not in the LovlyNet remote list. $remoteFilenamesLower = array_map(fn($f) => strtolower($f['filename'] ?? ''), $result['files']); $localOnlyFiles = array_values(array_filter($allLocalFiles, function (array $lf) use ($remoteFilenamesLower): bool { return !in_array(strtolower($lf['filename']), $remoteFilenamesLower, true); })); echo json_encode([ 'success' => true, 'files' => $files, 'local_only_files' => $localOnlyFiles, ]); }); /** * POST /admin/api/lovlynet/hatch-file * Hatch a local file to LovlyNet uplinks via the admin daemon. */ SimpleRouter::post('/admin/api/lovlynet/hatch-file', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $data = json_decode(file_get_contents('php://input'), true) ?? []; $fileId = (int)($data['file_id'] ?? 0); if ($fileId <= 0) { apiError( 'errors.admin.lovlynet.invalid_file_id', apiLocalizedText('errors.admin.lovlynet.invalid_file_id', 'Invalid file ID', $user), 400, ['success' => false] ); } $daemon = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $daemon->rehatchFile($fileId); if (!($result['ok'] ?? false)) { apiError( 'errors.admin.lovlynet.hatch_failed', apiLocalizedText('errors.admin.lovlynet.hatch_failed', 'Failed to hatch file', $user), 500, ['success' => false] ); } echo json_encode(['success' => true]); }); /** * GET /admin/api/lovlynet/registration * Return the local LovlyNet registration status and BinkpConfig defaults for the update form. */ SimpleRouter::get('/admin/api/lovlynet/registration', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $client = new \BinktermPHP\LovlyNetClient(); $status = $client->getRegistrationStatus(); if (!$status['success']) { http_response_code(404); echo json_encode([ 'error_code' => 'errors.admin.lovlynet.not_registered', 'error' => $status['error'], ]); return; } // Try to fetch current values from the LovlyNet server; fall back to local BinkpConfig $remote = $client->getRemoteRegistration(); if ($remote['success']) { $defaults = [ 'system_name' => $remote['system_name'], 'sysop_name' => $remote['sysop_name'], 'hostname' => $remote['hostname'], 'binkp_port' => $remote['binkp_port'], 'site_url' => $remote['site_url'], ]; $isPassive = $remote['is_passive']; } else { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $defaults = [ 'system_name' => $binkpConfig->getSystemName(), 'sysop_name' => $binkpConfig->getSystemSysop(), 'hostname' => $binkpConfig->getSystemHostname(), 'binkp_port' => $binkpConfig->getBinkpPort(), 'site_url' => \BinktermPHP\Config::getSiteUrl(), ]; $isPassive = $status['is_passive']; } echo json_encode([ 'ftn_address' => $status['ftn_address'], 'hub_address' => $status['hub_address'], 'registered_at' => $status['registered_at'], 'updated_at' => $status['updated_at'], 'is_passive' => $isPassive, 'defaults' => $defaults, ]); }); /** * POST /admin/api/lovlynet/update-registration * Update this node's registration with the LovlyNet registry. * * Body: { "system_name": "...", "sysop_name": "...", "hostname": "...", * "binkp_port": 24554, "site_url": "...", "is_passive": false } */ SimpleRouter::post('/admin/api/lovlynet/update-registration', function() { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { apiError( 'errors.admin.lovlynet.invalid_json', apiLocalizedText('errors.admin.lovlynet.invalid_json', 'Invalid request payload', $user), 400 ); return; } $systemName = trim((string)($data['system_name'] ?? '')); $sysopName = trim((string)($data['sysop_name'] ?? '')); $hostname = trim((string)($data['hostname'] ?? '')); $siteUrl = trim((string)($data['site_url'] ?? '')); $binkpPort = (int)($data['binkp_port'] ?? 0); $isPassive = (bool)($data['is_passive'] ?? false); if ($systemName === '' || $sysopName === '' || $hostname === '') { apiError( 'errors.admin.lovlynet.registration_update_failed', apiLocalizedText('errors.admin.lovlynet.registration_update_failed', 'Registration update failed', $user), 400 ); return; } $client = new \BinktermPHP\LovlyNetClient(); $result = $client->updateRegistration([ 'system_name' => $systemName, 'sysop_name' => $sysopName, 'hostname' => $hostname, 'binkp_port' => $binkpPort, 'site_url' => $siteUrl, 'is_passive' => $isPassive, ]); if (!$result['success']) { apiError( 'errors.admin.lovlynet.registration_update_failed', $result['error'] ?? apiLocalizedText('errors.admin.lovlynet.registration_update_failed', 'Registration update failed', $user), 502 ); return; } $regData = $result['data']['data'] ?? $result['data'] ?? []; $regData['is_passive'] = $isPassive; if (!$client->saveRegistrationUpdate($regData)) { getServerLogger()->error('LovlyNet: saveRegistrationUpdate failed to write config/lovlynet.json'); } echo json_encode(['success' => true]); }); /** * GET /admin/api/lovlynet/checklist * Return the status of LovlyNet setup checklist items. */ SimpleRouter::get('/admin/api/lovlynet/checklist', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $recommendedPattern = '/^LOVLYNET\\.(Z|A|L|R|J)[0-9]{2}$/i'; $areaKey = 'LVLY_NODELIST@LOVLYNET'; $ruleExists = false; $patternMatches = false; $nodelistRuleDaemonError = false; try { $daemonClient = new \BinktermPHP\Admin\AdminDaemonClient(); $rulesConfig = $daemonClient->getFileAreaRulesConfig(); $configJson = $rulesConfig['config_json'] ?? null; if ($configJson !== null) { $parsed = json_decode($configJson, true); if (is_array($parsed)) { $areaRules = $parsed['area_rules'][$areaKey] ?? $parsed['area_rules']['LVLY_NODELIST'] ?? []; foreach ($areaRules as $rule) { if (isset($rule['pattern'])) { $ruleExists = true; if ($rule['pattern'] === $recommendedPattern) { $patternMatches = true; break; } } } } } } catch (\Exception $e) { getServerLogger()->error('LovlyNet checklist: failed to load file area rules: ' . $e->getMessage()); $nodelistRuleDaemonError = true; } // Check default area subscriptions using is_default flag from areas response $defaultAreasItem = ['id' => 'default_areas', 'ok' => true, 'unsubscribed_echo' => [], 'unsubscribed_file' => [], 'fetch_error' => false]; $lovlyClient = new \BinktermPHP\LovlyNetClient(); $areasResult = $lovlyClient->getAreas(); if (!$areasResult['success']) { $defaultAreasItem['fetch_error'] = true; } else { $missingEcho = []; foreach ($areasResult['echoareas'] as $area) { if (!empty($area['is_default']) && empty($area['subscribed'])) { $missingEcho[] = strtoupper((string)($area['tag'] ?? '')); } } $missingFile = []; foreach ($areasResult['fileareas'] as $area) { if (!empty($area['is_default']) && empty($area['subscribed'])) { $missingFile[] = strtoupper((string)($area['tag'] ?? '')); } } $defaultAreasItem['unsubscribed_echo'] = $missingEcho; $defaultAreasItem['unsubscribed_file'] = $missingFile; $defaultAreasItem['ok'] = ($missingEcho === [] && $missingFile === []); } // Check whether a LovlyNet uplink is configured in binkp.json $uplinkConfigured = false; try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $lovlyUplink = $binkpConfig->getUplinkByDomain('lovlynet'); $uplinkConfigured = $lovlyUplink !== null && !empty($lovlyUplink['me']) && !empty($lovlyUplink['address']) && !empty($lovlyUplink['networks']) && ($lovlyUplink['enabled'] ?? true); } catch (\Exception $e) { // leave as false } // Perform a binkp authentication test: connect to the LovlyNet hub and authenticate. // sendCommand() returns $response['result'] directly on success and throws // RuntimeException on failure. Connection failures begin with "Failed to connect // to admin daemon"; binkp errors begin with "Admin daemon error: ". $binkpAuthOk = false; $binkpAuthError = null; $binkpAuthMethod = null; $binkpDaemonError = false; try { $daemonClient2 = new \BinktermPHP\Admin\AdminDaemonClient(); $binkpAuthResult = $daemonClient2->binkpAuthTest('lovlynet'); $binkpAuthOk = true; $binkpAuthMethod = $binkpAuthResult['auth_method'] ?? null; } catch (\RuntimeException $e) { $msg = $e->getMessage(); if (str_starts_with($msg, 'Failed to connect to admin daemon')) { $binkpDaemonError = true; } else { // Strip "Admin daemon error: " prefix to surface the real message $binkpAuthError = (string)preg_replace('/^Admin daemon error:\s*/', '', $msg); } } echo json_encode([ 'success' => true, 'items' => [ [ 'id' => 'registration', 'ok' => true, ], [ 'id' => 'uplink_configured', 'ok' => $uplinkConfigured, ], [ 'id' => 'binkp_auth_test', 'ok' => $binkpAuthOk, 'auth_method' => $binkpAuthMethod, 'error' => $binkpAuthError, 'daemon_error' => $binkpDaemonError, ], [ 'id' => 'nodelist_rule', 'ok' => !$nodelistRuleDaemonError && $ruleExists && $patternMatches, 'has_rule' => $ruleExists, 'pattern_matches' => $patternMatches, 'daemon_error' => $nodelistRuleDaemonError, ], $defaultAreasItem, ], ]); }); /** * GET /admin/api/lovlynet/checklist/{id} * Run a single LovlyNet setup checklist item and return its result. * Accepted IDs: registration, uplink_configured, binkp_auth_test, nodelist_rule, default_areas */ SimpleRouter::get('/admin/api/lovlynet/checklist/{id}', function(string $id) { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $recommendedPattern = '/^LOVLYNET\\.(Z|A|L|R|J)[0-9]{2}$/i'; $areaKey = 'LVLY_NODELIST@LOVLYNET'; switch ($id) { case 'registration': echo json_encode(['success' => true, 'item' => ['id' => 'registration', 'ok' => true]]); break; case 'uplink_configured': $uplinkConfigured = false; try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $lovlyUplink = $binkpConfig->getUplinkByDomain('lovlynet'); $uplinkConfigured = $lovlyUplink !== null && !empty($lovlyUplink['me']) && !empty($lovlyUplink['address']) && !empty($lovlyUplink['networks']) && ($lovlyUplink['enabled'] ?? true); } catch (\Exception $e) { // leave as false } echo json_encode(['success' => true, 'item' => ['id' => 'uplink_configured', 'ok' => $uplinkConfigured]]); break; case 'binkp_auth_test': $binkpAuthOk = false; $binkpAuthError = null; $binkpAuthMethod = null; $binkpDaemonError = false; try { $daemonClient = new \BinktermPHP\Admin\AdminDaemonClient(); $binkpAuthResult = $daemonClient->binkpAuthTest('lovlynet'); $binkpAuthOk = true; $binkpAuthMethod = $binkpAuthResult['auth_method'] ?? null; } catch (\RuntimeException $e) { $msg = $e->getMessage(); if (str_starts_with($msg, 'Failed to connect to admin daemon')) { $binkpDaemonError = true; } else { $binkpAuthError = (string)preg_replace('/^Admin daemon error:\s*/', '', $msg); } } echo json_encode(['success' => true, 'item' => [ 'id' => 'binkp_auth_test', 'ok' => $binkpAuthOk, 'auth_method' => $binkpAuthMethod, 'error' => $binkpAuthError, 'daemon_error' => $binkpDaemonError, ]]); break; case 'nodelist_rule': $ruleExists = false; $patternMatches = false; $nodelistRuleDaemonError = false; try { $daemonClient = new \BinktermPHP\Admin\AdminDaemonClient(); $rulesConfig = $daemonClient->getFileAreaRulesConfig(); $configJson = $rulesConfig['config_json'] ?? null; if ($configJson !== null) { $parsed = json_decode($configJson, true); if (is_array($parsed)) { $areaRules = $parsed['area_rules'][$areaKey] ?? $parsed['area_rules']['LVLY_NODELIST'] ?? []; foreach ($areaRules as $rule) { if (isset($rule['pattern'])) { $ruleExists = true; if ($rule['pattern'] === $recommendedPattern) { $patternMatches = true; break; } } } } } } catch (\Exception $e) { getServerLogger()->error('LovlyNet checklist: failed to load file area rules: ' . $e->getMessage()); $nodelistRuleDaemonError = true; } echo json_encode(['success' => true, 'item' => [ 'id' => 'nodelist_rule', 'ok' => !$nodelistRuleDaemonError && $ruleExists && $patternMatches, 'has_rule' => $ruleExists, 'pattern_matches' => $patternMatches, 'daemon_error' => $nodelistRuleDaemonError, ]]); break; case 'default_areas': $defaultAreasItem = ['id' => 'default_areas', 'ok' => true, 'unsubscribed_echo' => [], 'unsubscribed_file' => [], 'fetch_error' => false]; $lovlyClient = new \BinktermPHP\LovlyNetClient(); $areasResult = $lovlyClient->getAreas(); if (!$areasResult['success']) { $defaultAreasItem['fetch_error'] = true; } else { $missingEcho = []; foreach ($areasResult['echoareas'] as $area) { if (!empty($area['is_default']) && empty($area['subscribed'])) { $missingEcho[] = strtoupper((string)($area['tag'] ?? '')); } } $missingFile = []; foreach ($areasResult['fileareas'] as $area) { if (!empty($area['is_default']) && empty($area['subscribed'])) { $missingFile[] = strtoupper((string)($area['tag'] ?? '')); } } $defaultAreasItem['unsubscribed_echo'] = $missingEcho; $defaultAreasItem['unsubscribed_file'] = $missingFile; $defaultAreasItem['ok'] = ($missingEcho === [] && $missingFile === []); } echo json_encode(['success' => true, 'item' => $defaultAreasItem]); break; default: http_response_code(400); echo json_encode(['success' => false, 'error' => 'unknown_item']); break; } }); /** * POST /admin/api/lovlynet/checklist/fix-nodelist-rule * Add/fix the LVLY_NODELIST file area rule with the recommended pattern and script. */ SimpleRouter::post('/admin/api/lovlynet/checklist/fix-nodelist-rule', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $areaKey = 'LVLY_NODELIST@LOVLYNET'; $newRule = [ 'name' => 'Import LovlyNet Nodelist', 'domain' => 'lovlynet', 'pattern' => '/^LOVLYNET\\.(Z|A|L|R|J)[0-9]{2}$/i', 'script' => 'php %basedir%/scripts/import_nodelist.php %filepath% %domain% --force', 'success_action' => 'keep', 'fail_action' => 'keep+notify', 'enabled' => true, 'timeout' => 300, ]; try { $daemonClient = new \BinktermPHP\Admin\AdminDaemonClient(); $rulesConfig = $daemonClient->getFileAreaRulesConfig(); $configJson = $rulesConfig['config_json'] ?? null; $parsed = is_string($configJson) ? json_decode($configJson, true) : null; if (!is_array($parsed)) { $parsed = ['global_rules' => [], 'area_rules' => []]; } if (!isset($parsed['area_rules']) || !is_array($parsed['area_rules'])) { $parsed['area_rules'] = []; } // Keep any existing rules for this area that have a different pattern, // then append the canonical rule. $existing = $parsed['area_rules'][$areaKey] ?? []; $filtered = array_values(array_filter($existing, static function ($rule) use ($newRule) { return ($rule['pattern'] ?? '') !== $newRule['pattern']; })); $filtered[] = $newRule; $parsed['area_rules'][$areaKey] = $filtered; $json = json_encode($parsed, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new \RuntimeException('json_encode failed: ' . json_last_error_msg()); } $daemonClient->saveFileAreaRulesConfig($json); echo json_encode(['success' => true]); } catch (\Exception $e) { getServerLogger()->error('LovlyNet checklist fix-nodelist-rule failed: ' . $e->getMessage()); http_response_code(500); apiError('errors.admin.lovlynet.checklist_fix_failed', $e->getMessage()); } }); /** * GET /admin/api/zip-diag?id=FILE_ID&path=ENTRY_PATH * Diagnostic: test whether a ZIP entry can be extracted via ZipArchive or unzip. */ SimpleRouter::get('/admin/api/zip-diag', function() { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $id = (int)($_GET['id'] ?? 0); $entryPath = $_GET['path'] ?? ''; if (!$id || $entryPath === '') { echo json_encode(['error' => 'id and path are required']); return; } $manager = new \BinktermPHP\FileAreaManager(); $file = $manager->getFileById($id); $storagePath = $file ? $manager->resolveFilePath($file) : null; if (!$storagePath || !file_exists($storagePath)) { http_response_code(404); echo json_encode(['error' => 'File not found']); return; } $entryPath = str_replace('\\', '/', $entryPath); $result = ['entry' => $entryPath, 'ziparchive' => null, 'unzip_available' => null, 'unzip_result' => null]; // Test ZipArchive $zip = new ZipArchive(); if ($zip->open($storagePath) === true) { $exactName = null; $compMethod = null; $lowerTarget = strtolower($entryPath); for ($i = 0; $i < $zip->numFiles; $i++) { $stat = $zip->statIndex($i); if ($stat && strtolower(str_replace('\\', '/', $stat['name'])) === $lowerTarget) { $exactName = $stat['name']; $compMethod = $stat['comp_method']; $content = $zip->getFromIndex($i); $result['ziparchive'] = [ 'found' => true, 'exact_name' => $exactName, 'comp_method' => $compMethod, 'extracted' => $content !== false, 'bytes' => $content !== false ? strlen($content) : 0, ]; break; } } if ($exactName === null) { $result['ziparchive'] = ['found' => false]; } $zip->close(); // Test available extraction tools $isWindows = PHP_OS_FAMILY === 'Windows'; $null = $isWindows ? 'NUL' : '/dev/null'; $whichCmd = $isWindows ? 'where' : 'which'; // On Windows check both "unzip" and "unzip.exe" since where.exe may // not find extensionless names depending on PATHEXT configuration. $bins = $isWindows ? ['unzip', 'unzip.exe', '7z', '7za'] : ['unzip', '7z', '7za']; $tools = []; foreach ($bins as $bin) { $path = trim((string)@shell_exec("$whichCmd $bin 2>$null")); $tools[$bin] = $path !== '' ? $path : null; } $result['tools'] = $tools; if ($exactName !== null) { $zipArg = escapeshellarg($storagePath); $nameArg = escapeshellarg($exactName); $tried = []; foreach ([ 'unzip' => "unzip -p $zipArg $nameArg 2>$null", 'unzip.exe' => "unzip.exe -p $zipArg $nameArg 2>$null", '7z' => "7z e -so $zipArg $nameArg 2>$null", '7za' => "7za e -so $zipArg $nameArg 2>$null", ] as $tool => $cmd) { $out = @shell_exec($cmd); $tried[$tool] = [ 'success' => $out !== null && $out !== '', 'bytes' => $out !== null ? strlen($out) : 0, ]; if ($out !== null && $out !== '') break; } $result['extraction_attempts'] = $tried; } } else { $result['ziparchive'] = ['error' => 'Cannot open ZIP']; } echo json_encode($result, JSON_PRETTY_PRINT); }); // --------------------------------------------------------------------------- // Interests admin page // --------------------------------------------------------------------------- SimpleRouter::group(['prefix' => '/admin'], function() { SimpleRouter::get('/interests', function() { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $template = new Template(); $template->renderResponse('admin/interests.twig', [ 'ai_available' => !empty(\BinktermPHP\AI\AiService::create()->getConfiguredProviders()), ]); }); }); // --------------------------------------------------------------------------- // Interests admin API // --------------------------------------------------------------------------- SimpleRouter::group(['prefix' => '/api/admin'], function() { /** List all interests (including inactive). */ SimpleRouter::get('/interests', function() { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $manager = new \BinktermPHP\InterestManager(); echo json_encode($manager->getInterests(false)); }); /** List echo areas not assigned to any interest. */ SimpleRouter::get('/interests/unassigned-echoareas', function() { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } header('Content-Type: application/json'); $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->query(" SELECT e.id, e.tag, e.domain, e.description, e.is_active FROM echoareas e WHERE e.id NOT IN (SELECT echoarea_id FROM interest_echoareas) ORDER BY e.tag "); $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($rows as &$row) { $row['id'] = (int)$row['id']; $row['is_active'] = (bool)$row['is_active']; } unset($row); echo json_encode(['areas' => $rows, 'count' => count($rows)]); }); /** Get a single interest with its area lists. */ SimpleRouter::get('/interests/{id}', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $manager = new \BinktermPHP\InterestManager(); $interest = $manager->getInterest((int)$id); if (!$interest) { apiError('errors.interests.not_found', apiLocalizedText('errors.interests.not_found', 'Interest not found.'), 404); return; } echo json_encode($interest); }); /** Create a new interest. */ SimpleRouter::post('/interests', function() { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $data = json_decode(file_get_contents('php://input'), true) ?? []; if (empty(trim((string)($data['name'] ?? '')))) { apiError('errors.interests.name_required', apiLocalizedText('errors.interests.name_required', 'Interest name is required.'), 400); return; } try { $manager = new \BinktermPHP\InterestManager(); $id = $manager->createInterest($data); http_response_code(201); echo json_encode(['success' => true, 'id' => $id]); } catch (\PDOException $e) { if (str_contains($e->getMessage(), 'interests_name_key') || str_contains($e->getMessage(), 'unique constraint')) { apiError('errors.interests.name_taken', apiLocalizedText('errors.interests.name_taken', 'An interest with that name already exists.'), 409); } elseif (str_contains($e->getMessage(), 'interests_slug_key')) { apiError('errors.interests.slug_taken', apiLocalizedText('errors.interests.slug_taken', 'An interest with that slug already exists.'), 409); } else { throw $e; } } }); /** Update an interest's metadata. */ SimpleRouter::put('/interests/{id}', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $data = json_decode(file_get_contents('php://input'), true) ?? []; $manager = new \BinktermPHP\InterestManager(); $interest = $manager->getInterest((int)$id); if (!$interest) { apiError('errors.interests.not_found', apiLocalizedText('errors.interests.not_found', 'Interest not found.'), 404); return; } try { $manager->updateInterest((int)$id, $data); echo json_encode(['success' => true]); } catch (\PDOException $e) { if (str_contains($e->getMessage(), 'interests_name_key')) { apiError('errors.interests.name_taken', apiLocalizedText('errors.interests.name_taken', 'An interest with that name already exists.'), 409); } elseif (str_contains($e->getMessage(), 'interests_slug_key')) { apiError('errors.interests.slug_taken', apiLocalizedText('errors.interests.slug_taken', 'An interest with that slug already exists.'), 409); } else { throw $e; } } }); /** Delete an interest. */ SimpleRouter::delete('/interests/{id}', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $manager = new \BinktermPHP\InterestManager(); if (!$manager->deleteInterest((int)$id)) { apiError('errors.interests.not_found', apiLocalizedText('errors.interests.not_found', 'Interest not found.'), 404); return; } echo json_encode(['success' => true]); }); /** Set echo areas for an interest (replaces current list). */ SimpleRouter::post('/interests/{id}/echoareas', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $data = json_decode(file_get_contents('php://input'), true) ?? []; $ids = array_map('intval', (array)($data['ids'] ?? [])); $manager = new \BinktermPHP\InterestManager(); if (!$manager->getInterest((int)$id)) { apiError('errors.interests.not_found', apiLocalizedText('errors.interests.not_found', 'Interest not found.'), 404); return; } $manager->setEchoareas((int)$id, $ids); echo json_encode(['success' => true]); }); /** Set file areas for an interest (replaces current list). */ SimpleRouter::post('/interests/{id}/fileareas', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $data = json_decode(file_get_contents('php://input'), true) ?? []; $ids = array_map('intval', (array)($data['ids'] ?? [])); $manager = new \BinktermPHP\InterestManager(); if (!$manager->getInterest((int)$id)) { apiError('errors.interests.not_found', apiLocalizedText('errors.interests.not_found', 'Interest not found.'), 404); return; } $manager->setFileareas((int)$id, $ids); echo json_encode(['success' => true]); }); /** Echo areas not assigned to any interest. */ /** * Generate interest suggestions via keyword heuristics and optionally AI. * Does NOT create any interests ? returns suggestions for admin review only. */ SimpleRouter::post('/interests/generate', function() { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } $data = json_decode(file_get_contents('php://input'), true) ?? []; $useAi = (bool)($data['use_ai'] ?? true); $useKeywords = (bool)($data['use_keywords'] ?? true); $result = (new \BinktermPHP\InterestGenerator())->generate($useAi, $useKeywords); echo json_encode($result); }); /** Keyword-classify a single echo area (fast, no AI). */ SimpleRouter::get('/interests/echoarea/{id}/classify', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } header('Content-Type: application/json'); try { $result = (new \BinktermPHP\InterestGenerator())->classifyOne((int)$id, false); echo json_encode($result); } catch (\RuntimeException $e) { http_response_code(404); echo json_encode(['error' => $e->getMessage()]); } }); /** AI-classify a single echo area (slower, uses configured AI provider). */ SimpleRouter::post('/interests/echoarea/{id}/classify-ai', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } header('Content-Type: application/json'); try { $result = (new \BinktermPHP\InterestGenerator())->classifyOne((int)$id, true); echo json_encode($result); } catch (\RuntimeException $e) { http_response_code(404); echo json_encode(['error' => $e->getMessage()]); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); } }); /** Add a single echo area to an interest (non-destructive). */ SimpleRouter::post('/interests/{id}/echoareas/add', function($id) { RouteHelper::requireAdmin(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); return; } header('Content-Type: application/json'); $data = json_decode(file_get_contents('php://input'), true) ?? []; $echoareaId = (int)($data['echoarea_id'] ?? 0); if (!$echoareaId) { http_response_code(400); echo json_encode(['error' => 'echoarea_id required']); return; } $manager = new \BinktermPHP\InterestManager(); $interest = $manager->getInterest((int)$id); if (!$interest) { http_response_code(404); echo json_encode(['error' => 'Interest not found']); return; } $manager->addEchoarea((int)$id, $echoareaId); echo json_encode(['ok' => true]); }); }); // ============================================================ // AreaFix / FileFix Manager // ============================================================ /** * GET /admin/areafix * AreaFix / FileFix manager admin page. */ SimpleRouter::get('/admin/areafix', function () { $user = RouteHelper::requireAdmin(); $areafixManager = new \BinktermPHP\AreaFixManager(); $uplinks = $areafixManager->getConfiguredUplinks(); $template = new Template(); $template->renderResponse('admin/areafix.twig', [ 'uplinks' => $uplinks, 'has_configured_uplinks' => !empty($uplinks), ]); }); /** * GET /api/admin/areafix/uplinks * Return uplinks that have areafix or filefix passwords configured. */ SimpleRouter::get('/api/admin/areafix/uplinks', function () { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $areafixManager = new \BinktermPHP\AreaFixManager(); $uplinks = $areafixManager->getConfiguredUplinks(); echo json_encode(['success' => true, 'uplinks' => $uplinks]); }); /** * POST /api/admin/areafix/send * Send AreaFix or FileFix commands to a hub uplink. * Body: { uplink: string, robot: "areafix"|"filefix", commands: string[] } */ SimpleRouter::post('/api/admin/areafix/send', function () { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { apiError( 'errors.admin.areafix.invalid_json', apiLocalizedText('errors.admin.areafix.invalid_json', 'Invalid request payload', $user), 400, ['success' => false] ); } $uplinkAddress = trim((string)($body['uplink'] ?? '')); $robot = strtolower(trim((string)($body['robot'] ?? ''))); $commands = $body['commands'] ?? []; if ($uplinkAddress === '') { apiError( 'errors.admin.areafix.uplink_required', apiLocalizedText('errors.admin.areafix.uplink_required', 'Uplink address is required', $user), 400, ['success' => false] ); } if (!in_array($robot, ['areafix', 'filefix'], true)) { apiError( 'errors.admin.areafix.invalid_robot', apiLocalizedText('errors.admin.areafix.invalid_robot', 'Robot must be "areafix" or "filefix"', $user), 400, ['success' => false] ); } if (empty($commands) || !is_array($commands)) { apiError( 'errors.admin.areafix.commands_required', apiLocalizedText('errors.admin.areafix.commands_required', 'At least one command is required', $user), 400, ['success' => false] ); } // Sanitize commands: must be non-empty strings $commands = array_values(array_filter(array_map('trim', $commands), static fn ($c) => $c !== '')); if (empty($commands)) { apiError( 'errors.admin.areafix.commands_required', apiLocalizedText('errors.admin.areafix.commands_required', 'At least one command is required', $user), 400, ['success' => false] ); } $sysopUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); try { $areafixManager = new \BinktermPHP\AreaFixManager(); $areafixManager->sendCommand($uplinkAddress, $commands, $robot, $sysopUserId); } catch (\RuntimeException $e) { apiError( 'errors.admin.areafix.send_failed', $e->getMessage(), 400, ['success' => false] ); } catch (\Throwable $e) { apiError( 'errors.admin.areafix.send_failed', apiLocalizedText('errors.admin.areafix.send_failed', 'Failed to send command', $user), 500, ['success' => false] ); } echo json_encode(['success' => true]); }); /** * GET /api/admin/areafix/history * Return AreaFix/FileFix message history for an uplink. * Query params: uplink=<address> */ SimpleRouter::get('/api/admin/areafix/history', function () { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $uplinkAddress = trim((string)($_GET['uplink'] ?? '')); if ($uplinkAddress === '') { apiError( 'errors.admin.areafix.uplink_required', apiLocalizedText('errors.admin.areafix.uplink_required', 'Uplink address is required', $user), 400, ['success' => false] ); } $sysopUserId = (int)($user['user_id'] ?? $user['id'] ?? 0); try { $areafixManager = new \BinktermPHP\AreaFixManager(); $messages = $areafixManager->getHistory($uplinkAddress, $sysopUserId); } catch (\Throwable $e) { apiError( 'errors.admin.areafix.history_failed', apiLocalizedText('errors.admin.areafix.history_failed', 'Failed to load message history', $user), 500, ['success' => false] ); } echo json_encode(['success' => true, 'messages' => $messages]); }); /** * POST /api/admin/areafix/sync * Parse area list and sync to local echo/file area table. * Body: { uplink: string, robot: "areafix"|"filefix", areas: [{name,description},...], deactivate_missing: bool } */ SimpleRouter::post('/api/admin/areafix/sync', function () { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $body = json_decode(file_get_contents('php://input'), true); if (!is_array($body)) { apiError( 'errors.admin.areafix.invalid_json', apiLocalizedText('errors.admin.areafix.invalid_json', 'Invalid request payload', $user), 400, ['success' => false] ); } $uplinkAddress = trim((string)($body['uplink'] ?? '')); $robot = strtolower(trim((string)($body['robot'] ?? ''))); $parsedAreas = $body['areas'] ?? []; $deactivateMissing = (bool)($body['deactivate_missing'] ?? false); if ($uplinkAddress === '') { apiError( 'errors.admin.areafix.uplink_required', apiLocalizedText('errors.admin.areafix.uplink_required', 'Uplink address is required', $user), 400, ['success' => false] ); } if (!in_array($robot, ['areafix', 'filefix'], true)) { apiError( 'errors.admin.areafix.invalid_robot', apiLocalizedText('errors.admin.areafix.invalid_robot', 'Robot must be "areafix" or "filefix"', $user), 400, ['success' => false] ); } if (!is_array($parsedAreas)) { apiError( 'errors.admin.areafix.invalid_json', apiLocalizedText('errors.admin.areafix.invalid_json', 'Invalid request payload', $user), 400, ['success' => false] ); } // Look up domain for this uplink $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $uplink = $binkpConfig->getUplinkByAddress($uplinkAddress); $domain = (string)($uplink['domain'] ?? 'fidonet'); try { $areafixManager = new \BinktermPHP\AreaFixManager(); $summary = $areafixManager->syncSubscribedAreas( $uplinkAddress, $domain, $parsedAreas, $deactivateMissing, $robot ); } catch (\Throwable $e) { apiError( 'errors.admin.areafix.sync_failed', apiLocalizedText('errors.admin.areafix.sync_failed', 'Failed to sync areas', $user), 500, ['success' => false] ); } echo json_encode(['success' => true, 'summary' => $summary]); }); // GET /admin/api/uplinks ? list configured uplink addresses for the admin terminal SimpleRouter::get('/admin/api/uplinks', function () { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $config = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $uplinks = array_map(function ($u) { return [ 'address' => $u['address'] ?? '', 'host' => $u['host'] ?? '', 'domain' => $u['domain'] ?? '', ]; }, $config->getUplinks()); echo json_encode(['success' => true, 'uplinks' => $uplinks]); }); // POST /admin/api/poll ? synchronous binkp poll for the admin terminal SimpleRouter::post('/admin/api/poll', function () { $user = RouteHelper::requireAdmin(); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $upstream = trim((string)($input['upstream'] ?? 'all')); if ($upstream === '') { $upstream = 'all'; } try { $client = new \BinktermPHP\Admin\AdminDaemonClient(); $result = $client->binkPollSync($upstream); $client->close(); echo json_encode(['success' => true, 'result' => $result]); } catch (\Throwable $e) { apiError('errors.admin.poll.failed', 'Poll failed: ' . $e->getMessage(), 500); } }); // GET /admin/api/last ? recent callers within the past N hours (default 168 = 1 week) SimpleRouter::get('/admin/api/last', function () { RouteHelper::requireAdmin(); header('Content-Type: application/json'); $hours = isset($_GET['hours']) ? max(1, (int)$_GET['hours']) : 168; $auth = new \BinktermPHP\Auth(); $callers = $auth->getRecentCallers($hours); echo json_encode(['callers' => $callers, 'hours' => $hours]); }); // POST /admin/api/wall ? broadcast a wall message to all connected users SimpleRouter::post('/admin/api/wall', function () { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $message = trim((string)($input['message'] ?? '')); if ($message === '') { apiError('errors.admin.wall.empty_message', 'Message cannot be empty', 400); return; } if (mb_strlen($message) > 1000) { apiError('errors.admin.wall.message_too_long', 'Message too long (max 1000 characters)', 400); return; } $db = \BinktermPHP\Database::getInstance()->getPdo(); \BinktermPHP\Realtime\BinkStream::emit($db, 'wall_message', [ 'from' => $user['username'], 'message' => $message, ], null, false); echo json_encode(['success' => true]); }); // POST /admin/api/msg ? send a private message to a specific user SimpleRouter::post('/admin/api/msg', function () { $auth = new Auth(); $user = $auth->requireAuth(); $adminController = new AdminController(); $adminController->requireAdmin($user); header('Content-Type: application/json'); $input = json_decode(file_get_contents('php://input'), true); $targetUsername = trim((string)($input['username'] ?? '')); $message = trim((string)($input['message'] ?? '')); if ($targetUsername === '') { apiError('errors.admin.msg.no_username', 'Username is required', 400); return; } if ($message === '') { apiError('errors.admin.msg.empty_message', 'Message cannot be empty', 400); return; } if (mb_strlen($message) > 1000) { apiError('errors.admin.msg.message_too_long', 'Message too long (max 1000 characters)', 400); return; } $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare("SELECT id FROM users WHERE LOWER(username) = LOWER(?) LIMIT 1"); $stmt->execute([$targetUsername]); $target = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$target) { apiError('errors.admin.msg.user_not_found', 'User not found', 404); return; } \BinktermPHP\Realtime\BinkStream::emit($db, 'wall_message', [ 'from' => $user['username'], 'message' => $message, 'private' => true, ], (int)$target['id'], false); echo json_encode(['success' => true]); });