fix: restore reliable authenticated live recognition
This commit is contained in:
parent
0e23425ef0
commit
934615b593
40 changed files with 2556 additions and 820 deletions
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue