<?php
/**
* Door Game Routes
*
* API endpoints for launching and managing DOSBox door game sessions
*/
use BinktermPHP\ActivityTracker;
use BinktermPHP\DoorSessionManager;
use BinktermPHP\DoorManager;
use BinktermPHP\NativeDoorManager;
use BinktermPHP\Database;
use BinktermPHP\I18n\LocaleResolver;
use BinktermPHP\I18n\Translator;
use BinktermPHP\RouteHelper;
use Pecee\SimpleRouter\SimpleRouter;
function getDoorLogger(): \BinktermPHP\Binkp\Logger
{
static $logger = null;
if ($logger === null) {
$logger = new \BinktermPHP\Binkp\Logger(
\BinktermPHP\Config::getLogPath('dosdoor.log'),
\BinktermPHP\Binkp\Logger::LEVEL_INFO,
false
);
}
return $logger;
}
function doorApiError(string $errorCode, string $message, int $status = 400, array $extra = []): void
{
http_response_code($status);
$localized = doorLocalizedText($errorCode, $message);
echo json_encode(array_merge([
'success' => false,
'error_code' => $errorCode,
'error' => $localized,
], $extra));
}
function doorLocalizedText(string $key, string $fallback, array $params = [], ?array $user = null): 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 \BinktermPHP\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, ['errors']);
return $translated === $key ? $fallback : $translated;
}
// Launch a door game session
SimpleRouter::post('/api/door/launch', function() {
header('Content-Type: application/json');
// Require authentication
$user = RouteHelper::requireAuth();
$userId = $user['user_id'] ?? $user['id'] ?? null;
$doorName = $_POST['door'] ?? null;
getDoorLogger()->info("DOSDOOR: [API] User ID: $userId, Username: " . ($user['username'] ?? 'unknown') . ", Door: $doorName");
if (!$doorName) {
doorApiError('errors.door.door_name_required', 'Door name required', 400);
return;
}
try {
// Get BBS configuration for system name and sysop
$binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance();
$systemName = $binkpConfig->getSystemName();
$sysopName = $binkpConfig->getSystemSysop();
// Split sysop name into first/last for DOOR.SYS format
$sysopParts = explode(' ', $sysopName, 2);
$sysopFirst = $sysopParts[0] ?? 'Sysop';
$sysopLast = $sysopParts[1] ?? '';
// Prepare user data for drop file (using actual schema columns)
$userData = [
'id' => $userId,
'real_name' => $user['username'], // Use username for door games
'location' => 'BinktermPHP BBS', // Default location
'security_level' => $user['is_admin'] ? 255 : 30,
'total_logins' => 1, // Default
'last_login' => date('Y-m-d H:i:s'),
'ansi_enabled' => true, // Default to ANSI
'bbs_name' => $systemName,
'sysop_name' => $sysopName,
'sysop_first' => $sysopFirst,
'sysop_last' => $sysopLast,
];
// Create session manager in headless (production) mode and start session
$sessionManager = new DoorSessionManager(null, true);
// Check if user already has an active session for this door
$existingSession = $sessionManager->getUserSession($userId, $doorName);
if ($existingSession) {
// User already has an active session for this door - return it
// Bridge v3 owns the lifecycle, so if it's in DB, it's active
getDoorLogger()->info("DOSDOOR: [API] Resuming existing session: {$existingSession['session_id']}");
// Build WebSocket URL
$wsUrl = \BinktermPHP\Config::env('DOSDOOR_WS_URL');
if (empty($wsUrl)) {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws';
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
$port = $existingSession['ws_port'];
$wsUrl = "{$protocol}://{$host}:{$port}";
}
echo json_encode([
'success' => true,
'session' => [
'session_id' => $existingSession['session_id'],
'door_name' => $existingSession['door_name'],
'node' => $existingSession['node'],
'ws_port' => $existingSession['ws_port'],
'ws_token' => $existingSession['ws_token'],
'ws_url' => $wsUrl,
],
'message_code' => 'ui.api.door.session_resumed'
]);
return;
}
// Determine door type: check native doors first, then DOS doors
$db = \BinktermPHP\Database::getInstance()->getPdo();
$nativeDoorManager = new \BinktermPHP\NativeDoorManager();
$doorManager = new \BinktermPHP\DoorManager();
$nativeDoor = $nativeDoorManager->getDoor($doorName);
$doorType = $nativeDoor ? 'native' : 'dos';
$activeDoorManager = $nativeDoor ? $nativeDoorManager : $doorManager;
getDoorLogger()->info("DOSDOOR: [API] Door '$doorName' detected as type: $doorType");
// Ensure door exists in database (fallback sync)
$stmt = $db->prepare("SELECT id FROM dosbox_doors WHERE door_id = ?");
$stmt->execute([$doorName]);
if (!$stmt->fetch()) {
// Door not in database - try to sync it
getDoorLogger()->info("DOSDOOR: [API] Door '$doorName' not in database, attempting sync...");
$syncResult = $activeDoorManager->syncDoorsToDatabase();
getDoorLogger()->info("DOSDOOR: [API] Sync result: synced={$syncResult['synced']}, errors=" . json_encode($syncResult['errors']));
// Check again after sync
$stmt->execute([$doorName]);
if (!$stmt->fetch()) {
throw new \Exception("Door '$doorName' is not available or not enabled. Please contact the sysop.");
}
}
// Block admin-only doors for non-admins
$doorManifestCheck = $activeDoorManager->getDoor($doorName);
if ($doorManifestCheck && !empty($doorManifestCheck['admin_only']) && empty($user['is_admin'])) {
doorApiError('errors.door.admin_only', 'This door is restricted to administrators', 403);
return;
}
// Check credits requirement
$configFile = $doorType === 'native'
? __DIR__ . '/../config/nativedoors.json'
: __DIR__ . '/../config/dosdoors.json';
if (file_exists($configFile)) {
$doorConfigs = json_decode(file_get_contents($configFile), true);
$doorConfig = $doorConfigs[$doorName] ?? null;
if ($doorConfig && isset($doorConfig['credit_cost']) && $doorConfig['credit_cost'] > 0) {
$creditCost = (int)$doorConfig['credit_cost'];
// Check if credits system is enabled
$userCredit = new \BinktermPHP\UserCredit($userId);
if ($userCredit->isEnabled()) {
$currentBalance = $userCredit->getBalance();
if ($currentBalance < $creditCost) {
getDoorLogger()->warning("DOSDOOR: [API] Insufficient credits for $doorName - Required: $creditCost, Balance: $currentBalance");
doorApiError('errors.door.insufficient_credits_detail', 'Insufficient credits', 402, [
'required' => $creditCost,
'balance' => $currentBalance
]);
return;
}
// Deduct credits
if (!$userCredit->deductCredits($creditCost, 'dosdoor_launch', "Launched door: $doorName")) {
getDoorLogger()->error("DOSDOOR: [API] Failed to deduct credits for $doorName");
throw new \Exception("Failed to process credit payment. Please try again.");
}
getDoorLogger()->info("DOSDOOR: [API] Deducted $creditCost credits for $doorName - New balance: " . $userCredit->getBalance());
}
}
}
// Check door's max_nodes limit (per-door concurrency limit)
$doorManifest = $activeDoorManager->getDoor($doorName);
if ($doorManifest && isset($doorManifest['max_nodes'])) {
$maxNodes = (int)$doorManifest['max_nodes'];
// Count active sessions for this specific door
$stmt = $db->prepare("
SELECT COUNT(*) as count
FROM door_sessions
WHERE door_id = ? AND ended_at IS NULL AND expires_at > NOW()
");
$stmt->execute([$doorName]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$activeSessions = (int)$result['count'];
if ($activeSessions >= $maxNodes) {
getDoorLogger()->warning("DOSDOOR: [API] Door '$doorName' at max capacity - Active: $activeSessions, Max: $maxNodes");
doorApiError('errors.door.capacity_reached_detail', 'Door at capacity', 503, [
'active_sessions' => $activeSessions,
'max_nodes' => $maxNodes
]);
return;
}
getDoorLogger()->info("DOSDOOR: [API] Door '$doorName' capacity check passed - Active: $activeSessions, Max: $maxNodes");
}
// Start new session
$session = $sessionManager->startSession($userId, $doorName, $userData, $doorType);
ActivityTracker::track($userId, ActivityTracker::TYPE_DOSDOOR_PLAY, null, $doorName);
// Build WebSocket URL for browser
$wsUrl = \BinktermPHP\Config::env('DOSDOOR_WS_URL');
if (empty($wsUrl)) {
// Auto-detect: use current request protocol and hostname (without port)
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws';
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
$port = $session['ws_port'];
$wsUrl = "{$protocol}://{$host}:{$port}";
}
echo json_encode([
'success' => true,
'session' => [
'session_id' => $session['session_id'],
'door_name' => $session['door_name'],
'node' => $session['node'],
'ws_port' => $session['ws_port'],
'ws_token' => $session['ws_token'],
'ws_url' => $wsUrl,
]
]);
} catch (Exception $e) {
doorApiError('errors.door.launch_failed', 'Failed to start door session', 500);
}
});
// Launch a native door session as an anonymous guest (no authentication required)
SimpleRouter::post('/api/door/guest/launch', function() {
header('Content-Type: application/json');
$doorName = $_POST['door'] ?? null;
if (!$doorName) {
http_response_code(400);
echo json_encode(['error' => 'Door name required']);
return;
}
try {
// Only native doors support anonymous access
$nativeDoorManager = new NativeDoorManager();
$nativeDoor = $nativeDoorManager->getDoor($doorName);
if (!$nativeDoor) {
http_response_code(404);
echo json_encode(['error' => 'Door not found']);
return;
}
if (empty($nativeDoor['config']['enabled'])) {
http_response_code(403);
echo json_encode(['error' => 'Door is not available']);
return;
}
if (!\BinktermPHP\NativeDoorConfig::isAnonymousAllowed($doorName)) {
http_response_code(403);
echo json_encode(['error' => 'This door does not allow anonymous access']);
return;
}
// Anonymous sessions must always be free
if ((int)($nativeDoor['config']['credit_cost'] ?? 0) > 0) {
http_response_code(403);
echo json_encode(['error' => 'Doors with a credit cost cannot be accessed anonymously']);
return;
}
$guestUserId = \BinktermPHP\GuestUser::getId();
if (!$guestUserId) {
http_response_code(500);
echo json_encode(['error' => 'Guest user not configured. Run php scripts/setup.php.']);
return;
}
$db = \BinktermPHP\Database::getInstance()->getPdo();
// Check guest concurrency limit for this door
$guestMax = \BinktermPHP\NativeDoorConfig::getGuestMaxSessions($doorName);
$stmt = $db->prepare("
SELECT COUNT(*) as count FROM door_sessions
WHERE door_id = ? AND user_id = ? AND ended_at IS NULL AND expires_at > NOW()
");
$stmt->execute([$doorName, $guestUserId]);
if ((int)$stmt->fetch(\PDO::FETCH_ASSOC)['count'] >= $guestMax) {
http_response_code(503);
echo json_encode([
'success' => false,
'error' => 'Guest sessions at capacity',
'message' => "This door currently has $guestMax guest session(s) active. Please try again later.",
]);
return;
}
// Check overall door max_nodes
$maxNodes = (int)($nativeDoor['max_nodes'] ?? 10);
$stmt = $db->prepare("
SELECT COUNT(*) as count FROM door_sessions WHERE door_id = ? AND ended_at IS NULL AND expires_at > NOW()
");
$stmt->execute([$doorName]);
if ((int)$stmt->fetch(\PDO::FETCH_ASSOC)['count'] >= $maxNodes) {
http_response_code(503);
echo json_encode([
'success' => false,
'error' => 'Door at capacity',
'message' => "This door is currently full ($maxNodes player(s) maximum). Please try again later.",
]);
return;
}
// Build guest user data for drop file
$binkpConfig = \BinktermPHP\Binkp\Config\BinkpConfig::getInstance();
$systemName = $binkpConfig->getSystemName();
$sysopName = $binkpConfig->getSystemSysop();
$sysopParts = explode(' ', $sysopName, 2);
$userData = [
'id' => $guestUserId,
'real_name' => 'Guest',
'location' => 'Anonymous',
'security_level' => 5,
'total_logins' => 0,
'last_login' => date('Y-m-d H:i:s'),
'ansi_enabled' => true,
'bbs_name' => $systemName,
'sysop_name' => $sysopName,
'sysop_first' => $sysopParts[0] ?? 'Sysop',
'sysop_last' => $sysopParts[1] ?? '',
];
$sessionManager = new DoorSessionManager(null, true);
$session = $sessionManager->startSession($guestUserId, $doorName, $userData, 'native');
$wsUrl = \BinktermPHP\Config::env('DOSDOOR_WS_URL');
if (empty($wsUrl)) {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws';
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
$wsUrl = "{$protocol}://{$host}:{$session['ws_port']}";
}
$termConfig = \BinktermPHP\NativeDoorConfig::getTerminalConfig($doorName);
echo json_encode([
'success' => true,
'session' => [
'session_id' => $session['session_id'],
'door_name' => $session['door_name'],
'node' => $session['node'],
'ws_port' => $session['ws_port'],
'ws_token' => $session['ws_token'],
'ws_url' => $wsUrl,
'terminal_cols' => $termConfig['cols'],
'terminal_rows' => $termConfig['rows'],
'autofit' => $termConfig['autofit'],
],
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'error' => 'Failed to start guest door session',
'message' => $e->getMessage(),
]);
}
});
// End a door game session
SimpleRouter::post('/api/door/end', function() {
header('Content-Type: application/json');
// Require authentication
$user = RouteHelper::requireAuth();
$sessionId = $_POST['session_id'] ?? null;
if (!$sessionId) {
doorApiError('errors.door.session_id_required', 'Session ID required', 400);
return;
}
try {
$sessionManager = new DoorSessionManager(null, true);
$session = $sessionManager->getSession($sessionId);
// Verify session belongs to current user
$userId = $user['user_id'] ?? $user['id'];
if (!$session || $session['user_id'] !== $userId) {
doorApiError('errors.door.session_unauthorized', 'Unauthorized', 403);
return;
}
// End the session
$success = $sessionManager->endSession($sessionId);
echo json_encode([
'success' => $success
]);
} catch (Exception $e) {
doorApiError('errors.door.session_end_failed', 'Failed to end session', 500);
}
});
// Get current user's active door session
SimpleRouter::get('/api/door/session', function() {
header('Content-Type: application/json');
// Require authentication
$user = RouteHelper::requireAuth();
$userId = $user['user_id'] ?? $user['id'];
$doorId = $_GET['door'] ?? null;
getDoorLogger()->info("DOSDOOR: [GetSession] User ID: $userId, Username: " . ($user['username'] ?? 'unknown') . ", Door: " . ($doorId ?? 'any'));
try {
$sessionManager = new DoorSessionManager(null, true);
$session = $sessionManager->getUserSession($userId, $doorId);
if ($session) {
getDoorLogger()->info("DOSDOOR: [GetSession] Found session: {$session['session_id']} for user $userId");
// Bridge v3 owns the entire lifecycle - no need to validate processes here
} else {
getDoorLogger()->info("DOSDOOR: [GetSession] No session found for user $userId");
}
if ($session) {
// Build WebSocket URL for browser
$wsUrl = \BinktermPHP\Config::env('DOSDOOR_WS_URL');
if (empty($wsUrl)) {
// Auto-detect: use current request protocol and hostname (without port)
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws';
$host = $_SERVER['SERVER_NAME'] ?? 'localhost';
$port = $session['ws_port'];
$wsUrl = "{$protocol}://{$host}:{$port}";
}
echo json_encode([
'success' => true,
'session' => [
'session_id' => $session['session_id'],
'door_name' => $session['door_name'],
'node' => $session['node'],
'ws_port' => $session['ws_port'],
'ws_token' => $session['ws_token'],
'ws_url' => $wsUrl,
'started_at' => $session['started_at'],
]
]);
} else {
echo json_encode([
'success' => true,
'session' => null
]);
}
} catch (Exception $e) {
doorApiError('errors.door.session_get_failed', 'Failed to get session', 500);
}
});
// Public guest doors listing page
// GET /guest-doors ? lists all anonymous-accessible native doors
SimpleRouter::get('/guest-doors', function() {
if (!\BinktermPHP\BbsConfig::isFeatureEnabled('guest_doors_page')) {
http_response_code(404);
$template = new \BinktermPHP\Template();
$template->renderResponse('404.twig');
return;
}
$nativeDoorManager = new NativeDoorManager();
$allDoors = $nativeDoorManager->getAllDoors();
$guestDoors = [];
foreach ($allDoors as $doorId => $door) {
if (!empty($door['config']['enabled']) && !empty($door['config']['allow_anonymous'])) {
$guestDoors[] = $door;
}
}
$template = new \BinktermPHP\Template();
$template->renderResponse('guest_doors.twig', ['doors' => $guestDoors]);
});
// Public guest door player page
// GET /play/{doorid} ? serves the xterm.js player for anonymous-accessible native doors
SimpleRouter::get('/play/{doorid}', function($doorid) {
// Sanitize door ID
$doorId = preg_replace('/[^a-zA-Z0-9_-]/', '', $doorid);
if (empty($doorId)) {
http_response_code(404);
echo "Door not found";
return;
}
// Verify door exists and allows anonymous access
$nativeDoorManager = new NativeDoorManager();
$door = $nativeDoorManager->getDoor($doorId);
if (!$door) {
http_response_code(404);
echo "Door not found";
return;
}
if (!$nativeDoorManager->isDoorAvailable($doorId)) {
http_response_code(403);
echo "This door is currently disabled";
return;
}
if (!\BinktermPHP\NativeDoorConfig::isAnonymousAllowed($doorId)) {
http_response_code(403);
echo "This door does not allow guest access";
return;
}
require __DIR__ . '/../public_html/guest-door-player.php';
});
// Serve door assets (icons, screenshots, etc.)
// Only serves assets explicitly declared in the door's manifest for security
SimpleRouter::get('/door-assets/{doorid}/{asset}', function($doorid, $asset) {
// Sanitize door ID
$doorid = preg_replace('/[^a-zA-Z0-9_-]/', '', $doorid);
// Only allow specific asset types
$allowedAssets = ['icon', 'screenshot'];
if (!in_array($asset, $allowedAssets)) {
http_response_code(404);
echo doorLocalizedText('errors.door.asset.invalid_type', 'Invalid asset type');
return;
}
// Load door manifest ? check native doors first, then DOS doors
$nativeDoorManager = new \BinktermPHP\NativeDoorManager();
$door = $nativeDoorManager->getDoor($doorid);
if ($door) {
$doorBasePath = __DIR__ . "/../native-doors/doors/{$doorid}";
} else {
$door = (new \BinktermPHP\DoorManager())->getDoor($doorid);
$doorBasePath = __DIR__ . "/../dosbox-bridge/dos/DOORS/" . strtoupper($doorid);
}
if (!$door) {
http_response_code(404);
echo doorLocalizedText('errors.door.asset.door_not_found', 'Door not found');
return;
}
// Get filename from manifest
$filename = $door[$asset] ?? null;
if (!$filename) {
http_response_code(404);
echo doorLocalizedText('errors.door.asset.not_defined', 'Asset not defined in manifest');
return;
}
// Build path to asset file (only using manifest-declared filename)
$filename = basename($filename); // Extra safety
$doorPath = $doorBasePath . "/{$filename}";
// Verify file exists
if (!file_exists($doorPath) || !is_file($doorPath)) {
http_response_code(404);
echo doorLocalizedText('errors.door.asset.file_not_found', 'Asset file not found');
return;
}
// Verify file is in the door directory (prevent traversal)
$realPath = realpath($doorPath);
$allowedBase = realpath($doorBasePath);
if ($allowedBase === false || strpos($realPath, $allowedBase) !== 0) {
http_response_code(403);
echo doorLocalizedText('errors.door.asset.access_denied', 'Access denied');
return;
}
// Determine MIME type
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$mimeTypes = [
'gif' => 'image/gif',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'svg' => 'image/svg+xml',
'bmp' => 'image/bmp',
'ico' => 'image/x-icon'
];
$mimeType = $mimeTypes[$extension] ?? 'application/octet-stream';
// Serve the file
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . filesize($doorPath));
header('Cache-Control: public, max-age=86400'); // Cache for 24 hours
readfile($doorPath);
});
|