diff --git a/mediapreview/__main__.py b/mediapreview/__main__.py index 12d7f3f..8a89c51 100644 --- a/mediapreview/__main__.py +++ b/mediapreview/__main__.py @@ -2,16 +2,16 @@ Usage: mediapreview [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z] - mediapreview oosetup [] [] + mediapreview oosetup [] mediapreview (-h | --help) Generate an AVIF preview for a media file (one-shot, in-process), or set up -the bundled OnlyOffice container. +the bundled OnlyOffice container (isolated network, reachable from the host +at its fixed container IP). Arguments: media file to preview container name [default: onlyoffice-mediapreview] - container host port [default: 8988] Options: -o OUTPUT output .avif file (default: write AVIF bytes to stdout) @@ -43,7 +43,7 @@ def _configure_logging() -> None: logging.getLogger("pyvips").setLevel(logging.WARNING) -def _oosetup(name: str, port: int) -> None: +def _oosetup(name: str) -> None: try: # Lazy import: keeps the base CLI free of office-extra concerns. from mediapreview.office import setup_docker # noqa: PLC0415 @@ -52,7 +52,7 @@ def _oosetup(name: str, port: int) -> None: sys.exit(1) try: # Logs go to stderr; stdout carries only the secret line below. - secret = setup_docker(name=name, port=port) + secret = setup_docker(name=name) except Exception as e: sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n") sys.exit(1) @@ -102,13 +102,8 @@ def main() -> None: # by the pattern if it came second. Dispatch it before parsing; # the main help above still documents both modes. if sys.argv[1:2] == ["oosetup"]: - args = docopt( - "Usage:\n mediapreview oosetup [] []", argv=sys.argv[1:] - ) - _oosetup( - args[""] or "onlyoffice-mediapreview", - int(args[""] or 8988), - ) + args = docopt("Usage:\n mediapreview oosetup []", argv=sys.argv[1:]) + _oosetup(args[""] or "onlyoffice-mediapreview") return _preview(docopt(__doc__)) diff --git a/mediapreview/exceptions.py b/mediapreview/exceptions.py index e5cf2b3..e6e13bc 100644 --- a/mediapreview/exceptions.py +++ b/mediapreview/exceptions.py @@ -92,9 +92,11 @@ class PreviewTimeoutError(PreviewError): *, timeout_seconds: float = 0.0, backend: str | None = None, + fetched: bool | None = None, ): super().__init__(message, short, backend=backend) self.timeout_seconds = timeout_seconds + self.fetched = fetched class PreviewCancelledError(PreviewError): @@ -170,12 +172,21 @@ def backend_error(backend: str, message: str) -> PreviewBackendError: ) -def preview_timeout_error(backend: str, timeout_seconds: float) -> PreviewTimeoutError: +def preview_timeout_error( + backend: str, timeout_seconds: float, fetched: bool | None = None +) -> PreviewTimeoutError: + log = f"{backend.capitalize()} preview timed out after {timeout_seconds}s" + if fetched is not None: + # OnlyOffice: whether it ever downloaded the input file from our + # callback server distinguishes network/callback failures from a + # stalled conversion. + log += " (input file fetched)" if fetched else " (input file never fetched)" return PreviewTimeoutError( - f"{backend.capitalize()} preview timed out after {timeout_seconds}s", + log, "timeout", backend=backend, timeout_seconds=timeout_seconds, + fetched=fetched, ) diff --git a/mediapreview/office.py b/mediapreview/office.py index c8861b0..aced592 100644 --- a/mediapreview/office.py +++ b/mediapreview/office.py @@ -49,8 +49,11 @@ logger = logging.getLogger(__name__) # Isolated docker network for the OnlyOffice container: internal-only (no # outbound internet), the container can only reach the host on this bridge. +# Docker discards published ports on internal networks, so the container is +# reached at its fixed IP instead of a published localhost port. OO_NETWORK = "oonet" OO_SUBNET = "172.30.0.0/24" +OO_CONTAINER_IP = "172.30.0.2" # --------------------------------------------------------------------------- # Configuration helpers @@ -62,10 +65,16 @@ _httpx_client_loop: asyncio.AbstractEventLoop | None = None def _get_onlyoffice_url() -> str: - return os.environ.get( - "ONLYOFFICE_URL", - os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988"), - ) + if url := os.environ.get( + "ONLYOFFICE_URL", os.environ.get("ONLYOFFICE_CISTA_URL") + ): + return url + # When the isolated network exists, the container is at its fixed IP and + # no localhost port is published (Docker discards ports on internal + # networks). Otherwise assume a legacy setup with a published port. + if _docker_network_gateway(OO_NETWORK): + return f"http://{OO_CONTAINER_IP}" + return "http://localhost:8988" def _get_jwt_secret() -> str: @@ -190,12 +199,14 @@ def log_reachable_info() -> None: logger.warning("OnlyOffice probe failed%s", suffix) -def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str: +def setup_docker(name: str = "onlyoffice-mediapreview") -> str: """Build and run the patched OnlyOffice Docker image. - Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a random secret. - Returns the secret used, so the caller is responsible for persisting it - (the CLI prints it as `ONLYOFFICE_JWT_SECRET=`). + The container runs on an isolated internal network (OO_NETWORK) with no + outbound internet and no published ports; the host reaches it at + OO_CONTAINER_IP. Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a + random secret. Returns the secret used, so the caller is responsible for + persisting it (the CLI prints it as `ONLYOFFICE_JWT_SECRET=`). The Docker build context ships inside the package at `mediapreview/docker`. """ if secret := _get_jwt_secret(): @@ -239,10 +250,10 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str "docker", "run", "-d", - "-p", - f"{port}:80", "--network", OO_NETWORK, + "--ip", + OO_CONTAINER_IP, "-e", f"JWT_SECRET={secret}", "-e", @@ -257,7 +268,9 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603 if result.returncode != 0: raise RuntimeError("Failed to start OnlyOffice container") - logger.info("OnlyOffice is running on http://localhost:%d", port) + # Docker discards published ports on internal networks, so the container + # is reached at its fixed IP; no localhost port is exposed. + logger.info("OnlyOffice is running on http://%s", OO_CONTAINER_IP) logger.info( "Callback host for file downloads: %s", _docker_network_gateway(OO_NETWORK) ) @@ -317,7 +330,9 @@ async def is_available_cached() -> bool: class _QuietHandler(SimpleHTTPRequestHandler): def log_message(self, fmt, *args) -> None: - pass + # Any request logged here means a client (OnlyOffice) connected to + # fetch the file; record it for timeout diagnostics. + self.server.oo_fetched = True def _get_free_port() -> int: @@ -334,6 +349,7 @@ def _serve_file_temporarily(file_path: Path): handler = partial(_QuietHandler, directory=directory) httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104 + httpd.oo_fetched = False thread = threading.Thread(target=httpd.serve_forever, daemon=True) thread.start() @@ -402,7 +418,9 @@ async def convert_to_png_async( ) response.raise_for_status() except httpx.TimeoutException as e: - raise preview_timeout_error("onlyoffice", request_timeout) from e + raise preview_timeout_error( + "onlyoffice", request_timeout, fetched=httpd.oo_fetched + ) from e except httpx.HTTPStatusError as e: raise onlyoffice_http_error(e.response.status_code) from e except httpx.RequestError as e: