cc-haha/src/server/api/h5-access.ts
程序员阿江(Relakkes) 53d8e7ad77 feat(desktop): stabilize H5 access tokens, ports, and background sessions (#767, #764)
H5 远程访问的三处不稳定来源修复,让手机出门在外也能稳定连接、长任务不丢。

#767 令牌与端口固定:
- 令牌明文持久化到 cc-haha/settings.json,重启后二维码/令牌随时可查、可复制;
  enable 复用现有令牌、disable 保留令牌,仅 regenerate 才轮换。手改 token 字段
  即自定义固定令牌。完整令牌只经 local-trusted 面返回,远端 403。
- 新增可选固定端口 fixedPort,并在未配置时复用上次端口(desktop-server-state.json
  sticky,Electron/Tauri 双壳共享),反向代理/手机书签跨重启不失效;占用时回退随机。

#764 断连不杀正在运行的 CLI:
- 客户端断开时若该会话仍在跑一轮任务,不再 30s 后强杀子进程,而是等任务跑完;
  手机锁屏/切后台时长任务在后台跑完,重连即见结果。
- 空闲清理超时改为可配 disconnectGraceSeconds(H5 访问设置页,默认 30s),
  经 disconnectGraceConfig 同步缓存供 close 处理读取。

server / Electron / Tauri / React 四层 + 五语言 i18n + 配套单测全部覆盖。
2026-06-12 16:37:00 +08:00

106 lines
3.4 KiB
TypeScript

import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { H5AccessService } from '../services/h5AccessService.js'
import { refreshDisconnectGraceMs } from '../ws/disconnectGraceConfig.js'
const h5AccessService = new H5AccessService()
function methodNotAllowed(method: string, route: string): ApiError {
return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED')
}
function getBearerToken(req: Request): string | null {
const authorization = req.headers.get('authorization')
if (!authorization) {
return null
}
const match = authorization.match(/^Bearer\s+(.+)$/i)
return match?.[1] ?? null
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
const body = await req.json()
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw ApiError.badRequest('Invalid JSON body')
}
return body as Record<string, unknown>
} catch (error) {
if (error instanceof ApiError) {
throw error
}
throw ApiError.badRequest('Invalid JSON body')
}
}
export async function handleH5AccessApi(
req: Request,
_url: URL,
segments: string[],
): Promise<Response> {
try {
const sub = segments[2]
switch (sub) {
case undefined:
if (req.method === 'GET') {
const [settings, diagnostics] = await Promise.all([
h5AccessService.getSettings(),
h5AccessService.getDiagnostics(),
])
return Response.json({ settings, diagnostics })
}
if (req.method === 'PUT') {
const body = await parseJsonBody(req)
const settings = await h5AccessService.updateSettings({
allowedOrigins: body.allowedOrigins as string[] | undefined,
publicBaseUrl: body.publicBaseUrl as string | null | undefined,
fixedPort: body.fixedPort as number | null | undefined,
disconnectGraceSeconds: body.disconnectGraceSeconds as number | null | undefined,
})
// Keep the synchronous disconnect-cleanup cache in step with the new value.
await refreshDisconnectGraceMs()
return Response.json({ settings })
}
throw methodNotAllowed(req.method, '/api/h5-access')
case 'enable':
if (req.method !== 'POST') {
throw methodNotAllowed(req.method, '/api/h5-access/enable')
}
return Response.json(await h5AccessService.enable())
case 'disable':
if (req.method !== 'POST') {
throw methodNotAllowed(req.method, '/api/h5-access/disable')
}
return Response.json({ settings: await h5AccessService.disable() })
case 'regenerate':
if (req.method !== 'POST') {
throw methodNotAllowed(req.method, '/api/h5-access/regenerate')
}
return Response.json(await h5AccessService.regenerateToken())
case 'verify': {
if (req.method !== 'POST') {
throw methodNotAllowed(req.method, '/api/h5-access/verify')
}
const token = getBearerToken(req)
const isValid = await h5AccessService.validateToken(token)
if (!isValid) {
throw new ApiError(401, 'Invalid or missing H5 access token', 'UNAUTHORIZED')
}
return Response.json({ ok: true })
}
default:
throw ApiError.notFound(`Unknown h5-access endpoint: ${sub}`)
}
} catch (error) {
return errorResponse(error)
}
}