Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board (waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag. Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays blocked until every line is pulled or short and every pulled line has a heat. Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind, optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events table (migration 0005) shown on the Admin Storage tab. Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""Virus scan through clamd (ClamAV), optional. Off when CLAMAV_HOST is empty.
|
||||
|
||||
The file is streamed to clamd with zINSTREAM: the command, then chunks each led by a 4-byte big-endian length, then a
|
||||
zero length. clamd answers `stream: OK` or `stream: <signature> FOUND`. When a scanner is configured and cannot be
|
||||
reached, the upload is refused (fail closed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..api.errors import ApiError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CHUNK = 256 * 1024
|
||||
REPLY_MAX = 4096
|
||||
MSG_INFECTED = "That file failed the virus scan and was not stored."
|
||||
MSG_UNAVAILABLE = "The virus scanner is not reachable. Try again in a minute."
|
||||
|
||||
|
||||
class ScanError(Exception):
|
||||
"""The scanner could not be reached or answered badly."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanResult:
|
||||
clean: bool
|
||||
signature: str | None
|
||||
|
||||
|
||||
class ClamdScanner:
|
||||
def __init__(self, host: str, port: int = 3310, timeout: float = 30.0):
|
||||
self.host = host
|
||||
self.port = int(port)
|
||||
self.timeout = float(timeout)
|
||||
|
||||
def _connect(self, timeout: float | None = None) -> socket.socket:
|
||||
try:
|
||||
return socket.create_connection((self.host, self.port), timeout=timeout or self.timeout)
|
||||
except OSError as e:
|
||||
raise ScanError(f"can't reach clamd at {self.host}:{self.port}: {type(e).__name__}") from None
|
||||
|
||||
@staticmethod
|
||||
def _reply(sock: socket.socket) -> str:
|
||||
"""Read one NUL-terminated reply (or up to the close)."""
|
||||
buf = b""
|
||||
while b"\0" not in buf and len(buf) < REPLY_MAX:
|
||||
part = sock.recv(1024)
|
||||
if not part:
|
||||
break
|
||||
buf += part
|
||||
return buf.split(b"\0", 1)[0].decode("utf-8", "replace").strip()
|
||||
|
||||
def ping(self, timeout: float | None = None) -> bool:
|
||||
"""True when clamd answers PING with PONG."""
|
||||
try:
|
||||
with self._connect(timeout) as sock:
|
||||
sock.sendall(b"zPING\0")
|
||||
return self._reply(sock) == "PONG"
|
||||
except (ScanError, OSError) as e:
|
||||
log.info("clamd ping failed: %s", type(e).__name__)
|
||||
return False
|
||||
|
||||
def scan_path(self, path: Path) -> ScanResult:
|
||||
"""Stream the file to clamd. Raises ScanError when clamd is down or its answer is not OK or FOUND."""
|
||||
try:
|
||||
with self._connect() as sock, Path(path).open("rb") as f:
|
||||
sock.sendall(b"zINSTREAM\0")
|
||||
while True:
|
||||
chunk = f.read(CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
sock.sendall(struct.pack(">I", len(chunk)) + chunk)
|
||||
sock.sendall(struct.pack(">I", 0))
|
||||
reply = self._reply(sock)
|
||||
except ScanError:
|
||||
raise
|
||||
except OSError as e:
|
||||
raise ScanError(f"clamd scan failed: {type(e).__name__}") from None
|
||||
return parse_reply(reply)
|
||||
|
||||
|
||||
def parse_reply(reply: str) -> ScanResult:
|
||||
"""`stream: OK` is clean; `stream: <sig> FOUND` is a hit; anything else is a ScanError."""
|
||||
text = reply.strip()
|
||||
if text.startswith("stream:"):
|
||||
text = text[len("stream:") :].strip()
|
||||
if text == "OK":
|
||||
return ScanResult(clean=True, signature=None)
|
||||
if text.endswith(" FOUND"):
|
||||
sig = text[: -len(" FOUND")].strip()[:200]
|
||||
return ScanResult(clean=False, signature=sig or "unknown")
|
||||
raise ScanError(f"clamd answered: {text[:200]}")
|
||||
|
||||
|
||||
def scanner_for(settings: Any) -> ClamdScanner | None:
|
||||
"""The configured scanner, or None when CLAMAV_HOST is empty."""
|
||||
host = str(getattr(settings, "clamav_host", "") or "").strip()
|
||||
if not host:
|
||||
return None
|
||||
return ClamdScanner(
|
||||
host,
|
||||
int(getattr(settings, "clamav_port", 3310)),
|
||||
float(getattr(settings, "clamav_timeout_seconds", 30.0)),
|
||||
)
|
||||
|
||||
|
||||
def check(settings: Any, path: Path) -> ScanResult | None:
|
||||
"""Scan a complete file. None when no scanner is configured.
|
||||
|
||||
Raises 422 infected (with the signature) on a hit and 503 scan_unavailable when the scanner fails.
|
||||
"""
|
||||
scanner = scanner_for(settings)
|
||||
if scanner is None:
|
||||
return None
|
||||
try:
|
||||
result = scanner.scan_path(path)
|
||||
except ScanError as e:
|
||||
log.warning("virus scan unavailable: %s", e)
|
||||
raise ApiError(503, "scan_unavailable", MSG_UNAVAILABLE) from None
|
||||
if not result.clean:
|
||||
log.warning("upload refused by the virus scan: %s", result.signature)
|
||||
raise ApiError(422, "infected", MSG_INFECTED, extra={"signature": result.signature})
|
||||
return result
|
||||
Reference in New Issue
Block a user