PHP Classes

File: routes/web-routes.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   routes/web-routes.php   Download  
File: routes/web-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: 80,335 bytes
 

Contents

Class file image Download
<?php // Web routes use BinktermPHP\AppearanceConfig; use BinktermPHP\Auth; use BinktermPHP\Advertising; use BinktermPHP\BbsConfig; use BinktermPHP\BulletinManager; use BinktermPHP\Config; use BinktermPHP\I18n\LocaleResolver; use BinktermPHP\I18n\Translator; use BinktermPHP\MessageHandler; use BinktermPHP\RouteHelper; use BinktermPHP\Template; use BinktermPHP\UserCredit; use Pecee\SimpleRouter\SimpleRouter; if (!function_exists('webLocalizedText')) { function webLocalizedText(string $key, string $fallback, ?array $user = null, array $params = [], string $namespace = 'common'): string { static $translator = null; static $resolver = null; if ($translator === null || $resolver === null) { $translator = new Translator(); $resolver = new 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('getHttpBasicCredentials')) { /** * Return HTTP Basic credentials from the current request when present. * * @return array{username:string,password:string}|null */ function getHttpBasicCredentials(): ?array { $username = $_SERVER['PHP_AUTH_USER'] ?? null; $password = $_SERVER['PHP_AUTH_PW'] ?? null; if ($username !== null) { return [ 'username' => (string)$username, 'password' => (string)($password ?? ''), ]; } $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? null; if (!is_string($header) || stripos($header, 'Basic ') !== 0) { return null; } $decoded = base64_decode(substr($header, 6), true); if ($decoded === false || !str_contains($decoded, ':')) { return null; } [$basicUser, $basicPass] = explode(':', $decoded, 2); return [ 'username' => $basicUser, 'password' => $basicPass, ]; } } if (!function_exists('requireBasicAuthUser')) { /** * Require HTTP Basic auth and return the authenticated user. * * @return array */ function requireBasicAuthUser(string $realm = 'BinktermPHP QWK'): array { $credentials = getHttpBasicCredentials(); if ($credentials === null) { header('WWW-Authenticate: Basic realm="' . addslashes($realm) . '"'); http_response_code(401); echo 'HTTP Basic authentication required.'; exit; } $auth = new Auth(); $user = $auth->authenticateCredentials($credentials['username'], $credentials['password']); if ($user === false) { header('WWW-Authenticate: Basic realm="' . addslashes($realm) . '"'); http_response_code(401); echo 'Invalid username or password.'; exit; } return $user; } } if (!function_exists('bbsDirectoryEntrySlug')) { /** * Build a URL-safe slug from a BBS directory entry name. * * @param string $name * @return string */ function bbsDirectoryEntrySlug(string $name): string { $slug = trim($name); if (function_exists('iconv')) { $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $slug); if (is_string($converted) && $converted !== '') { $slug = $converted; } } $slug = strtolower($slug); $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); $slug = trim((string)$slug, '-'); return $slug !== '' ? substr($slug, 0, 120) : 'bbs'; } } if (!function_exists('bbsDirectoryEntryPath')) { /** * Build the public path for a BBS directory entry. * * @param array $entry * @return string */ function bbsDirectoryEntryPath(array $entry): string { $id = (int)($entry['id'] ?? 0); $slug = bbsDirectoryEntrySlug((string)($entry['name'] ?? '')); return '/bbs-directory/' . $id . '/' . $slug; } } SimpleRouter::get('/', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); if (!empty($_GET['skip_bulletins'])) { unset($_SESSION['show_login_bulletins_for_session']); } if (empty($_GET['skip_bulletins'])) { try { $bulletinManager = new BulletinManager(); $bulletinCount = $bulletinManager->getUnreadCount($userId); if (BbsConfig::shouldAlwaysDisplayBulletins()) { $sessionId = (string)($_COOKIE['binktermphp_session'] ?? ''); $pendingSessionId = (string)($_SESSION['show_login_bulletins_for_session'] ?? ''); if ($sessionId !== '' && hash_equals($sessionId, $pendingSessionId)) { unset($_SESSION['show_login_bulletins_for_session']); $bulletinCount = count($bulletinManager->getActiveBulletins($userId)); } else { $bulletinCount = 0; } } if ($bulletinCount > 0) { return SimpleRouter::response()->redirect('/bulletins?unread=1'); } } catch (\Throwable $e) { getServerLogger()->warning("Bulletin login redirect check failed: " . $e->getMessage()); } } $template = new Template(); $bulletinUnreadCount = 0; try { $bulletinUnreadCount = (new BulletinManager())->getUnreadCount($userId); } catch (\Throwable $e) { getServerLogger()->warning("Dashboard bulletin count failed: " . $e->getMessage()); } $ads = new Advertising(); $dashboardAds = $ads->getDashboardAds(5); $ad = $dashboardAds[0] ?? null; $bbsConfig = \BinktermPHP\BbsConfig::getConfig(); $dashboardAdRotateInterval = (int)($bbsConfig['dashboard_ad_rotate_interval_seconds'] ?? 20); if ($dashboardAdRotateInterval < 5 || $dashboardAdRotateInterval > 300) { $dashboardAdRotateInterval = 20; } // Generate system news content $systemNewsContent = $template->renderSystemNews(); // Load shell art content for the bbs-menu ANSI variant $shellArtContent = null; $bbsMenu = \BinktermPHP\AppearanceConfig::getBbsMenuConfig(); if (($bbsMenu['variant'] ?? '') === 'ansi' && !empty($bbsMenu['ansi_file'])) { $artPath = dirname(__DIR__) . '/data/shell_art/' . basename($bbsMenu['ansi_file']); if (is_file($artPath)) { $raw = file_get_contents($artPath); // Strip SAUCE record: \x1A is the traditional EOF/SAUCE delimiter $saucePos = strpos($raw, "\x1A"); if ($saucePos !== false) { $raw = substr($raw, 0, $saucePos); } // Convert CP437 (DOS encoding) to UTF-8 so block drawing characters render correctly $shellArtContent = @iconv('CP437', 'UTF-8//TRANSLIT//IGNORE', $raw) ?: mb_convert_encoding($raw, 'UTF-8', 'CP437'); } } $onlineCount = $auth->getOnlineUserCount(15); $activeTodayCount = $auth->getActiveTodayCount(); $adminTimezone = 'UTC'; $handler = new \BinktermPHP\MessageHandler(); $userSettings = $handler->getUserSettings($userId); if (!empty($user['is_admin'])) { $tz = $userSettings['timezone'] ?? ''; if ($tz !== '') { try { new \DateTimeZone($tz); // validate $adminTimezone = $tz; } catch (\Throwable $e) { // fall back to UTC } } } $todaysCallers = !empty($user['is_admin']) ? $auth->getTodaysCallers($adminTimezone) : null; // Build dashboard card registry and layout $creditsConfig = $bbsConfig['credits'] ?? []; $referralEnabled = !empty($creditsConfig['enabled']) && !empty($creditsConfig['referral_enabled']); $packetBbsNodesExist = (new \BinktermPHP\PacketBbs\PacketBbsNodeService())->getNodeCount() > 0; $cardConditions = [ 'referral_enabled' => $referralEnabled, 'packetbbs_nodes_exist' => $packetBbsNodesExist, ]; $availableCards = \BinktermPHP\DashboardCardRegistry::getAvailableCards($user, $cardConditions); $savedLayoutRaw = $userSettings['dashboard_layout'] ?? null; if ($savedLayoutRaw) { $savedLayout = is_string($savedLayoutRaw) ? json_decode($savedLayoutRaw, true) : $savedLayoutRaw; $dashboardLayout = \BinktermPHP\DashboardCardRegistry::mergeLayout( is_array($savedLayout) ? $savedLayout : [], $availableCards ); } else { $dashboardLayout = \BinktermPHP\DashboardCardRegistry::getDefaultLayout($availableCards); } $template->renderResponse('dashboard.twig', [ 'system_news_content' => $systemNewsContent, 'dashboard_ad' => $ad, 'dashboard_ads' => $dashboardAds, 'dashboard_ad_rotate_interval_seconds' => $dashboardAdRotateInterval, 'shell_art_content' => $shellArtContent, 'online_user_count' => $onlineCount, 'active_today_count' => $activeTodayCount, 'todays_callers' => $todaysCallers, 'bulletin_unread_count' => $bulletinUnreadCount, 'dashboard_layout' => $dashboardLayout, 'dashboard_available_cards' => $availableCards, 'echomail_badge_mode' => $userSettings['echomail_badge_mode'] ?? 'new', 'packetbbs_nodes_exist' => $packetBbsNodesExist, ]); }); SimpleRouter::get('/bulletins', function() { $user = RouteHelper::requireAuth(); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); $manager = new BulletinManager(); unset($_SESSION['show_login_bulletins_for_session']); $showUnreadOnly = !empty($_GET['unread']); $activeBulletins = $manager->getActiveBulletins($userId); $displayBulletins = $showUnreadOnly && !BbsConfig::shouldAlwaysDisplayBulletins() ? $manager->getUnreadBulletins($userId) : $activeBulletins; $template = new Template(); $template->renderResponse('bulletins.twig', [ 'bulletins' => $activeBulletins, 'display_bulletins' => $displayBulletins, 'show_unread_only' => $showUnreadOnly, ]); }); SimpleRouter::get('/ads/random', function() { $user = RouteHelper::requireAuth(); $ads = new Advertising(); $ad = $ads->getRandomAd(); $template = new Template(); $template->renderResponse('ads/ad_full.twig', [ 'ad' => $ad ]); }); SimpleRouter::get('/ads/{name}', function($name) { $user = RouteHelper::requireAuth(); $ads = new Advertising(); $ad = $ads->getAdByName($name); $template = new Template(); $template->renderResponse('ads/ad_full.twig', [ 'ad' => $ad ]); })->where(['name' => '[A-Za-z0-9._-]+']); // Web routes SimpleRouter::get('/appmanifestjson', function() { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); //$systemName = $binkpConfig->getSystemSysop() . "'s System"; $systemName = $binkpConfig->getSystemName(); $favicon = Config::env("FAVICONSVG")??"/favicon.svg"; $ret=<<<_EOT_ { "name": "{$systemName}", "short_name": "{$systemName}", "description": "A modern web and BBS interface for Fidonet echomail, netmail, and classic door games", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#0000ff", "icons": [ { "src": "{$favicon}", "sizes": "192x192", "type": "image/svg+xml" }, { "src": "{$favicon}", "sizes": "512x512", "type": "image/svg+xml" } ], "shortcuts": [ { "name": "Compose Netmail", "short_name": "New Netmail", "description": "Compose a new netmail message", "url": "/compose/netmail" }, { "name": "Netmail", "short_name": "Netmail", "description": "Read your netmail", "url": "/netmail" }, { "name": "Echomail", "short_name": "Echomail", "description": "Browse echomail areas", "url": "/echomail" }, { "name": "Nodelist", "short_name": "Nodelist", "description":"Browse the nodelist", "url":"/nodelist" }, { "name": "Doors", "short_name": "Doors", "description":"Browse doors and games", "url":"/games" }, { "name": "Files", "short_name": "Files", "description":"Browse files", "url":"/files" } ] } _EOT_; header("Content-type: application/json"); echo $ret; exit; }); SimpleRouter::get('/login', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if ($user) { return SimpleRouter::response()->redirect('/'); } // Read welcome message if it exists $welcomeMessage = ''; $welcomeFile = __DIR__ . '/../config/welcome.txt'; if (file_exists($welcomeFile)) { $welcomeMessage = file_get_contents($welcomeFile); } // Check if PubTerm door is enabled and allows anonymous access $pubTermEnabled = false; try { $nativeDoorManager = new \BinktermPHP\NativeDoorManager(); $pubTermEnabled = $nativeDoorManager->isDoorAvailable('pubterm') && \BinktermPHP\NativeDoorConfig::isAnonymousAllowed('pubterm'); } catch (\Throwable $e) {} // Splash takes precedence over the legacy welcome.txt $loginSplashHtml = null; if (\BinktermPHP\License::isValid()) { $loginSplashMd = \BinktermPHP\AppearanceConfig::getLoginSplashMarkdown(); if ($loginSplashMd !== null && trim($loginSplashMd) !== '') { $loginSplashHtml = \BinktermPHP\MarkdownRenderer::toHtml($loginSplashMd); $welcomeMessage = ''; // suppress legacy fallback } } $loginScreen = \BinktermPHP\AppearanceConfig::getLoginScreenConfig(); $loginAnsiArt = \BinktermPHP\AppearanceConfig::getLoginScreenAnsi(); $template = new Template(); $template->renderResponse('login.twig', [ 'welcome_message' => $welcomeMessage, 'pubterm_enabled' => $pubTermEnabled, 'login_splash' => $loginSplashHtml, 'login_screen' => $loginScreen, 'login_ansi_art' => $loginAnsiArt, ]); }); SimpleRouter::get('/register', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if ($user) { return SimpleRouter::response()->redirect('/'); } // Generate anti-spam timestamp if (session_status() === PHP_SESSION_NONE) { session_start(); } $_SESSION['registration_time'] = time(); // Capture referral code from URL parameter if (isset($_GET['ref']) && !empty($_GET['ref'])) { $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $_GET['ref']); if (!empty($sanitized)) { $_SESSION['referral_code'] = $sanitized; } } $registerSplashHtml = null; if (\BinktermPHP\License::isValid()) { $registerSplashMd = \BinktermPHP\AppearanceConfig::getRegisterSplashMarkdown(); if ($registerSplashMd !== null && trim($registerSplashMd) !== '') { $registerSplashHtml = \BinktermPHP\MarkdownRenderer::toHtml($registerSplashMd); } } $template = new Template(); $template->renderResponse('register.twig', [ 'register_splash' => $registerSplashHtml, ]); }); SimpleRouter::get('/forgot-password', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if ($user) { return SimpleRouter::response()->redirect('/'); } $template = new Template(); $template->renderResponse('forgot_password.twig'); }); SimpleRouter::get('/reset-password', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if ($user) { return SimpleRouter::response()->redirect('/'); } $token = $_GET['token'] ?? null; $template = new Template(); $template->renderResponse('reset_password.twig', ['token' => $token]); }); SimpleRouter::get('/netmail', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // Get system address for message filtering try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $systemAddress = $binkpConfig->getSystemAddress(); $crashmailEnabled = $binkpConfig->getCrashmailEnabled(); } catch (\Exception $e) { $systemAddress = webLocalizedText('ui.common.unknown', 'Unknown', $user); $crashmailEnabled = false; } $bbsConfig = BbsConfig::getConfig(); $aiAssistantEnabled = !empty($bbsConfig['ai_assistant']['enabled']); $template = new Template(); $template->renderResponse('netmail.twig', [ 'system_address' => $systemAddress, 'crashmail_enabled' => $crashmailEnabled, 'ai_assistant_enabled' => $aiAssistantEnabled, ]); }); SimpleRouter::get('/echomail', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // Check for echoarea and domain query parameters $echoareaParam = $_GET['echoarea'] ?? null; $domainParam = $_GET['domain'] ?? null; $echoarea = null; if ($echoareaParam) { // Combine echoarea with domain if both provided if ($domainParam) { $echoarea = $echoareaParam . '@' . $domainParam; } else { $echoarea = $echoareaParam; } } $echoDateOrderRaw = strtolower(trim((string)Config::env('ECHOMAIL_ORDER_DATE', 'received'))); $isAdmin = !empty($user['is_admin']); $echoDateOrder = ($isAdmin && in_array($echoDateOrderRaw, ['written', 'date_written'], true)) ? 'written' : 'received'; $bbsConfig = BbsConfig::getConfig(); $aiAssistantEnabled = !empty($bbsConfig['ai_assistant']['enabled']); $aiShareSummaryEnabled = !empty($bbsConfig['ai_assistant']['share_summary_enabled']); $hasInterests = false; if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') === 'true') { $im = new \BinktermPHP\InterestManager(); $activeInterests = $im->getInterests(true); $hasInterests = count($activeInterests) > 0; // First-visit onboarding: redirect to the guide the first time a user // visits echomail, regardless of their subscription state. if ($hasInterests && !$echoarea) { $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); $meta = new \BinktermPHP\UserMeta(); if (!$meta->getValue($userId, 'interests_onboarded')) { $meta->setValue($userId, 'interests_onboarded', '1'); return SimpleRouter::response()->redirect('/echo-onboarding?from=echomail'); } } } $template = new Template(); $template->renderResponse('echomail.twig', [ 'echoarea' => $echoarea, 'domain' => $domainParam, 'echomail_date_field' => $echoDateOrder, 'has_interests' => $hasInterests, 'ai_assistant_enabled' => $aiAssistantEnabled, 'ai_share_summary_enabled' => $aiShareSummaryEnabled, ]); }); SimpleRouter::get('/echomail/{echoarea}', function($echoarea) { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // URL decode the echoarea parameter to handle dots and special characters $echoarea = urldecode($echoarea); $echoDateOrderRaw = strtolower(trim((string)Config::env('ECHOMAIL_ORDER_DATE', 'received'))); $isAdmin = !empty($user['is_admin']); $echoDateOrder = ($isAdmin && in_array($echoDateOrderRaw, ['written', 'date_written'], true)) ? 'written' : 'received'; $bbsConfig = BbsConfig::getConfig(); $aiAssistantEnabled = !empty($bbsConfig['ai_assistant']['enabled']); $aiShareSummaryEnabled = !empty($bbsConfig['ai_assistant']['share_summary_enabled']); $hasInterests = false; if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') === 'true') { $im = new \BinktermPHP\InterestManager(); $hasInterests = count($im->getInterests(true)) > 0; } $template = new Template(); $template->renderResponse('echomail.twig', [ 'echoarea' => $echoarea, 'echomail_date_field' => $echoDateOrder, 'has_interests' => $hasInterests, 'ai_assistant_enabled' => $aiAssistantEnabled, 'ai_share_summary_enabled' => $aiShareSummaryEnabled, ]); })->where(['echoarea' => "[-A-Za-z0-9@._'!%]+"]); SimpleRouter::get('/shared-image/{slug}', function($slug) { // Public, no-auth route used by social media crawlers to fetch og:image previews. // The slug is the stored filename including extension (e.g. abc123def....jpg), // looked up directly ? no extraction needed. $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare(" SELECT og_image_path FROM shared_messages WHERE og_image_slug = ? AND is_active = TRUE AND (expires_at IS NULL OR expires_at > NOW()) "); $stmt->execute([$slug]); $row = $stmt->fetch(); if (!$row || empty($row['og_image_path']) || !file_exists($row['og_image_path'])) { http_response_code(404); exit; } $path = $row['og_image_path']; $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $mimes = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', ]; if (!isset($mimes[$ext])) { http_response_code(404); exit; } header('Content-Type: ' . $mimes[$ext]); header('Content-Length: ' . filesize($path)); header('Cache-Control: public, max-age=86400'); header('X-Content-Type-Options: nosniff'); readfile($path); exit; })->where(['slug' => '[a-f0-9]{32}\.[a-zA-Z0-9]+']); SimpleRouter::get('/shared/{area}/{slug}', function($area, $slug) { $auth = new Auth(); $user = $auth->getCurrentUser(); $userId = $user ? ($user['user_id'] ?? $user['id'] ?? null) : null; $messageData = null; $shareInfo = null; $shareKey = null; try { $handler = new MessageHandler(); $result = $handler->getSharedMessageBySlug($area, $slug, $userId, true, $_SERVER['HTTP_REFERER'] ?? null); if ($result['success']) { $messageData = $result['message']; $shareInfo = $result['share_info']; $shareKey = $result['share_key'] ?? ($shareInfo['share_key'] ?? null); } } catch (Exception $e) { // JavaScript will handle showing the error to the user } $shareUrl = \BinktermPHP\Config::getSiteUrl() . '/shared/' . rawurlencode($area) . '/' . rawurlencode($slug); $ogImageUrl = null; $ogImageMime = null; $ogImageW = null; $ogImageH = null; if (!empty($shareInfo['og_image_slug']) && !empty($shareInfo['og_image_path']) && file_exists($shareInfo['og_image_path'])) { $ogImageUrl = \BinktermPHP\Config::getSiteUrl() . '/shared-image/' . rawurlencode($shareInfo['og_image_slug']); $ogImageMime = mime_content_type($shareInfo['og_image_path']) ?: null; $imgSize = @getimagesize($shareInfo['og_image_path']); if ($imgSize) { $ogImageW = $imgSize[0]; $ogImageH = $imgSize[1]; } } $template = new Template(); $template->renderResponse('shared_message.twig', [ 'shareKey' => $shareKey, 'shareArea' => $area, 'shareSlug' => $slug, 'message' => $messageData, 'share_info' => $shareInfo, 'share_url' => $shareUrl, 'ai_og_summary' => $shareInfo['ai_og_summary'] ?? null, 'og_image_url' => $ogImageUrl, 'og_image_mime' => $ogImageMime, 'og_image_width' => $ogImageW, 'og_image_height'=> $ogImageH, ]); })->where(['area' => '[A-Za-z0-9@._-]+', 'slug' => '[A-Za-z0-9_-]+']); SimpleRouter::get('/shared/{shareKey}', function($shareKey) { // Don't require authentication for shared messages - the API will handle access control // But we need to fetch the message data for SEO meta tags $auth = new Auth(); $user = $auth->getCurrentUser(); $userId = $user ? ($user['user_id'] ?? $user['id'] ?? null) : null; $messageData = null; $shareInfo = null; try { $handler = new MessageHandler(); $result = $handler->getSharedMessage($shareKey, $userId, true, $_SERVER['HTTP_REFERER'] ?? null); if ($result['success']) { $messageData = $result['message']; $shareInfo = $result['share_info']; } } catch (Exception $e) { // If there's an error fetching the message, we'll still render the page // The JavaScript will handle showing the error to the user } // Build the full share URL for meta tags $shareUrl = \BinktermPHP\Config::getSiteUrl() . '/shared/' . $shareKey; $ogImageUrl = null; $ogImageMime = null; $ogImageW = null; $ogImageH = null; if (!empty($shareInfo['og_image_slug']) && !empty($shareInfo['og_image_path']) && file_exists($shareInfo['og_image_path'])) { $ogImageUrl = \BinktermPHP\Config::getSiteUrl() . '/shared-image/' . rawurlencode($shareInfo['og_image_slug']); $ogImageMime = mime_content_type($shareInfo['og_image_path']) ?: null; $imgSize = @getimagesize($shareInfo['og_image_path']); if ($imgSize) { $ogImageW = $imgSize[0]; $ogImageH = $imgSize[1]; } } $template = new Template(); $template->renderResponse('shared_message.twig', [ 'shareKey' => $shareKey, 'message' => $messageData, 'share_info' => $shareInfo, 'share_url' => $shareUrl, 'ai_og_summary' => $shareInfo['ai_og_summary'] ?? null, 'og_image_url' => $ogImageUrl, 'og_image_mime' => $ogImageMime, 'og_image_width' => $ogImageW, 'og_image_height' => $ogImageH, ]); })->where(['shareKey' => '[a-f0-9]{32}']); SimpleRouter::get('/shared/file/{area}/{filename}', function($area, $filename) { // No auth required ? file info is public; download requires login (handled in template) $auth = new Auth(); $user = $auth->getCurrentUser(); $userId = $user ? ($user['user_id'] ?? $user['id'] ?? null) : null; $fileData = null; $shareInfo = null; try { $manager = new \BinktermPHP\FileAreaManager(); $result = $manager->getSharedFile($area, $filename, $userId, true, $_SERVER['HTTP_REFERER'] ?? null); if ($result['success']) { $fileData = $result['file']; $shareInfo = $result['share_info']; } } catch (\Exception $e) { // Render error state below } $shareUrl = \BinktermPHP\Config::getSiteUrl() . '/shared/file/' . rawurlencode($area) . '/' . rawurlencode($filename); $template = new Template(); $template->renderResponse('shared_file.twig', [ 'file' => $fileData, 'share_info' => $shareInfo, 'share_url' => $shareUrl, 'is_logged_in'=> $userId !== null, ]); })->where(['area' => '[A-Za-z0-9@._-]+', 'filename' => '[A-Za-z0-9._-]+']); SimpleRouter::get('/binkp', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // Check if user is admin if (!$user['is_admin']) { http_response_code(403); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.access_error', 'error_code' => 'ui.web.errors.binkp_admin_only' ]); return; } $template = new Template(); $template->renderResponse('binkp.twig'); }); SimpleRouter::get('/profile', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // Get system configuration for display try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $systemName = $binkpConfig->getSystemName(); $systemAddress = $binkpConfig->getSystemAddress(); $sysopName = $binkpConfig->getSystemSysop(); } catch (\Exception $e) { $systemName = webLocalizedText('ui.web.fallback.system_name', 'BinktermPHP System', $user); $systemAddress = webLocalizedText('ui.common.not_configured', 'Not configured', $user); $sysopName = webLocalizedText('ui.common.unknown', 'Unknown', $user); } $creditsConfig = \BinktermPHP\BbsConfig::getConfig()['credits'] ?? []; $creditsEnabled = $creditsConfig['enabled'] ?? true; $creditsSymbol = $creditsConfig['symbol'] ?? '$'; $creditBalance = 0; if ($creditsEnabled) { try { $creditBalance = \BinktermPHP\UserCredit::getBalance((int)($user['user_id'] ?? $user['id'])); } catch (\Throwable $e) { $creditBalance = 0; } } $templateVars = [ 'user_username' => $user['username'], 'user_real_name' => $user['real_name'] ?? '', 'user_email' => $user['email'] ?? '', 'user_location' => $user['location'] ?? '', 'user_about_me' => $user['about_me'] ?? '', 'user_created_at' => $user['created_at'], 'user_last_login' => $user['last_login'], 'user_is_admin' => (bool)$user['is_admin'], 'system_name_display' => $systemName, 'system_address_display' => $systemAddress, 'system_sysop' => $sysopName, 'credits_enabled' => !empty($creditsEnabled), 'credits_symbol' => $creditsSymbol, 'credit_balance' => $creditBalance, 'credits_config' => $creditsConfig ]; $template = new Template(); $template->renderResponse('profile.twig', $templateVars); }); SimpleRouter::get('/profile/{username}', function($username) { $auth = new Auth(); $currentUser = $auth->getCurrentUser(); if (!$currentUser) { return SimpleRouter::response()->redirect('/login'); } // Get the target user's information $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare(' SELECT id, username, real_name, location, about_me, fidonet_address, created_at, last_login, is_admin, is_active FROM users WHERE username = ? AND is_active = TRUE '); $stmt->execute([$username]); $targetUser = $stmt->fetch(); if (!$targetUser) { // User not found or inactive http_response_code(404); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.not_found', 'error_code' => 'ui.web.errors.profile_user_not_found' ]); return; } // Get credits configuration $creditsConfig = \BinktermPHP\BbsConfig::getConfig()['credits'] ?? []; $creditsEnabled = $creditsConfig['enabled'] ?? true; $creditsSymbol = $creditsConfig['symbol'] ?? '$'; // Get credit balance (public information) $creditBalance = 0; if ($creditsEnabled) { try { $creditBalance = \BinktermPHP\UserCredit::getBalance((int)$targetUser['id']); } catch (\Throwable $e) { $creditBalance = 0; } } // Check if viewing own profile $isOwnProfile = ($currentUser['username'] === $username); $canViewSensitive = $isOwnProfile || !empty($currentUser['is_admin']); $viewerIsAdmin = !empty($currentUser['is_admin']); // Get transaction history and activity log for admins $transactions = []; if ($viewerIsAdmin && $creditsEnabled) { try { $transactions = \BinktermPHP\UserCredit::getTransactionHistory((int)$targetUser['id'], 10); } catch (\Throwable $e) { $transactions = []; } } $activityLog = []; if ($viewerIsAdmin) { try { $actStmt = $db->prepare(' SELECT ual.id, ual.created_at, ac.name AS category, at.label AS activity, ual.object_name, ual.meta FROM user_activity_log ual JOIN activity_types at ON at.id = ual.activity_type_id JOIN activity_categories ac ON ac.id = at.category_id WHERE ual.user_id = ? ORDER BY ual.created_at DESC LIMIT 25 '); $actStmt->execute([(int)$targetUser['id']]); $activityLog = $actStmt->fetchAll(); } catch (\Throwable $e) { $activityLog = []; } } // Get transfer fee percentage $transferFeePercent = isset($creditsConfig['transfer_fee_percent']) ? (float)$creditsConfig['transfer_fee_percent'] : 0.05; $transferFeePercent = max(0, min(1, $transferFeePercent)); $templateVars = [ 'profile_username' => $targetUser['username'], 'profile_real_name' => $targetUser['real_name'] ?? '', 'profile_location' => $targetUser['location'] ?? '', 'profile_about_me_html' => $targetUser['about_me'] ? \BinktermPHP\MarkdownRenderer::toHtml($targetUser['about_me']) : '', 'profile_fidonet_address' => $targetUser['fidonet_address'] ?? '', 'profile_created_at' => $targetUser['created_at'], 'profile_last_login' => $targetUser['last_login'], 'profile_is_admin' => (bool)$targetUser['is_admin'], 'profile_user_id' => $targetUser['id'], 'credits_enabled' => !empty($creditsEnabled), 'credits_symbol' => $creditsSymbol, 'credit_balance' => $creditBalance, 'is_own_profile' => $isOwnProfile, 'can_view_sensitive' => $canViewSensitive, 'viewer_is_admin' => $viewerIsAdmin, 'transactions' => $transactions, 'activity_log' => $activityLog, 'transfer_fee_percent' => $transferFeePercent ]; $template = new Template(); $template->renderResponse('user_profile.twig', $templateVars); })->where(['username' => '[\w ]+']); // [\w ]+ allows spaces: default [\w-]+ rejects decoded spaces in path SimpleRouter::get('/settings', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // Get system configuration for display try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $systemName = $binkpConfig->getSystemName(); $systemAddress = $binkpConfig->getSystemAddress(); $sysopName = $binkpConfig->getSystemSysop(); } catch (\Exception $e) { $systemName = webLocalizedText('ui.web.fallback.system_name', 'BinktermPHP System', $user); $systemAddress = webLocalizedText('ui.common.not_configured', 'Not configured', $user); $sysopName = webLocalizedText('ui.common.unknown', 'Unknown', $user); } $taglines = []; $defaultTagline = ''; try { $taglinesPath = __DIR__ . '/../config/taglines.txt'; $raw = file_exists($taglinesPath) ? file_get_contents($taglinesPath) : ''; $lines = preg_split('/\r\n|\r|\n/', (string)$raw) ?: []; foreach ($lines as $line) { $trimmed = trim($line); if ($trimmed !== '') { $taglines[] = $trimmed; } } } catch (\Exception $e) { $taglines = []; } try { $handler = new MessageHandler(); $userId = $user['user_id'] ?? $user['id'] ?? null; if ($userId) { $settings = $handler->getUserSettings($userId); $defaultTagline = (string)($settings['default_tagline'] ?? ''); } } catch (\Exception $e) { $defaultTagline = ''; } $notificationSounds = []; try { $notificationSounds = ['disabled']; $soundDir = __DIR__ . '/../public_html/sounds'; $soundFiles = glob($soundDir . '/notify*.mp3') ?: []; natsort($soundFiles); foreach ($soundFiles as $soundFile) { $notificationSounds[] = pathinfo($soundFile, PATHINFO_FILENAME); } } catch (\Exception $e) { $notificationSounds = []; } if (empty($notificationSounds)) { $notificationSounds = ['disabled', 'notify1', 'notify2', 'notify3', 'notify4', 'notify5']; } $templateVars = [ 'system_name_display' => $systemName, 'system_address_display' => $systemAddress, 'system_sysop' => $sysopName, 'taglines' => $taglines, 'default_tagline' => $defaultTagline, 'notification_sounds' => $notificationSounds, 'license_valid' => \BinktermPHP\License::isValid(), 'mcp_server_url' => \BinktermPHP\Config::env('MCP_SERVER_URL', ''), 'mcp_service_running' => (bool)((\BinktermPHP\SystemStatus::getDaemonStatus()['mcp_server']['running'] ?? false)), ]; $template = new Template(); $template->renderResponse('settings.twig', $templateVars); }); SimpleRouter::get('/chat', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } if (!BbsConfig::isFeatureEnabled('chat')) { $template = new Template(); $template->renderResponse('error.twig', [ 'error_code' => 'ui.web.errors.chat_disabled' ]); exit; } $template = new Template(); $template->renderResponse('chat.twig'); }); SimpleRouter::get('/whosonline', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } $onlineUsers = $auth->getOnlineSessions(15); $onlineUserCount = $auth->getOnlineUserCount(15); $template = new Template(); $template->renderResponse('whos_online.twig', [ 'online_users' => $onlineUsers, 'online_user_count' => $onlineUserCount, 'online_minutes' => 15 ]); }); SimpleRouter::get('/echoareas', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // Check if user is admin (echoareas management is admin only) if (!$user['is_admin']) { http_response_code(403); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.access_error', 'error_code' => 'ui.web.errors.echoareas_admin_only' ]); return; } $networkManager = new \BinktermPHP\NetworkManager(); $template = new Template(); $template->renderResponse('echoareas.twig', [ 'networks' => $networkManager->getAll(), ]); }); SimpleRouter::get('/echoareas/import', function() { $user = RouteHelper::requireAdmin(); $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $domains = []; foreach ($binkpConfig->getUplinks() as $uplink) { $d = strtolower(trim($uplink['domain'] ?? '')); if ($d !== '' && !in_array($d, $domains, true)) { $domains[] = $d; } } sort($domains); $template = new Template(); $template->renderResponse('echoareas_import.twig', ['domains' => $domains]); }); SimpleRouter::post('/echoareas/import', function() { $user = RouteHelper::requireAdmin(); $summary = null; $error = null; $errorCode = null; $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $domains = []; foreach ($binkpConfig->getUplinks() as $uplink) { $d = strtolower(trim($uplink['domain'] ?? '')); if ($d !== '' && !in_array($d, $domains, true)) { $domains[] = $d; } } sort($domains); try { $format = trim($_POST['import_format'] ?? 'csv'); if ($format === 'na') { if (!isset($_FILES['echoareas_na']) || !is_array($_FILES['echoareas_na'])) { $errorCode = 'ui.echoareas_import.error_choose_na'; throw new \RuntimeException(''); } $upload = $_FILES['echoareas_na']; if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $errorCode = 'ui.echoareas_import.error_upload_failed'; throw new \RuntimeException(''); } $tmpName = $upload['tmp_name'] ?? ''; if ($tmpName === '' || !is_uploaded_file($tmpName)) { $errorCode = 'ui.echoareas_import.error_invalid_upload'; throw new \RuntimeException(''); } $naDomain = trim($_POST['na_domain'] ?? ''); $importer = new \BinktermPHP\EchoareaImporter(); $summary = $importer->importNa($tmpName, $naDomain); } else { if (!isset($_FILES['echoareas_csv']) || !is_array($_FILES['echoareas_csv'])) { $errorCode = 'ui.echoareas_import.error_choose_csv'; throw new \RuntimeException(''); } $upload = $_FILES['echoareas_csv']; if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { $errorCode = 'ui.echoareas_import.error_upload_failed'; throw new \RuntimeException(''); } $tmpName = $upload['tmp_name'] ?? ''; if ($tmpName === '' || !is_uploaded_file($tmpName)) { $errorCode = 'ui.echoareas_import.error_invalid_upload'; throw new \RuntimeException(''); } $importer = new \BinktermPHP\EchoareaImporter(); $summary = $importer->importCsv($tmpName); } } catch (\Throwable $e) { $error = $e->getMessage(); if ($error === '') { $error = null; } } if (is_array($summary) && isset($summary['errors']) && is_array($summary['errors'])) { $localizedErrors = []; foreach ($summary['errors'] as $item) { if (is_array($item) && !empty($item['error_code'])) { $message = webLocalizedText( (string)$item['error_code'], (string)($item['error_fallback'] ?? ''), $user, is_array($item['error_params'] ?? null) ? $item['error_params'] : [] ); $lineNumber = isset($item['line']) ? (int)$item['line'] : 0; if ($lineNumber > 0) { $message = webLocalizedText( 'ui.echoareas_import.error_line_prefix', 'Line {line}: {message}', $user, ['line' => $lineNumber, 'message' => $message] ); } $localizedErrors[] = $message; } else { $localizedErrors[] = (string)$item; } } $summary['errors'] = $localizedErrors; } $template = new Template(); $template->renderResponse('echoareas_import.twig', [ 'import_summary' => $summary, 'import_error' => $error, 'import_error_code' => $errorCode, 'domains' => $domains, 'active_format' => trim($_POST['import_format'] ?? 'csv'), ]); }); SimpleRouter::get('/echolist', function() { $user = RouteHelper::requireAuth(); // First-visit onboarding: same guard as /echomail if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') === 'true') { $im = new \BinktermPHP\InterestManager(); if (count($im->getInterests(true)) > 0) { $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); $meta = new \BinktermPHP\UserMeta(); if (!$meta->getValue($userId, 'interests_onboarded')) { $meta->setValue($userId, 'interests_onboarded', '1'); return SimpleRouter::response()->redirect('/echo-onboarding?from=echolist'); } } } $template = new Template(); $template->renderResponse('echolist.twig'); }); SimpleRouter::get('/fileareas', function() { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } // Check if user is admin (file areas management is admin only) if (!$user['is_admin']) { http_response_code(403); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.access_error', 'error_code' => 'ui.web.errors.fileareas_admin_only' ]); return; } $networkManager = new \BinktermPHP\NetworkManager(); $template = new Template(); $template->renderResponse('fileareas.twig', [ 'networks' => $networkManager->getAll(), ]); }); SimpleRouter::get('/files', function() { $user = RouteHelper::requireAuth(); if (!\BinktermPHP\BbsConfig::isFeatureEnabled('file_areas')) { http_response_code(404); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.not_found', 'error_code' => 'ui.web.errors.files_feature_disabled' ]); return; } $template = new Template(); $template->renderResponse('files.twig', [ 'virus_scan_disabled' => \BinktermPHP\Config::env('VIRUS_SCAN_DISABLED', 'false') === 'true', 'file_areas_appearance' => \BinktermPHP\AppearanceConfig::getFileAreasConfig(), ]); }); SimpleRouter::get('/files/{tag}', function($tag) { if (!\BinktermPHP\BbsConfig::isFeatureEnabled('file_areas')) { http_response_code(404); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.not_found', 'error_code' => 'ui.web.errors.files_feature_disabled' ]); return; } // Check if this area is public ? if so, auth is optional $manager = new \BinktermPHP\FileAreaManager(); $area = $manager->getFileAreaByTag(strtoupper($tag)); $isPublic = !empty($area['is_public']) && empty($area['is_private']); if ($isPublic) { $auth = new \BinktermPHP\Auth(); $user = $auth->getCurrentUser(); // may be null for guests } else { $user = RouteHelper::requireAuth(); } $template = new Template(); $template->renderResponse('files.twig', [ 'virus_scan_disabled' => \BinktermPHP\Config::env('VIRUS_SCAN_DISABLED', 'false') === 'true', 'initial_area_tag' => strtoupper($tag), 'is_public_area' => $isPublic, 'initial_area' => $area ?: null, 'file_areas_appearance' => \BinktermPHP\AppearanceConfig::getFileAreasConfig(), ]); }); SimpleRouter::get('/public-files', function() { if (!\BinktermPHP\BbsConfig::isFeatureEnabled('file_areas') || !\BinktermPHP\BbsConfig::isFeatureEnabled('public_files_index')) { http_response_code(404); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.not_found', 'error_code' => 'ui.web.errors.not_found' ]); return; } $template = new Template(); $template->renderResponse('files.twig', [ 'virus_scan_disabled' => \BinktermPHP\Config::env('VIRUS_SCAN_DISABLED', 'false') === 'true', 'is_public_index' => true, 'file_areas_appearance' => \BinktermPHP\AppearanceConfig::getFileAreasConfig(), ]); }); SimpleRouter::get('/compose/{type}', function($type) { $auth = new Auth(); $user = $auth->getCurrentUser(); if (!$user) { return SimpleRouter::response()->redirect('/login'); } if (!in_array($type, ['netmail', 'echomail'])) { http_response_code(404); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.not_found', 'error_code' => 'ui.web.errors.compose_type_invalid' ]); return; } // Handle reply/re-post and echoarea parameters $replyId = $_GET['reply'] ?? null; $repostId = $_GET['repost'] ?? null; $prefillMessageId = $replyId ?: $repostId; $sourceType = $_GET['source_type'] ?? $type; $echoarea = $_GET['echoarea'] ?? null; $domainParam = $_GET['domain'] ?? null; $returnTo = $_GET['return_to'] ?? null; if (!in_array($sourceType, ['netmail', 'echomail'], true)) { $sourceType = $type; } // Interest context: restrict echo area list and cross-post list to interest areas $interestSlug = $_GET['interest'] ?? null; $interestData = null; $interestEchoareas = []; if ($interestSlug && \BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') === 'true') { $im = new \BinktermPHP\InterestManager(); $interestData = $im->getInterestBySlug($interestSlug); if ($interestData) { // Fetch the interest's echo areas with tag/domain for the compose selects $db = \BinktermPHP\Database::getInstance()->getPdo(); $stmt = $db->prepare(" SELECT e.tag, e.domain, e.description, e.color, COUNT(em.id) AS message_count FROM echoareas e INNER JOIN interest_echoareas ie ON ie.echoarea_id = e.id LEFT JOIN echomail em ON em.echoarea_id = e.id WHERE ie.interest_id = ? AND e.is_active = TRUE GROUP BY e.id, e.tag, e.domain, e.description, e.color ORDER BY message_count DESC, e.tag ASC "); $stmt->execute([(int)$interestData['id']]); $interestEchoareas = $stmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($interestEchoareas as &$row) { $row['message_count'] = (int)$row['message_count']; } unset($row); } else { $interestSlug = null; // slug not found ? fall back to normal compose } } // Handle new message parameters (from nodelist or address book) $toAddress = $_GET['to'] ?? null; $toName = $_GET['to_name'] ?? null; $subject = $_GET['subject'] ?? null; $prefillCrashmail = !empty($_GET['crashmail']); // Get system configuration for display try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $systemName = $binkpConfig->getSystemName(); $systemAddress = $binkpConfig->getSystemAddress(); $crashmailEnabled = $binkpConfig->getCrashmailEnabled(); $sysopName = $binkpConfig->getSystemSysop(); } catch (\Exception $e) { $systemName = webLocalizedText('ui.web.fallback.system_name', 'BinktermPHP System', $user); $systemAddress = webLocalizedText('ui.common.not_configured', 'Not configured', $user); $crashmailEnabled = false; $sysopName = ''; } // Get credit costs for display $netmailCost = \BinktermPHP\UserCredit::getCreditCost('netmail', 1); $crashmailCost = \BinktermPHP\UserCredit::getCreditCost('crashmail', 10); $currencySymbol = \BinktermPHP\UserCredit::getCurrencySymbol(); $creditsEnabled = \BinktermPHP\UserCredit::isEnabled(); $taglines = []; $defaultTagline = ''; try { $taglinesPath = __DIR__ . '/../config/taglines.txt'; $raw = file_exists($taglinesPath) ? file_get_contents($taglinesPath) : ''; $lines = preg_split('/\r\n|\r|\n/', (string)$raw) ?: []; foreach ($lines as $line) { $trimmed = trim($line); if ($trimmed !== '') { $taglines[] = $trimmed; } } } catch (\Exception $e) { $taglines = []; } $bbsConfig = \BinktermPHP\BbsConfig::getConfig(); $maxCrossPost = (int)($bbsConfig['max_cross_post_areas'] ?? 5); // Determine the default charset for new messages (may be overridden by reply_charset for replies). // Build a domain?charset map so the compose page can update the selector when the echo area changes. $defaultCharset = \BinktermPHP\BbsConfig::getOutgoingCharset(); $domainCharsets = []; // domain => charset override (only entries that differ from BBS default) try { $binkpCfg = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); foreach ($binkpCfg->getUplinks() as $uplink) { $uplinkDomain = strtolower($uplink['domain'] ?? ''); $networkCharset = $uplinkDomain !== '' ? $binkpCfg->getDefaultCharsetForDomain($uplinkDomain) : null; if ($uplinkDomain !== '' && $networkCharset !== null) { $domainCharsets[$uplinkDomain] = strtoupper($networkCharset); } } // Apply override for initial charset if domain is already known from GET param (echomail) if ($domainParam && isset($domainCharsets[strtolower($domainParam)])) { $defaultCharset = $domainCharsets[strtolower($domainParam)]; } // For netmail with a pre-filled destination address, resolve the uplink at render time // so the charset selector is correct on page load without waiting for the JS to call // the markdown-support API. if ($type === 'netmail' && !empty($toAddress) && !$prefillMessageId) { if ($binkpCfg->isMyAddress(trim((string)$toAddress))) { $defaultCharset = 'UTF-8'; } else { $networkCharset = $binkpCfg->getDefaultCharsetForDestination((string)$toAddress); if ($networkCharset !== null) { $defaultCharset = strtoupper($networkCharset); } } } } catch (\Exception $e) { // Config unavailable; fall back to BBS default } $templateVars = [ 'type' => $type, 'current_user' => $user, 'user_name' => $user['real_name'] ?: $user['username'], 'system_name_display' => $systemName, 'system_address_display' => $systemAddress, 'system_sysop' => $sysopName, 'crashmail_enabled' => $crashmailEnabled, 'netmail_cost' => $netmailCost, 'crashmail_cost' => $crashmailCost, 'currency_symbol' => $currencySymbol, 'credits_enabled' => $creditsEnabled, 'taglines' => $taglines, 'default_tagline' => $defaultTagline, 'max_cross_post_areas' => $maxCrossPost, 'prefill_crashmail' => $prefillCrashmail, 'interest_slug' => $interestSlug, 'interest_name' => $interestData ? $interestData['name'] : null, 'interest_echoareas' => $interestEchoareas, 'default_charset' => $defaultCharset, 'domain_charsets' => $domainCharsets, 'return_to' => is_string($returnTo) ? $returnTo : '', ]; if ($prefillMessageId) { $handler = new MessageHandler(); $userId = $user['user_id'] ?? $user['id'] ?? null; $originalMessage = $handler->getMessage($prefillMessageId, $sourceType, $userId); if ($originalMessage) { $templateVars['reply_markup_type'] = getMessageMarkupType($originalMessage) ?? ''; $rawCharset = (string)($originalMessage['message_charset'] ?? 'UTF-8'); $templateVars['reply_charset'] = \BinktermPHP\Binkp\Config\BinkpConfig::normalizeCharset($rawCharset) ?: 'UTF-8'; if ($replyId) { if ($type === 'netmail') { $templateVars['reply_to_id'] = $replyId; // Try to parse REPLYTO kludge from the original message text $replyToData = parseReplyToKludge($originalMessage['message_text']); if ($replyToData) { // Use REPLYTO address and name if valid FidoNet address found $templateVars['reply_to_address'] = $replyToData['address']; $templateVars['reply_to_name'] = $replyToData['name'] ?: $originalMessage['from_name']; } else { // Fallback to existing logic if no valid REPLYTO found $templateVars['reply_to_address'] = $originalMessage['reply_address'] ?: ($originalMessage['original_author_address'] ?: $originalMessage['from_address']); $templateVars['reply_to_name'] = $originalMessage['from_name']; } $subject = $originalMessage['subject'] ?? ''; // Remove "Re: " prefix if it exists (case insensitive) $cleanSubject = preg_replace('/^Re:\s*/i', '', $subject); $templateVars['reply_subject'] = 'Re: ' . $cleanSubject; // Filter out kludge lines but preserve blank lines so quoted structure is intact $cleanMessageText = filterKludgeLinesPreserveEmptyLines($originalMessage['message_text']); if (($templateVars['reply_markup_type'] ?? '') === 'markdown') { $quotedText = quoteMarkdownMessage($cleanMessageText); } else { // Quote using FSC-0032 style for plain/stylecoded messages $initials = generateInitials($originalMessage['from_name']); $quotedText = quoteMessageText($cleanMessageText, $initials); } $replyDate = date('F j Y', strtotime($originalMessage['date_written'])); $attribution = webLocalizedText('ui.compose.reply_attribution', 'On {date}, {name} wrote:', $user, [ 'date' => $replyDate, 'name' => $originalMessage['from_name'], ]); $separator = (($templateVars['reply_markup_type'] ?? '') === 'markdown') ? "\n\n" : "\n"; $templateVars['reply_text'] = "\n" . $attribution . $separator . $quotedText; } else { $templateVars['reply_to_id'] = $replyId; $templateVars['reply_to_name'] = $originalMessage['from_name']; $subject = $originalMessage['subject'] ?? ''; // Remove "Re: " prefix if it exists (case insensitive) $cleanSubject = preg_replace('/^Re:\s*/i', '', $subject); $templateVars['reply_subject'] = 'Re: ' . $cleanSubject; // Set echoarea for proper select matching ? only append @domain when // domain is non-empty, matching the JS option format: tag@domain or tag $echoarea = $originalMessage['echoarea']; if (!empty($originalMessage['domain'])) { $echoarea .= '@' . $originalMessage['domain']; } $templateVars['domain'] = $originalMessage['domain']; // Filter out kludge lines but preserve blank lines so quoted structure is intact $cleanMessageText = filterKludgeLinesPreserveEmptyLines($originalMessage['message_text']); if (($templateVars['reply_markup_type'] ?? '') === 'markdown') { $quotedText = quoteMarkdownMessage($cleanMessageText); } else { // Quote the message intelligently - only quote original lines, not existing quotes $initials = generateInitials($originalMessage['from_name']); $quotedText = quoteMessageText($cleanMessageText, $initials); } $replyDate = date('F j Y', strtotime($originalMessage['date_written'])); $attribution = webLocalizedText('ui.compose.reply_attribution', 'On {date}, {name} wrote:', $user, [ 'date' => $replyDate, 'name' => $originalMessage['from_name'], ]); $separator = (($templateVars['reply_markup_type'] ?? '') === 'markdown') ? "\n\n" : "\n"; $templateVars['reply_text'] = "\n" . $attribution . $separator . $quotedText; } } else { $subject = trim((string)($originalMessage['subject'] ?? '')); $templateVars['reply_subject'] = 'FWD: ' . $subject; $templateVars['reply_text'] = filterKludgeLinesPreserveEmptyLines($originalMessage['message_text']); } } } if ($echoarea) { // Combine echoarea with domain if provided separately and not already in tag@domain format if ($domainParam && strpos($echoarea, '@') === false) { $echoarea = $echoarea . '@' . $domainParam; } $templateVars['echoarea'] = $echoarea; } // Handle new message parameters (from nodelist) if ($toAddress && $type === 'netmail' && !$prefillMessageId) { $templateVars['reply_to_address'] = $toAddress; if ($toName) { $templateVars['reply_to_name'] = $toName; } } // Handle subject parameter independently (for user-click-to-compose functionality) if ($subject && $type === 'netmail' && !$prefillMessageId) { $templateVars['reply_subject'] = $subject; } // Ensure reply_to_name has a safe default value and add a processed version if (!isset($templateVars['reply_to_name']) || $templateVars['reply_to_name'] === '') { $templateVars['reply_to_name'] = ($type === 'echomail') ? webLocalizedText('ui.common.all', 'All', $user) : ''; } // Add a safe processed version for template display $templateVars['to_name_value'] = $templateVars['reply_to_name'] ?: (($type === 'echomail') ? webLocalizedText('ui.common.all', 'All', $user) : ''); // Apply user signature to compose text (server-side to avoid late AJAX overwrites) try { $handler = new MessageHandler(); $userId = $user['user_id'] ?? $user['id'] ?? null; if ($userId) { $settings = $handler->getUserSettings($userId); $signatureText = trim((string)($settings['signature_text'] ?? '')); $defaultTagline = (string)($settings['default_tagline'] ?? ''); // Resolve random tagline selection at compose time if ($defaultTagline === '__random__' && !empty($templateVars['taglines'])) { $defaultTagline = $templateVars['taglines'][array_rand($templateVars['taglines'])]; } $templateVars['default_tagline'] = $defaultTagline; if ($signatureText !== '') { $sigLines = preg_split('/\r\n|\r|\n/', $signatureText) ?: []; $sigLines = array_slice($sigLines, 0, 4); $sigLines = array_map('rtrim', $sigLines); $signaturePlain = implode("\n", $sigLines); $replyText = (string)($templateVars['reply_text'] ?? ''); $replyLines = preg_split('/\r\n|\r|\n/', rtrim($replyText, "\r\n")) ?: []; while (!empty($replyLines) && trim((string)end($replyLines)) === '') { array_pop($replyLines); } $tail = array_slice($replyLines, -count($sigLines)); $alreadyHasSignature = ($sigLines !== [] && $tail === $sigLines); if (!$alreadyHasSignature) { $base = rtrim($replyText); $templateVars['reply_text'] = $base === '' ? "\n\n\n" . $signaturePlain : $base . "\n\n\n" . $signaturePlain; } } } } catch (\Exception $e) { // Ignore signature errors to keep compose functional } $template = new Template(); $template->renderResponse('compose.twig', $templateVars); }); // Subscription management routes SimpleRouter::get('/subscriptions', function() { $user = RouteHelper::requireAuth(); $controller = new BinktermPHP\SubscriptionController(); $data = $controller->renderUserSubscriptionPage(); // Only render template if we got data back (not redirected) if ($data !== null) { $template = new Template(); $template->renderResponse('user_subscriptions.twig', $data); } }); SimpleRouter::get('/polls/create', function() { $user = RouteHelper::requireAuth(); // Get poll creation cost $pollCost = UserCredit::getCreditCost('poll_creation', 15); // Get user's credit balance $balance = UserCredit::getBalance($user['user_id'] ?? $user['id']); $template = new Template(); $template->renderResponse('create_poll.twig', [ 'poll_cost' => $pollCost, 'credit_balance' => $balance ]); }); SimpleRouter::get('/polls', function() { $user = RouteHelper::requireAuth(); if (!BbsConfig::isFeatureEnabled('voting_booth')) { http_response_code(404); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.not_found', 'error_code' => 'ui.web.errors.polls_disabled' ]); return; } $template = new Template(); $template->renderResponse('polls.twig'); }); SimpleRouter::get('/shoutbox', function() { $user = RouteHelper::requireAuth(); if (!BbsConfig::isFeatureEnabled('shoutbox')) { http_response_code(404); $template = new Template(); $template->renderResponse('error.twig', [ 'error_title_code' => 'ui.error.not_found', 'error_code' => 'ui.web.errors.shoutbox_disabled' ]); return; } $template = new Template(); $template->renderResponse('shoutbox.twig'); }); // Serve shell art files from data/shell_art/ (public read, admin-only write) SimpleRouter::get('/shell-art/{name}', function(string $name) { // Sanitize: only allow safe filenames, no path traversal $name = basename($name); if (!preg_match('/^[a-zA-Z0-9_\-]+\.(ans|asc|txt)$/i', $name)) { http_response_code(404); return; } $dir = dirname(__DIR__) . '/data/shell_art'; $path = $dir . '/' . $name; if (!file_exists($path) || !is_file($path)) { http_response_code(404); return; } header('Content-Type: application/octet-stream'); header('Content-Length: ' . filesize($path)); header('Cache-Control: public, max-age=3600'); readfile($path); }); // Public Meshcore Nodes page SimpleRouter::get('/packetbbs-nodes', function() { $service = new \BinktermPHP\PacketBbs\PacketBbsNodeService(); $nodes = $service->getPublicNodes(); $mappableNodes = $service->getMappableNodes(); $template = new Template(); $template->renderResponse('meshcore_nodes.twig', [ 'nodes' => $nodes, 'mappable_nodes' => $mappableNodes, ]); }); // Public BBS Directory page SimpleRouter::get('/bbs-directory', function() { if (!\BinktermPHP\BbsConfig::isFeatureEnabled('bbs_directory')) { http_response_code(404); (new Template())->renderResponse('404.twig'); return; } $db = \BinktermPHP\Database::getInstance()->getPdo(); $directory = new \BinktermPHP\BbsDirectory($db); $entries = $directory->getActiveEntries(); foreach ($entries as &$entry) { $entry['public_path'] = bbsDirectoryEntryPath($entry); } unset($entry); $template = new Template(); $template->renderResponse('bbs_directory.twig', ['entries' => $entries]); }); // Individual BBS detail page $renderBbsDirectoryEntry = function($id) { if (!\BinktermPHP\BbsConfig::isFeatureEnabled('bbs_directory')) { http_response_code(404); (new Template())->renderResponse('404.twig'); return; } $id = (int)$id; if ($id <= 0) { http_response_code(404); (new Template())->renderResponse('404.twig'); return; } $db = \BinktermPHP\Database::getInstance()->getPdo(); $directory = new \BinktermPHP\BbsDirectory($db); $entry = $directory->getActiveEntryById($id); if (!$entry) { http_response_code(404); (new Template())->renderResponse('404.twig'); return; } $entry['public_path'] = bbsDirectoryEntryPath($entry); $entry['public_url'] = \BinktermPHP\Config::getSiteUrl() . $entry['public_path']; $template = new Template(); $template->renderResponse('bbs_directory_entry.twig', ['entry' => $entry]); }; SimpleRouter::get('/bbs-directory/{id}', $renderBbsDirectoryEntry) ->where(['id' => '[0-9]+']); SimpleRouter::get('/bbs-directory/{id}/{slug}', function($id, $slug) use ($renderBbsDirectoryEntry) { return $renderBbsDirectoryEntry($id); })->where(['id' => '[0-9]+', 'slug' => '[A-Za-z0-9._~-]+']); // Submit a BBS listing (authenticated users only ? creates pending entry) SimpleRouter::post('/api/bbs-directory/submit', function() { if (!\BinktermPHP\BbsConfig::isFeatureEnabled('bbs_directory')) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['error' => 'Not found']); return; } $user = RouteHelper::requireAuth(); $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.bbs_directory.name_required', apiLocalizedText('errors.bbs_directory.name_required', 'BBS name is required')); return; } $directory = new \BinktermPHP\BbsDirectory($db); $userId = (int)($user['user_id'] ?? $user['id'] ?? 0); try { $id = $directory->createPendingEntry($input, $userId); echo json_encode(['success' => true, 'id' => $id]); // Notify the sysop that a new BBS listing is awaiting approval $bbsName = $input['name'] ?? 'Unknown'; $approvalUrl = \BinktermPHP\Config::getSiteUrl() . '/admin/bbs-directory'; \BinktermPHP\SysopNotificationService::sendNoticeToSysop( 'New BBS listing pending approval', "A new BBS listing has been submitted and is awaiting your review.\n\n" . "BBS Name: {$bbsName}\n\n" . "Approve or reject it at:\n{$approvalUrl}" ); } catch (\PDOException $e) { http_response_code(400); if (strpos($e->getMessage(), 'duplicate key') !== false || strpos($e->getMessage(), 'unique') !== false) { apiError('errors.bbs_directory.duplicate_name', apiLocalizedText('errors.bbs_directory.duplicate_name', 'A BBS with that name already exists')); } else { apiError('errors.bbs_directory.submit_failed', apiLocalizedText('errors.bbs_directory.submit_failed', 'Submission failed')); } } }); // Public /about page (only when enabled in appearance settings) SimpleRouter::get('/about', function() { if (!\BinktermPHP\AppearanceConfig::isAboutPageEnabled()) { http_response_code(404); $template = new Template(); $template->renderResponse('404.twig'); return; } $template = new Template(); $template->renderResponse('about.twig'); }); // Echomail onboarding guide SimpleRouter::get('/echo-onboarding', function() { RouteHelper::requireAuth(); $from = $_GET['from'] ?? 'echomail'; // Only allow known destinations $skipUrl = $from === 'echolist' ? '/echolist' : '/echomail'; // Count distinct domains across enabled uplinks so the template can // provide multi-network context when more than one network is connected. $networkCount = 0; try { $binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance(); $domains = []; foreach ($binkpConfig->getEnabledUplinks() as $uplink) { $domain = trim((string)($uplink['domain'] ?? '')); if ($domain !== '') { $domains[$domain] = true; } } $networkCount = count($domains); } catch (\Throwable $e) { // Config unavailable ? leave count at 0; template falls back gracefully } $template = new Template(); $template->renderResponse('echo-onboarding.twig', [ 'skip_url' => $skipUrl, 'network_count' => $networkCount, ]); }); // Interests page SimpleRouter::get('/interests', function() { $user = RouteHelper::requireAuth(); if (\BinktermPHP\Config::env('ENABLE_INTERESTS', 'true') !== 'true') { http_response_code(404); $template = new Template(); $template->renderResponse('404.twig'); return; } $template = new Template(); $template->renderResponse('interests.twig'); }); // QWK Offline Mail page SimpleRouter::get('/qwk', function() { $user = RouteHelper::requireAuth(); if (!\BinktermPHP\BbsConfig::isFeatureEnabled('qwk')) { http_response_code(404); $template = new Template(); $template->renderResponse('404.twig'); return; } $template = new Template(); $template->renderResponse('qwk.twig'); }); SimpleRouter::match([\Pecee\Http\Request::REQUEST_TYPE_GET, \Pecee\Http\Request::REQUEST_TYPE_HEAD], '/qwk/download', function() { $user = requireBasicAuthUser(); $userId = (int)($user['user_id'] ?? $user['id']); try { $controller = new \BinktermPHP\Qwk\QwkHttpController(); $metadata = $controller->getDownloadMetadata($userId); $filename = (string)$metadata['filename']; $safeFilename = str_replace(['\\', '"', "\r", "\n"], ['_', '_', '', ''], $filename); $encodedFilename = rawurlencode($filename); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . $encodedFilename); header('X-QWK-BBS-ID: ' . $metadata['bbs_id']); header('X-QWK-Reply-Filename: ' . $metadata['reply_filename']); header('Cache-Control: no-store, no-cache, must-revalidate'); header('Pragma: no-cache'); if (strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') { exit; } $download = $controller->buildDownloadPacket($userId); header('Content-Length: ' . $download['filesize']); readfile($download['path']); @unlink($download['path']); exit; } catch (\DomainException $e) { http_response_code(403); echo htmlspecialchars($e->getMessage()); } catch (\Throwable $e) { getServerLogger()->error('[QWK] basic-auth download failed for user ' . $userId . ': ' . $e->getMessage()); http_response_code(500); echo 'Failed to build QWK packet: ' . htmlspecialchars($e->getMessage()); } }); SimpleRouter::post('/qwk/upload', function() { $user = requireBasicAuthUser(); $userId = (int)($user['user_id'] ?? $user['id']); header('Content-Type: application/json'); try { $controller = new \BinktermPHP\Qwk\QwkHttpController(); $file = $controller->getUploadedRepFromRequest(); echo json_encode($controller->processUploadedRep($file, $userId)); } catch (\InvalidArgumentException $e) { http_response_code(400); echo json_encode([ 'success' => false, 'error' => $e->getMessage(), ]); } catch (\DomainException $e) { http_response_code(403); echo json_encode([ 'success' => false, 'error' => $e->getMessage(), ]); } catch (\Throwable $e) { getServerLogger()->error('[QWK] basic-auth upload failed for user ' . $userId . ': ' . $e->getMessage()); http_response_code(500); echo json_encode([ 'success' => false, 'error' => 'Failed to process REP packet: ' . $e->getMessage(), ]); } }); /** * Serve a markdown post image file to the browser. * @param array $file File row from FileAreaManager (must include storage_path, filename) */ function serveMarkdownImage(array $file): void { if (!file_exists($file['storage_path'])) { http_response_code(404); echo 'Image not found'; return; } $ext = strtolower(pathinfo((string)$file['filename'], PATHINFO_EXTENSION)); $mimes = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', ]; $contentType = $mimes[$ext] ?? (mime_content_type($file['storage_path']) ?: 'application/octet-stream'); $safeName = addslashes(basename((string)$file['filename'])); header('Content-Type: ' . $contentType); header('Content-Length: ' . filesize($file['storage_path'])); header('Content-Disposition: inline; filename="' . $safeName . '"'); header('Cache-Control: public, max-age=86400'); header('X-Content-Type-Options: nosniff'); readfile($file['storage_path']); exit; } // Serve a markdown post image by human-readable slug: /echomail-images/{username}/{slug} // No authentication required ? images are embedded in public posts. SimpleRouter::get('/echomail-images/{username}/{slug}', function(string $username, string $slug) { $manager = new \BinktermPHP\FileAreaManager(); $file = $manager->getMarkdownImageBySlug($username, $slug); if (!$file) { http_response_code(404); echo 'Image not found'; return; } serveMarkdownImage($file); })->where(['username' => '[\w ]+', 'slug' => '[\w.-]+']); // slug allows dots for file extensions; username allows spaces // Legacy route: serve by SHA-256 hash for URLs embedded in older posts. SimpleRouter::get('/echomail-images/{hash}', function(string $hash) { $manager = new \BinktermPHP\FileAreaManager(); $file = $manager->getMarkdownImageByHash($hash); if (!$file) { http_response_code(404); echo 'Image not found'; return; } serveMarkdownImage($file); }); // User guide SimpleRouter::get('/user-guide', function() { try { $auth = new \BinktermPHP\Auth(); $user = $auth->getCurrentUser(); $resolver = new \BinktermPHP\I18n\LocaleResolver(new \BinktermPHP\I18n\Translator()); $locale = $resolver->resolveLocale(null, is_array($user) ? $user : null); } catch (\Throwable $e) { $locale = 'en'; } $basePath = __DIR__ . '/../docs/userguide/index'; $path = \BinktermPHP\Web\DocsController::resolveLocalizedPath($basePath, $locale); $content = null; if ($path !== null) { $content = \BinktermPHP\MarkdownRenderer::toHtml(file_get_contents($path), 0, true); } $template = new Template(); $template->renderResponse('userguide.twig', ['content' => $content]); }); // Include local/custom routes if they exist $localRoutes = __DIR__ . '/web-routes.local.php'; if (file_exists($localRoutes)) { require_once $localRoutes; }