visionA/visionA-frontend/src/lib/local-agent.ts
jim800121chen 9031153553 feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連
local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 +
推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。

三條 stream(全數過 reviewer + security code-level 複審 APPROVED):

local-agent(Go):
- CORS 雲端 origin 完整精確比對 + Allow-Credentials:false + HostGuard(loopback)
  + PNA header(middleware.go)
- 新 route /api/local/media/upload/*(一律要 token、不看 Origin,關 C1 後門)
- one-time token store(crypto/rand、TTL 120s、綁 deviceId、single-flight consume、
  上限 32→429;200 goroutine -race 綠)
- GET /api/local/hello(回 salted SHA-256 serialHashes、最小揭露)
  + POST /api/local/issue-token(Host-based)
- LocalUploadGuard(token+size 驗證放 FormFile 前);video≤500MB / batch 合計 80MB
  → 413;stopActivePipeline + batch 生命週期 temp 檔清理

cloud(visionA-backend):
- POST /api/devices/:serial/local-upload-ticket(OIDC + 裝置歸屬 + 經 tunnel
  轉發 issue-token;IDOR-safe、錯誤不洩漏)

frontend(visionA-frontend):
- lib/local-agent.ts(port 探測 3721-3740 並發+快取、Web Crypto serial hash 比對
  同機判定、uploadToLocalAgent 通用函式)
- validateBatchFiles 合計大小檢查(MAX_BATCH_TOTAL_BYTES=80MB,消 50×19MB 撞 413 地雷)

回歸:ADR-019 相關 270 測試全綠、既有 tunnel 路徑未被打斷、無 regression。
既有 tunnel(無 Origin)不要求 token(C1 route 分離相容性保證)。

Refs: ADR-019。WP-0(PNA 實機)/WP-4(影片分頁接線)下一批。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 12:32:26 +08:00

444 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Local-agent localhost 直連工具 — visionA Cloud 前端ADR-019 WP-3
*
* 職責(純 client util、endpoint 無關):
* - `probeLocalAgentPort()`:探測本機 local-agent 的動態 port
* sessionStorage 快取 → 3721 → 37223740 並發,各 timeout 500ms打 GET /api/local/hello
* - `resolveLocalAgent(serial)`:探測 + 同機判定 + serial 身分驗證,回傳 { port } 或
* LOCAL_AGENT_NOT_FOUND無回應/ LOCAL_AGENT_MISMATCH有回應但 serial 不符)。
* - `uploadToLocalAgent(port, path, form, options)`endpoint 無關的通用上傳函式
* (三個 caller 換 path 即可),打 http://127.0.0.1:<port><path>,帶 X-Visiona-Local-Token。
*
* 為什麼是獨立檔(不併進 media.ts
* media.ts 的上傳走雲端 same-origin cookie sessionBFFlocal-agent 直連走的是
* loopback 絕對 URL + one-time token header認證模型完全不同ADR-019 §2.1 混合路徑)。
* 拆開避免兩套認證邏輯混淆。
*
* 契約來源(不自行更改,見 api-spec §6.26.5 / ADR-019 §2.3
* - port 範圍 37213740local-agent pickPort 動態)
* - GET /api/local/hello 回 { serialHashes: string[], supportsLocalUpload: boolean }
* - serialHashes[i] = SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex
* - salt 常數 "visiona-local-v1" 前後端共用寫死
* - 上傳路徑 /api/local/media/upload/{video|image|batch-images}Header X-Visiona-Local-Token
*/
import {
AbortError,
ApiError,
NetworkError,
TimeoutError,
} from "@/lib/api";
import type { ApiErrorShape } from "@/types/api";
import type { MediaUploadResponse } from "@/types/camera";
import type { UploadMediaOptions } from "@/lib/media";
/* -------------------------------------------------------------------------- */
/* 契約常數 */
/* -------------------------------------------------------------------------- */
/** local-agent loopback host強制綁 127.0.0.1,見 local-agent server/config.go。 */
export const LOCAL_AGENT_HOST = "127.0.0.1";
/** port 探測範圍local-agent pickPort 3721 → 3740 fallbackADR-019 §2.3)。 */
export const LOCAL_AGENT_PORT_START = 3721;
export const LOCAL_AGENT_PORT_END = 3740;
/** 每次探測單一 port 的 timeout毫秒ADR-019 §2.3)。 */
export const PROBE_TIMEOUT_MS = 500;
/** sessionStorage 快取「上次探到的 port」的 key同分頁 session 內免重掃)。 */
export const PORT_CACHE_KEY = "visiona.localAgent.port";
/** 同機偵測 / 身分驗證用的固定公開 salt前後端共用寫死api-spec §6.3**不可改**)。 */
export const SERIAL_HASH_SALT = "visiona-local-v1";
/** 探測 endpoint 路徑(專用、非 /api/system/healthADR-019 §2.3)。 */
export const LOCAL_HELLO_PATH = "/api/local/hello";
/** 上傳 token 的 request header 名api-spec §6.2)。 */
export const LOCAL_TOKEN_HEADER = "X-Visiona-Local-Token";
/** 直連上傳 route 路徑endpoint 無關函式的 caller 換這三個之一)。 */
export const LOCAL_UPLOAD_IMAGE_PATH = "/api/local/media/upload/image";
export const LOCAL_UPLOAD_VIDEO_PATH = "/api/local/media/upload/video";
export const LOCAL_UPLOAD_BATCH_PATH = "/api/local/media/upload/batch-images";
/* -------------------------------------------------------------------------- */
/* 型別 */
/* -------------------------------------------------------------------------- */
/** GET /api/local/hello 回傳的 dataapi-spec §6.3,最小揭露)。 */
export interface LocalHelloData {
/** SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex 陣列。 */
serialHashes: string[];
/** 是否支援 local upload布林取代原 agentVersion。 */
supportsLocalUpload: boolean;
}
/** 探測到的單一候選(某 port 有回應且回了 hello data。 */
interface ProbeHit {
port: number;
data: LocalHelloData;
}
/**
* resolveLocalAgent 的結果碼(對齊 api-spec §6.5 前端內部狀態):
* - OK 找到同機且 serial 相符的 agent
* - NOT_FOUND 掃描無任何回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND
* - MISMATCH 有回應但沒有任一 serial 相符 → LOCAL_AGENT_MISMATCH
*/
export type LocalAgentResolveStatus = "OK" | "NOT_FOUND" | "MISMATCH";
export interface LocalAgentResolveResult {
status: LocalAgentResolveStatus;
/** status === "OK" 時為探到的 port否則 undefined。 */
port?: number;
}
/* -------------------------------------------------------------------------- */
/* SHA-256 serial hashWeb Crypto前端獨立重算比對 */
/* -------------------------------------------------------------------------- */
/**
* 算 `SHA-256("visiona-local-v1" || serial)` 的 lowercase hex。
*
* 用 Web Crypto `crypto.subtle.digest`jsdom / Node 20+ / Chrome / Edge 皆有)。
* 與後端 salted SHA-256 契約一致api-spec §6.3),供同機身分比對。
*/
export async function computeSerialHash(serial: string): Promise<string> {
const bytes = new TextEncoder().encode(`${SERIAL_HASH_SALT}${serial}`);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return bytesToHex(new Uint8Array(digest));
}
/** Uint8Array → lowercase hex 字串(與後端 hex.EncodeToString 對齊)。 */
function bytesToHex(bytes: Uint8Array): string {
let hex = "";
for (const b of bytes) {
hex += b.toString(16).padStart(2, "0");
}
return hex;
}
/* -------------------------------------------------------------------------- */
/* sessionStorage 快取 */
/* -------------------------------------------------------------------------- */
/** 讀 sessionStorage 快取的 port無效 / 不存在 / 超出範圍 → null。 */
function readCachedPort(): number | null {
try {
const raw = globalThis.sessionStorage?.getItem(PORT_CACHE_KEY);
if (!raw) return null;
const port = Number.parseInt(raw, 10);
if (
Number.isInteger(port) &&
port >= LOCAL_AGENT_PORT_START &&
port <= LOCAL_AGENT_PORT_END
) {
return port;
}
return null;
} catch {
// sessionStorage 不可用SSR / 隱私模式)→ 當作無快取
return null;
}
}
/** 寫 sessionStorage 快取(失敗靜默——快取只是最佳化,不可用不影響功能)。 */
function writeCachedPort(port: number): void {
try {
globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, String(port));
} catch {
// ignore快取寫入失敗不影響探測結果
}
}
/** 清掉快取(快取的 port 探測失敗時呼叫,避免下次又先撞舊 port。 */
function clearCachedPort(): void {
try {
globalThis.sessionStorage?.removeItem(PORT_CACHE_KEY);
} catch {
// ignore
}
}
/* -------------------------------------------------------------------------- */
/* 單一 port 探測 */
/* -------------------------------------------------------------------------- */
/**
* 打單一 port 的 GET /api/local/hellotimeout 500ms。
*
* 回傳 ProbeHit成功且拿到合法 hello data或 reject無回應 / 非預期格式 / timeout
* 這裡**只判斷「有沒有 local-agent 在這個 port」**,不做 serial 比對(比對在 resolveLocalAgent
*/
async function probeSinglePort(port: number): Promise<ProbeHit> {
const url = `http://${LOCAL_AGENT_HOST}:${port}${LOCAL_HELLO_PATH}`;
const ctrl = new AbortController();
const timeoutId = setTimeout(() => ctrl.abort("probe-timeout"), PROBE_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: "GET",
signal: ctrl.signal,
// 直連 local-agent 用 header token、不需 cookieapi-spec §6.4 Allow-Credentials: false
credentials: "omit",
});
if (!res.ok) {
throw new Error(`hello returned HTTP ${res.status}`);
}
const parsed: unknown = await res.json();
const data = extractHelloData(parsed);
if (!data) {
throw new Error("hello response shape invalid");
}
return { port, data };
} finally {
clearTimeout(timeoutId);
}
}
/** 從 hello 回應解 envelope 取 data並驗證 serialHashes 型別。回傳 null 表格式不符。 */
function extractHelloData(parsed: unknown): LocalHelloData | null {
if (
!parsed ||
typeof parsed !== "object" ||
!("success" in parsed) ||
(parsed as { success: unknown }).success !== true ||
!("data" in parsed)
) {
return null;
}
const data = (parsed as { data: unknown }).data;
if (!data || typeof data !== "object" || !("serialHashes" in data)) {
return null;
}
const hashes = (data as { serialHashes: unknown }).serialHashes;
if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === "string")) {
return null;
}
const supports =
"supportsLocalUpload" in data
? Boolean((data as { supportsLocalUpload: unknown }).supportsLocalUpload)
: false;
return { serialHashes: hashes as string[], supportsLocalUpload: supports };
}
/* -------------------------------------------------------------------------- */
/* port 探測(快取 → 3721 → 37223740 並發) */
/* -------------------------------------------------------------------------- */
/**
* 探測本機 local-agent 的 port回傳第一個有回應者的 ProbeHit。
*
* 策略ADR-019 §2.3
* 1. sessionStorage 快取的 port同分頁 session 內免重掃)
* 2. 3721最常見的預設 port
* 3. 37223740 並發Promise.any 取第一個成功者)
* 每次 timeout 500ms。全部失敗 → reject呼叫端視為 NOT_FOUND
*
* 為什麼分三段而非一次全並發:
* 快取 / 3721 命中率最高,先試可避免每次都對 20 個 port 發 preflight成本 + 觸發防火牆告警)。
*/
export async function probeLocalAgentPort(): Promise<ProbeHit> {
// 1) 快取
const cached = readCachedPort();
if (cached !== null) {
try {
const hit = await probeSinglePort(cached);
writeCachedPort(hit.port);
return hit;
} catch {
// 快取失效agent 換 port / 沒跑)→ 清掉,往下重掃
clearCachedPort();
}
}
// 2) 3721 預設 port避開重複試快取剛失敗的那個
if (cached !== LOCAL_AGENT_PORT_START) {
try {
const hit = await probeSinglePort(LOCAL_AGENT_PORT_START);
writeCachedPort(hit.port);
return hit;
} catch {
// 往下並發掃剩餘範圍
}
}
// 3) 37223740 並發,取第一個成功者
const rest: number[] = [];
for (let p = LOCAL_AGENT_PORT_START + 1; p <= LOCAL_AGENT_PORT_END; p++) {
if (p !== cached) rest.push(p);
}
if (rest.length === 0) {
throw new NetworkError("No local-agent found on any candidate port");
}
try {
const hit = await Promise.any(rest.map((p) => probeSinglePort(p)));
writeCachedPort(hit.port);
return hit;
} catch {
// Promise.any 全 reject → AggregateError
throw new NetworkError("No local-agent found on any candidate port");
}
}
/* -------------------------------------------------------------------------- */
/* 同機判定 + serial 身分驗證 */
/* -------------------------------------------------------------------------- */
/**
* 探測 + 同機判定 + serial 身分驗證。
*
* @param serial 目前選定裝置的 serialNumberkn_numberADR-018 serial 路由)
* @returns
* - { status: "OK", port } 找到同機 agent 且其 serialHashes 含此 serial 的雜湊
* - { status: "NOT_FOUND" } 掃描無回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND
* - { status: "MISMATCH" } 有回應但無任一 serial 相符(同機跑著別台 agent→ LOCAL_AGENT_MISMATCH
*
* 為什麼比對 serial防「同機跑著另一台 agent、影片被送到錯的裝置」的靜默錯誤ADR-019 §2.3 / R-4
*/
export async function resolveLocalAgent(
serial: string,
): Promise<LocalAgentResolveResult> {
let hit: ProbeHit;
try {
hit = await probeLocalAgentPort();
} catch {
return { status: "NOT_FOUND" };
}
const expected = await computeSerialHash(serial);
if (hit.data.serialHashes.includes(expected)) {
return { status: "OK", port: hit.port };
}
// 有回應但 serial 不符 → 快取的 port 可能是別台 agent清掉避免誤導下次
clearCachedPort();
return { status: "MISMATCH" };
}
/* -------------------------------------------------------------------------- */
/* 通用上傳endpoint 無關,帶 token header */
/* -------------------------------------------------------------------------- */
/** uploadToLocalAgent 的選項:沿用 media 的進度 / 取消 / timeout加 token。 */
export interface LocalUploadOptions extends UploadMediaOptions {
/** one-time upload token經雲端 ticket 取得,放 X-Visiona-Local-Token header。 */
token: string;
}
/**
* 以 XHR + FormData 直連 local-agent 上傳 multipartendpoint 無關)。
*
* 三個 callerimage / video / batch只需換 `path`
* LOCAL_UPLOAD_IMAGE_PATH | LOCAL_UPLOAD_VIDEO_PATH | LOCAL_UPLOAD_BATCH_PATH
*
* @param port probeLocalAgentPort / resolveLocalAgent 探到的 port
* @param path 直連上傳 route 相對路徑(/api/local/media/upload/*
* @param form 已組好的 FormData含 deviceId + file/files格式與雲端 route 相同)
* @param options token + 進度 / 取消 / timeout
* @returns 解開 envelope 的 MediaUploadResponse與雲端 route 回傳格式相同)
* @throws ApiError | NetworkError | TimeoutError | AbortError與 api.ts 一致的錯誤體系)
*
* 認證與雲端 route 不同:
* - 走絕對 loopback URLhttp://127.0.0.1:<port>),非 same-origin
* - credentials 不帶 cookiewithCredentials = falseapi-spec §6.4 Allow-Credentials: false
* - token 放 header X-Visiona-Local-Token不放 URL避免洩漏到 log / referrer
*/
export function uploadToLocalAgent(
port: number,
path: string,
form: FormData,
options: LocalUploadOptions,
): Promise<MediaUploadResponse> {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = `http://${LOCAL_AGENT_HOST}:${port}${normalizedPath}`;
return new Promise<MediaUploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url, true);
// 直連 loopback不帶 cookietoken 走 header
xhr.withCredentials = false;
// 不設 Content-Type讓瀏覽器帶 multipart boundary
xhr.setRequestHeader(LOCAL_TOKEN_HEADER, options.token);
if (options.timeoutMs && options.timeoutMs > 0) {
xhr.timeout = options.timeoutMs;
}
if (options.onProgress) {
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
options.onProgress!(
Math.min(100, Math.round((ev.loaded / ev.total) * 100)),
);
}
};
}
xhr.onload = () => {
let parsed: unknown = null;
try {
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null;
} catch {
// 非 JSON body
}
if (xhr.status >= 200 && xhr.status < 300) {
if (
parsed &&
typeof parsed === "object" &&
"success" in parsed &&
(parsed as { success: boolean }).success === true &&
"data" in parsed
) {
resolve((parsed as { data: MediaUploadResponse }).data);
return;
}
reject(
new ApiError(xhr.status, {
code: "PARSE_ERROR",
message: "Unexpected upload response shape",
}),
);
return;
}
// non-2xx盡量取 envelope 的 errorLOCAL_TOKEN_INVALID / LOCAL_UPLOAD_TOO_LARGE 等)
let errShape: ApiErrorShape = {
code: xhr.status === 401 ? "LOCAL_TOKEN_INVALID" : "INTERNAL_ERROR",
message: `Local upload failed: HTTP ${xhr.status}`,
};
if (
parsed &&
typeof parsed === "object" &&
"error" in parsed &&
(parsed as { error?: unknown }).error
) {
errShape = (parsed as { error: ApiErrorShape }).error;
}
reject(new ApiError(xhr.status, errShape));
};
xhr.onerror = () => reject(new NetworkError(`Local upload to ${url} failed`));
xhr.ontimeout = () => reject(new TimeoutError(`Local upload to ${url} timed out`));
if (options.signal) {
if (options.signal.aborted) {
reject(new AbortError());
return;
}
options.signal.addEventListener(
"abort",
() => {
xhr.abort();
reject(new AbortError());
},
{ once: true },
);
}
xhr.send(form);
});
}