fix: restore reliable authenticated live recognition

This commit is contained in:
kiyo9w 2026-09-28 12:38:15 +09:00
parent 0e23425ef0
commit 934615b593
40 changed files with 2556 additions and 820 deletions

View file

@ -20,7 +20,7 @@ from __future__ import annotations
import argparse
import asyncio
import base64
import hmac
from contextlib import asynccontextmanager
import json
import logging
import os
@ -74,6 +74,17 @@ from engine import ( # noqa: E402
)
from record import FrameRecorder # noqa: E402
from live import LiveSession, LiveSettings # noqa: E402
from transport import ( # noqa: E402
BodyLimit,
ServiceAdvertiser,
advertise_host,
bearer_matches,
declared_length,
is_loopback_host,
read_bounded,
require_token_for_bind,
token_matches,
)
LOGGER = logging.getLogger("dorayaki")
@ -94,7 +105,8 @@ class ServerSettings:
host: str = "127.0.0.1"
port: int = 8080
token: Optional[str] = None
cors_origins: str = "*"
# Empty means same-origin only. "*" reflects every caller and is not a default.
cors_origins: str = ""
max_sessions: int = DEFAULT_MAX_SESSIONS
live_method: str = "hybrid"
live_conf: float = 0.1
@ -124,6 +136,26 @@ def server_settings_from_env() -> ServerSettings:
)
def load_local_token(path: str = "config/local.env") -> None:
"""Fill DORAYAKI_API_TOKEN from an ignored env file. Never prints the value.
ponytail: one KEY=VALUE file, no quoting. Upgrade path is a real env loader
once a second secret needs the same file.
"""
if os.environ.get("DORAYAKI_API_TOKEN"):
return
try:
with open(path, "r", encoding="utf-8") as handle:
lines = handle.readlines()
except OSError:
return
for line in lines:
key, sep, value = line.strip().partition("=")
if key == "DORAYAKI_API_TOKEN" and sep and value:
os.environ["DORAYAKI_API_TOKEN"] = value
return
# ---------------------------------------------------------------------------
# request models
# ---------------------------------------------------------------------------
@ -234,18 +266,29 @@ def create_app(
scans: Any = None,
) -> FastAPI:
settings = settings or server_settings_from_env()
@asynccontextmanager
async def lifespan(_app: FastAPI):
yield
for session in list(sessions.values()):
session.close()
engine.shutdown()
app = FastAPI(
title="Dorayaki Recognition Server",
version="1.0",
description="Shelf product detection, tracking and classification for capture clients.",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.cors_origins.split(",") if o.strip()] or ["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# Same-origin is the default. A configured list is echoed back; "*" is not.
cors = [o.strip() for o in settings.cors_origins.split(",") if o.strip() and o.strip() != "*"]
if cors:
app.add_middleware(
CORSMiddleware,
allow_origins=cors,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
scans = scans if scans is not None else build_legacy_scans(engine)
# A live session can be recorded so a real scan becomes data to label later.
@ -260,8 +303,7 @@ def create_app(
async def authorize(authorization: Optional[str] = Header(default=None)) -> None:
if not settings.token:
return
expected = f"Bearer {settings.token}"
if not authorization or not hmac.compare_digest(authorization, expected):
if not bearer_matches(authorization, settings.token):
raise _error("UNAUTHORIZED", "missing or invalid bearer token", 401)
# ---------------------------------------------------------------- health
@ -316,19 +358,22 @@ def create_app(
raise _error("NOT_FOUND", f"unknown sku '{sku_id}'", 404)
return {"status": "success", "product": product}
# ---------------------------------------------------------------- samples
@app.get("/v1/samples", dependencies=[Depends(authorize)])
async def list_samples() -> Dict[str, Any]:
def _sample_listing() -> List[Dict[str, Any]]:
listing = []
import cv2
for item in samples.values():
entry = {"id": item["id"], "label": item["label"], "url": f"/v1/samples/{item['id']}"}
import cv2
image = cv2.imread(item["path"])
if image is not None:
entry["width"], entry["height"] = int(image.shape[1]), int(image.shape[0])
listing.append(entry)
return {"samples": listing}
return listing
@app.get("/v1/samples", dependencies=[Depends(authorize)])
async def list_samples() -> Dict[str, Any]:
loop = asyncio.get_running_loop()
return {"samples": await loop.run_in_executor(None, _sample_listing)}
@app.get("/v1/samples/{sample_id}", dependencies=[Depends(authorize)])
async def get_sample(sample_id: str) -> Response:
@ -350,37 +395,44 @@ def create_app(
if method is not None:
_require_method(method)
content_type = (request.headers.get("content-type") or "").split(";")[0].strip().lower()
announced = declared_length(request.headers.get("content-length"))
if announced is not None and announced > MAX_UPLOAD_BYTES:
raise _error("PAYLOAD_TOO_LARGE", f"image larger than {MAX_UPLOAD_BYTES} bytes", 413)
if content_type == "multipart/form-data":
form = await request.form()
form = await request.form(max_part_size=MAX_UPLOAD_BYTES)
upload = form.get("file")
if upload is None or not hasattr(upload, "read"):
raise _error("BAD_REQUEST", "multipart body needs a 'file' field", 400)
payload = await upload.read()
payload = await read_bounded(upload, MAX_UPLOAD_BYTES)
else:
if content_type and not content_type.startswith("image/") and content_type != "application/octet-stream":
raise _error("UNSUPPORTED_MEDIA_TYPE", f"unsupported content type '{content_type}'", 415)
payload = await request.body()
payload = await read_bounded(request.stream(), MAX_UPLOAD_BYTES)
if not payload:
raise _error("BAD_REQUEST", "empty request body", 400)
if len(payload) > MAX_UPLOAD_BYTES:
raise _error("PAYLOAD_TOO_LARGE", f"image larger than {MAX_UPLOAD_BYTES} bytes", 413)
loop = asyncio.get_running_loop()
try:
image = engine.decode_image(payload)
image = await loop.run_in_executor(None, engine.decode_image, payload)
except UndecodableImage as exc:
raise _error("UNDECODABLE_IMAGE", str(exc), 422)
try:
result = engine.recognize(
image,
classify=classify,
method=method,
conf=conf,
with_empty_slots=empty_slots,
result = await loop.run_in_executor(
None,
lambda: engine.recognize(
image,
classify=classify,
method=method,
conf=conf,
with_empty_slots=empty_slots,
),
)
except UnknownMethod as exc:
raise _error("BAD_REQUEST", str(exc), 400)
if tags:
result = _attach_still_tags(result, image)
result = await loop.run_in_executor(None, _attach_still_tags, result, image)
return result
def _is_price_card(crop: Any) -> bool:
@ -514,7 +566,10 @@ def create_app(
wanted = set(int(i) for i in body.detection_ids)
detections = [d for d in detections if int(d["id"]) in wanted]
started = time.perf_counter()
rows = engine.classify_boxes(stored.image, detections, method=method)
loop = asyncio.get_running_loop()
rows = await loop.run_in_executor(
None, lambda: engine.classify_boxes(stored.image, detections, method=method)
)
rows_by_id = {row["detection_id"]: row for row in rows}
for detection in stored.detections:
row = rows_by_id.get(int(detection["id"]))
@ -567,21 +622,24 @@ def create_app(
stored = engine.image_store.get(image_id)
except ImageExpired:
raise _error("IMAGE_EXPIRED", f"image '{image_id}' is no longer available", 404)
return engine.inspect(stored.image, body.box, method=method)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: engine.inspect(stored.image, body.box, method=method)
)
# ---------------------------------------------------------------- live stream
@app.websocket("/v1/stream")
async def stream(websocket: WebSocket) -> None:
if settings.token:
supplied = websocket.query_params.get("token") or ""
header = websocket.headers.get("authorization") or ""
expected_header = f"Bearer {settings.token}"
if not (
hmac.compare_digest(supplied, settings.token)
or hmac.compare_digest(header, expected_header)
):
await websocket.close(code=1008)
return
# Query tokens are rejected. A wrong header is rejected before hello.
# A missing header is the browser case: hello goes out, then the first
# client text must be {"type":"auth","token":"..."} or the socket closes.
if websocket.query_params.get("token"):
await websocket.close(code=1008)
return
header = websocket.headers.get("authorization")
if settings.token and header and not bearer_matches(header, settings.token):
await websocket.close(code=1008)
return
async with sessions_lock:
if len(sessions) >= settings.max_sessions:
await websocket.close(code=1013)
@ -644,9 +702,10 @@ def create_app(
"message": "frame processing failed"})
continue
if recorder is not None:
# Record what the client sent and what we saw in it, so a real
# scan can be labelled later instead of only measured on photos.
recorder.write(session_id, frame_id, payload, result)
# Disk writes must not sit on the event loop between results.
await loop.run_in_executor(
None, recorder.write, session_id, frame_id, payload, result
)
await send(result)
async def handle_inspect(message: Dict[str, Any]) -> None:
@ -682,8 +741,23 @@ def create_app(
processing = asyncio.create_task(processing_loop())
async def handle_control(message: Dict[str, Any]) -> None:
authed = not settings.token or bearer_matches(
websocket.headers.get("authorization"), settings.token
)
async def handle_control(message: Dict[str, Any]) -> bool:
"""Apply one control message. False closes the socket (auth failed)."""
nonlocal authed
kind = message.get("type")
if not authed:
presented = message.get("token") if kind == "auth" else None
if isinstance(presented, str) and token_matches(presented, settings.token):
authed = True
return True
await websocket.close(code=1008)
return False
if kind == "auth":
return True
if kind == "config":
try:
config = session.configure(
@ -694,7 +768,7 @@ def create_app(
)
except (TypeError, ValueError):
await send({"type": "error", "code": "BAD_MESSAGE", "message": "bad config values"})
return
return True
await send({"type": "config_ack", "config": config})
elif kind == "reset":
session.reset()
@ -705,6 +779,7 @@ def create_app(
await handle_inspect(message)
else:
await send({"type": "error", "code": "BAD_MESSAGE", "message": f"unknown type '{kind}'"})
return True
try:
while True:
@ -717,6 +792,9 @@ def create_app(
text = message.get("text")
data = message.get("bytes")
if data is not None:
if not authed:
await websocket.close(code=1008)
break
if len(data) < 5:
await send({"type": "error", "code": "BAD_FRAME", "message": "frame shorter than 5 bytes"})
continue
@ -733,7 +811,8 @@ def create_app(
except (json.JSONDecodeError, ValueError):
await send({"type": "error", "code": "BAD_MESSAGE", "message": "invalid JSON"})
continue
await handle_control(parsed)
if not await handle_control(parsed):
break
except WebSocketDisconnect:
pass
finally:
@ -747,7 +826,7 @@ def create_app(
sessions.pop(session_id, None)
# ---------------------------------------------------------------- legacy session routes
@app.post("/api/sessions")
@app.post("/api/sessions", dependencies=[Depends(authorize)])
async def legacy_start_session(payload: Dict[str, Any] = None) -> Dict[str, Any]:
payload = payload or {}
session = scans.start_session(
@ -757,7 +836,7 @@ def create_app(
)
return {"status": "success", "session": session.snapshot()}
@app.post("/api/sessions/{session_id}/frames")
@app.post("/api/sessions/{session_id}/frames", dependencies=[Depends(authorize)])
async def legacy_observe_frame(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import FrameRequest, ObserveError
@ -765,11 +844,19 @@ def create_app(
try:
session = scans.get_session(session_id)
raw = payload.get("frame_bytes_b64") or payload.get("image_base64") or ""
if not isinstance(raw, str):
raw = ""
if "," in raw:
raw = raw.split(",", 1)[1]
# Base64 expands ~4/3. Reject the text before decoding it.
if len(raw) > (MAX_FRAME_BYTES * 4) // 3 + 8:
raise _error("PAYLOAD_TOO_LARGE", f"frame larger than {MAX_FRAME_BYTES} bytes", 413)
frame_bytes = base64.b64decode(raw) if raw else b""
if len(frame_bytes) > MAX_FRAME_BYTES:
raise _error("PAYLOAD_TOO_LARGE", f"frame larger than {MAX_FRAME_BYTES} bytes", 413)
request = FrameRequest(
frame_id=str(payload.get("frame_id") or ""),
frame_bytes=base64.b64decode(raw) if raw else b"",
frame_bytes=frame_bytes,
sequence=int(payload.get("sequence", 0)),
captured_at=float(payload.get("captured_at") or 0),
session_id=session.session_id,
@ -781,12 +868,13 @@ def create_app(
)
if not request.frame_id:
raise _error("BAD_REQUEST", "frame_id required", 400)
observation = scans.observe_frame(request)
loop = asyncio.get_running_loop()
observation = await loop.run_in_executor(None, scans.observe_frame, request)
return {"status": "success", "observation": observation.as_dict(), "session": session.snapshot()}
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.get("/api/sessions/{session_id}")
@app.get("/api/sessions/{session_id}", dependencies=[Depends(authorize)])
async def legacy_get_session(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
@ -795,7 +883,7 @@ def create_app(
except ObserveError as exc:
raise _error("NOT_FOUND", str(exc), 404)
@app.post("/api/sessions/{session_id}/review")
@app.post("/api/sessions/{session_id}/review", dependencies=[Depends(authorize)])
async def legacy_review(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import ObserveError
@ -812,7 +900,7 @@ def create_app(
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/sessions/{session_id}/finalize")
@app.post("/api/sessions/{session_id}/finalize", dependencies=[Depends(authorize)])
async def legacy_finalize(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
@ -821,7 +909,7 @@ def create_app(
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/sessions/{session_id}/cancel")
@app.post("/api/sessions/{session_id}/cancel", dependencies=[Depends(authorize)])
async def legacy_cancel(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
@ -830,7 +918,7 @@ def create_app(
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/sessions/{session_id}/rescan")
@app.post("/api/sessions/{session_id}/rescan", dependencies=[Depends(authorize)])
async def legacy_rescan(session_id: str) -> Dict[str, Any]:
from observe import ObserveError
@ -839,7 +927,7 @@ def create_app(
except ObserveError as exc:
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/compare_live_vs_still")
@app.post("/api/compare_live_vs_still", dependencies=[Depends(authorize)])
async def legacy_compare(payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import ObserveError
@ -881,14 +969,11 @@ def create_app(
# ---------------------------------------------------------------- static web client
resolved_web_dir = web_dir or os.path.join(current_dir, "web")
app.add_middleware(BodyLimit, max_bytes=MAX_UPLOAD_BYTES)
if os.path.isdir(resolved_web_dir):
app.mount("/", StaticFiles(directory=resolved_web_dir, html=True), name="web")
@app.on_event("shutdown")
async def _shutdown() -> None:
for session in list(sessions.values()):
session.close()
engine.shutdown()
return app
@ -960,6 +1045,11 @@ def main() -> None:
if args.log_level is not None:
server.log_level = args.log_level
load_local_token()
if not server.token:
server.token = os.environ.get("DORAYAKI_API_TOKEN") or None
require_token_for_bind(server.host, server.token)
engine_settings = settings_from_env()
if args.record_frames is not None:
server.record_frames = args.record_frames
@ -996,23 +1086,34 @@ def main() -> None:
scheme = "https" if server.ssl_certfile else "http"
LOGGER.info("Dorayaki recognition server on %s://%s:%d (token %s)",
scheme, server.host, server.port, "required" if server.token else "off")
if server.host in ("127.0.0.1", "localhost"):
if is_loopback_host(server.host):
LOGGER.info("Open %s://127.0.0.1:%d in a browser for the web client", scheme, server.port)
else:
LOGGER.info("LAN mode: live camera needs https (secure context); use --ssl-certfile or a tunnel")
import uvicorn
uvicorn.run(
app,
host=server.host,
port=server.port,
log_level=(server.log_level or "info").lower(),
access_log=False, # access log would print ?token=... query strings
ws="websockets-sansio",
ssl_certfile=server.ssl_certfile,
ssl_keyfile=server.ssl_keyfile,
)
advertised = advertise_host(server.host)
advertiser = ServiceAdvertiser(advertised, server.port) if advertised else None
if advertiser is not None and advertiser.start():
LOGGER.info("Advertising _dorayaki._tcp on %s:%d", advertised, server.port)
elif advertised:
LOGGER.info("No dns-sd on this host; _dorayaki._tcp was not advertised")
try:
uvicorn.run(
app,
host=server.host,
port=server.port,
log_level=(server.log_level or "info").lower(),
access_log=False, # request lines can carry Authorization; keep them off
ws="websockets-sansio",
ssl_certfile=server.ssl_certfile,
ssl_keyfile=server.ssl_keyfile,
)
finally:
if advertiser is not None:
advertiser.close()
if __name__ == "__main__":