onlyoffice: fixed container IP instead of published port; fetch-aware timeout
Docker silently discards published ports on internal networks, so the
isolated oonet setup left the container unreachable at localhost:8988.
Reach it at its fixed IP (172.30.0.2) on the bridge instead; the host
is the gateway, so this needs no port publishing at all. The oosetup
CLI drops its now-meaningless <port> argument.
Also: record whether OnlyOffice fetched the input file from the
temporary callback server and report it in the timeout error message
('input file never fetched' = network/callback failure, vs a stalled
conversion). Convert POST timeout is 7s (conversion runs inside the
request), result PNG download stays at 2s.
This commit is contained in:
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
mediapreview <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
|
mediapreview <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
|
||||||
mediapreview oosetup [<name>] [<port>]
|
mediapreview oosetup [<name>]
|
||||||
mediapreview (-h | --help)
|
mediapreview (-h | --help)
|
||||||
|
|
||||||
Generate an AVIF preview for a media file (one-shot, in-process), or set up
|
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:
|
Arguments:
|
||||||
<path> media file to preview
|
<path> media file to preview
|
||||||
<name> container name [default: onlyoffice-mediapreview]
|
<name> container name [default: onlyoffice-mediapreview]
|
||||||
<port> container host port [default: 8988]
|
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-o OUTPUT output .avif file (default: write AVIF bytes to stdout)
|
-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)
|
logging.getLogger("pyvips").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
def _oosetup(name: str, port: int) -> None:
|
def _oosetup(name: str) -> None:
|
||||||
try:
|
try:
|
||||||
# Lazy import: keeps the base CLI free of office-extra concerns.
|
# Lazy import: keeps the base CLI free of office-extra concerns.
|
||||||
from mediapreview.office import setup_docker # noqa: PLC0415
|
from mediapreview.office import setup_docker # noqa: PLC0415
|
||||||
@@ -52,7 +52,7 @@ def _oosetup(name: str, port: int) -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
try:
|
try:
|
||||||
# Logs go to stderr; stdout carries only the secret line below.
|
# 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:
|
except Exception as e:
|
||||||
sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n")
|
sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -102,13 +102,8 @@ def main() -> None:
|
|||||||
# by the <path> pattern if it came second. Dispatch it before parsing;
|
# by the <path> pattern if it came second. Dispatch it before parsing;
|
||||||
# the main help above still documents both modes.
|
# the main help above still documents both modes.
|
||||||
if sys.argv[1:2] == ["oosetup"]:
|
if sys.argv[1:2] == ["oosetup"]:
|
||||||
args = docopt(
|
args = docopt("Usage:\n mediapreview oosetup [<name>]", argv=sys.argv[1:])
|
||||||
"Usage:\n mediapreview oosetup [<name>] [<port>]", argv=sys.argv[1:]
|
_oosetup(args["<name>"] or "onlyoffice-mediapreview")
|
||||||
)
|
|
||||||
_oosetup(
|
|
||||||
args["<name>"] or "onlyoffice-mediapreview",
|
|
||||||
int(args["<port>"] or 8988),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
_preview(docopt(__doc__))
|
_preview(docopt(__doc__))
|
||||||
|
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ class PreviewTimeoutError(PreviewError):
|
|||||||
*,
|
*,
|
||||||
timeout_seconds: float = 0.0,
|
timeout_seconds: float = 0.0,
|
||||||
backend: str | None = None,
|
backend: str | None = None,
|
||||||
|
fetched: bool | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(message, short, backend=backend)
|
super().__init__(message, short, backend=backend)
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.fetched = fetched
|
||||||
|
|
||||||
|
|
||||||
class PreviewCancelledError(PreviewError):
|
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(
|
return PreviewTimeoutError(
|
||||||
f"{backend.capitalize()} preview timed out after {timeout_seconds}s",
|
log,
|
||||||
"timeout",
|
"timeout",
|
||||||
backend=backend,
|
backend=backend,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
|
fetched=fetched,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+31
-13
@@ -49,8 +49,11 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Isolated docker network for the OnlyOffice container: internal-only (no
|
# Isolated docker network for the OnlyOffice container: internal-only (no
|
||||||
# outbound internet), the container can only reach the host on this bridge.
|
# 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_NETWORK = "oonet"
|
||||||
OO_SUBNET = "172.30.0.0/24"
|
OO_SUBNET = "172.30.0.0/24"
|
||||||
|
OO_CONTAINER_IP = "172.30.0.2"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration helpers
|
# Configuration helpers
|
||||||
@@ -62,10 +65,16 @@ _httpx_client_loop: asyncio.AbstractEventLoop | None = None
|
|||||||
|
|
||||||
|
|
||||||
def _get_onlyoffice_url() -> str:
|
def _get_onlyoffice_url() -> str:
|
||||||
return os.environ.get(
|
if url := os.environ.get(
|
||||||
"ONLYOFFICE_URL",
|
"ONLYOFFICE_URL", os.environ.get("ONLYOFFICE_CISTA_URL")
|
||||||
os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988"),
|
):
|
||||||
)
|
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:
|
def _get_jwt_secret() -> str:
|
||||||
@@ -190,12 +199,14 @@ def log_reachable_info() -> None:
|
|||||||
logger.warning("OnlyOffice probe failed%s", suffix)
|
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.
|
"""Build and run the patched OnlyOffice Docker image.
|
||||||
|
|
||||||
Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a random secret.
|
The container runs on an isolated internal network (OO_NETWORK) with no
|
||||||
Returns the secret used, so the caller is responsible for persisting it
|
outbound internet and no published ports; the host reaches it at
|
||||||
(the CLI prints it as `ONLYOFFICE_JWT_SECRET=<token>`).
|
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=<token>`).
|
||||||
The Docker build context ships inside the package at `mediapreview/docker`.
|
The Docker build context ships inside the package at `mediapreview/docker`.
|
||||||
"""
|
"""
|
||||||
if secret := _get_jwt_secret():
|
if secret := _get_jwt_secret():
|
||||||
@@ -239,10 +250,10 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
|
|||||||
"docker",
|
"docker",
|
||||||
"run",
|
"run",
|
||||||
"-d",
|
"-d",
|
||||||
"-p",
|
|
||||||
f"{port}:80",
|
|
||||||
"--network",
|
"--network",
|
||||||
OO_NETWORK,
|
OO_NETWORK,
|
||||||
|
"--ip",
|
||||||
|
OO_CONTAINER_IP,
|
||||||
"-e",
|
"-e",
|
||||||
f"JWT_SECRET={secret}",
|
f"JWT_SECRET={secret}",
|
||||||
"-e",
|
"-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
|
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError("Failed to start OnlyOffice container")
|
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(
|
logger.info(
|
||||||
"Callback host for file downloads: %s", _docker_network_gateway(OO_NETWORK)
|
"Callback host for file downloads: %s", _docker_network_gateway(OO_NETWORK)
|
||||||
)
|
)
|
||||||
@@ -317,7 +330,9 @@ async def is_available_cached() -> bool:
|
|||||||
|
|
||||||
class _QuietHandler(SimpleHTTPRequestHandler):
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||||
def log_message(self, fmt, *args) -> None:
|
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:
|
def _get_free_port() -> int:
|
||||||
@@ -334,6 +349,7 @@ def _serve_file_temporarily(file_path: Path):
|
|||||||
|
|
||||||
handler = partial(_QuietHandler, directory=directory)
|
handler = partial(_QuietHandler, directory=directory)
|
||||||
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
|
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 = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
@@ -402,7 +418,9 @@ async def convert_to_png_async(
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except httpx.TimeoutException as e:
|
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:
|
except httpx.HTTPStatusError as e:
|
||||||
raise onlyoffice_http_error(e.response.status_code) from e
|
raise onlyoffice_http_error(e.response.status_code) from e
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user