<?php
/**
* DOS Door Player
*
* This file can be included by routes or accessed directly.
* When included, $doorId should be set by the calling code.
*/
use BinktermPHP\RouteHelper;
use BinktermPHP\UserMeta;
use BinktermPHP\I18n\LocaleResolver;
use BinktermPHP\I18n\Translator;
$user = RouteHelper::requireAuth();
$csrfUserId = (int)($user['user_id'] ?? $user['id'] ?? 0);
$csrfToken = '';
if ($csrfUserId > 0) {
try {
$meta = new UserMeta();
$csrfToken = $meta->getValue($csrfUserId, 'csrf_token') ?? '';
} catch (\Throwable $e) {}
}
$translator = new Translator();
$localeResolver = new LocaleResolver($translator);
$locale = $localeResolver->resolveLocale((string)($user['locale'] ?? ''), $user);
$localeResolver->persistLocale($locale);
$t = static function (string $key, string $fallback, array $params = [], string $namespace = 'common') use ($translator, $locale): string {
$translated = $translator->translate($key, $params, $locale, [$namespace]);
return $translated === $key ? $fallback : $translated;
};
// If accessed directly, try to extract door ID from URL
if (!isset($doorId)) {
$requestUri = $_SERVER['REQUEST_URI'];
$pattern = '#/webdoors/dosdoors/([^/]+)#';
preg_match($pattern, $requestUri, $matches);
$doorId = $matches[1] ?? '';
// Clean the door ID (remove query string if present)
$doorId = preg_replace('/\?.*$/', '', $doorId);
}
if (empty($doorId)) {
http_response_code(404);
echo htmlspecialchars($t('ui.dosdoor_player.error_no_door_specified', 'Error: No door ID specified'), ENT_QUOTES);
exit;
}
?>
<!DOCTYPE html>
<html lang="<?= htmlspecialchars($locale, ENT_QUOTES) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="<?= htmlspecialchars($csrfToken, ENT_QUOTES) ?>">
<title><?= htmlspecialchars($t('ui.dosdoor_player.page_title', 'DOS Door Player'), ENT_QUOTES) ?></title>
<link rel="stylesheet" href="/webdoors/terminal/assets/xterm.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
background: #000;
}
.terminal-controls {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 5px 10px;
background: #1a1a2e;
height: 35px;
border-bottom: 1px solid #333;
}
#terminal-container {
position: absolute;
top: 35px;
left: 0;
right: 0;
bottom: 0;
background: #000;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}
#terminal-container .xterm {
margin-top: 6px;
}
/* Force terminal surface to pure black */
#terminal-container .xterm,
#terminal-container .xterm-viewport,
#terminal-container .xterm-screen,
#terminal-container .xterm-screen canvas {
background-color: #000 !important;
}
.connection-status {
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-family: monospace;
}
.status-disconnected {
background-color: #dc3545;
color: white;
}
.status-connecting {
background-color: #ffc107;
color: black;
}
.status-connected {
background-color: #28a745;
color: white;
}
.door-header {
margin: 0;
font-size: 0.9rem;
color: #fff;
font-family: monospace;
justify-self: start;
}
#endSessionBtn {
padding: 4px 12px;
font-size: 12px;
background: #dc3545;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
justify-self: end;
font-family: monospace;
}
#endSessionBtn:hover {
background: #c82333;
}
#connectionStatus {
justify-self: center;
}
.error-message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1a2e;
color: #ff5555;
padding: 20px;
border-radius: 5px;
font-family: monospace;
max-width: 80%;
text-align: center;
}
/* Xterm helpers: keep off-screen, but do not break measurement logic */
.xterm-helpers {
position: absolute !important;
left: -9999em !important;
top: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
.xterm-helpers .xterm-helper-textarea {
position: absolute !important;
left: -9999em !important;
top: 0 !important;
opacity: 0 !important;
pointer-events: none !important;
}
.xterm-char-measure-element {
position: absolute !important;
left: -9999em !important;
top: 0 !important;
visibility: hidden !important;
}
</style>
</head>
<body>
<div class="terminal-controls">
<h5 class="door-header" id="doorTitle"><?= htmlspecialchars($t('ui.dosdoor_player.page_title', 'DOS Door Player'), ENT_QUOTES) ?></h5>
<div id="connectionStatus" class="connection-status status-disconnected">
<?= htmlspecialchars($t('ui.dosdoor_player.status_prefix', 'Status:'), ENT_QUOTES) ?> <?= htmlspecialchars($t('ui.dosdoor_player.status_disconnected', 'Disconnected'), ENT_QUOTES) ?>
</div>
<button id="endSessionBtn"><?= htmlspecialchars($t('ui.dosdoor_player.end_session', 'End Session'), ENT_QUOTES) ?></button>
</div>
<div id="terminal-container"></div>
<script src="/webdoors/terminal/assets/xterm.js"></script>
<script>
let term = null;
const TERM_COLS = 80;
const TERM_ROWS = 25;
let socket = null;
let sessionId = null;
let wsPort = null;
let wsToken = null;
const doorId = <?php echo json_encode($doorId); ?>;
const I18N = <?php echo json_encode([
'statusPrefix' => $t('ui.dosdoor_player.status_prefix', 'Status:'),
'statusDisconnected' => $t('ui.dosdoor_player.status_disconnected', 'Disconnected'),
'statusLaunching' => $t('ui.dosdoor_player.status_launching', 'Launching...'),
'statusLaunchFailed' => $t('ui.dosdoor_player.status_launch_failed', 'Launch failed'),
'statusConnecting' => $t('ui.dosdoor_player.status_connecting', 'Connecting...'),
'statusConnected' => $t('ui.dosdoor_player.status_connected', 'Connected'),
'statusConnectionError' => $t('ui.dosdoor_player.status_connection_error', 'Connection error'),
'statusError' => $t('ui.dosdoor_player.status_error', 'Error'),
'launchingDoorLine' => $t('ui.dosdoor_player.launching_door_line', 'Launching door game...'),
'failedLaunchLine' => $t('ui.dosdoor_player.failed_launch_line', 'Failed to launch door session.'),
'connectingToPrefix' => $t('ui.dosdoor_player.connecting_to_prefix', 'Connecting to'),
'connectedLine' => $t('ui.dosdoor_player.connected_line', 'Connected!'),
'connectionClosedLine' => $t('ui.dosdoor_player.connection_closed_line', '[Connection closed]'),
'connectionErrorLine' => $t('ui.dosdoor_player.connection_error_line', '[Connection error]'),
'failedToConnectPrefix' => $t('ui.dosdoor_player.failed_to_connect_prefix', 'Failed to connect:'),
'confirmEndSession' => $t('ui.dosdoor_player.confirm_end_session', 'Are you sure you want to end this door session?'),
'failedEndSession' => $t('ui.dosdoor_player.failed_end_session', 'Failed to end session'),
'errorEndingSession' => $t('ui.dosdoor_player.error_ending_session', 'Error ending session'),
'errorNoDoorSpecified' => $t('ui.dosdoor_player.error_no_door_specified', 'Error: No door ID specified'),
'failedLaunchDoor' => $t('ui.dosdoor_player.failed_launch_door', 'Failed to launch door'),
'documentTitleSuffix' => $t('ui.dosdoor_player.document_title_suffix', 'DOS Door'),
'apiErrors' => [
'errors.door.door_name_required' => $t('errors.door.door_name_required', 'Door name required', [], 'errors'),
'errors.door.admin_only' => $t('errors.door.admin_only', 'This door is restricted to administrators', [], 'errors'),
'errors.door.insufficient_credits' => $t('errors.door.insufficient_credits', 'Insufficient credits', [], 'errors'),
'errors.door.insufficient_credits_detail' => $t('errors.door.insufficient_credits_detail', 'This door costs {required} credits. You have {balance} credits.', [], 'errors'),
'errors.door.capacity_reached' => $t('errors.door.capacity_reached', 'Door is at capacity', [], 'errors'),
'errors.door.capacity_reached_detail' => $t('errors.door.capacity_reached_detail', 'This door is currently in use. Only {max_nodes} player(s) allowed at a time. Please try again later.', [], 'errors'),
'errors.door.launch_failed' => $t('errors.door.launch_failed', 'Failed to start door session', [], 'errors'),
'errors.door.session_id_required' => $t('errors.door.session_id_required', 'Session ID required', [], 'errors'),
'errors.door.session_unauthorized' => $t('errors.door.session_unauthorized', 'Unauthorized', [], 'errors'),
'errors.door.session_end_failed' => $t('errors.door.session_end_failed', 'Failed to end session', [], 'errors'),
'errors.door.session_get_failed' => $t('errors.door.session_get_failed', 'Failed to get session', [], 'errors'),
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?>;
function interpolateTemplate(template, params) {
if (!template || !params || typeof params !== 'object') {
return template;
}
return String(template).replace(/\{(\w+)\}/g, (_, key) => {
return Object.prototype.hasOwnProperty.call(params, key)
? String(params[key])
: `{${key}}`;
});
}
function resolveApiError(payload, fallback) {
if (payload && payload.error_code && I18N.apiErrors[payload.error_code]) {
return interpolateTemplate(I18N.apiErrors[payload.error_code], payload);
}
if (payload && payload.error) {
return String(payload.error);
}
return fallback;
}
// Initialize terminal
function initTerminal() {
console.log('[INIT] Starting terminal initialization');
const container = document.getElementById('terminal-container');
term = new Terminal({
cursorBlink: true,
cols: TERM_COLS,
rows: TERM_ROWS,
fontSize: 16,
fontFamily: 'Courier New, monospace',
scrollback: 0,
theme: {
background: '#000000',
foreground: '#AAAAAA',
cursor: '#00FF00',
black: '#000000',
red: '#AA0000',
green: '#00AA00',
yellow: '#AA5500',
blue: '#0000AA',
magenta: '#AA00AA',
cyan: '#00AAAA',
white: '#AAAAAA',
brightBlack: '#555555',
brightRed: '#FF5555',
brightGreen: '#55FF55',
brightYellow: '#FFFF55',
brightBlue: '#5555FF',
brightMagenta: '#FF55FF',
brightCyan: '#55FFFF',
brightWhite: '#FFFFFF'
},
convertEol: false
});
console.log('[INIT] Terminal created, opening in container');
term.open(container);
term.resize(TERM_COLS, TERM_ROWS);
scheduleFixedTerminalSize();
// Handle terminal input
term.onData((data) => {
// Remap DEL (0x7f) to Backspace (0x08) for DOS compatibility
if (data === '\x7f') data = '\x08';
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
});
// Key handler: intercept extended keys before xterm generates ANSI sequences.
// DOS apps running via Doorway expect Doorway Protocol (\x00 + IBM PC scan code)
// for navigation/function keys, not ANSI escape sequences.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
// Extended key Doorway Protocol scan codes (no modifier)
const doorwayKeys = {
'ArrowUp': 0x48, 'ArrowDown': 0x50,
'ArrowLeft': 0x4B, 'ArrowRight': 0x4D,
'Home': 0x47, 'End': 0x4F,
'PageUp': 0x49, 'PageDown': 0x51,
'Insert': 0x52, 'Delete': 0x53,
'F1': 0x3B, 'F2': 0x3C, 'F3': 0x3D, 'F4': 0x3E,
'F5': 0x3F, 'F6': 0x40, 'F7': 0x41, 'F8': 0x42,
'F9': 0x43, 'F10': 0x44, 'F11': 0x85, 'F12': 0x86,
};
// Extended key Doorway Protocol scan codes (Ctrl modifier)
const doorwayCtrlKeys = {
'ArrowLeft': 0x73, 'ArrowRight': 0x74,
'Home': 0x77, 'End': 0x75,
'PageUp': 0x84, 'PageDown': 0x76,
};
if (!e.altKey) {
let scanCode = null;
if (e.ctrlKey && doorwayCtrlKeys[e.key] !== undefined) {
scanCode = doorwayCtrlKeys[e.key];
} else if (!e.ctrlKey && doorwayKeys[e.key] !== undefined) {
scanCode = doorwayKeys[e.key];
}
if (scanCode !== null) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send('\x00' + String.fromCharCode(scanCode));
}
return false; // Suppress xterm's ANSI sequence output
}
}
// Ctrl+key: prevent browser from capturing common combos (Ctrl+W, Ctrl+T, etc.)
// and let xterm.js encode them as control characters
if (e.ctrlKey && !e.altKey && e.key.length === 1) {
e.preventDefault();
return true; // xterm handles the encoding
}
return true;
});
term.onRender(() => {
scheduleFixedTerminalSize();
});
}
function updateStatus(message, state) {
const statusDiv = document.getElementById('connectionStatus');
statusDiv.textContent = I18N.statusPrefix + ' ' + message;
statusDiv.className = 'connection-status status-' + state;
}
function showError(message) {
const container = document.getElementById('terminal-container');
const errorDiv = document.createElement('div');
errorDiv.className = 'error-message';
errorDiv.textContent = message;
container.appendChild(errorDiv);
}
function launchDoorSession() {
console.log('[LAUNCH] Launching door session for:', doorId);
updateStatus(I18N.statusLaunching, 'connecting');
term.writeln('\x1b[1;33m' + I18N.launchingDoorLine + '\x1b[0m');
const formData = new FormData();
formData.append('door', doorId);
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
return fetch('/api/door/launch', {
method: 'POST',
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {},
body: formData
})
.then(response => response.json())
.then(data => {
console.log('[LAUNCH] Launch response:', data);
if (!data.success) {
throw new Error(resolveApiError(data, I18N.failedLaunchDoor));
}
return data.session;
});
}
function connectToSession() {
console.log('[CONNECT] connectToSession called, term exists:', !!term);
// Get current session for this specific door
fetch('/api/door/session?door=' + encodeURIComponent(doorId))
.then(response => response.json())
.then(data => {
console.log('[CONNECT] Session data received:', data);
if (!data.success || !data.session) {
// No session exists, launch one
console.log('[CONNECT] No session found, launching...');
return launchDoorSession().then(session => {
data.session = session;
return data;
});
}
return data;
})
.then(data => {
if (!data.session) {
updateStatus(I18N.statusLaunchFailed, 'disconnected');
term.clear();
term.writeln('\x1b[1;31m' + I18N.failedLaunchLine + '\x1b[0m');
return;
}
sessionId = data.session.session_id;
wsPort = data.session.ws_port;
wsToken = data.session.ws_token;
console.log('[TOKEN] Received token:', wsToken ? wsToken.substring(0, 16) + '...' : 'MISSING!');
const doorTitle = document.getElementById('doorTitle');
if (doorTitle && data.session.door_name) {
doorTitle.textContent = data.session.door_name;
document.title = data.session.door_name + ' - ' + I18N.documentTitleSuffix;
}
// Clear terminal initialization artifacts
console.log('[CONNECT] Clearing terminal');
term.clear();
// Connect to WebSocket with authentication token
updateStatus(I18N.statusConnecting, 'connecting');
term.writeln('\x1b[1;33m' + I18N.connectingToPrefix + ' ' + data.session.door_name + '...\x1b[0m');
// Use WebSocket URL from server (configured or auto-detected)
const wsBaseUrl = data.session.ws_url || ('ws://' + window.location.hostname + ':' + wsPort);
const wsUrl = wsBaseUrl + (wsToken ? '?token=' + encodeURIComponent(wsToken) : '');
console.log('[CONNECT] Connecting to WebSocket:', wsBaseUrl + ' (token present:', !!wsToken, ')');
socket = new WebSocket(wsUrl);
socket.onopen = () => {
updateStatus(I18N.statusConnected, 'connected');
term.writeln('\x1b[1;32m' + I18N.connectedLine + '\x1b[0m');
term.writeln('');
term.focus();
};
socket.onmessage = (event) => {
term.write(event.data);
};
socket.onclose = (event) => {
updateStatus(I18N.statusDisconnected, 'disconnected');
term.writeln('');
term.writeln('\x1b[1;31m' + I18N.connectionClosedLine + '\x1b[0m');
};
socket.onerror = (error) => {
updateStatus(I18N.statusConnectionError, 'disconnected');
term.writeln('\x1b[1;31m' + I18N.connectionErrorLine + '\x1b[0m');
console.error('WebSocket error:', error);
};
})
.catch(error => {
console.error('Failed to get session:', error);
updateStatus(I18N.statusError, 'disconnected');
term.writeln('\x1b[1;31m' + I18N.failedToConnectPrefix + ' ' + error.message + '\x1b[0m');
});
}
function endSession() {
if (!sessionId) {
return;
}
if (!confirm(I18N.confirmEndSession)) {
return;
}
const csrfTokenEnd = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('/api/door/end', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfTokenEnd ? { 'X-CSRF-Token': csrfTokenEnd } : {})
},
body: 'session_id=' + encodeURIComponent(sessionId)
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (socket) {
socket.close();
}
window.top.location.href = '/games';
} else {
alert(resolveApiError(data, I18N.failedEndSession));
}
})
.catch(error => {
console.error('Failed to end session:', error);
alert(I18N.errorEndingSession);
});
}
function setFixedTerminalSize() {
if (!term || !term.element) {
return;
}
const core = term._core;
if (!core || !core._renderService || !core._renderService.dimensions) {
return;
}
const dims = core._renderService.dimensions.css;
if (!dims || !dims.cell) {
return;
}
const width = Math.ceil(dims.cell.width * TERM_COLS);
const height = Math.ceil(dims.cell.height * TERM_ROWS);
term.element.style.width = width + 'px';
term.element.style.height = height + 'px';
}
function scheduleFixedTerminalSize() {
if (window.requestAnimationFrame) {
window.requestAnimationFrame(setFixedTerminalSize);
} else {
setFixedTerminalSize();
}
}
// Initialize on page load
window.addEventListener('DOMContentLoaded', () => {
if (!doorId) {
showError(I18N.errorNoDoorSpecified);
updateStatus(I18N.statusError, 'disconnected');
return;
}
console.log('[INIT] Door ID:', doorId);
initTerminal();
connectToSession();
});
window.addEventListener('resize', () => {
scheduleFixedTerminalSize();
});
// End session button
document.getElementById('endSessionBtn').addEventListener('click', endSession);
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (socket) {
socket.close();
}
});
// Alt+key handling via capture phase - fires before browser menu/shortcut handling
// attachCustomKeyEventHandler is too late on Windows; Alt is consumed first
// Uses Doorway Protocol: \x00 + IBM PC scan code (standard for DOS BBS programs)
document.addEventListener('keydown', function(e) {
if (!e.altKey || e.ctrlKey || !term || !socket || socket.readyState !== WebSocket.OPEN) return;
// IBM PC scan codes for Alt+letter (Doorway Protocol)
const altLetterCodes = {
'a': 0x1E, 'b': 0x30, 'c': 0x2E, 'd': 0x20,
'e': 0x12, 'f': 0x21, 'g': 0x22, 'h': 0x23,
'i': 0x17, 'j': 0x24, 'k': 0x25, 'l': 0x26,
'm': 0x32, 'n': 0x31, 'o': 0x18, 'p': 0x19,
'q': 0x10, 'r': 0x13, 's': 0x1F, 't': 0x14,
'u': 0x16, 'v': 0x2F, 'w': 0x11, 'x': 0x2D,
'y': 0x15, 'z': 0x2C
};
// IBM PC scan codes for Alt+digit (Doorway Protocol)
const altDigitCodes = {
'1': 0x78, '2': 0x79, '3': 0x7A, '4': 0x7B, '5': 0x7C,
'6': 0x7D, '7': 0x7E, '8': 0x7F, '9': 0x80, '0': 0x81
};
let scanCode = null;
if (e.code.startsWith('Key')) {
const ch = e.code.slice(3).toLowerCase();
scanCode = altLetterCodes[ch] ?? null;
} else if (e.code.startsWith('Digit')) {
const digit = e.code.slice(5);
scanCode = altDigitCodes[digit] ?? null;
}
if (scanCode !== null) {
e.preventDefault();
e.stopPropagation();
const seq = '\x00' + String.fromCharCode(scanCode);
console.log('[ALT] code=' + e.code + ' scanCode=0x' + scanCode.toString(16) + ' socketState=' + socket.readyState);
socket.send(seq);
}
}, true); // true = capture phase, fires before browser handles it
</script>
</body>
</html>
|