5 Commits
Author SHA1 Message Date
LeoVasanko 720c4f06e5 logging: add opt-in quiet_vips_logging() helper
pyvips redirects all GLib messages ("VIPS: threadpool completed ..."
per operation) onto the 'pyvips' logger at INFO. The helper caps that
logger at WARNING while keeping 'pyvips.voperation' (deprecated-argument
notices) at INFO. Library import remains free of logging side effects;
the CLI and worker entry points call the helper instead of their old
bare setLevel, which also hid the deprecation notices.
2026-09-18 03:42:36 +00:00
Leo Vasanko c15e964e6d backends: route office formats through dispatch(); diagnostic unknown-extension errors
Synchronous dispatch() now converts office documents via OnlyOffice
(asyncio.run around OOConversionManager.convert), refusing to run inside
a live event loop and pointing async callers at
pool.generate_office_preview(). The office import stays lazy so the base
install works without the 'office' extra.

Unknown file types now name the offending extension (or its absence)
instead of a generic 'preview unsupported'.
2026-09-15 02:11:35 +00:00
LeoVasanko 7633ee0d84 onlyoffice: bind temp file server to bridge IP, silence handler tracebacks, self-limit lifetime
The temporary HTTP server that OnlyOffice downloads the source document
from was bound to 0.0.0.0, so internet scanners could (and did) connect,
and socketserver dumped a full traceback to stderr for every dropped
connection. It also stayed up for the whole conversion attempt, leaving
the port exposed when conversions hang.

- Bind only to the callback host (oonet gateway by default) so the port
  is unreachable from the internet.
- Override handle_error to log at debug level instead of printing
  tracebacks.
- Watchdog shuts the server down ~2s after the file is fetched, or at
  max_lifetime (request_timeout + 30s), and the socket is closed with
  server_close() in the normal path.
