feat: add seat firmware workbench and OTA delivery

This commit is contained in:
vee
2026-09-16 17:55:30 +08:00
parent 3eeca2ea75
commit ab8e43050c
70 changed files with 22594 additions and 316 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
"""Conservative, local defaults for adding named devices to an existing mode.
Only a complete *bare device list* uses these defaults. Explicit parameters and
actions stay in the normal parser; unrecognised list entries never disappear.
Defaults are proposals for a fully validated preview, not executable commands.
"""
from __future__ import annotations
import re
from copy import deepcopy
from typing import Any
_ADD_PREFIX = re.compile(
r"^(?:(?:请|帮我|帮忙|再|还|也|同时|顺便|继续|并且|并)\s*)*"
r"(?:加入|添加|增加|补充|加上|追加|新增)\s*"
)
_DEVICE_GROUPS = {
"空调": "climate", "冷气": "climate",
"氛围灯": "ambient_light", "气氛灯": "ambient_light",
"阅读灯": "reading", "读书灯": "reading",
"车窗": "window", "窗户": "window",
"遮阳帘": "roof", "天幕遮阳帘": "roof", "天窗遮阳帘": "roof",
"室内灯": "indoor", "顶灯": "indoor",
}
_LIST_SEPARATOR = re.compile(r"[、,,;;\s]+|以及|还有|和|与|及")
_EXPLICIT_ACTION = re.compile(
r"\d|[零一二三四五六七八九十百]+(?:度|档|挡|秒|分钟|%)|"
r"打开|开启|关闭|关掉|开到|关到|调整|设置|调到|调成|变成|改成|"
r"升高|降低|调高|调低|调亮|调暗|全开|全关|半开|一半|"
r"红色|蓝色|绿色|白色|暖色|冷色|亮度|温度|风量|内循环|外循环"
)
def strip_mode_addition_prefix(change_text: str) -> str:
"""Remove only the editing verb, not any device, value or negation."""
return _ADD_PREFIX.sub("", change_text.strip(), count=1)
def mode_device_additions(
change_text: str,
base_operations: list[dict[str, Any]],
default_operations: list[dict[str, Any]],
) -> list[dict[str, Any]] | None:
"""Return missing device defaults, None for non-list/explicit edits.
Existing groups are already configured and are left completely untouched,
including their target zones, power state and custom parameter values.
Reading and indoor lights are separate groups within the same domain.
"""
if not _ADD_PREFIX.match(change_text.strip()):
return None
body = strip_mode_addition_prefix(change_text).strip("。.!!吧 ")
if _EXPLICIT_ACTION.search(body):
return None
body = re.sub(r"(?:等)?(?:相关)?(?:设备)?(?:的)?(?:控制)?(?:动作|能力|联动)?$", "", body)
body = re.sub(r"等$", "", body)
devices = [item for item in _LIST_SEPARATOR.split(body) if item]
groups: list[str] = []
for device in devices:
device = re.sub(r"(?:设备)?(?:的)?(?:控制)?(?:动作|能力)$", "", device)
group = _DEVICE_GROUPS.get(device)
if group is None:
raise ValueError(f"未识别或暂不支持增补设备“{device}”;未修改任何模式动作。")
if group not in groups:
groups.append(group)
if not groups:
raise ValueError("请说明要加入哪些设备,或提供明确的设备动作。")
def belongs(operation: dict[str, Any], group: str) -> bool:
capability = str(operation.get("capability") or "")
if group == "reading":
return capability == "interior_light.set_reading_power"
if group == "indoor":
return capability == "interior_light.set_indoor_power"
return operation.get("domain") == group
additions: list[dict[str, Any]] = []
for group in groups:
if any(belongs(operation, group) for operation in base_operations):
continue
candidates = [operation for operation in default_operations if belongs(operation, group)]
if not candidates:
raise ValueError(f"该设备尚无经过登记的默认编排,请补充明确目标:{group}。")
additions.extend(deepcopy(candidates))
return additions
@@ -170,11 +170,12 @@ _MODE_PROFILE_DELETE_BA = re.compile(
)
_MODE_PROFILE_UPDATE_NEGATION = re.compile(
r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|"
r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存"
r"取消(?:修改|调整|更改|更新|变更|加入|添加|增加|补充|加上|追加|新增|操作)|"
r"撤销|算了|不执行|不下发|不保存"
)
_MODE_PROFILE_UPDATE_PREFIX_NEGATION = re.compile(
r"^(?:请|麻烦)?(?:先|暂时)?(?:不要|别|不用|无需|不必|禁止|不许|不得)"
r"(?:把|将|修改|调整|更改|更新|变更)"
r"(?:把|将|在|给|为|往|向|修改|调整|更改|更新|变更)"
)
_MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)"
_MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])"
@@ -197,7 +198,19 @@ _CONTEXTUAL_NAME_FIRST_UPDATE = re.compile(
rf"^{_LEADING_POLITE}{_QUOTED_MODE_NAME}[,,::]"
r"(?P<change>.+?)[。.!!]?$"
)
_MODE_PROFILE_APPEND_VERB = r"(?:加入|添加|增加|补充|加上|追加|新增)"
_APPEND_MODE_UPDATE = re.compile(
rf"^{_LEADING_POLITE}(?:在|给|为|往|向)?{_QUOTED_MODE_NAME}"
rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?[,,::]?"
rf"(?:再|还|也|同时|另外)?(?P<change>{_MODE_PROFILE_APPEND_VERB}.+?)[。.!!]?$"
)
_APPEND_TO_MODE_UPDATE = re.compile(
rf"^{_LEADING_POLITE}(?:把|将)(?P<change>.+?)"
rf"(?P<append_verb>{_MODE_PROFILE_APPEND_VERB})(?:到|进)?{_QUOTED_MODE_NAME}"
rf"(?:{_MODE_POSSESSIVE_BOUNDARY})?(?:吧|呀|啊|哦|谢谢(?:你)?)?[。.!!]?$"
)
MODE_PROFILE_CHANGE_PREDICATE = re.compile(
rf"{_MODE_PROFILE_APPEND_VERB}|"
r"修改|改成|改为|更改|更新|变更|变成|变为|换成|"
r"调整|调节|调高|调低|调到|调成|调为|调至|"
r"设置|设为|设成|设到|设至|"
@@ -208,7 +221,7 @@ MODE_PROFILE_CHANGE_PREDICATE = re.compile(
r"(?:重复|循环)(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)次"
)
MODE_PROFILE_QUERY_CUE = re.compile(
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|"
r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|能否|能不能|可不可以|会不会|"
r"合适(?:吗)?|合理(?:吗)?|会(?:怎么样|怎样|如何)|"
r"怎么|如何|列(?:出|一下)|介绍(?:一下)?|说明(?:一下)?|"
r"告诉我|查询|查看|有什么作用|是何作用|还是|[??]|(?:吗|么|呢)$"
@@ -610,6 +623,8 @@ def _candidate_matches(
) -> list[tuple[re.Match[str], bool]]:
matches: list[tuple[re.Match[str], bool]] = []
for pattern, predicate_required in (
(_APPEND_MODE_UPDATE, True),
(_APPEND_TO_MODE_UPDATE, False),
(_PREFIX_UPDATE, False),
(_BA_UPDATE, True),
(_NAME_FIRST_UPDATE, True),
@@ -629,6 +644,14 @@ def _candidate_matches(
return matches
def _mode_profile_update_change(match: re.Match[str], name: str) -> str:
change = _strip_repeated_mode_update_subject(str(match.group("change") or ""), name)
append_verb = str(match.groupdict().get("append_verb") or "")
# Keep reverse-order additions on the same service contract as name-first
# additions. Device names, explicit targets and parameters remain unchanged.
return f"{append_verb}{change}" if append_verb else change
def _strip_repeated_mode_update_subject(change: str, mode_name: str) -> str:
"""Remove a repeated ``把<same mode>的`` wrapper from an update body."""
@@ -708,10 +731,7 @@ def is_mode_profile_update_shape(
and _is_unquoted_device_property_mode(match, name)
):
continue
change = _strip_repeated_mode_update_subject(
str(match.group("change") or ""),
name,
)
change = _mode_profile_update_change(match, name)
if change and (
not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change)
):
@@ -746,10 +766,9 @@ def parse_mode_profile_update(
and _is_unquoted_device_property_mode(match, name)
):
continue
change = _strip_repeated_mode_update_subject(
str(match.group("change") or ""),
name,
)
change = _mode_profile_update_change(match, name)
if re.fullmatch(rf"(?:再|还|也|同时|另外)?{_MODE_PROFILE_APPEND_VERB}(?:一下)?", change):
continue
if (
(not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change))
and not _MODE_PROFILE_UPDATE_NEGATION.search(change)
+308
View File
@@ -0,0 +1,308 @@
"""Read-only HTTP surface for vehicle firmware discovery and download."""
from __future__ import annotations
import argparse
import json
import mimetypes
import os
import re
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
from cognitive_agent.config_loader import load_local_env
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
load_local_env(REPOSITORY_ROOT / ".env", override=True)
RELEASE_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]{7,80}")
DOWNLOAD_PATH_PATTERN = re.compile(
r"/api/firmware-workbench/releases/(?P<release_id>[a-z0-9-]+)/download"
)
LATEST_PATHS = frozenset(
{
"/latest",
"/api/firmware-workbench/releases/latest",
}
)
RELEASES_PATHS = frozenset(
{
"/releases",
"/api/firmware-workbench/releases",
}
)
class FirmwareOtaRepository:
"""Resolve atomically published OTA manifests without loading workbench state."""
def __init__(self, root: Path, public_base_url: str) -> None:
self.root = Path(root).resolve()
self.public_base_url = str(public_base_url).rstrip("/")
def latest(self) -> dict[str, object]:
manifest_path = self.root / "latest.json"
manifest = self._read_manifest(manifest_path)
return self._public_manifest(manifest)
def releases(self) -> dict[str, object]:
releases_root = self.root / "releases"
manifests: list[dict[str, object]] = []
if releases_root.is_dir():
for release_root in releases_root.iterdir():
if not release_root.is_dir():
continue
try:
manifest = self._read_manifest(release_root / "release.json")
release_id = self._release_id(manifest)
if release_id != release_root.name:
continue
package, _, _ = self.release_package(release_id)
expected_size = manifest.get("size_bytes")
if (
expected_size is not None
and int(expected_size) != package.stat().st_size
):
continue
if str(manifest.get("status") or "published") != "published":
continue
manifests.append(self._public_manifest(manifest))
except (FileNotFoundError, OSError, TypeError, ValueError):
continue
manifests.sort(
key=lambda item: str(item.get("published_at") or ""), reverse=True
)
latest_release_id = (
str(manifests[0]["release_id"]) if manifests else None
)
return {
"schema": "n7-ota-release-list/v1",
"count": len(manifests),
"latest_release_id": latest_release_id,
"releases": manifests,
}
def release_package(self, release_id: str) -> tuple[Path, str, str]:
checked = str(release_id).strip()
if not RELEASE_ID_PATTERN.fullmatch(checked):
raise FileNotFoundError("release not found")
release_root = (self.root / "releases" / checked).resolve()
try:
release_root.relative_to((self.root / "releases").resolve())
except ValueError as exc:
raise FileNotFoundError("release not found") from exc
manifest = self._read_manifest(release_root / "release.json")
if self._release_id(manifest) != checked:
raise FileNotFoundError("release not found")
filename = str(manifest.get("firmware_filename") or "")
if not filename or Path(filename).name != filename:
raise FileNotFoundError("firmware package not found")
package = (release_root / filename).resolve()
try:
package.relative_to(release_root)
except ValueError as exc:
raise FileNotFoundError("firmware package not found") from exc
if not package.is_file():
raise FileNotFoundError("firmware package not found")
expected_hash = str(manifest.get("sha256") or "").lower()
return package, filename, expected_hash
def _public_manifest(self, manifest: dict[str, object]) -> dict[str, object]:
payload = dict(manifest)
release_id = self._release_id(payload)
payload["schema"] = "n7-ota-release/v1"
payload["release_id"] = release_id
payload["download_url"] = f"{self.public_base_url}/{release_id}/download"
payload.pop("package_path", None)
return payload
@staticmethod
def _read_manifest(path: Path) -> dict[str, object]:
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError) as exc:
raise FileNotFoundError("manifest not found") from exc
if not isinstance(document, dict):
raise FileNotFoundError("manifest not found")
return document
@staticmethod
def _release_id(manifest: dict[str, object]) -> str:
release_id = str(manifest.get("release_id") or manifest.get("id") or "")
if not RELEASE_ID_PATTERN.fullmatch(release_id):
raise FileNotFoundError("release not found")
return release_id
class FirmwareOtaHandler(BaseHTTPRequestHandler):
"""Expose health, release manifests, and immutable firmware packages."""
repository: FirmwareOtaRepository
server_version = "N7FirmwareOta/1.0"
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
self._handle_request(send_body=True)
def do_HEAD(self) -> None: # noqa: N802 - stdlib handler API
self._handle_request(send_body=False)
def _handle_request(self, *, send_body: bool) -> None:
path = unquote(urlparse(self.path).path)
if path == "/healthz":
self._json({"status": "ok", "service": "n7-firmware-ota"}, send_body)
return
if path in LATEST_PATHS:
try:
self._json(self.repository.latest(), send_body)
except FileNotFoundError:
self._json(
{"error": "not_found", "message": "暂无可供车机升级的固件。"},
send_body,
HTTPStatus.NOT_FOUND,
)
return
if path in RELEASES_PATHS:
self._json(self.repository.releases(), send_body)
return
match = DOWNLOAD_PATH_PATTERN.fullmatch(path)
if match:
try:
package, filename, expected_hash = self.repository.release_package(
match.group("release_id")
)
self._file(package, filename, expected_hash, send_body)
except FileNotFoundError:
self._json(
{"error": "not_found", "message": "OTA 固件包不存在。"},
send_body,
HTTPStatus.NOT_FOUND,
)
return
self._json(
{"error": "not_found", "message": "接口不存在。"},
send_body,
HTTPStatus.NOT_FOUND,
)
def _file(
self,
path: Path,
filename: str,
expected_hash: str,
send_body: bool,
) -> None:
size = path.stat().st_size
try:
start, end, partial = self._byte_range(size)
except ValueError:
self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
self.send_header("Content-Range", f"bytes */{size}")
self.send_header("Content-Length", "0")
self._security_headers()
self.end_headers()
return
content_length = end - start + 1
self.send_response(HTTPStatus.PARTIAL_CONTENT if partial else HTTPStatus.OK)
self.send_header(
"Content-Type", mimetypes.guess_type(filename)[0] or "application/octet-stream"
)
self.send_header("Content-Length", str(content_length))
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
if expected_hash:
self.send_header("X-Firmware-SHA256", expected_hash)
if partial:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self._security_headers()
self.end_headers()
if not send_body:
return
with path.open("rb") as source:
source.seek(start)
remaining = content_length
while remaining:
chunk = source.read(min(64 * 1024, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def _byte_range(self, size: int) -> tuple[int, int, bool]:
value = str(self.headers.get("Range") or "").strip()
if not value:
return 0, size - 1, False
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value)
if not match or not any(match.groups()):
raise ValueError("invalid byte range")
first, last = match.groups()
if first:
start = int(first)
end = int(last) if last else size - 1
else:
suffix = int(last)
if suffix <= 0:
raise ValueError("invalid byte range")
start = max(0, size - suffix)
end = size - 1
if start >= size or end < start:
raise ValueError("invalid byte range")
return start, min(end, size - 1), True
def _json(
self,
payload: object,
send_body: bool,
status: HTTPStatus = HTTPStatus.OK,
) -> None:
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self._security_headers()
self.end_headers()
if send_body:
self.wfile.write(content)
def _security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
def main() -> None:
parser = argparse.ArgumentParser(description="Run the read-only vehicle OTA service")
parser.add_argument(
"--host", default=os.getenv("FIRMWARE_OTA_BIND_HOST", "127.0.0.1")
)
parser.add_argument(
"--port", type=int, default=int(os.getenv("FIRMWARE_OTA_PORT", "8012"))
)
args = parser.parse_args()
configured_root = os.getenv("FIRMWARE_WORKBENCH_OTA_ROOT", "").strip()
ota_root = (
Path(configured_root)
if configured_root
else REPOSITORY_ROOT / "artifacts" / "server" / "firmware-ota"
)
public_base_url = os.getenv(
"FIRMWARE_WORKBENCH_OTA_PUBLIC_BASE_URL",
f"http://{args.host}:{args.port}/api/firmware-workbench/releases",
)
FirmwareOtaHandler.repository = FirmwareOtaRepository(ota_root, public_base_url)
server = ThreadingHTTPServer((args.host, args.port), FirmwareOtaHandler)
print(f"N7 firmware OTA service: http://{args.host}:{args.port}", flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+167 -3
View File
@@ -43,6 +43,10 @@ from cognitive_agent.application.codex_assistant import ( # noqa: E402
CodexAssistantService,
CodexVehicleToolError,
)
from cognitive_agent.application.firmware_workbench import ( # noqa: E402
FirmwareWorkbenchError,
FirmwareWorkbenchService,
)
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name,
@@ -103,7 +107,11 @@ PUBLIC_AUTH_PATHS = {
"/network.js",
"/api/auth/status",
"/api/auth/login",
"/api/firmware-workbench/releases/latest",
}
PUBLIC_OTA_DOWNLOAD_PATH = re.compile(
r"/api/firmware-workbench/releases/[a-z0-9-]+/download"
)
NON_DISPATCH_DISPLAY_SOURCES = frozenset({"mode_editor_compile"})
@@ -207,6 +215,7 @@ def _lan_ipv4_address() -> str | None:
class SeatDemoHandler(BaseHTTPRequestHandler):
service = SeatDemoService()
firmware_workbench = FirmwareWorkbenchService(REPOSITORY_ROOT)
# Codex defaults to its own empty temporary workspace. A deployment may
# opt into another isolated directory through CODEX_ASSISTANT_WORKSPACE,
# but it must never inherit access to this application's source tree.
@@ -317,6 +326,63 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/assistant/capabilities":
self._json(self._assistant_capability_catalog().snapshot())
return
if path == "/api/firmware-workbench/state":
self._json(self.firmware_workbench.snapshot())
return
if path == "/api/firmware-workbench/releases/latest":
try:
self._json(self.firmware_workbench.latest_release())
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
)
return
firmware_task_match = re.fullmatch(
r"/api/firmware-workbench/tasks/(?P<task_id>[a-z0-9-]+)", path
)
if firmware_task_match:
try:
self._json(
self.firmware_workbench.task(firmware_task_match.group("task_id"))
)
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
)
return
firmware_artifact_download_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)/download",
path,
)
if firmware_artifact_download_match:
self._serve_firmware_download(
"artifact", firmware_artifact_download_match.group("artifact_id")
)
return
firmware_release_download_match = re.fullmatch(
r"/api/firmware-workbench/releases/(?P<release_id>[a-z0-9-]+)/download",
path,
)
if firmware_release_download_match:
self._serve_firmware_download(
"release", firmware_release_download_match.group("release_id")
)
return
firmware_artifact_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)", path
)
if firmware_artifact_match:
try:
self._json(
self.firmware_workbench.artifact(
firmware_artifact_match.group("artifact_id")
)
)
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status)
)
return
if path == "/api/runtime":
self._json(self.service.runtime_info())
return
@@ -510,6 +576,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
if not self._require_json_content_type():
return
if path == "/api/firmware-workbench/tasks" or path.startswith(
"/api/firmware-workbench/"
):
self._handle_firmware_workbench_post(path)
return
if path in {
"/api/hardware/access/request",
"/api/hardware/access/release",
@@ -2529,6 +2600,24 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.BAD_REQUEST,
)
return
firmware_artifact_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)",
path,
)
if firmware_artifact_match is not None:
try:
self._json(
self.firmware_workbench.delete_artifact(
firmware_artifact_match.group("artifact_id")
),
HTTPStatus.OK,
)
except FirmwareWorkbenchError as exc:
self._json(
{"error": exc.code, "message": str(exc)},
HTTPStatus(exc.status),
)
return
prefix = "/api/mode-profiles/"
if not path.startswith(prefix):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -3259,6 +3348,70 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
raise ValueError("request body must be a JSON object")
return payload
def _handle_firmware_workbench_post(self, path: str) -> None:
try:
payload = self._read_json_body(max_bytes=16 * 1024)
if path == "/api/firmware-workbench/tasks":
result = self.firmware_workbench.start_task(
str(payload.get("instruction", "")),
str(payload.get("baseline_id", "time_only")),
)
self._json(result, HTTPStatus.ACCEPTED)
return
task_action_match = re.fullmatch(
r"/api/firmware-workbench/tasks/(?P<task_id>[a-z0-9-]+)/(?P<action>cancel|retry)",
path,
)
if task_action_match:
task_id = task_action_match.group("task_id")
if task_action_match.group("action") == "cancel":
self._json(self.firmware_workbench.cancel_task(task_id))
else:
self._json(
self.firmware_workbench.retry_task(task_id), HTTPStatus.ACCEPTED
)
return
artifact_publish_match = re.fullmatch(
r"/api/firmware-workbench/artifacts/(?P<artifact_id>[a-z0-9-]+)/publish",
path,
)
if artifact_publish_match:
self._json(
self.firmware_workbench.publish_artifact(
artifact_publish_match.group("artifact_id")
)
)
return
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
except (ValueError, json.JSONDecodeError) as exc:
self._json(
{"error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
except FirmwareWorkbenchError as exc:
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status))
def _serve_firmware_download(self, kind: str, item_id: str) -> None:
try:
if kind == "release":
path, filename = self.firmware_workbench.resolve_release_download(item_id)
else:
path, filename = self.firmware_workbench.resolve_artifact_download(item_id)
content = path.read_bytes()
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/zip")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "private, no-store")
self.send_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{quote(filename, safe='.-_')}",
)
self._send_security_headers()
self.end_headers()
self.wfile.write(content)
except FirmwareWorkbenchError as exc:
self._json({"error": exc.code, "message": str(exc)}, HTTPStatus(exc.status))
def _require_json_content_type(self) -> bool:
if is_json_content_type(self.headers.get("Content-Type", "")):
return True
@@ -3505,7 +3658,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
pass
def _authorize(self, path: str) -> bool:
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
if (
not self.auth.config.enabled
or path in PUBLIC_AUTH_PATHS
or PUBLIC_OTA_DOWNLOAD_PATH.fullmatch(path)
):
return True
if self._session_username() is not None:
return True
@@ -3780,8 +3937,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self.end_headers()
def _send_security_headers(self) -> None:
allow_same_origin_frame = (
unquote(urlparse(self.path).path) == "/firmware-workbench.html"
)
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header(
"X-Frame-Options", "SAMEORIGIN" if allow_same_origin_frame else "DENY"
)
self.send_header("Referrer-Policy", "no-referrer")
self.send_header(
"Permissions-Policy",
@@ -3796,7 +3958,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"media-src 'self' blob:; "
"frame-src 'self' http://*:3001; "
"connect-src 'self' blob: http://172.20.10.6:8889; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
"object-src 'none'; base-uri 'none'; frame-ancestors "
+ ("'self'" if allow_same_origin_frame else "'none'"),
)
if self._is_https():
self.send_header(
@@ -3893,6 +4056,7 @@ def main() -> None:
SeatDemoHandler.nfc.close()
SeatDemoHandler.calibration.close()
SeatDemoHandler.repair.close()
SeatDemoHandler.firmware_workbench.close()
if __name__ == "__main__":