實作 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>
444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
/**
|
||
* Local-agent localhost 直連工具 — visionA Cloud 前端(ADR-019 WP-3)
|
||
*
|
||
* 職責(純 client util、endpoint 無關):
|
||
* - `probeLocalAgentPort()`:探測本機 local-agent 的動態 port
|
||
* (sessionStorage 快取 → 3721 → 3722–3740 並發,各 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 session(BFF);local-agent 直連走的是
|
||
* loopback 絕對 URL + one-time token header,認證模型完全不同(ADR-019 §2.1 混合路徑)。
|
||
* 拆開避免兩套認證邏輯混淆。
|
||
*
|
||
* 契約來源(不自行更改,見 api-spec §6.2–6.5 / ADR-019 §2.3):
|
||
* - port 範圍 3721–3740(local-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 fallback,ADR-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/health,ADR-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 回傳的 data(api-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 hash(Web 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/hello,timeout 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、不需 cookie(api-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 → 3722–3740 並發) */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 探測本機 local-agent 的 port,回傳第一個有回應者的 ProbeHit。
|
||
*
|
||
* 策略(ADR-019 §2.3):
|
||
* 1. sessionStorage 快取的 port(同分頁 session 內免重掃)
|
||
* 2. 3721(最常見的預設 port)
|
||
* 3. 3722–3740 並發(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) 3722–3740 並發,取第一個成功者
|
||
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 目前選定裝置的 serialNumber(kn_number;ADR-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 上傳 multipart(endpoint 無關)。
|
||
*
|
||
* 三個 caller(image / 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 URL(http://127.0.0.1:<port>),非 same-origin
|
||
* - credentials 不帶 cookie(withCredentials = false;api-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:不帶 cookie(token 走 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 的 error(LOCAL_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);
|
||
});
|
||
}
|