2026-09-10 20:22:41 +00:00
LeoVasanko d4dfc57994 onlyoffice: hardcode oonet gateway as callback host, drop docker detection
The cista service account has no docker CLI access, so detecting the
gateway via docker network inspect silently fell back to docker0/legacy
behavior. With the pinned subnet the gateway is always 172.30.0.1;
ONLYOFFICE_CALLBACK_HOST remains as an env override.
2026-08-13 07:38:52 +00:00
LeoVasanko 65e2fddf1a onlyoffice: drop legacy localhost:8988 URL fallback
The container always lives on the isolated oonet network at the fixed
IP; falling back to a published localhost port silently masked broken
setups with confusing 'not reachable at localhost:8988' diagnostics.
2026-08-13 07:26:57 +00:00
7 changed files with 173 additions and 78 deletions
+2 -3
View File
@@ -32,15 +32,14 @@ from docopt import docopt
from mediapreview.backends import dispatch
from mediapreview.exceptions import PreviewError
from mediapreview.util.logformat import EmojiFormatter
from mediapreview.util.logformat import EmojiFormatter, quiet_vips_logging
def _configure_logging() -> None:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(EmojiFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
logging.getLogger("pyvips").setLevel(logging.WARNING)
quiet_vips_logging()
def _oosetup(name: str) -> None:
+41 -2
View File
@@ -5,6 +5,7 @@ plus quality/size parameters and returning `(avif_bytes, PreviewResponse)`.
`dispatch` picks the right backend for a path.
"""
import asyncio
import logging
import mimetypes
@@ -16,7 +17,7 @@ from mediapreview.backends.image import (
from mediapreview.backends.pdf import process_pdf
from mediapreview.backends.video import process_video
from mediapreview.exceptions import PreviewError, backend_error
from mediapreview.formats import DOC_PREVIEW_SUFFIXES
from mediapreview.formats import DOC_PREVIEW_SUFFIXES, OFFICE_PREVIEW_SUFFIXES
__all__ = [
"dispatch",
@@ -42,6 +43,42 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
if suffix in DOC_PREVIEW_SUFFIXES:
backend = "pdf"
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
if suffix in OFFICE_PREVIEW_SUFFIXES:
backend = "onlyoffice"
try:
from mediapreview.office import ( # noqa: PLC0415
close_oo_client,
get_oo_manager,
)
except ImportError as e:
raise ImportError(
"Office document previews require the 'office' extra:"
" pip install mediapreview[office]"
) from e
try:
asyncio.get_running_loop()
except RuntimeError:
pass # no event loop, asyncio.run() is safe
else:
raise RuntimeError(
"Office preview via dispatch() cannot be called inside a running"
" event loop; use mediapreview.pool.generate_office_preview() instead"
)
async def _convert_office() -> bytes:
manager = get_oo_manager()
try:
return await manager.convert(path)
finally:
await close_oo_client()
png_bytes = asyncio.run(_convert_office())
result, resp = process_image_buffer(
png_bytes, quality=quality, maxsize=maxsize, maxzoom=maxzoom
)
if resp is not None:
resp.backend = "onlyoffice+" + (resp.backend or "vips")
return result, resp
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"):
backend = "video"
@@ -62,4 +99,6 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
except Exception as e:
logger.exception("Preview dispatch failed for %s", path)
raise backend_error(backend, str(e)) from e
raise backend_error(backend, "preview unsupported")
if not suffix:
raise backend_error(backend, "unknown file type: no file extension")
raise backend_error(backend, f"unknown file extension: {suffix!r}")
+63 -70
View File
@@ -50,9 +50,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.
# reached at its fixed IP instead of a published localhost port. The host is
# always the first address of the pinned subnet (the bridge gateway).
OO_NETWORK = "oonet"
OO_SUBNET = "172.30.0.0/24"
OO_GATEWAY = "172.30.0.1"
OO_CONTAINER_IP = "172.30.0.2"
# ---------------------------------------------------------------------------
@@ -65,73 +67,28 @@ _httpx_client_loop: asyncio.AbstractEventLoop | None = None
def _get_onlyoffice_url() -> str:
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"
# The container runs on the isolated oonet network at a fixed IP; the
# host is the bridge gateway and reaches it directly, no published port.
return os.environ.get(
"ONLYOFFICE_URL",
os.environ.get("ONLYOFFICE_CISTA_URL", f"http://{OO_CONTAINER_IP}"),
)
def _get_jwt_secret() -> str:
return os.environ.get("ONLYOFFICE_JWT_SECRET", "")
def _docker_network_gateway(network: str) -> str | None:
"""Return the host-side gateway IP of a docker network, or None."""
try:
result = subprocess.run(
[
"docker",
"network",
"inspect",
network,
"--format",
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
],
capture_output=True,
text=True,
timeout=2,
check=False,
)
gateway = result.stdout.strip()
if result.returncode == 0 and gateway:
return gateway
except Exception:
logger.debug("Failed to inspect docker network %s", network)
return None
@lru_cache(maxsize=1)
def _get_callback_host() -> str:
"""Return the host IP that OnlyOffice (in Docker) can use to reach us."""
"""Return the host IP that OnlyOffice (in Docker) can use to reach us.
The host is always the gateway of the pinned oonet subnet; no detection
is needed (the cista service account may not have docker CLI access).
"""
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
return host
# Prefer the gateway of the isolated network setup_docker() creates —
# this is the network the container is actually attached to.
if gateway := _docker_network_gateway(OO_NETWORK):
return gateway
# Fall back to the default docker bridge IP
try:
result = subprocess.run(
["/sbin/ip", "-4", "addr", "show", "docker0"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
for line in result.stdout.splitlines():
if "inet " in line:
parts = line.strip().split()
addr_part = parts[1] # e.g. 172.17.0.1/16
return addr_part.split("/")[0]
except Exception:
logger.debug("Failed to auto-detect docker bridge IP")
return "127.0.0.1"
return OO_GATEWAY
# ---------------------------------------------------------------------------
@@ -271,9 +228,7 @@ def setup_docker(name: str = "onlyoffice-mediapreview") -> str:
# 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)
)
logger.info("Callback host for file downloads: %s", OO_GATEWAY)
return secret
@@ -328,32 +283,65 @@ async def is_available_cached() -> bool:
# ---------------------------------------------------------------------------
class _TempServer(socketserver.TCPServer):
"""TCPServer that logs handler errors instead of dumping tracebacks to stderr."""
daemon_threads = True
oo_fetched: bool
def handle_error(self, request, client_address) -> None: # noqa: ARG002
# Dropped connections (client disconnects mid-request, port scanners)
# are routine noise; socketserver's default prints a full traceback.
logger.debug("Temp file server: error from %s", client_address)
class _QuietHandler(SimpleHTTPRequestHandler):
server: _TempServer
def log_message(self, fmt, *args) -> None:
# 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(host: str) -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("0.0.0.0", 0)) # noqa: S104
s.bind((host, 0))
return s.getsockname()[1]
def _serve_file_temporarily(file_path: Path):
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
def _serve_file_temporarily(file_path: Path, max_lifetime: float = 60.0):
"""Start a temporary HTTP server for *file_path* and return (url, server).
The server binds only to the callback host address (the docker bridge
gateway by default), not 0.0.0.0, so it is unreachable from the internet.
It shuts itself down shortly after the file has been fetched, or when
*max_lifetime* elapses, so a hung OnlyOffice request cannot leave the
port open indefinitely.
"""
directory = str(file_path.parent)
filename = file_path.name
port = _get_free_port()
host = _get_callback_host()
port = _get_free_port(host)
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
httpd = _TempServer((host, port), handler)
httpd.oo_fetched = False
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host = _get_callback_host()
def _watchdog() -> None:
deadline = perf_counter() + max_lifetime
while perf_counter() < deadline and not httpd.oo_fetched:
threading.Event().wait(0.1)
if httpd.oo_fetched:
# Brief grace so the in-flight response finishes transferring.
threading.Event().wait(2.0)
httpd.shutdown()
httpd.server_close()
threading.Thread(target=_watchdog, daemon=True).start()
url = f"http://{host}:{port}/{quote(filename)}"
return url, httpd
@@ -388,8 +376,12 @@ async def convert_to_png_async(
convert_url = f"{oo_url}/ConvertService.ashx"
client = get_httpx_client()
# Start temporary HTTP server so OnlyOffice can fetch the file
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
# Start temporary HTTP server so OnlyOffice can fetch the file. The
# watchdog lifetime covers the full conversion plus slack so a hung
# conversion cannot leave the port open forever.
doc_url, httpd = await asyncio.to_thread(
_serve_file_temporarily, file_path, request_timeout + 30.0
)
try:
suffix = file_path.suffix.lstrip(".").lower()
payload = {
@@ -460,6 +452,7 @@ async def convert_to_png_async(
return png_response.content
finally:
await asyncio.to_thread(httpd.shutdown)
await asyncio.to_thread(httpd.server_close)
# ---------------------------------------------------------------------------
+15
View File
@@ -31,3 +31,18 @@ class EmojiFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return format_level_prefix(record.levelno) + record.getMessage()
def quiet_vips_logging() -> None:
"""Silence libvips per-operation chatter without hiding deprecations.
pyvips redirects every GLib message ("VIPS: threadpool completed ...")
onto the ``pyvips`` logger at INFO; cap that logger at WARNING. pyvips's
own diagnostics (e.g. deprecated-argument notices) are logged on the
``pyvips.voperation`` child logger and stay at the default INFO.
Opt-in: applications that want quiet vips output call this once during
their own logging setup. mediapreview never calls it on import.
"""
logging.getLogger("pyvips").setLevel(logging.WARNING)
logging.getLogger("pyvips.voperation").setLevel(logging.INFO)
+2 -3
View File
@@ -40,7 +40,7 @@ except ImportError: # pragma: no cover - optional worker extra
from mediapreview.backends import dispatch
from mediapreview.exceptions import PreviewError
from mediapreview.protocol import PreviewRequest, PreviewResponse
from mediapreview.util.logformat import format_level_prefix
from mediapreview.util.logformat import format_level_prefix, quiet_vips_logging
logger = logging.getLogger(__name__)
@@ -171,8 +171,7 @@ def main() -> None:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(_WorkerLogFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
logging.getLogger("pyvips").setLevel(logging.WARNING)
quiet_vips_logging()
# NOTE: standalone package no longer depends on cista config loading.
# Consumers can load their own configuration before starting workers.
if len(sys.argv) > 1:
+4
View File
@@ -98,7 +98,11 @@ async def test_generate_office_preview_raises_structured_error(monkeypatch):
async def fake_convert(_filepath: Path, request_timeout: float = 5.0) -> bytes:
raise onlyoffice_error_from_code("-8")
async def fake_available() -> bool:
return True
monkeypatch.setattr(office, "convert_to_png_async", fake_convert)
monkeypatch.setattr(office, "is_available_cached", fake_available)
with pytest.raises(OnlyOfficeError) as exc_info:
await generate_office_preview(
+46
View File
@@ -28,6 +28,7 @@ from mediapreview.backends.image import (
)
from mediapreview.backends.pdf import process_pdf
from mediapreview.backends.video import process_video
from mediapreview.exceptions import PreviewBackendError
from mediapreview.office import is_available_async
from mediapreview.pool import generate_office_preview
@@ -159,6 +160,51 @@ def test_dispatch(
assert resp.height == expected_height
def test_dispatch_office(monkeypatch) -> None:
"""dispatch() converts office documents via OnlyOffice when called directly."""
fake_png = (FILES / "Landscape_1.jpg").read_bytes()
class _FakeManager:
async def convert(self, filepath: Path) -> bytes:
assert filepath == FILES / "file-sample_100kB.docx"
return fake_png
async def _noop() -> None:
return None
monkeypatch.setattr("mediapreview.office.get_oo_manager", _FakeManager)
monkeypatch.setattr("mediapreview.office.close_oo_client", _noop)
data, resp = dispatch(
FILES / "file-sample_100kB.docx",
quality=60,
maxsize=512,
maxzoom=2.0,
)
_assert_ok(data, resp)
assert resp.backend == "onlyoffice+vips"
def test_dispatch_unknown_extension(tmp_path: Path) -> None:
"""Unsupported extensions produce a diagnostic naming the extension."""
path = tmp_path / "unknown-file.xyz"
path.write_text("not a previewable file")
with pytest.raises(PreviewBackendError) as exc_info:
dispatch(path, quality=60, maxsize=512, maxzoom=2.0)
assert "unknown file extension: '.xyz'" in str(exc_info.value)
assert exc_info.value.backend == "unknown"
def test_dispatch_no_extension(tmp_path: Path) -> None:
"""Files without an extension produce a diagnostic saying so."""
path = tmp_path / "unknown-file-no-ext"
path.write_text("not a previewable file")
with pytest.raises(PreviewBackendError) as exc_info:
dispatch(path, quality=60, maxsize=512, maxzoom=2.0)
assert "unknown file type: no file extension" in str(exc_info.value)
assert exc_info.value.backend == "unknown"
# ---------------------------------------------------------------------------
# Office previews via OnlyOffice
# ---------------------------------------------------------------------------