Files
BetterDesk/web-nodejs/views/remote-viewer.ejs
T
UNITRONIX 2bfa1f3d7c Log requests, sanitize login, network & XSS fixes
Add broad request logging in auth middleware and more granular debug logs for login handling (JSON decode errors and non-password login fields) to aid troubleshooting. Trim and sanitize username/password on the web login route and log whitespace/empty username cases and missing credentials. Extend isSameNetwork logic to treat loopback ↔ private-IP pairs as same network (covers local server → LAN target cases). Fix remote viewer templates to avoid inline string interpolation by injecting device data via JSON and setting DOM textContent (prevents XSS and EJS injection); also minor formatting/alignment tweaks in role handler comments/structs.
2026-04-11 19:07:03 +02:00

662 lines
23 KiB
HTML

<%- include('layouts/viewer', {
title: (device && device.hostname) ? device.hostname : deviceId,
pageScripts: [],
body: `
<!-- Beta Banner -->
<div class="bdv-beta-banner" id="bdv-beta-banner">
<span>🔧 Web Remote Client — Beta</span>
<button id="bdv-beta-dismiss" class="bdv-beta-dismiss">&times;</button>
</div>
<!-- BetterDesk Native Remote Desktop Viewer -->
<div class="bdv-container" id="bdv-container" style="margin-top: 48px;">
<!-- Overlay shown before connection -->
<div class="bdv-overlay" id="bdv-overlay">
<div class="bdv-overlay-card">
<div class="bdv-overlay-icon">
<span class="material-icons">connected_tv</span>
</div>
<h2 class="bdv-overlay-title">Remote Desktop</h2>
<p class="bdv-overlay-device" id="bdv-overlay-hostname"></p>
<p class="bdv-overlay-id" id="bdv-overlay-deviceid"></p>
<div class="bdv-overlay-status" id="bdv-status-area">
<div class="spinner"></div>
<span id="bdv-status-text">Connecting to agent…</span>
</div>
<div class="bdv-overlay-actions" id="bdv-overlay-actions" style="display:none;">
<button class="btn btn-primary" id="btnReconnect">
<span class="material-icons">refresh</span> Reconnect
</button>
<a href="/devices" class="btn btn-secondary">
<span class="material-icons">arrow_back</span> Back
</a>
</div>
</div>
</div>
<!-- Main canvas — shows JPEG frames -->
<canvas id="bdv-canvas" class="bdv-canvas" tabindex="0"></canvas>
<!-- Top toolbar (visible on hover / always when not streaming) -->
<div class="bdv-toolbar" id="bdv-toolbar">
<div class="bdv-toolbar-left">
<span class="material-icons">connected_tv</span>
<span id="bdv-toolbar-title"></span>
<span class="bdv-toolbar-sep"></span>
<span class="bdv-badge" id="bdv-fps-badge"></span>
</div>
<div class="bdv-toolbar-center">
<!-- Scale mode selector -->
<select class="bdv-select" id="bdvScaleMode" title="Scale Mode">
<option value="fit" selected>Fit</option>
<option value="fill">Fill</option>
<option value="1:1">1:1</option>
<option value="stretch">Stretch</option>
</select>
<!-- Monitor selector (hidden until multi-monitor detected) -->
<select class="bdv-select bdv-hidden" id="bdvMonitor" title="Monitor"></select>
</div>
<div class="bdv-toolbar-right">
<button class="bdv-btn" id="btnClipboard" title="Clipboard Sync">
<span class="material-icons">content_paste</span>
</button>
<button class="bdv-btn" id="btnSpecialKeys" title="Special Keys">
<span class="material-icons">keyboard</span>
</button>
<button class="bdv-btn" id="btnChat" title="Open Chat">
<span class="material-icons">chat</span>
</button>
<button class="bdv-btn" id="btnFullscreen" title="Fullscreen (F11)">
<span class="material-icons">fullscreen</span>
</button>
<button class="bdv-btn bdv-btn--danger" id="btnStop" title="Stop session">
<span class="material-icons">stop_circle</span>
</button>
</div>
</div>
<!-- Special Keys dropdown menu -->
<div class="bdv-dropdown bdv-hidden" id="bdv-special-keys-menu">
<button class="bdv-dropdown-item" data-keys="ctrl+alt+delete">Ctrl+Alt+Del</button>
<button class="bdv-dropdown-item" data-keys="meta">Win / Super</button>
<button class="bdv-dropdown-item" data-keys="printscreen">Print Screen</button>
<button class="bdv-dropdown-item" data-keys="alt+tab">Alt+Tab</button>
<button class="bdv-dropdown-item" data-keys="alt+f4">Alt+F4</button>
<button class="bdv-dropdown-item" data-keys="ctrl+shift+escape">Task Manager</button>
</div>
<!-- Chat sidebar -->
<div class="bdv-chat" id="bdv-chat">
<div class="bdv-chat-header">
<span id="bdv-chat-title">Chat</span>
<button class="bdv-btn" id="btnCloseChat">
<span class="material-icons">close</span>
</button>
</div>
<div class="bdv-chat-messages" id="bdv-chat-messages"></div>
<div class="bdv-chat-input-row">
<input type="text" id="bdv-chat-input" class="bdv-chat-inp" placeholder="Type a message…" autocomplete="off" />
<button class="bdv-btn" id="btnSendChat">
<span class="material-icons">send</span>
</button>
</div>
</div>
</div>
<script nonce="${cspNonce}">
(function() {
'use strict';
// ---- Config ----
const DEVICE_ID = ${JSON.stringify(deviceId)};
const HOSTNAME = ${JSON.stringify(device && device.hostname ? device.hostname : '')};
const WS_BASE = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host;
const REMOTE_URL = WS_BASE + '/ws/remote-viewer/' + encodeURIComponent(DEVICE_ID);
const CHAT_URL = WS_BASE + '/ws/chat-operator/' + encodeURIComponent(DEVICE_ID);
// ---- Safely set dynamic text content (prevents XSS/EJS injection) ----
document.getElementById('bdv-overlay-hostname').textContent = HOSTNAME;
document.getElementById('bdv-overlay-deviceid').textContent = DEVICE_ID;
document.getElementById('bdv-toolbar-title').textContent = HOSTNAME || DEVICE_ID;
document.getElementById('bdv-chat-title').textContent = 'Chat — ' + DEVICE_ID;
// ---- DOM ----
const canvas = document.getElementById('bdv-canvas');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('bdv-overlay');
const statusTxt = document.getElementById('bdv-status-text');
const actions = document.getElementById('bdv-overlay-actions');
const fpsBadge = document.getElementById('bdv-fps-badge');
const chatSide = document.getElementById('bdv-chat');
const chatMsgs = document.getElementById('bdv-chat-messages');
const chatInp = document.getElementById('bdv-chat-input');
const toolbar = document.getElementById('bdv-toolbar');
// ---- State ----
let remoteWs = null;
let chatWs = null;
let streaming = false;
let frameCount = 0;
let lastFpsTick = performance.now();
let chatOpen = false;
let mouseButtonsDown = 0;
// ---- FPS Counter ----
setInterval(() => {
const now = performance.now();
const dt = (now - lastFpsTick) / 1000;
const fps = (frameCount / dt).toFixed(1);
fpsBadge.textContent = streaming ? fps + ' fps' : '—';
frameCount = 0;
lastFpsTick = now;
}, 2000);
// ---- Remote WebSocket ----
function connectRemote() {
setStatus('Connecting to server…');
remoteWs = new WebSocket(REMOTE_URL);
remoteWs.binaryType = 'arraybuffer';
remoteWs.onopen = () => {
setStatus('Waiting for agent…');
};
remoteWs.onclose = (ev) => {
streaming = false;
setStatus('Disconnected — ' + (ev.reason || 'connection closed'));
showActions();
};
remoteWs.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
// Binary = JPEG frame
handleJpegFrame(ev.data);
} else {
handleJsonFrame(JSON.parse(ev.data));
}
};
remoteWs.onerror = () => {
setStatus('Connection error');
showActions();
};
}
function handleJsonFrame(frame) {
switch (frame.type) {
case 'session-info':
if (frame.agent_ready) {
setStatus(frame.streaming ? 'Streaming…' : 'Agent ready — starting stream…');
} else {
setStatus('Waiting for agent to connect…');
}
streaming = frame.streaming;
break;
case 'agent-ready':
setStatus('Agent connected — starting stream…');
break;
case 'stream-started':
streaming = true;
overlay.style.display = 'none';
canvas.focus();
break;
case 'stream-stopped':
streaming = false;
setStatus('Stream stopped — ' + (frame.reason || ''));
overlay.style.display = 'flex';
showActions();
break;
case 'agent-disconnected':
streaming = false;
setStatus('Agent disconnected');
overlay.style.display = 'flex';
showActions();
break;
case 'clipboard':
// Receive clipboard text from remote device
if (frame.text && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(frame.text).catch(() => {});
}
break;
case 'monitors':
// Populate monitor selector when remote reports multiple displays
if (frame.list && frame.list.length > 1) {
monitorSelect.innerHTML = '';
frame.list.forEach((m, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = m.name || ('Monitor ' + (i + 1));
monitorSelect.appendChild(opt);
});
monitorSelect.classList.remove('bdv-hidden');
}
break;
}
}
function handleJpegFrame(buffer) {
const blob = new Blob([buffer], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width;
canvas.height = img.height;
}
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
frameCount++;
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
// ---- Input forwarding ----
canvas.addEventListener('mousemove', (e) => {
if (!streaming) return;
const { rx, ry } = canvasCoords(e);
sendRemote({ type: 'input', event_type: 'mouse_move', x: rx, y: ry });
});
canvas.addEventListener('mousedown', (e) => {
if (!streaming) return;
mouseButtonsDown |= (1 << e.button);
const { rx, ry } = canvasCoords(e);
sendRemote({ type: 'input', event_type: 'mouse_down', x: rx, y: ry, button: btnName(e.button) });
});
canvas.addEventListener('mouseup', (e) => {
if (!streaming) return;
mouseButtonsDown &= ~(1 << e.button);
const { rx, ry } = canvasCoords(e);
sendRemote({ type: 'input', event_type: 'mouse_up', x: rx, y: ry, button: btnName(e.button) });
});
canvas.addEventListener('wheel', (e) => {
if (!streaming) return;
e.preventDefault();
sendRemote({ type: 'input', event_type: 'wheel', delta_x: Math.round(e.deltaX), delta_y: Math.round(e.deltaY) });
}, { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('keydown', (e) => {
if (!streaming) return;
e.preventDefault();
sendRemote({ type: 'input', event_type: 'key_down', key: e.key });
});
canvas.addEventListener('keyup', (e) => {
if (!streaming) return;
e.preventDefault();
sendRemote({ type: 'input', event_type: 'key_up', key: e.key });
});
function canvasCoords(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
rx: Math.round((e.clientX - rect.left) * scaleX),
ry: Math.round((e.clientY - rect.top) * scaleY),
};
}
function btnName(b) {
return b === 2 ? 'right' : b === 1 ? 'middle' : 'left';
}
function sendRemote(obj) {
if (remoteWs && remoteWs.readyState === WebSocket.OPEN) {
remoteWs.send(JSON.stringify(obj));
}
}
// ---- Toolbar / UI ----
function setStatus(msg) { statusTxt.textContent = msg; }
function showActions() { actions.style.display = 'flex'; }
document.getElementById('bdv-beta-dismiss')?.addEventListener('click', () => {
document.getElementById('bdv-beta-banner').style.display = 'none';
});
document.getElementById('btnReconnect').onclick = () => {
actions.style.display = 'none';
if (remoteWs) remoteWs.close();
setTimeout(connectRemote, 200);
};
document.getElementById('btnStop').onclick = () => {
sendRemote({ type: 'stop' });
if (remoteWs) remoteWs.close();
};
document.getElementById('btnFullscreen').onclick = () => {
if (!document.fullscreenElement) {
document.getElementById('bdv-container').requestFullscreen();
} else {
document.exitFullscreen();
}
};
document.addEventListener('fullscreenchange', () => {
const icon = document.getElementById('btnFullscreen').querySelector('span');
icon.textContent = document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen';
});
// F11 shortcut for fullscreen
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') {
e.preventDefault();
document.getElementById('btnFullscreen').click();
}
});
// ---- Scale Mode ----
const scaleSelect = document.getElementById('bdvScaleMode');
scaleSelect.addEventListener('change', () => {
sendRemote({ type: 'scale_mode', mode: scaleSelect.value });
});
// ---- Monitor Selector ----
const monitorSelect = document.getElementById('bdvMonitor');
monitorSelect.addEventListener('change', () => {
const idx = parseInt(monitorSelect.value, 10);
sendRemote({ type: 'switch_monitor', index: idx });
});
// ---- Special Keys ----
const specialKeysMenu = document.getElementById('bdv-special-keys-menu');
document.getElementById('btnSpecialKeys').onclick = (e) => {
e.stopPropagation();
specialKeysMenu.classList.toggle('bdv-hidden');
};
document.addEventListener('click', () => specialKeysMenu.classList.add('bdv-hidden'));
specialKeysMenu.addEventListener('click', (e) => {
const item = e.target.closest('[data-keys]');
if (!item) return;
sendRemote({ type: 'special_key', combo: item.dataset.keys });
specialKeysMenu.classList.add('bdv-hidden');
});
// ---- Clipboard Sync ----
document.getElementById('btnClipboard').onclick = async () => {
try {
const text = await navigator.clipboard.readText();
if (text) sendRemote({ type: 'clipboard', text });
} catch {
try {
const text = await navigator.clipboard.readText();
sendRemote({ type: 'clipboard_request' });
} catch { /* Clipboard API not available */ }
}
};
// Toolbar auto-hide
let hideTimer;
document.getElementById('bdv-container').addEventListener('mousemove', () => {
toolbar.classList.add('bdv-toolbar--visible');
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (streaming) toolbar.classList.remove('bdv-toolbar--visible');
}, 2500);
});
// ---- Chat ----
function connectChat() {
chatWs = new WebSocket(CHAT_URL);
chatWs.onopen = () => {};
chatWs.onclose = () => {};
chatWs.onmessage = (ev) => {
const frame = JSON.parse(ev.data);
if (frame.type === 'message') appendChatMsg(frame);
if (frame.type === 'history') frame.messages.forEach(appendChatMsg);
};
}
function appendChatMsg(msg) {
const div = document.createElement('div');
div.className = 'bdv-chat-msg bdv-chat-msg--' + (msg.from === 'operator' ? 'out' : 'in');
div.innerHTML = '<span class="bdv-chat-msg-text">' + escHtml(msg.text) + '</span>' +
'<span class="bdv-chat-msg-time">' + new Date(msg.timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + '</span>';
chatMsgs.appendChild(div);
chatMsgs.scrollTop = chatMsgs.scrollHeight;
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function sendChatMsg() {
const text = chatInp.value.trim();
if (!text || !chatWs || chatWs.readyState !== WebSocket.OPEN) return;
chatWs.send(JSON.stringify({ type: 'message', text }));
chatInp.value = '';
}
document.getElementById('btnChat').onclick = () => {
chatOpen = !chatOpen;
chatSide.classList.toggle('bdv-chat--open', chatOpen);
};
document.getElementById('btnCloseChat').onclick = () => {
chatOpen = false;
chatSide.classList.remove('bdv-chat--open');
};
document.getElementById('btnSendChat').onclick = sendChatMsg;
chatInp.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChatMsg(); });
// ---- Init ----
connectRemote();
connectChat();
})();
</script>
<style>
/* ---- BetterDesk Remote Viewer styles (scope: this page only) ---- */
.bdv-container {
position: fixed; inset: 0;
background: #111;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.bdv-canvas {
display: block;
max-width: 100%; max-height: 100%;
object-fit: contain;
cursor: none;
outline: none;
}
.bdv-overlay {
position: absolute; inset: 0;
background: rgba(0,0,0,.82);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.bdv-overlay-card {
background: #1a1a2e;
border: 1px solid #2d2d4a;
border-radius: 16px;
padding: 40px 48px;
text-align: center;
max-width: 400px;
width: 90%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #e8e8f0;
}
.bdv-overlay-icon .material-icons { font-size: 56px; color: #4f6ef7; }
.bdv-overlay-title { font-size: 1.4rem; font-weight: 700; }
.bdv-overlay-device { font-size: 1rem; color: #9898b0; }
.bdv-overlay-id { font-size: 0.8rem; color: #606078; font-family: monospace; }
.bdv-overlay-status {
display: flex; align-items: center; gap: 10px;
color: #9898b0; font-size: 0.9rem;
}
.bdv-overlay-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.bdv-toolbar {
position: absolute;
top: 0; left: 0; right: 0;
height: 48px;
background: linear-gradient(to bottom, rgba(0,0,0,.8), transparent);
display: flex;
align-items: center;
padding: 0 16px;
gap: 10px;
z-index: 20;
opacity: 0;
transition: opacity .2s;
}
.bdv-toolbar--visible { opacity: 1; }
.bdv-toolbar-left, .bdv-toolbar-right {
display: flex; align-items: center; gap: 8px; color: #e8e8f0;
}
.bdv-toolbar-center {
display: flex; align-items: center; gap: 8px; margin: 0 auto;
}
.bdv-toolbar-right { margin-left: 0; }
.bdv-toolbar-sep { width: 1px; height: 16px; background: #444; }
.bdv-badge { background: rgba(255,255,255,.1); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; color: #ccc; }
.bdv-select {
background: rgba(255,255,255,.1);
border: 1px solid rgba(255,255,255,.15);
border-radius: 4px;
color: #e8e8f0;
font-size: 0.75rem;
padding: 3px 6px;
cursor: pointer;
outline: none;
}
.bdv-select:hover { background: rgba(255,255,255,.2); }
.bdv-select option { background: #1a1a2e; color: #e8e8f0; }
.bdv-hidden { display: none !important; }
.bdv-beta-banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
background: linear-gradient(90deg, #3b82f6, #2563eb);
color: #fff; text-align: center; padding: 6px 15px;
font-size: 12px; font-weight: 500;
display: flex; align-items: center; justify-content: center; gap: 12px;
}
.bdv-beta-dismiss {
background: none; border: none; color: rgba(255,255,255,.7);
cursor: pointer; font-size: 16px; line-height: 1;
}
.bdv-beta-dismiss:hover { color: #fff; }
.bdv-dropdown {
position: absolute; top: 52px; right: 200px;
background: #1a1a2e; border: 1px solid #2d2d4a;
border-radius: 8px; z-index: 25;
padding: 4px 0; min-width: 180px;
box-shadow: 0 4px 16px rgba(0,0,0,.5);
}
.bdv-dropdown-item {
display: block; width: 100%;
background: none; border: none; cursor: pointer;
color: #e8e8f0; font-size: 0.85rem;
padding: 8px 16px; text-align: left;
transition: background .15s;
}
.bdv-dropdown-item:hover { background: rgba(255,255,255,.1); }
.bdv-btn {
background: none; border: none; cursor: pointer; color: #e8e8f0;
padding: 4px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
.bdv-btn:hover { background: rgba(255,255,255,.15); }
.bdv-btn--danger:hover { background: rgba(239,68,68,.3); }
.bdv-chat {
position: absolute;
top: 0; right: -320px; bottom: 0;
width: 320px;
background: #1a1a2e;
border-left: 1px solid #2d2d4a;
z-index: 30;
display: flex; flex-direction: column;
transition: right .25s ease;
}
.bdv-chat--open { right: 0; }
.bdv-chat-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2d2d4a;
color: #e8e8f0; font-size: 0.9rem; font-weight: 600;
}
.bdv-chat-messages {
flex: 1; overflow-y: auto;
padding: 12px 14px;
display: flex; flex-direction: column; gap: 8px;
}
.bdv-chat-msg { display: flex; flex-direction: column; max-width: 85%; }
.bdv-chat-msg--out { align-self: flex-end; }
.bdv-chat-msg--in { align-self: flex-start; }
.bdv-chat-msg-text {
padding: 8px 12px;
border-radius: 12px;
font-size: 0.875rem;
word-break: break-word;
}
.bdv-chat-msg--out .bdv-chat-msg-text { background: #4f6ef7; color: #fff; border-bottom-right-radius: 4px; }
.bdv-chat-msg--in .bdv-chat-msg-text { background: #222240; color: #e8e8f0; border-bottom-left-radius: 4px; }
.bdv-chat-msg-time { font-size: 0.7rem; color: #606078; padding: 2px 4px; }
.bdv-chat-msg--out .bdv-chat-msg-time { text-align: right; }
.bdv-chat-input-row {
display: flex; gap: 6px; padding: 10px 12px;
border-top: 1px solid #2d2d4a;
}
.bdv-chat-inp {
flex: 1;
background: #222240; border: 1px solid #2d2d4a; border-radius: 8px;
padding: 8px 12px; color: #e8e8f0; font-size: 0.875rem;
outline: none;
}
.bdv-chat-inp:focus { border-color: #4f6ef7; }
/* Spinner reuse from theme.css (fallback inline) */
.spinner {
width: 20px; height: 20px;
border: 2px solid #2d2d4a;
border-top-color: #4f6ef7;
border-radius: 50%;
animation: spin .8s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
`
}) %>