mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-25 20:25:51 +08:00
feat(browser-use): add Camoufox noVNC session viewer
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
import { VIEWER_COOKIE_NAME, VIEWER_TOKEN_TTL_MS } from '@/modules/browser-use/browser-use.viewer.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -8,6 +9,45 @@ function readParam(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value[0] || '' : value || '';
|
||||
}
|
||||
|
||||
const SAFE_VIEWER_ROOT_FILES = new Set(['vnc.html', 'favicon.ico', 'manifest.json']);
|
||||
const SAFE_VIEWER_ROOT_DIRS = new Set(['app', 'core', 'vendor', 'assets', 'images', 'utils']);
|
||||
|
||||
function isSafeViewerPath(viewerPath: string): boolean {
|
||||
if (!viewerPath || viewerPath.startsWith('/') || viewerPath.includes('..') || viewerPath.includes('\\')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._~/-]*$/.test(viewerPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (SAFE_VIEWER_ROOT_FILES.has(viewerPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [rootDir] = viewerPath.split('/');
|
||||
return Boolean(rootDir && SAFE_VIEWER_ROOT_DIRS.has(rootDir));
|
||||
}
|
||||
|
||||
function isSecureRequest(req: express.Request): boolean {
|
||||
const forwardedProto = String(req.headers['x-forwarded-proto'] || '')
|
||||
.split(',')[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return req.secure || forwardedProto === 'https';
|
||||
}
|
||||
|
||||
function readQueryString(originalUrl: string): string {
|
||||
const queryIndex = originalUrl.indexOf('?');
|
||||
if (queryIndex < 0) {
|
||||
return '';
|
||||
}
|
||||
const params = new URLSearchParams(originalUrl.slice(queryIndex + 1));
|
||||
params.delete('viewerToken');
|
||||
const nextQuery = params.toString();
|
||||
return nextQuery ? `?${nextQuery}` : '';
|
||||
}
|
||||
|
||||
router.get('/status', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await browserUseService.getStatus() });
|
||||
@@ -62,13 +102,60 @@ router.get('/sessions', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: { sessions: await browserUseService.listSessions() } });
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions/:sessionId/viewer/*', async (req, res) => {
|
||||
try {
|
||||
const sessionId = readParam(req.params.sessionId);
|
||||
const originalPath = req.originalUrl.split('?')[0] || '';
|
||||
const viewerMarker = `/sessions/${sessionId}/viewer/`;
|
||||
const markerIndex = originalPath.indexOf(viewerMarker);
|
||||
const rawViewerPath = markerIndex >= 0 ? originalPath.slice(markerIndex + viewerMarker.length) : 'vnc.html';
|
||||
const viewerPath = decodeURIComponent(rawViewerPath).replace(/^\/+/, '') || 'vnc.html';
|
||||
if (!isSafeViewerPath(viewerPath)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid Browser viewer path.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const viewerToken = readParam(req.query.viewerToken as string | string[] | undefined);
|
||||
if (viewerPath === 'vnc.html' && browserUseService.validateViewerToken(sessionId, viewerToken)) {
|
||||
res.cookie(VIEWER_COOKIE_NAME, viewerToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isSecureRequest(req),
|
||||
maxAge: VIEWER_TOKEN_TTL_MS,
|
||||
path: '/api/browser-use/sessions/' + encodeURIComponent(sessionId) + '/viewer',
|
||||
});
|
||||
}
|
||||
const target = browserUseService.getViewerProxyTarget(sessionId);
|
||||
const query = readQueryString(req.originalUrl);
|
||||
const upstream = await fetch(`http://127.0.0.1:${target.websockifyPort}/${viewerPath}${query}`, {
|
||||
headers: {
|
||||
accept: String(req.headers.accept || '*/*'),
|
||||
},
|
||||
});
|
||||
const contentType = upstream.headers.get('content-type');
|
||||
if (contentType) {
|
||||
res.setHeader('content-type', contentType);
|
||||
}
|
||||
const cacheControl = viewerPath === 'vnc.html' ? 'no-store' : 'public, max-age=3600';
|
||||
res.setHeader('cache-control', cacheControl);
|
||||
res.status(upstream.status);
|
||||
const body = Buffer.from(await upstream.arrayBuffer());
|
||||
res.send(body);
|
||||
} catch (error) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Browser viewer is not available.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sessions/:sessionId/stop', async (req, res) => {
|
||||
try {
|
||||
const result = await browserUseService.stopSession(readParam(req.params.sessionId));
|
||||
|
||||
Reference in New Issue
Block a user