From 92780cedef21362234c651678f1ee040c39b2180 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 11:16:49 +0800 Subject: [PATCH] add auth check --- ControlPanel/server-client.js | 10 +- ReinLoop/api.py | 67 +++++- ReinLoop/core/data_collector.py | 5 +- ReinLoop/core/device_heartbeat.py | 8 +- ReinLoop/core/identification.py | 5 +- ReinLoop/core/identification_config.py | 7 +- ReinLoop/core/identification_feedback.py | 16 +- ReinLoop/core/model_manager.py | 9 +- ReinLoop/core/volume_config.py | 15 +- ReinLoop/license_utils.py | 2 +- ReinLoop/tests/test_data_collector.py | 4 + ReinLoop/tests/test_device_heartbeat.py | 22 +- ReinLoop/tests/test_identification_config.py | 44 +--- ReinLoop/tests/test_volume_config.py | 100 ++------- changelog.md | 44 ++++ server/features.md | 44 +++- server/src/app.js | 217 +++++++++++++++---- server/test/server.test.js | 4 +- 18 files changed, 381 insertions(+), 242 deletions(-) diff --git a/ControlPanel/server-client.js b/ControlPanel/server-client.js index 12d1933..e8948dc 100644 --- a/ControlPanel/server-client.js +++ b/ControlPanel/server-client.js @@ -39,21 +39,13 @@ async function callServer(payload, options = {}) { return result; } -function downloadHeaders(options = {}) { - if (!options.adminToken) return {}; - return { - authorization: `Bearer ${options.adminToken}`, - "x-admin-token": options.adminToken - }; -} - async function downloadFromUrl(url, fileName, options = {}) { await fs.promises.mkdir(DOWNLOAD_DIR, { recursive: true }); return downloadToPath(url, path.join(DOWNLOAD_DIR, path.basename(fileName)), options); } async function downloadToPath(url, destination, options = {}) { - const response = await fetch(url, { headers: downloadHeaders(options) }); + const response = await fetch(url); if (!response.ok || !response.body) { throw new Error(`下载失败: HTTP ${response.status}`); } diff --git a/ReinLoop/api.py b/ReinLoop/api.py index b10c257..3fe10a0 100644 --- a/ReinLoop/api.py +++ b/ReinLoop/api.py @@ -1,6 +1,10 @@ """ReinLoop cloud-server endpoint configuration shared by core modules.""" import os +import threading +import time + +import requests from license_utils import get_verified_license @@ -16,6 +20,7 @@ server_api_url = os.environ.get( # Compatibility alias used by existing modules. It points to the ReinLoop # Express server API, not a cloud-function endpoint. data_record_url = server_api_url +device_api_url = f"{server_api_url.rstrip('/')}/device" _license = get_verified_license() _license_device_id = (_license or {}).get("device_id", "").strip() _environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip() @@ -26,4 +31,64 @@ if _license_device_id and _environment_device_id and _license_device_id != _envi the_folder = _license_device_id or _environment_device_id or "local-test-device" if not the_folder: - raise RuntimeError("设备 ID 不能为空") \ No newline at end of file + raise RuntimeError("设备 ID 不能为空") + +_DEVICE_TOKEN = None +_DEVICE_TOKEN_EXPIRES_AT_MS = 0 +_DEVICE_TOKEN_LOCK = threading.Lock() + + +def _current_license_context(): + payload = get_verified_license() or {} + license_id = str(payload.get("license_id") or "").strip() + device_id = str(payload.get("device_id") or _environment_device_id or the_folder).strip() + return license_id, device_id + + +def _ensure_device_token(timeout=10): + global _DEVICE_TOKEN, _DEVICE_TOKEN_EXPIRES_AT_MS + with _DEVICE_TOKEN_LOCK: + now_ms = int(time.time() * 1000) + if _DEVICE_TOKEN and _DEVICE_TOKEN_EXPIRES_AT_MS - now_ms > 30_000: + return _DEVICE_TOKEN + + license_id, device_id = _current_license_context() + if not license_id: + raise RuntimeError("许可证未就绪,无法获取 deviceToken") + + response = requests.post(device_api_url, json={ + "type": "deviceAuth", + "licenseId": license_id, + "deviceId": device_id, + }, timeout=timeout) + response.raise_for_status() + result = response.json() + if not result.get("success"): + raise RuntimeError(result.get("errMsg") or "设备鉴权失败") + + _DEVICE_TOKEN = str(result.get("deviceToken") or "").strip() + _DEVICE_TOKEN_EXPIRES_AT_MS = int(result.get("expiresAtMs") or 0) + if not _DEVICE_TOKEN or _DEVICE_TOKEN_EXPIRES_AT_MS <= now_ms: + raise RuntimeError("设备鉴权返回了无效 deviceToken") + return _DEVICE_TOKEN + + +def device_post(payload, timeout=10): + """Call the device-scoped API route with an auto-renewed device token.""" + token = _ensure_device_token(timeout=timeout) + request_payload = {**payload, "deviceToken": token} + response = requests.post(device_api_url, json=request_payload, timeout=timeout) + response.raise_for_status() + result = response.json() + if result.get("success"): + return result + if result.get("errCode") in {"DEVICE_TOKEN_EXPIRED", "DEVICE_TOKEN_INVALID"}: + with _DEVICE_TOKEN_LOCK: + global _DEVICE_TOKEN, _DEVICE_TOKEN_EXPIRES_AT_MS + _DEVICE_TOKEN = None + _DEVICE_TOKEN_EXPIRES_AT_MS = 0 + token = _ensure_device_token(timeout=timeout) + response = requests.post(device_api_url, json={**payload, "deviceToken": token}, timeout=timeout) + response.raise_for_status() + result = response.json() + return result \ No newline at end of file diff --git a/ReinLoop/core/data_collector.py b/ReinLoop/core/data_collector.py index 7a7c037..0d90a31 100644 --- a/ReinLoop/core/data_collector.py +++ b/ReinLoop/core/data_collector.py @@ -11,7 +11,7 @@ import datetime import threading import requests -from api import base_url, data_record_url, the_folder +from api import device_post, the_folder class DataCollector: @@ -47,12 +47,11 @@ class DataCollector: def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool: """向 ReinLoop 云服务器申请上传地址并上传控制数据。""" try: - resp = requests.post(data_record_url, json={ + result = device_post({ "type": "uploadDataFile", "fileName": filename, "folder": folder, }, timeout=30) - result = resp.json() except Exception as e: self.log(f"向云服务器申请上传地址异常: {e}") return False diff --git a/ReinLoop/core/device_heartbeat.py b/ReinLoop/core/device_heartbeat.py index 707c508..4752df2 100644 --- a/ReinLoop/core/device_heartbeat.py +++ b/ReinLoop/core/device_heartbeat.py @@ -3,16 +3,12 @@ def heartbeat_device(timeout=5): """Refresh the current device's Server heartbeat and return its timestamp.""" - import requests - from api import data_record_url, the_folder + from api import device_post try: - response = requests.post(data_record_url, json={ + result = device_post({ "type": "deviceHeartbeat", - "deviceId": the_folder, }, timeout=timeout) - response.raise_for_status() - result = response.json() except Exception as exc: raise ValueError(f"设备心跳请求失败: {exc}") from exc if not result.get("success"): diff --git a/ReinLoop/core/identification.py b/ReinLoop/core/identification.py index 741b021..bbfb0d1 100644 --- a/ReinLoop/core/identification.py +++ b/ReinLoop/core/identification.py @@ -16,7 +16,7 @@ from collections import deque from get_V import measure_volume from ind_collector import collect_data_with_prbs -from api import base_url, data_record_url, the_folder +from api import device_post, the_folder class IdentificationManager: @@ -66,12 +66,11 @@ class IdentificationManager: """ # Step 1: 向业务服务器申请一次性上传地址(不传文件内容) try: - resp = requests.post(data_record_url, json={ + result = device_post({ "type": "uploadDataFile", "fileName": filename, "folder": folder, }, timeout=30) - result = resp.json() except Exception as e: self.log(f"向云服务器申请上传地址异常: {e}") return False diff --git a/ReinLoop/core/identification_config.py b/ReinLoop/core/identification_config.py index 63b3a45..50763f2 100644 --- a/ReinLoop/core/identification_config.py +++ b/ReinLoop/core/identification_config.py @@ -130,15 +130,12 @@ def parse_identification_config_csv(csv_text: str) -> dict: def download_identification_config(timeout=20) -> dict: """Download the current customer's CSV config through the cloud server.""" import requests - from api import data_record_url, the_folder + from api import device_post try: - response = requests.post(data_record_url, json={ + result = device_post({ "type": "getIdentificationConfig", - "deviceId": the_folder, }, timeout=timeout) - response.raise_for_status() - result = response.json() except Exception as exc: raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc diff --git a/ReinLoop/core/identification_feedback.py b/ReinLoop/core/identification_feedback.py index 7763aa7..b536f1f 100644 --- a/ReinLoop/core/identification_feedback.py +++ b/ReinLoop/core/identification_feedback.py @@ -2,13 +2,10 @@ def _post(payload, timeout=10): - import requests - from api import data_record_url + from api import device_post try: - response = requests.post(data_record_url, json=payload, timeout=timeout) - response.raise_for_status() - result = response.json() + result = device_post(payload, timeout=timeout) except Exception as exc: raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc if not result.get("success"): @@ -18,13 +15,10 @@ def _post(payload, timeout=10): def register_identification_result(run_id: str, timeout=10) -> None: """Register one uploaded CSV as the customer's current review target.""" - from api import the_folder - if not run_id: raise ValueError("辨识结果缺少 run_id") _post({ "type": "registerIdentificationResult", - "deviceId": the_folder, "runId": run_id, "fileName": run_id, }, timeout=timeout) @@ -32,11 +26,8 @@ def register_identification_result(run_id: str, timeout=10) -> None: def get_identification_feedback(run_id: str, timeout=10): """Return None while pending, otherwise return the integer 0 or 1.""" - from api import the_folder - result = _post({ "type": "getIdentificationFeedback", - "deviceId": the_folder, "runId": run_id, }, timeout=timeout) if not result.get("ready"): @@ -49,10 +40,7 @@ def get_identification_feedback(run_id: str, timeout=10): def acknowledge_identification_feedback(run_id: str, timeout=10) -> None: """Delete the consumed review record so stale feedback cannot be reused.""" - from api import the_folder - _post({ "type": "ackIdentificationFeedback", - "deviceId": the_folder, "runId": run_id, }, timeout=timeout) diff --git a/ReinLoop/core/model_manager.py b/ReinLoop/core/model_manager.py index 4fc84aa..abcf23b 100644 --- a/ReinLoop/core/model_manager.py +++ b/ReinLoop/core/model_manager.py @@ -6,14 +6,13 @@ import threading import io -import requests import torch from stable_baselines3 import SAC # 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault torch.set_num_threads(1) -from api import base_url, data_record_url, the_folder +from api import device_post, the_folder class ModelManager: @@ -50,8 +49,7 @@ class ModelManager: def fetch_models(): try: payload = {"type": "listModels", "folder": f"{the_folder}/model_config"} - resp = requests.post(data_record_url, json=payload, timeout=10) - result = resp.json() + result = device_post(payload, timeout=10) if result.get("success"): files = result.get("files", []) @@ -98,8 +96,7 @@ class ModelManager: # 获取临时下载 URL payload = {"type": "downloadModel", "fileID": file_id} - resp = requests.post(data_record_url, json=payload, timeout=15) - result = resp.json() + result = device_post(payload, timeout=15) if not result.get("success"): err = result.get('errMsg', '未知错误') diff --git a/ReinLoop/core/volume_config.py b/ReinLoop/core/volume_config.py index 825ba21..6abc33c 100644 --- a/ReinLoop/core/volume_config.py +++ b/ReinLoop/core/volume_config.py @@ -84,13 +84,10 @@ def validate_volume_config(config) -> dict: def _post_volume_request(payload, timeout=10): - import requests - from api import data_record_url + from api import device_post try: - response = requests.post(data_record_url, json=payload, timeout=timeout) - response.raise_for_status() - result = response.json() + result = device_post(payload, timeout=timeout) except Exception as exc: raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc @@ -101,11 +98,8 @@ def _post_volume_request(payload, timeout=10): def create_volume_config_request(timeout=10) -> dict: """Create exactly one cloud request after the customer clicks Test.""" - from api import the_folder - result = _post_volume_request({ "type": "createVolumeConfigRequest", - "deviceId": the_folder, }, timeout=timeout) if not result.get("requestId") or not result.get("expiresAtMs"): raise ValueError("云端未返回有效的容积参数请求编号") @@ -118,11 +112,9 @@ def create_volume_config_request(timeout=10) -> dict: def poll_volume_config_request(request_id: str, timeout=10) -> dict: """Poll one request; download and validate JSON only when it is ready.""" import requests - from api import the_folder result = _post_volume_request({ "type": "getVolumeConfigRequest", - "deviceId": the_folder, "requestId": request_id, }, timeout=timeout) if result.get("expired"): @@ -145,10 +137,7 @@ def poll_volume_config_request(request_id: str, timeout=10) -> dict: def acknowledge_volume_config_request(request_id: str, timeout=10) -> None: """Delete the consumed/abandoned request and its temporary JSON file.""" - from api import the_folder - _post_volume_request({ "type": "ackVolumeConfigRequest", - "deviceId": the_folder, "requestId": request_id, }, timeout=timeout) diff --git a/ReinLoop/license_utils.py b/ReinLoop/license_utils.py index ceee6d5..db92b2a 100644 --- a/ReinLoop/license_utils.py +++ b/ReinLoop/license_utils.py @@ -47,7 +47,7 @@ LICENSE_FILE = "license.lic" # 巡检间隔(分钟) DEFAULT_CHECK_INTERVAL = 5 ONLINE_CHECK_TIMEOUT_SECONDS = 5 -DEFAULT_OFFLINE_HOURS = 72 +DEFAULT_OFFLINE_HOURS = 100 # 过期后宽限期(小时),给用户保存工作的时间 GRACE_PERIOD_HOURS = 2 diff --git a/ReinLoop/tests/test_data_collector.py b/ReinLoop/tests/test_data_collector.py index 2451701..933c958 100644 --- a/ReinLoop/tests/test_data_collector.py +++ b/ReinLoop/tests/test_data_collector.py @@ -15,6 +15,10 @@ def load_data_collector_module(): api = types.ModuleType("api") api.base_url = "https://cloud.example" api.data_record_url = "https://cloud.example" + api.device_post = lambda payload, timeout=10: { + "success": True, + "uploadMetadata": {"url": "https://upload.example"} + } api.the_folder = "customer-a/line-1" requests = types.ModuleType("requests") diff --git a/ReinLoop/tests/test_device_heartbeat.py b/ReinLoop/tests/test_device_heartbeat.py index 66588a1..dfc4855 100644 --- a/ReinLoop/tests/test_device_heartbeat.py +++ b/ReinLoop/tests/test_device_heartbeat.py @@ -16,26 +16,12 @@ class DeviceHeartbeatTests(unittest.TestCase): def test_sends_current_device_id_to_server(self): calls = [] requests_module = types.ModuleType("requests") - - class Response: - def raise_for_status(self): - return None - - def json(self): - return {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"} - - def post(url, json, timeout): - calls.append((url, json, timeout)) - return Response() - - requests_module.post = post api_module = types.ModuleType("api") - api_module.data_record_url = "https://server.example" - api_module.the_folder = "company/line" + api_module.device_post = lambda payload, timeout=5: ( + calls.append((payload, timeout)) or {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"} + ) with patch.dict(sys.modules, {"requests": requests_module, "api": api_module}): timestamp = HEARTBEAT.heartbeat_device(timeout=7) self.assertEqual(timestamp, "2026-07-28T00:00:00.000Z") - self.assertEqual(calls, [("https://server.example", { - "type": "deviceHeartbeat", "deviceId": "company/line" - }, 7)]) \ No newline at end of file + self.assertEqual(calls, [({"type": "deviceHeartbeat"}, 7)]) \ No newline at end of file diff --git a/ReinLoop/tests/test_identification_config.py b/ReinLoop/tests/test_identification_config.py index 502fbb1..07d6979 100644 --- a/ReinLoop/tests/test_identification_config.py +++ b/ReinLoop/tests/test_identification_config.py @@ -87,33 +87,23 @@ class IdentificationConfigTests(unittest.TestCase): def test_download_requests_customer_config_and_validates_it(self): calls = [] - class FakeResponse: - def __init__(self, body=None, text=None): - self.body = body - self.text = text - - def raise_for_status(self): - return None - - def json(self): - return self.body - requests_module = types.ModuleType("requests") requests_module.RequestException = Exception - def post(url, json, timeout): - calls.append(("post", url, json, timeout)) - return FakeResponse({"success": True, "url": "https://temp/config"}) + def device_post(payload, timeout): + calls.append(("device_post", payload, timeout)) + return {"success": True, "url": "https://temp/config"} def get(url, timeout): calls.append(("get", url, timeout)) - return FakeResponse(text=config_csv(VALID_CONFIG)) + return types.SimpleNamespace( + text=config_csv(VALID_CONFIG), + raise_for_status=lambda: None + ) - requests_module.post = post requests_module.get = get api_module = types.ModuleType("api") - api_module.data_record_url = "https://cloud/data_record" - api_module.the_folder = "客户A" + api_module.device_post = device_post with patch.dict(sys.modules, { "requests": requests_module, @@ -122,28 +112,14 @@ class IdentificationConfigTests(unittest.TestCase): result = download_identification_config(timeout=7) self.assertEqual(result["repeat"], 2) - self.assertEqual(calls[0], ( - "post", - "https://cloud/data_record", - {"type": "getIdentificationConfig", "deviceId": "客户A"}, - 7, - )) + self.assertEqual(calls[0], ("device_post", {"type": "getIdentificationConfig"}, 7)) self.assertEqual(calls[1], ("get", "https://temp/config", 7)) def test_download_reports_cloud_rejection(self): - class FakeResponse: - def raise_for_status(self): - return None - - def json(self): - return {"success": False, "errMsg": "配置不存在"} - requests_module = types.ModuleType("requests") requests_module.RequestException = Exception - requests_module.post = lambda *args, **kwargs: FakeResponse() api_module = types.ModuleType("api") - api_module.data_record_url = "https://cloud/data_record" - api_module.the_folder = "客户A" + api_module.device_post = lambda *args, **kwargs: {"success": False, "errMsg": "配置不存在"} with patch.dict(sys.modules, { "requests": requests_module, diff --git a/ReinLoop/tests/test_volume_config.py b/ReinLoop/tests/test_volume_config.py index 6a9da41..50e654a 100644 --- a/ReinLoop/tests/test_volume_config.py +++ b/ReinLoop/tests/test_volume_config.py @@ -57,28 +57,13 @@ class VolumeConfigTests(unittest.TestCase): def test_customer_creates_exactly_one_request_instruction(self): calls = [] - - class FakeResponse: - def raise_for_status(self): - return None - - def json(self): - return { - "success": True, - "requestId": "request-1", - "expiresAtMs": 123456, - } - requests_module = types.ModuleType("requests") - - def post(url, json, timeout): - calls.append((url, json, timeout)) - return FakeResponse() - - requests_module.post = post api_module = types.ModuleType("api") - api_module.data_record_url = "https://cloud/data_record" - api_module.the_folder = "客户A" + api_module.device_post = lambda payload, timeout=10: (calls.append((payload, timeout)) or { + "success": True, + "requestId": "request-1", + "expiresAtMs": 123456, + }) with patch.dict(sys.modules, { "requests": requests_module, @@ -90,30 +75,16 @@ class VolumeConfigTests(unittest.TestCase): "request_id": "request-1", "expires_at_ms": 123456, }) - self.assertEqual(calls, [( - "https://cloud/data_record", - {"type": "createVolumeConfigRequest", "deviceId": "客户A"}, - 7, - )]) + self.assertEqual(calls, [({"type": "createVolumeConfigRequest"}, 7)]) def test_pending_request_does_not_download_a_file(self): calls = [] - - class FakeResponse: - def raise_for_status(self): - return None - - def json(self): - return {"success": True, "ready": False, "expired": False} - requests_module = types.ModuleType("requests") - requests_module.post = lambda *args, **kwargs: ( - calls.append(("post", kwargs["json"])) or FakeResponse() - ) requests_module.get = lambda *args, **kwargs: calls.append(("get", args[0])) api_module = types.ModuleType("api") - api_module.data_record_url = "https://cloud/data_record" - api_module.the_folder = "客户A" + api_module.device_post = lambda payload, timeout=10: ( + calls.append(("device_post", payload)) or {"success": True, "ready": False, "expired": False} + ) with patch.dict(sys.modules, { "requests": requests_module, @@ -122,36 +93,25 @@ class VolumeConfigTests(unittest.TestCase): result = poll_volume_config_request("request-1") self.assertEqual(result, {"ready": False, "expired": False}) - self.assertEqual(calls, [("post", { + self.assertEqual(calls, [("device_post", { "type": "getVolumeConfigRequest", - "deviceId": "客户A", "requestId": "request-1", })]) def test_ready_request_downloads_and_validates_json(self): - class FakeResponse: - def __init__(self, body): - self.body = body - - def raise_for_status(self): - return None - - def json(self): - return self.body - requests_module = types.ModuleType("requests") - requests_module.post = lambda *args, **kwargs: FakeResponse({ + requests_module.post = lambda *args, **kwargs: types.SimpleNamespace() + api_module = types.ModuleType("api") + api_module.device_post = lambda payload, timeout=10: { "success": True, "ready": True, "expired": False, "url": "https://temp/volume.json", - }) - requests_module.get = lambda *args, **kwargs: FakeResponse( - dict(VALID_CONFIG) + } + requests_module.get = lambda *args, **kwargs: types.SimpleNamespace( + raise_for_status=lambda: None, + json=lambda: dict(VALID_CONFIG) ) - api_module = types.ModuleType("api") - api_module.data_record_url = "https://cloud/data_record" - api_module.the_folder = "客户A" with patch.dict(sys.modules, { "requests": requests_module, @@ -163,18 +123,9 @@ class VolumeConfigTests(unittest.TestCase): self.assertEqual(result["config"], VALID_CONFIG) def test_create_request_reports_server_rejection(self): - class FakeResponse: - def raise_for_status(self): - return None - - def json(self): - return {"success": False, "errMsg": "尚未配置"} - requests_module = types.ModuleType("requests") - requests_module.post = lambda *args, **kwargs: FakeResponse() api_module = types.ModuleType("api") - api_module.data_record_url = "https://cloud/data_record" - api_module.the_folder = "客户A" + api_module.device_post = lambda *args, **kwargs: {"success": False, "errMsg": "尚未配置"} with patch.dict(sys.modules, { "requests": requests_module, @@ -185,21 +136,9 @@ class VolumeConfigTests(unittest.TestCase): def test_acknowledges_the_same_request_for_cleanup(self): calls = [] - - class FakeResponse: - def raise_for_status(self): - return None - - def json(self): - return {"success": True, "deleted": 1} - requests_module = types.ModuleType("requests") - requests_module.post = lambda *args, **kwargs: ( - calls.append(kwargs["json"]) or FakeResponse() - ) api_module = types.ModuleType("api") - api_module.data_record_url = "https://cloud/data_record" - api_module.the_folder = "客户A" + api_module.device_post = lambda payload, timeout=10: (calls.append(payload) or {"success": True, "deleted": 1}) with patch.dict(sys.modules, { "requests": requests_module, @@ -209,7 +148,6 @@ class VolumeConfigTests(unittest.TestCase): self.assertEqual(calls, [{ "type": "ackVolumeConfigRequest", - "deviceId": "客户A", "requestId": "request-1", }]) diff --git a/changelog.md b/changelog.md index 94beed3..cc4296a 100644 --- a/changelog.md +++ b/changelog.md @@ -140,3 +140,47 @@ ### 验证 - 已完成修改文件的 Python、JavaScript 语法检查,未发现语法错误。 + +## 2026-08-03 + +### Server:下载接口改造为一次性临时 URL 并绑定来源 IP + +- 文件下载流程由“业务接口返回可复用直链”改为“业务接口签发一次性临时 URL + 下载后立即失效”。 +- 新增下载入口 `GET /downloads/:ticket`,票据校验包含有效期、一次性消费和请求来源 IP 一致性。 +- 旧入口 `GET /files/:fileID` 已停用并固定返回 `403`,避免 fileID 直链被转发复用。 +- `downloadModel` 统一要求 `adminToken`,下载能力与管理权限保持一致。 +- 返回下载 URL 的业务接口已统一改为临时票据地址:`downloadModel`、`getPendingPanelFile`、`getIdentificationFileDownload`、`getControlFileDownload`、`getIdentificationConfig`、`getVolumeConfigFile`、`getVolumeConfigRequest`。 + +涉及文件: + +- `server/src/app.js` +- `server/test/server.test.js` +- `ControlPanel/server-client.js` +- `server/features.md` + +### 验证 + +- 已完成 `server` 全量测试(21 项)通过。 +- 已完成 ControlPanel 无 GUI 内核联调测试通过(`model-handlers` 与 `license-manager` 相关用例)。 +- 已重启 `reinloop-server.service` 并验证:同一临时下载 URL 首次下载成功,二次访问返回 `403`。 + +### Server + ReinLoop:设备分路由与最小权限访问 + +- 新增 `POST /device` 设备侧路由,ReinLoop 通过 `deviceAuth`(`licenseId` + `deviceId`)换取短期 `deviceToken` 后访问设备接口。 +- 设备侧接口采用白名单权限,不再使用 `adminToken`,并强制按 `deviceId` 隔离目录和模型访问范围。 +- ReinLoop 客户端核心模块已切换为 `/device` 访问链路:设备心跳、模型列表与下载、辨识配置下载、容积请求、辨识反馈、控制与辨识结果上传申请。 +- ReinLoop 离线宽限默认调整为 `100` 小时(`REINLOOP_LICENSE_OFFLINE_HOURS` 默认值)。 + +涉及文件: + +- `server/src/app.js` +- `server/features.md` +- `ReinLoop/api.py` +- `ReinLoop/license_utils.py` +- `ReinLoop/core/model_manager.py` +- `ReinLoop/core/identification_config.py` +- `ReinLoop/core/volume_config.py` +- `ReinLoop/core/identification_feedback.py` +- `ReinLoop/core/device_heartbeat.py` +- `ReinLoop/core/data_collector.py` +- `ReinLoop/core/identification.py` diff --git a/server/features.md b/server/features.md index af24b53..861acaa 100644 --- a/server/features.md +++ b/server/features.md @@ -17,10 +17,38 @@ | 方法 | 路径 | 用途 | | --- | --- | --- | | `POST` | `/` | 主业务 API | +| `POST` | `/device` | ReinLoop 设备侧 API(license -> deviceToken) | | `POST` | `/upload/:token` | 根据上传凭证接收 multipart 文件 | -| `GET` | `/files/:fileID` | 下载已存储文件 | +| `GET` | `/downloads/:ticket` | 使用一次性临时下载票据获取文件 | +| `GET` | `/files/:fileID` | 旧直链下载入口(已停用,固定返回 403) | | `GET` | `/health` | 服务存活检查 | +## 设备侧鉴权与分路由 + +`/device` 仅用于 ReinLoop 客户端,禁止使用 `adminToken`。 + +调用方式: + +1. 先 `POST /device`,`type=deviceAuth`,字段 `licenseId`、`deviceId`。 +2. 服务端校验许可证状态(存在、未过期、未撤销、deviceId 匹配)后签发 `deviceToken`。 +3. ReinLoop 后续调用 `/device` 白名单接口时携带 `deviceToken`。 + +`deviceToken` 默认有效期由 `DEVICE_TOKEN_TTL_MS` 控制(默认 15 分钟)。 + +离线宽限由 ReinLoop 客户端控制,当前默认 `REINLOOP_LICENSE_OFFLINE_HOURS=100`(100 小时)。 + +## 下载安全调用链 + +统一链路为:业务 `POST /`(鉴权) -> 返回临时 URL -> `GET /downloads/:ticket`(一次性消费)。 + +安全校验点: + +- `POST /`:按业务类型执行权限校验。 +- `GET /downloads/:ticket`: + - 票据存在且未过期(`DOWNLOAD_URL_TTL_MS`,兼容旧环境变量 `DOWNLOAD_TOKEN_TTL_MS`)。 + - 票据仅可消费一次,成功下载或校验失败后均失效。 + - 下载请求来源 IP 必须与签发票据的 `POST` 请求来源 IP 一致。 + ## 设备心跳与组织 | type | 鉴权 | 请求字段 | 功能与响应要点 | @@ -51,13 +79,19 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行 | `uploadDataFile` | 视目录而定 | `fileName`、`folder` | 签发通用两步上传凭证。目录为 `*/model_config` 时必须 Admin;其他既有 ReinLoop 数据上传保持兼容。 | | `issueModelUpload` | Admin | `deviceId`、`fileName`、可选 `modelName`、`overwrite` | `fileName` 是本地原始文件名;传入 `modelName` 时以该名称存储和识别模型,并保留 `originalFileName`。同名模型已存在时返回 `conflict: true`;仅 `overwrite: true` 可签发覆盖凭证。 | | `listModels` | 无 | `folder` | 返回 `files` 当前模型名数组和 `fileList` 元数据数组,最多 100 条;每条同时包含 `fileName` 和 `originalFileName`。 | -| `downloadModel` | 视文件而定 | `fileID` | 模型保持兼容;非模型文件要求 Admin 并返回短期签名下载 URL。 | +| `downloadModel` | Admin | `fileID` | 返回一次性临时下载 URL(`/downloads/:ticket`)。 | | `deleteFile` | Admin | `fileID`,或 `folder` 与 `fileName` | 删除文件及元数据;同名文件不唯一时必须使用 `fileID`。 | | `deleteModel` | Admin | 同 `deleteFile` | 模型删除的明确管理端别名。 | 上传分两步:先调用 `uploadDataFile` 或 `issueModelUpload`,再将文件作为 `multipart/form-data` 的 `file` 字段提交到响应中的 `uploadMetadata.url`。上传成功返回 HTTP `204`;响应中的 `fileID` 可用于下载和删除。 模型重命名不会修改文件格式,因此 `modelName` 与原始 `fileName` 的扩展名必须一致。未传 `modelName` 时两者相同,旧客户端行为不变。 +设备侧(`/device`)模型访问约束: + +- `listModels` 固定返回当前 `deviceId/model_config` 目录。 +- `downloadModel` 仅允许下载当前 `deviceId/model_config` 下文件。 +- 设备侧不允许模型上传与删除。 + 上传到 `/ind_data` 的 `.csv`、`.json` 会自动进入 Panel inbox。 ## 配置发布与读取 @@ -67,7 +101,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行 | `publishIdentificationConfig` | Admin | `deviceId`、`parameters` | 校验辨识参数并为 `/identification_config/identification_config.csv` 签发上传凭证。 | | `getIdentificationConfig` | 无 | `deviceId` | 返回该设备辨识 CSV 的 `fileID` 和 `url`。 | | `publishVolumeConfig` | Admin | `parameters` | 校验容积参数并签发 `volume_config.json` 上传凭证。上传完成后更新功能参数记录。 | -| `getVolumeConfigFile` | 无 | 无 | 返回已发布容积 JSON 的 `fileID`、`cloudPath`、`url`。 | +| `getVolumeConfigFile` | Admin | `deviceId` | 返回指定设备已发布容积 JSON 的 `fileID`、`cloudPath`、`url`。 | | `getFunctionConfig` | 无 | `configType: "volume"` | 返回已发布容积参数的 `parameters`、`version`、`updateTime`。 | 发布接口仅签发上传凭证;客户端完成二步上传后,读取接口才会返回新文件或参数。 @@ -83,7 +117,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行 | `getPendingPanelFile` | Admin | `deviceId` | 获取指定设备下一条待处理 CSV/JSON;无数据时 `pending: false`,有数据时返回文件信息和 `url`。 | | `ackPanelFile` | Admin | `deviceId`、`fileID` | Panel 处理完成后确认,移除 inbox 项并标记历史记录为 `processed`,不立即删除文件。 | | `listIdentificationFiles` | Admin | `deviceId`、可选 `mediaType`、`status`、`page`、`pageSize` | 分页返回设备的辨识 CSV/JSON 暂存历史。 | -| `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的短期签名下载 URL。 | +| `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的一次性临时下载 URL。 | | `deleteIdentificationFile` | Admin | `fileID` | 显式删除辨识文件、历史记录及待处理消息。 | CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,才会出现在 `getPendingPanelFile`;初始行程 JSON 可直接获取。 @@ -97,7 +131,7 @@ CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`, | `createVolumeConfigRequest` | 无 | `deviceId` | ReinLoop 创建一次性上传请求,返回 `requestId`、`createdAtMs`、`expiresAtMs`。同设备旧请求会被替换。 | | `getPendingVolumeConfigRequest` | 无 | `deviceId` | 查询是否存在待上传请求,返回 `pending` 和请求时间信息。 | | `submitVolumeConfigFile` | 无 | `deviceId`、`requestId`、`fileID`、可选 `fileName` | 将已上传到 `/volume_config_requests//` 的文件绑定至请求。 | -| `getVolumeConfigRequest` | 无 | `deviceId`、`requestId` | 轮询配置是否就绪,返回 `ready`、`expired`;就绪时包含下载 `url`。 | +| `getVolumeConfigRequest` | 无 | `deviceId`、`requestId` | 轮询配置是否就绪,返回 `ready`、`expired`;就绪时包含一次性临时下载 `url`。 | | `ackVolumeConfigRequest` | 无 | `deviceId`、`requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 | 请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。 diff --git a/server/src/app.js b/server/src/app.js index 68b8b79..4d48a93 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -1,12 +1,13 @@ const fs = require("node:fs"); const path = require("node:path"); -const { randomUUID, constants, createHmac, timingSafeEqual, verify } = require("node:crypto"); +const { randomUUID, constants, verify } = require("node:crypto"); const express = require("express"); const multer = require("multer"); const BASE_FOLDER = "ReinLoop_GUI"; const VOLUME_REQUEST_TTL_MS = Number(process.env.VOLUME_REQUEST_TTL_MS || 300000); -const DOWNLOAD_TOKEN_TTL_MS = Number(process.env.DOWNLOAD_TOKEN_TTL_MS || 300000); +const DOWNLOAD_URL_TTL_MS = Number(process.env.DOWNLOAD_URL_TTL_MS || process.env.DOWNLOAD_TOKEN_TTL_MS || 300000); +const DEVICE_TOKEN_TTL_MS = Number(process.env.DEVICE_TOKEN_TTL_MS || 15 * 60 * 1000); const IDENTIFICATION_RETENTION_MS = Number(process.env.IDENTIFICATION_RETENTION_MS || 30 * 24 * 60 * 60 * 1000); const DEVICE_HEARTBEAT_TTL_MS = 30_000; const CONFIG_SCHEMAS = { @@ -161,6 +162,8 @@ function createApp({ const app = express(); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } }); const pendingUploads = new Map(); + const pendingDownloads = new Map(); + const pendingDeviceTokens = new Map(); app.disable("x-powered-by"); app.use(express.json({ limit: "2mb" })); @@ -200,23 +203,68 @@ function createApp({ }; } - function signDownload(fileID, expiresAtMs) { - return createHmac("sha256", adminToken).update(`${fileID}\n${expiresAtMs}`).digest("hex"); + function requestIp(req) { + return String(req.ip || req.socket?.remoteAddress || "").trim(); } - function downloadUrl(req, fileID) { - const baseUrl = `${publicBaseUrl(req)}/files/${encodeURIComponent(fileID)}`; - if (fileID.startsWith("model://")) return baseUrl; - const expires = Date.now() + DOWNLOAD_TOKEN_TTL_MS; - return `${baseUrl}?expires=${expires}&token=${signDownload(fileID, expires)}`; + function issueDownloadUrl(req, fileID) { + const ticket = randomUUID().replace(/-/g, ""); + pendingDownloads.set(ticket, { + fileID, + requestIp: requestIp(req), + expiresAtMs: Date.now() + DOWNLOAD_URL_TTL_MS + }); + return `${publicBaseUrl(req)}/downloads/${ticket}`; } - function hasValidDownloadToken(req, fileID) { - const expires = Number(req.query.expires); - const token = String(req.query.token || ""); - if (!Number.isSafeInteger(expires) || expires < Date.now() || !/^[0-9a-f]{64}$/.test(token)) return false; - const expected = signDownload(fileID, expires); - return timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex")); + function consumeDownloadTicket(req, ticket) { + const request = pendingDownloads.get(ticket); + if (!request) return { ok: false, errMsg: "下载链接无效或已失效" }; + pendingDownloads.delete(ticket); + if (request.expiresAtMs < Date.now()) return { ok: false, errMsg: "下载链接已过期" }; + if (!request.requestIp || request.requestIp !== requestIp(req)) { + return { ok: false, errMsg: "下载请求来源IP不匹配" }; + } + return { ok: true, fileID: request.fileID }; + } + + function verifyLicenseRecord(record, deviceId) { + if (!record) return { success: false, valid: false, status: "not_found" }; + if (deviceId && deviceId !== record.deviceId) { + return { success: true, valid: false, status: "device_mismatch", licenseId: record.licenseId }; + } + if (new Date(record.expiryAt || parseLicenseTimestamp(record.expiry, "expiry")) <= new Date()) { + return { success: true, valid: false, status: "expired", licenseId: record.licenseId }; + } + return { + success: true, + valid: record.status === "active", + status: record.status, + licenseId: record.licenseId + }; + } + + function issueDeviceToken({ deviceId, licenseId }) { + const token = randomUUID().replace(/-/g, ""); + const expiresAtMs = Date.now() + DEVICE_TOKEN_TTL_MS; + pendingDeviceTokens.set(token, { deviceId, licenseId, expiresAtMs }); + return { token, expiresAtMs }; + } + + function consumeDeviceContext(event) { + const deviceToken = String(event.deviceToken || "").trim(); + if (!deviceToken) { + return { ok: false, errCode: "DEVICE_TOKEN_REQUIRED", errMsg: "缺少 deviceToken" }; + } + const session = pendingDeviceTokens.get(deviceToken); + if (!session) { + return { ok: false, errCode: "DEVICE_TOKEN_INVALID", errMsg: "deviceToken 无效" }; + } + if (session.expiresAtMs <= Date.now()) { + pendingDeviceTokens.delete(deviceToken); + return { ok: false, errCode: "DEVICE_TOKEN_EXPIRED", errMsg: "deviceToken 已过期" }; + } + return { ok: true, ...session }; } //config test----------------------------------- @@ -540,17 +588,8 @@ function createApp({ case "validateLicense": { const database = await store.read(); const record = database.licenses.find((item) => item.licenseId === event.licenseId); - if (!record) return { success: true, valid: false, status: "not_found" }; - if (event.deviceId && normalizeDeviceId(event.deviceId) !== record.deviceId) { - return { success: true, valid: false, status: "device_mismatch", licenseId: record.licenseId }; - } - if (new Date(record.expiryAt || parseLicenseTimestamp(record.expiry, "expiry")) <= new Date()) { - return { success: true, valid: false, status: "expired", licenseId: record.licenseId }; - } - return { - success: true, valid: record.status === "active", - status: record.status, licenseId: record.licenseId - }; + const deviceId = event.deviceId ? normalizeDeviceId(event.deviceId) : null; + return verifyLicenseRecord(record, deviceId); } case "uploadDataFile": if (normalizeRelativePath(event.folder, "data_record").endsWith("/model_config")) { @@ -584,13 +623,11 @@ function createApp({ const database = await store.read(); const record = database.fileRecords.find((item) => item.fileID === event.fileID); if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" }; - if (!record.fileID.startsWith("model://")) { - const authError = requireAdmin(event); - if (authError) return { success: false, errMsg: authError }; - } + const authError = requireAdmin(event); + if (authError) return { success: false, errMsg: authError }; return { success: true, - url: downloadUrl(req, record.fileID), + url: issueDownloadUrl(req, record.fileID), fileName: record.fileName, originalFileName: record.originalFileName || record.fileName }; @@ -651,7 +688,7 @@ function createApp({ if (!record) return { success: true, found: false, deviceId }; return { success: true, found: true, deviceId, fileID: record.fileID, - fileName: record.fileName, url: downloadUrl(req, record.fileID), + fileName: record.fileName, url: issueDownloadUrl(req, record.fileID), updatedAtMs: config.updatedAtMs, requestId: config.requestId }; } @@ -667,7 +704,7 @@ function createApp({ const record = database.fileRecords.find((item) => item.folder === `${deviceId}/identification_config` && item.fileName === "identification_config.csv"); if (!record) return { success: false, errMsg: "服务器尚未配置辨识参数" }; logConfigRead({ configType: "identification", deviceId, record }); - return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: downloadUrl(req, record.fileID) }; + return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: issueDownloadUrl(req, record.fileID) }; } case "getPendingPanelFile": { const authError = requireAdmin(event); @@ -689,7 +726,7 @@ function createApp({ fileName: message.fileName, mediaType: message.mediaType, uploadTime: message.uploadTime, - url: downloadUrl(req, message.fileID) + url: issueDownloadUrl(req, message.fileID) }; } case "getPendingPanelNotification": { @@ -812,7 +849,7 @@ function createApp({ fileName: record.fileName, uploadTime: record.uploadTime, size: record.size, - url: downloadUrl(req, record.fileID) + url: issueDownloadUrl(req, record.fileID) }; } case "deleteControlFile": { @@ -844,7 +881,7 @@ function createApp({ fileID: fileRecord.fileID, fileName: fileRecord.fileName, mediaType: historyRecord.mediaType, - url: downloadUrl(req, fileRecord.fileID) + url: issueDownloadUrl(req, fileRecord.fileID) }; } case "deleteIdentificationFile": { @@ -969,7 +1006,7 @@ function createApp({ const fileRecord = database.fileRecords.find((item) => item.fileID === record.configFileID); if (!fileRecord) return { success: false, errMsg: "容积配置文件记录不存在" }; logConfigRead({ configType: "volume", deviceId, requestId: record.requestId, record: fileRecord }); - return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: downloadUrl(req, record.configFileID) }; + return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: issueDownloadUrl(req, record.configFileID) }; } case "ackVolumeConfigRequest": { const deviceId = normalizeDeviceId(event.deviceId); @@ -989,6 +1026,88 @@ function createApp({ } } + async function dispatchDevice(event, req) { + if (event.type === "deviceAuth") { + const licenseId = String(event.licenseId || "").trim(); + if (!licenseId) return { success: false, errCode: "LICENSE_ID_REQUIRED", errMsg: "缺少 licenseId" }; + const deviceId = normalizeDeviceId(event.deviceId); + const database = await store.read(); + const record = database.licenses.find((item) => item.licenseId === licenseId); + const validation = verifyLicenseRecord(record, deviceId); + if (!validation.valid) { + const errCodeMap = { + not_found: "LICENSE_NOT_FOUND", + device_mismatch: "DEVICE_ID_MISMATCH", + expired: "LICENSE_EXPIRED", + revoked: "LICENSE_REVOKED" + }; + return { + success: false, + errCode: errCodeMap[validation.status] || "LICENSE_INVALID", + errMsg: `许可证不可用: ${validation.status}` + }; + } + const issued = issueDeviceToken({ deviceId, licenseId }); + return { + success: true, + deviceId, + licenseId, + deviceToken: issued.token, + expiresAtMs: issued.expiresAtMs + }; + } + + const context = consumeDeviceContext(event); + if (!context.ok) return { success: false, errCode: context.errCode, errMsg: context.errMsg }; + const deviceId = context.deviceId; + + switch (event.type) { + case "deviceHeartbeat": + case "registerIdentificationResult": + case "getIdentificationFeedback": + case "ackIdentificationFeedback": + case "createVolumeConfigRequest": + case "getPendingVolumeConfigRequest": + case "submitVolumeConfigFile": + case "getVolumeConfigRequest": + case "ackVolumeConfigRequest": + case "getIdentificationConfig": + return dispatch({ ...event, deviceId }, req); + case "uploadDataFile": { + const folder = normalizeRelativePath(event.folder, "data_record"); + const devicePrefix = `${deviceId}/`; + if (!folder.startsWith(devicePrefix)) { + return { success: false, errMsg: "设备接口仅允许访问当前 deviceId 目录" }; + } + if (folder.endsWith("/model_config")) { + return { success: false, errMsg: "设备接口不允许上传模型" }; + } + return dispatch({ ...event, folder }, req); + } + case "listModels": { + const folder = `${deviceId}/model_config`; + return dispatch({ ...event, folder }, req); + } + case "downloadModel": { + if (!event.fileID) return { success: false, errMsg: "缺少 fileID" }; + const database = await store.read(); + const record = database.fileRecords.find((item) => item.fileID === event.fileID); + if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" }; + if (record.folder !== `${deviceId}/model_config`) { + return { success: false, errMsg: "设备接口无权下载该模型" }; + } + return { + success: true, + url: issueDownloadUrl(req, record.fileID), + fileName: record.fileName, + originalFileName: record.originalFileName || record.fileName + }; + } + default: + return { success: false, errMsg: "无效的 type 字段" }; + } + } + app.get("/health", (req, res) => res.json({ success: true, service: "reinloop-server" })); app.post("/upload/:token", upload.single("file"), async (req, res, next) => { @@ -1085,12 +1204,13 @@ function createApp({ } }); - app.get("/files/:fileID", async (req, res, next) => { + app.get("/downloads/:ticket", async (req, res, next) => { try { - const fileID = decodeURIComponent(req.params.fileID); - if (!fileID.startsWith("model://") && !hasValidDownloadToken(req, fileID)) { - return res.status(403).json({ success: false, errMsg: "下载凭证无效或已过期" }); - } + const ticket = String(req.params.ticket || "").trim(); + if (!ticket) return res.status(403).json({ success: false, errMsg: "下载链接无效或已失效" }); + const consumed = consumeDownloadTicket(req, ticket); + if (!consumed.ok) return res.status(403).json({ success: false, errMsg: consumed.errMsg }); + const fileID = consumed.fileID; const database = await store.read(); const record = database.fileRecords.find((item) => item.fileID === fileID); const filePath = record && store.resolveStoredFile(fileID); @@ -1103,6 +1223,10 @@ function createApp({ } }); + app.get("/files/:fileID", (req, res) => { + res.status(403).json({ success: false, errMsg: "旧下载链接已停用,请先通过 API 获取临时下载链接" }); + }); + const apiHandler = async (req, res) => { try { res.json(await dispatch(normalizeEventCompat(req.body || {}), req)); @@ -1112,6 +1236,15 @@ function createApp({ }; app.post("/", apiHandler); + const deviceApiHandler = async (req, res) => { + try { + res.json(await dispatchDevice(normalizeEventCompat(req.body || {}), req)); + } catch (error) { + res.status(400).json({ success: false, errMsg: error.message }); + } + }; + app.post("/device", deviceApiHandler); + app.use((error, req, res, next) => { console.error(error); res.status(500).json({ success: false, errMsg: "服务器内部错误" }); diff --git a/server/test/server.test.js b/server/test/server.test.js index 696d98c..16a33fc 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -121,6 +121,7 @@ test("existing two-step client flow uploads, lists, and downloads a file", async }); const downloaded = await fetch(download.url); assert.equal(await downloaded.text(), "time,pressure\n0,10\n"); + assert.equal((await fetch(download.url)).status, 403); }); test("admin lists, downloads, and deletes only the selected device control data", async () => { @@ -159,8 +160,9 @@ test("admin lists, downloads, and deletes only the selected device control data" }); assert.equal(download.success, true); assert.equal(download.size, Buffer.byteLength('{"parts":1}')); - assert.match(download.url, /expires=.*token=/); + assert.match(download.url, /\/downloads\//); assert.equal(await (await fetch(download.url)).text(), '{"parts":1}'); + assert.equal((await fetch(download.url)).status, 403); const forbiddenDelete = await post({ type: "deleteControlFile", fileID: "model://control-co/line-1/controller.bin", adminToken: "test-token"