feat(office_ui): native PDF preview + OnlyOffice reverse proxy (#174) #3

Closed
mik-tf wants to merge 3 commits from development_mik_proxy_onlyoffice into development
Owner

Summary

Two coordinated changes in hero_office_ui that together unlock Office viewing/editing on deployments routing through a single hero_router gateway, without extending hero_router itself.

  1. Native PDF preview in editor_page (browser <embed type="application/pdf"> pointing at proxy_file?inline=1). PDF viewing skips the OnlyOffice editor for read-only access.
  2. OnlyOffice reverse proxy at /onlyoffice/* so the Document Server (Docker container on TCP 127.0.0.1:8088 by default) is reachable through the existing per-service URL space at https://<gw>/hero_office/ui/onlyoffice/....

Why this approach

hero_router proxies to per-service Unix sockets (hero_router/src/server/routes.rs:1941-1948). OnlyOffice runs as a TCP server in a container — the natural path forward without extending the router is to bridge inside an existing service that already does HTTP proxying. hero_office_ui already has proxy_file for foundry webdav; proxy_onlyoffice_* mirrors that pattern.

Context

  • home#174 — OnlyOffice deployment, this PR is the routing/proxy half
  • home#178 — at-click libreoffice PDF preview (companion plan; this PR's PDF short-circuit doesn't conflict)

What this changes

  • hero_office_ui/src/handlers.rs
    • editor_page PDF short-circuit (renders inline via embed)
    • proxy_file learns ?inline=1 query (toggles Content-Disposition)
    • proxy_onlyoffice_root + proxy_onlyoffice_path handlers (forward arbitrary HTTP method/headers/body, drop hop-by-hop + forwarded-prefix, shared reqwest::Client via OnceLock)
  • hero_office_ui/src/config.rsoo_upstream_base: Option<String> from OO_UPSTREAM_BASE env
  • hero_office_ui/src/main.rs — three new any() routes for OnlyOffice paths
  • hero_office_ui/Cargo.toml — adds reqwest (already a workspace dep)

Demo state

Same patch applied as a hotfix on herodemo.gent01.grid.tf. With:

  • OnlyOffice container (onlyoffice/documentserver:latest) on the VM at 127.0.0.1:8088
  • OO_SERVER_URL=https://herodemo.gent01.grid.tf/hero_office/ui/onlyoffice in hero_office_ui action env
  • Matching JWT_SECRET on both sides

clicking any .docx/.xlsx/.pptx loads the real OnlyOffice editor in the iframe; .pdf renders via browser-native viewer.

Tests

Manual:

  • .pdf → embed renders, no OnlyOffice load
  • .docx/.xlsx/.pptx → OnlyOffice editor opens, edits save back through callback
  • OnlyOffice down → graceful 502 with diagnostic message (not a hung connection)

Automated test ideas (not in this PR):

  • Smoke: HEAD /hero_office/ui/onlyoffice/healthcheck returns 200 when container up
  • Browser: Playwright opens .docx, asserts OnlyOffice DocsAPI loads

Risk

  • Blast radius: 1 file's-worth of new code in hero_office_ui, plus a new dep (reqwest, already in workspace)
  • Rollback: trivial revert; OnlyOffice Document Server can also be turned off without code changes (clients fall back to whatever fallback exists)

Closes (refs) lhumina_code/home#174

Signed-off-by: mik-tf

## Summary Two coordinated changes in `hero_office_ui` that together unlock Office viewing/editing on deployments routing through a single hero_router gateway, without extending hero_router itself. 1. **Native PDF preview** in `editor_page` (browser `<embed type="application/pdf">` pointing at `proxy_file?inline=1`). PDF viewing skips the OnlyOffice editor for read-only access. 2. **OnlyOffice reverse proxy** at `/onlyoffice/*` so the Document Server (Docker container on TCP `127.0.0.1:8088` by default) is reachable through the existing per-service URL space at `https://<gw>/hero_office/ui/onlyoffice/...`. ## Why this approach hero_router proxies to per-service Unix sockets (`hero_router/src/server/routes.rs:1941-1948`). OnlyOffice runs as a TCP server in a container — the natural path forward without extending the router is to bridge inside an existing service that already does HTTP proxying. `hero_office_ui` already has `proxy_file` for foundry webdav; `proxy_onlyoffice_*` mirrors that pattern. ## Context - home#174 — OnlyOffice deployment, this PR is the routing/proxy half - home#178 — at-click libreoffice PDF preview (companion plan; this PR's PDF short-circuit doesn't conflict) ## What this changes - `hero_office_ui/src/handlers.rs` - `editor_page` PDF short-circuit (renders inline via embed) - `proxy_file` learns `?inline=1` query (toggles Content-Disposition) - `proxy_onlyoffice_root` + `proxy_onlyoffice_path` handlers (forward arbitrary HTTP method/headers/body, drop hop-by-hop + forwarded-prefix, shared `reqwest::Client` via `OnceLock`) - `hero_office_ui/src/config.rs` — `oo_upstream_base: Option<String>` from `OO_UPSTREAM_BASE` env - `hero_office_ui/src/main.rs` — three new `any()` routes for OnlyOffice paths - `hero_office_ui/Cargo.toml` — adds `reqwest` (already a workspace dep) ## Demo state Same patch applied as a hotfix on herodemo.gent01.grid.tf. With: - OnlyOffice container (`onlyoffice/documentserver:latest`) on the VM at 127.0.0.1:8088 - `OO_SERVER_URL=https://herodemo.gent01.grid.tf/hero_office/ui/onlyoffice` in `hero_office_ui` action env - Matching `JWT_SECRET` on both sides clicking any .docx/.xlsx/.pptx loads the real OnlyOffice editor in the iframe; .pdf renders via browser-native viewer. ## Tests Manual: - [x] .pdf → embed renders, no OnlyOffice load - [x] .docx/.xlsx/.pptx → OnlyOffice editor opens, edits save back through callback - [x] OnlyOffice down → graceful 502 with diagnostic message (not a hung connection) Automated test ideas (not in this PR): - Smoke: HEAD `/hero_office/ui/onlyoffice/healthcheck` returns 200 when container up - Browser: Playwright opens .docx, asserts OnlyOffice DocsAPI loads ## Risk - Blast radius: 1 file's-worth of new code in hero_office_ui, plus a new dep (reqwest, already in workspace) - Rollback: trivial revert; OnlyOffice Document Server can also be turned off without code changes (clients fall back to whatever fallback exists) Closes (refs) https://forge.ourworld.tf/lhumina_code/home/issues/174 Signed-off-by: mik-tf
Two changes that together make Office viewing/editing work end-to-end on
deployments that route everything through a single hero_router gateway,
without extending hero_router itself.

1. Native PDF short-circuit in editor_page

   When the user clicks a .pdf, render via the browser's built-in PDF
   viewer (`<embed type="application/pdf">`) pointing at proxy_file with
   `?inline=1`. proxy_file gained a Query<ProxyFileQuery> extractor that
   switches Content-Disposition from `attachment` to `inline` when the
   flag is set. Browser-native viewer is faster and avoids OnlyOffice's
   editor JS for read-only PDF viewing.

2. OnlyOffice reverse proxy at /onlyoffice/*

   hero_router's per-service-Unix-socket model doesn't accommodate
   OnlyOffice (Docker container, TCP). Adding TCP-target routing to the
   router would be a cross-cutting change. Instead, hero_office_ui
   itself proxies /onlyoffice/* to the OnlyOffice container (default
   127.0.0.1:8088, configurable via OO_UPSTREAM_BASE).

   Mirrors the existing proxy_file pattern. Hop-by-hop and forwarded-prefix
   headers are stripped on both directions; a shared reqwest::Client is
   stashed in a OnceLock to keep keep-alive connections.

   The user-facing OO_SERVER_URL becomes:
       https://<gateway>/hero_office/ui/onlyoffice
   so OnlyOffice traffic stays on the single TF Grid name proxy, no
   extra subdomain or contract needed.

UiConfig grew an optional oo_upstream_base for non-default OnlyOffice
hosts (e.g. running OO on a separate node).

Routes added in main.rs: /onlyoffice, /onlyoffice/, /onlyoffice/*rest
(any HTTP method).

Closes the OnlyOffice deployment-side of
  lhumina_code/home#174
Verified live on herodemo.gent01.grid.tf.

Signed-off-by: mik-tf
Round 2 on the OnlyOffice reverse proxy: solves the "Download failed"
symptom that appears in OnlyOffice's editor with the simple HTTP-only
proxy.

Two fixes in proxy_onlyoffice:

1. WebSocket support
   OnlyOffice uses socket.io which prefers WebSocket transport for
   real-time editor state. Without WS pass-through, socket.io falls
   back to HTTP long-polling, which then also fails because polling
   sessions reference state that the WS handshake never created.
   - Option<WebSocketUpgrade> extractor on proxy_onlyoffice_root and
     proxy_onlyoffice_path. When present, dispatch to ws_proxy_handler.
   - ws_proxy_handler accepts the upgrade (axum), opens a fresh WS
     connection upstream via tokio_tungstenite::connect_async, and
     bidirectionally pipes Message frames between client and upstream.
   - Hop-by-hop and Sec-WebSocket-* headers are stripped per spec;
     tungstenite generates fresh handshake headers for the upstream.

2. Streaming HTTP response body
   The original handler did `resp.bytes().await` which collects the
   entire upstream body before responding. socket.io's HTTP polling
   fallback uses long-polling — server holds connection open until
   a message arrives — and buffering forever breaks it.
   Switched to `Body::from_stream(resp.bytes_stream().map(...))`.
   Also drops content-length from forwarded response headers (the
   stream sets its own length / chunked encoding).

Cargo.toml additions:
   axum: enabled `ws` feature
   tokio-tungstenite 0.24 (with rustls-tls-native-roots)
   futures-util 0.3
   reqwest: enabled `stream` feature

Verified live on herodemo: WebSocket 101 returned through the full
chain (TF Grid gateway -> nginx auth -> hero_router -> hero_office_ui
-> upstream OnlyOffice container) on a synthetic curl handshake.

Refs lhumina_code/home#174

Signed-off-by: mik-tf
Three changes that close out the OnlyOffice integration on a single-domain
HTTPS gateway with auth in front:

1. Forward X-Forwarded-Host and X-Forwarded-Proto when proxying to OO
   (proxy_onlyoffice + ws_proxy_handler).
   X-Forwarded-Host carries `<host>/hero_office/ui/onlyoffice` so OnlyOffice
   builds public-facing URLs with the correct prefix; X-Forwarded-Proto
   ensures OO uses https in any URL it generates internally.

2. CSP `upgrade-insecure-requests` on editor_page response.
   OnlyOffice still emits some asset/cache URLs as `http://` regardless of
   forwarded headers (cache files keyed off internal listen address). The
   CSP directive tells the browser to silently rewrite those `http://`
   loads to `https://` before fetching, eliminating Mixed Content blocks.

3. Widen JWT permissions (build_editor_config).
   Was {edit, download} only — clicking format/style/comment actions
   triggered "You do not have rights for that action" warnings. Now also:
   print, copy, comment, review, fillForms, modifyFilter,
   modifyContentControl, chat, protect.

Verified live on herodemo.gent01.grid.tf:
  - .docx / .xlsx / .pptx all open in real OnlyOffice editor
  - Slide thumbnails render, formulas evaluate, comments work
  - Edits autosave back through callback URL
  - Single-domain TLS auth (admin:admin123 via nginx) doesn't break OO

Refs lhumina_code/home#174

Signed-off-by: mik-tf
mik-tf closed this pull request 2026-04-25 15:17:01 +00:00

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_office!3
No description provided.