298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { SessionManager, type BrowserSession, CAPTCHA_POOL_SESSION_ID } from '../engine/browser-session.js';
|
|
import type { Repository } from '../db/repository.js';
|
|
import { logger } from '../logger.js';
|
|
import { buildNovncPath, isNovncStaticInstalled } from './novnc-proxy.js';
|
|
import { canUserSeeTask, canEditEntity } from './visibility.js';
|
|
import { loadConfig } from '../config.js';
|
|
|
|
/**
|
|
* `available: false` なレスポンスに添える reason コードを決める純関数。
|
|
* 優先順位:
|
|
* 1. displayMode !== 'novnc' → 'headless_mode' (config で機能 OFF)
|
|
* 2. SessionManager が null → 'display_unavailable' (noVNC スタック未導入)
|
|
* 3. それ以外 → 'no_session' (まだ session が無いだけ)
|
|
*
|
|
* どのバイナリ (Xvfb/x11vnc/websockify) が欠けているかは漏らさない。
|
|
*/
|
|
export function unavailableReason(
|
|
displayMode: string | undefined,
|
|
sessionManagerPresent: boolean,
|
|
): 'headless_mode' | 'display_unavailable' | 'no_session' {
|
|
if ((displayMode ?? 'headless') !== 'novnc') return 'headless_mode';
|
|
if (!sessionManagerPresent) return 'display_unavailable';
|
|
return 'no_session';
|
|
}
|
|
|
|
/**
|
|
* 2026-05 redesign: CAPTCHA Pool (admin 専用) と Task Session (タスク
|
|
* visibility ベース) を分離した API。
|
|
*
|
|
* - GET /captcha-pool : admin only. Pool の noVNC パス + captchaPending
|
|
* - DELETE /captcha-pool : admin only. Pool を destroy (次の CAPTCHA で再生成)
|
|
* - GET /task-session/:taskId : visibility 通過なら novncPath、なければ available:false
|
|
* - POST /task-session/:taskId/release: owner or admin. その taskId の session を destroy
|
|
* - GET / : 自分が見えるタスクの task session 一覧 (Pool は除外)
|
|
* - GET /:id : 直 sessionId 指定 (admin / 旧来 owner)。kind=='task' のときは taskId 経由を推奨
|
|
* - DELETE /:id : admin or task owner
|
|
*
|
|
* 旧 /search-session, POST / は廃止 (Plan の clean break)。
|
|
*/
|
|
|
|
function isAdmin(req: Request): boolean {
|
|
const user = req.user as Express.User | undefined;
|
|
return user?.role === 'admin';
|
|
}
|
|
|
|
function getUser(req: Request): Express.User | undefined {
|
|
return req.user as Express.User | undefined;
|
|
}
|
|
|
|
/** auth 未設定 (dev モード) では req.user が undefined。その場合は全許可で互換維持 */
|
|
function isUnauthenticatedDev(req: Request): boolean {
|
|
return getUser(req) === undefined;
|
|
}
|
|
|
|
function serializeTaskSession(session: BrowserSession) {
|
|
return {
|
|
id: session.id,
|
|
kind: session.kind,
|
|
taskId: session.taskId,
|
|
state: session.state,
|
|
novncPath: buildNovncPath(session.id),
|
|
lockedByJobId: session.lockedByJobId,
|
|
createdAt: session.createdAt.toISOString(),
|
|
lastActiveAt: session.lastActiveAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 指定 task session に user がアクセスできるかを判定する (visibility ベース)。
|
|
* Pool は admin only。dev モードは全許可。
|
|
*/
|
|
async function canViewSession(
|
|
req: Request,
|
|
session: BrowserSession,
|
|
repo: Repository,
|
|
): Promise<boolean> {
|
|
if (isUnauthenticatedDev(req)) return true;
|
|
const user = getUser(req)!;
|
|
if (user.role === 'admin') return true;
|
|
if (session.kind === 'pool') return false;
|
|
if (session.kind === 'task' && session.taskId) {
|
|
const taskIdNum = Number(session.taskId);
|
|
if (!Number.isFinite(taskIdNum)) return false;
|
|
const task = await repo.getLocalTask(taskIdNum);
|
|
return task ? canUserSeeTask(user, task) : false;
|
|
}
|
|
// 旧来 (kind 未設定): owner だけ
|
|
return session.userId === user.id;
|
|
}
|
|
|
|
/**
|
|
* 指定 task session を user が destroy / release できるかを判定する。
|
|
* - admin: 常に可
|
|
* - task owner: 可
|
|
* - dev モード: 可
|
|
*/
|
|
async function canControlSession(
|
|
req: Request,
|
|
session: BrowserSession,
|
|
repo: Repository,
|
|
): Promise<boolean> {
|
|
if (isUnauthenticatedDev(req)) return true;
|
|
const user = getUser(req)!;
|
|
if (user.role === 'admin') return true;
|
|
if (session.kind === 'pool') return false;
|
|
if (session.kind === 'task' && session.taskId) {
|
|
const taskIdNum = Number(session.taskId);
|
|
if (!Number.isFinite(taskIdNum)) return false;
|
|
const task = await repo.getLocalTask(taskIdNum);
|
|
if (!task) return false;
|
|
return canEditEntity(user, task);
|
|
}
|
|
return session.userId === user.id;
|
|
}
|
|
|
|
export function createBrowserApi(sessionManager: SessionManager | null, repo: Repository): Router {
|
|
const router = Router();
|
|
|
|
// sessionManager が null (Xvfb 等が無い環境) でも /captcha-pool / /task-session
|
|
// は available: false を返したい。503 にしてしまうと UI 側でエラー扱いされてしまう。
|
|
if (!sessionManager) {
|
|
router.get('/captcha-pool', (_req: Request, res: Response) => {
|
|
const reason = unavailableReason(loadConfig().browser?.displayMode, false);
|
|
res.json({ available: false, reason });
|
|
});
|
|
router.get('/task-session/:taskId', (_req: Request, res: Response) => {
|
|
const reason = unavailableReason(loadConfig().browser?.displayMode, false);
|
|
res.json({ available: false, reason });
|
|
});
|
|
router.all('*', (_req: Request, res: Response) => {
|
|
res.status(503).json({ error: 'Browser sessions not available (missing system dependencies)' });
|
|
});
|
|
return router;
|
|
}
|
|
|
|
// --- CAPTCHA Pool (admin only) ---
|
|
|
|
router.get('/captcha-pool', (req: Request, res: Response) => {
|
|
if (!isUnauthenticatedDev(req) && !isAdmin(req)) {
|
|
res.status(403).json({ error: 'Admin role required' });
|
|
return;
|
|
}
|
|
const pool = sessionManager.getSession(CAPTCHA_POOL_SESSION_ID);
|
|
if (!pool) {
|
|
const reason = unavailableReason(loadConfig().browser?.displayMode, true);
|
|
res.json({ available: false, reason });
|
|
return;
|
|
}
|
|
if (!isNovncStaticInstalled()) {
|
|
res.json({ available: false, reason: 'novnc_not_installed' });
|
|
return;
|
|
}
|
|
res.json({
|
|
available: true,
|
|
sessionId: pool.id,
|
|
novncPath: buildNovncPath(pool.id),
|
|
display: pool.display,
|
|
captchaPending: pool.captchaPending === true,
|
|
createdAt: pool.createdAt.toISOString(),
|
|
});
|
|
});
|
|
|
|
router.delete('/captcha-pool', async (req: Request, res: Response) => {
|
|
if (!isUnauthenticatedDev(req) && !isAdmin(req)) {
|
|
res.status(403).json({ error: 'Admin role required' });
|
|
return;
|
|
}
|
|
// Pool destroy 時は web.ts の persistentContexts も連動して破棄する
|
|
// (Cookie の生残りで認証状態が混乱するのを防ぐ)
|
|
try {
|
|
const webMod = await import('../engine/tools/web.js') as { clearPersistentContexts?: () => void };
|
|
webMod.clearPersistentContexts?.();
|
|
} catch { /* ignore */ }
|
|
await sessionManager.destroySession(CAPTCHA_POOL_SESSION_ID);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Task Session (visibility-aware) ---
|
|
|
|
router.get('/task-session/:taskId', async (req: Request, res: Response) => {
|
|
const taskId = req.params.taskId;
|
|
const session = sessionManager
|
|
.listSessions()
|
|
.find((s) => s.kind === 'task' && s.taskId === taskId);
|
|
if (!session) {
|
|
const reason = unavailableReason(loadConfig().browser?.displayMode, true);
|
|
res.json({ available: false, reason });
|
|
return;
|
|
}
|
|
if (!(await canViewSession(req, session, repo))) {
|
|
// 認可失敗は available: false にして session 存在情報を漏らさない。
|
|
// reason も session が無いとき相当 (存在を漏らさない) で返す。
|
|
const reason = unavailableReason(loadConfig().browser?.displayMode, true);
|
|
res.json({ available: false, reason });
|
|
return;
|
|
}
|
|
if (!isNovncStaticInstalled()) {
|
|
// session は存在するが、iframe で読む vnc.html が配置されていない。
|
|
// UI 側で「scripts/setup-novnc.sh を実行してください」と案内する。
|
|
res.json({ available: false, reason: 'novnc_not_installed' });
|
|
return;
|
|
}
|
|
res.json({
|
|
available: true,
|
|
sessionId: session.id,
|
|
novncPath: buildNovncPath(session.id),
|
|
display: session.display,
|
|
state: session.state,
|
|
lockedByJobId: session.lockedByJobId,
|
|
createdAt: session.createdAt.toISOString(),
|
|
lastActiveAt: session.lastActiveAt.toISOString(),
|
|
});
|
|
});
|
|
|
|
router.post('/task-session/:taskId/release', async (req: Request, res: Response) => {
|
|
const taskId = req.params.taskId;
|
|
const session = sessionManager
|
|
.listSessions()
|
|
.find((s) => s.kind === 'task' && s.taskId === taskId);
|
|
if (!session) {
|
|
res.status(404).json({ error: 'Task session not found' });
|
|
return;
|
|
}
|
|
if (!(await canControlSession(req, session, repo))) {
|
|
res.status(403).json({ error: 'Forbidden' });
|
|
return;
|
|
}
|
|
await sessionManager.destroySession(session.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// --- Generic list / detail (task sessions only; pool excluded) ---
|
|
|
|
router.get('/', async (req: Request, res: Response) => {
|
|
const taskSessions = sessionManager.listSessions().filter((s) => s.kind === 'task');
|
|
const visible: BrowserSession[] = [];
|
|
for (const s of taskSessions) {
|
|
if (await canViewSession(req, s, repo)) visible.push(s);
|
|
}
|
|
res.json({ sessions: visible.map(serializeTaskSession) });
|
|
});
|
|
|
|
router.get('/:id', async (req: Request, res: Response) => {
|
|
const session = sessionManager.getSession(req.params.id);
|
|
if (!session || session.kind === 'pool') {
|
|
// Pool は /captcha-pool 経由でのみアクセスさせる (id 直指定では不可)
|
|
res.status(404).json({ error: 'Session not found' });
|
|
return;
|
|
}
|
|
if (!(await canViewSession(req, session, repo))) {
|
|
res.status(404).json({ error: 'Session not found' });
|
|
return;
|
|
}
|
|
res.json(serializeTaskSession(session));
|
|
});
|
|
|
|
router.delete('/:id', async (req: Request, res: Response) => {
|
|
const session = sessionManager.getSession(req.params.id);
|
|
if (!session || session.kind === 'pool') {
|
|
res.status(404).json({ error: 'Session not found' });
|
|
return;
|
|
}
|
|
if (!(await canControlSession(req, session, repo))) {
|
|
res.status(404).json({ error: 'Session not found' });
|
|
return;
|
|
}
|
|
await sessionManager.destroySession(session.id);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post('/:id/release', async (req: Request, res: Response) => {
|
|
const session = sessionManager.getSession(req.params.id);
|
|
if (!session || session.kind === 'pool') {
|
|
res.status(404).json({ error: 'Session not found' });
|
|
return;
|
|
}
|
|
if (!(await canControlSession(req, session, repo))) {
|
|
res.status(404).json({ error: 'Session not found' });
|
|
return;
|
|
}
|
|
sessionManager.releaseToAgent(session.id);
|
|
if (session.lockedByJobId) {
|
|
try {
|
|
await repo.updateJob(session.lockedByJobId, {
|
|
status: 'queued',
|
|
waitReason: null,
|
|
});
|
|
} catch (err) {
|
|
logger.warn(`[browser-api] failed to re-queue job ${session.lockedByJobId}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
res.json({ ok: true, state: 'agent_controlled' });
|
|
});
|
|
|
|
return router;
|
|
}
|