Bundle
dsh-sidebar-onlyoffice
DSH web plugin: open .docx/.xlsx/.pptx in the better-sidebar editor through a self-hosted ONLYOFFICE Document Server (JWT-signed config, in-network document/callback routes, save-back to disk; the DS browser UI is always served same-origin through the built-in reverse proxy).
- Source
- chendefine
- License
- MIT
- Updated
- Updated 2 days ago
Readme
# dsh-sidebar-onlyoffice
[中文](./README.zh-CN.md) · [npm](https://www.npmjs.com/package/dsh-sidebar-onlyoffice) · [GitHub](https://github.com/chendefine/dsh-sidebar-onlyoffice)
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) web plugin that opens and edits `.docx` / `.xlsx` / `.pptx` in the [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) file sidebar through a self-hosted [ONLYOFFICE Document Server](https://github.com/ONLYOFFICE/Docker-DocumentServer) — with JWT-signed configs, atomic save-back to disk, and **live refresh when the AI edits an open file**.
    
## Features
- **Real office editing** — the full ONLYOFFICE editor (words / sheets / slides) embedded in the better-sidebar viewer, not a static preview. Saves write back to the file on disk.
- **Same-origin proxy (always on)** — the whole Document Server browser UI (api.js, editor iframe, statics, co-authoring socket) is reverse-proxied through dsh web itself at `/sidebar/onlyoffice/ds`: the DS can stay on a private network the browser never reaches.
- **Signed and fenced by default** — editor configs are HS256-JWT-signed when a secret is configured; every file download is gated by a short-lived HMAC token; browser-facing routes sit behind the dsh web trust fence; files outside the session working directory are refused.
- **Live refresh on AI edits** — when the agent modifies a file that is open in the editor, the viewer swaps to the new version within ~a second via `refreshFile`, with no page reload and no api.js reload. A dirty editor is never auto-refreshed (a banner offers a manual reload instead).
- **Atomic saves, self-save suppression** — Document Server callbacks download the saved bytes and replace the file through temp-file + rename, serialized per document key; the plugin's own writes are absorbed by the watch hub so saving never echoes a refresh back.
- **In-network save fetch** — save URLs the DS reports against the public entry are rewritten onto `documentServerUrl` (container-to-container) instead of hairpinning through the public reverse proxy.
- **Coexists with the office preview plugin** — viewer ids `onlyoffice:docx|xlsx|pptx` (priority 10) never clash with `@huanlin/dsh-plugin-better-sidebar-plugin-office`'s `docx/xlsx/pptx` (priority 0); either side can be disabled per-viewer in the side card settings.
## How it works
| Half | Location | Responsibility |
| --- | --- | --- |
| Host (server) | `src/` | Registers four routes on the dsh web webServer implementing the "document storage service" role from the official ONLYOFFICE integration docs. |
| Browser (client) | `src/client/` | Registers the three better-sidebar file viewers; loads the Document Server api.js on demand, mounts `DocsAPI.DocEditor`, subscribes to the SSE watch stream. |
```
better-sidebar viewer (onlyoffice:docx|xlsx|pptx)
├─ GET /sidebar/onlyoffice/config (browser, trust-fenced)
│ → editor config + JWT + api.js URL + HMAC file token
├─ GET /sidebar/onlyoffice/file (Document Server; HMAC token = auth)
├─ POST /sidebar/onlyoffice/callback (Document Server; status 2/6 → atomic save-back)
├─ GET /sidebar/onlyoffice/watch (browser SSE; inotify-fed disk changes)
│ → change → re-fetch config → docEditor.refreshFile(config) [no reload]
└─ /sidebar/onlyoffice/ds/** (browser ↔ Document Server, same-origin)
→ api.js + editor iframe + statics + socket.io (WS JIT-routed, polling fallback)
```
- **Node half** — the config route builds the editor config for an absolute path inside the session cwd (documentType mapping, content-addressed `key = sha256(host+path+size+mtime)`), signs the whole config as an HS256 JWT, and registers the key→file mapping. The file route serves the raw bytes to the Document Server. The callback route (JWT-verified) downloads saved bytes on status 2/6 and **atomically replaces the file**, forgets the key on status 4, and always acknowledges with `{"error":0}`. The watch route streams disk changes as SSE, fed by an inotify watch hub (`fs.watch` on the file **and** its directory, debounced, signature-filtered through the same content-addressed key).
- **Browser half** — loads the Document Server api.js once per URL, mounts the editor, calls `destroyEditor()` on teardown; failures show an error panel plus a download fallback link (reusing better-sidebar's `/sidebar/file` route). While mounted it subscribes to the watch stream and reacts to outside (AI) edits by re-fetching the config and calling `docEditor.refreshFile(config)`.
### Live refresh on AI edits
When an agent modifies a file that is open in the ONLYOFFICE editor, the viewer finds out within ~a second: inotify → SSE `change` event → config re-fetch (whose content-addressed key changed with the file) → `refreshFile`. The editor iframe itself is reused — no page reload, no api.js reload, and the DS opens the new version as a fresh session (old key closed with status 4). Guard rails:
- A **dirty editor is never auto-refreshed** — `refreshFile` unconditionally drops unsaved edits (no `isDocumentModified` guard on the integrator path, verified in the DS source). Instead a banner appears ("file changed on disk — reloading discards unsaved edits") with a manual **Reload** button; clicking it is the user's confirmation.
- The DS's own `onRequestRefreshFile` (fired on reconnect / same-key saves, only while NOT modified) rides the same refresh path.
- The plugin's own callback saves are suppressed server-side (`noteSelfSave` records the just-written signature), so a user save doesn't bounce a refresh back and reset the cursor.
- File deletion pushes a `removed` notice; re-creation pushes a `change`.
## Requirements
- A DSH **web** profile (`dsh web`), Node.js ≥ 20, with the [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) plugin installed.
- A self-hosted ONLYOFFICE Document Server (verified against 9.4 community edition) that can reach the dsh web server over the network. The browser needs no access to it (the proxy mount is the only browser-facing DS entry).
- With JWT enabled (the DS default), the plugin's `jwtSecret` must equal the DS `JWT_SECRET`.
- The DS needs `ALLOW_PRIVATE_IP_ADDRESS=true` when `document.url` / `callbackUrl` are private addresses (the DS refuses them by default).
## Installation
From the npm registry (prebuilt — no build permission needed):
```sh
dsh plugin --profile web add dsh-sidebar-onlyoffice
```
From a GitHub repository (source — pnpm runs the `prepare` build; allowlist the package in `profiles/web/pnpm-workspace.yaml` if pnpm blocks the build script):
```sh
dsh plugin --profile web add github:chendefine/dsh-sidebar-onlyoffice
```
Or through the DSH plugin marketplace (设置 → DSH插件市场) — the repo carries the `dsh-plugin` topic and is indexed automatically.
After a bundle plugin is added to the profile layer stack, write your config into the profile's `cordis.patch.yml` layer (see below), **restart `dsh web`**, and hard-refresh the browser (Ctrl+Shift+R). Uninstall with `dsh plugin --profile web remove dsh-sidebar-onlyoffice` and restart again.
## Configuration
All keys are optional; the profile's `cordis.patch.yml` layer carries them:
```yaml
- id: dsh-sidebar-onlyoffice
config:
jwtSecret: "<the DS JWT_SECRET>" # empty = unsigned (only for JWT_ENABLED=false servers)
# documentServerUrl: http://onlyoffice-documentserver # the DS entry dsh web reaches directly (its own nginx)
# internalBaseUrl: http://172.31.255.4:3080 # how the DS reaches dsh web; empty = auto-detect
# publicBaseUrl: https://work.example.com # browser-visible origin when a gateway rewrites Host; empty = request Origin
# defaultMode: edit # edit | view
# username: User # editor display username (top-right corner)
# fileLimitMb: 100
# tokenTtlSec: 600
```
| Field | Default | Description |
| --- | --- | --- |
| `jwtSecret` | `''` | Shared secret with the Document Server (its `JWT_SECRET`). Editor configs and callbacks are JWT-signed/verified when set; empty only fits a DS running `JWT_ENABLED=false`. |
| `documentServerUrl` | `''` | **The one Document Server address**: the entry dsh web itself reaches directly — the DS container's own nginx on the docker network (`http://onlyoffice-documentserver`) or a published host port. Doubles as the reverse-proxy upstream and the base saved-document bytes are fetched through. |
| `internalBaseUrl` | *(auto-detect)* | Base URL the DS uses to reach this dsh web server (document download + callbacks). Empty = auto-detect the first non-loopback IPv4 + the webserver port. |
| `publicBaseUrl` | *(request Origin)* | Browser-visible origin (`scheme://host[:port]`), used when minting `X-Forwarded-Host`/`Proto` and matching DS-reported save URLs. Set it when a fronting gateway rewrites the `Host` header away from the browser-visible authority; empty = derive from each request's Origin. |
| `defaultMode` | `edit` | Default mode for newly opened files (edit/view). |
| `username` | `User` | Editor display username (`editorConfig.user.name`, the co-author name in the editor's top-right corner); empty reverts to `User`. Reopen files after a change. |
| `fileLimitMb` | `100` | Max file size (MB) for serving and saving back. |
| `tokenTtlSec` | `600` | Signed URL token lifetime in seconds. |
Legacy pre-0.2.0 keys (`proxy`, `proxyUpstream`, `internalDocumentServerUrl`, `proxyPublicBase`, `documentServerPort`) are mapped once at startup — `proxyUpstream`/`internalDocumentServerUrl` onto `documentServerUrl`, `proxyPublicBase` onto `publicBaseUrl` — with a warning; move to the new names.
### Graphical configuration (settings card)
When the composition mounts the DSH settings service (the standard `dsh web` bundle does), the plugin registers a settings section under the `dsh-sidebar-onlyoffice` namespace and the web client's **Settings → Plugins** tab renders a configuration card for it — all eight fields, zh/en copy, staged edits with a save/discard footer. A committed change is written to the deployment's user settings (`<DSH_HOME>/settings.yaml`), layered over the composition entry, and applies without a restart: both the route closures AND the reverse proxy read the live section (jwt/mode/username/limit/token/URLs take effect on the next editor-config fetch; a changed `documentServerUrl` swaps the proxy upstream at once — the discovered versioned prefix resets and re-probes, and an address that first resolves through the card claims the mount immediately). A deployment may therefore boot with an empty composition entry and be configured entirely through the card.
Precedence per field: card/user layer → the composition entry (`cordis.patch.yml` `config:` block) → schema defaults. Removing a card edit (the "Reset to default" control) reverts to the composition layer; hand-editing `settings.yaml` works too (the section re-resolves live).
### Save-URL rewrite (in-network fetch)
Two directions are configured independently: DS → this server via `internalBaseUrl` (document download + callbacks), and this server → DS for the saved bytes its callbacks report. The DS mints those save URLs against the browser-visible base the proxy forwards (`publicBaseUrl`, else the request Origin) — behind a reverse proxy they would hairpin out through the public entry (DNS + TLS + terminator + gateway). The plugin rewrites the minted prefix onto `documentServerUrl` (e.g. `http://onlyoffice-documentserver`, the docker-network container name) — path suffix and query stay intact — for a direct container-to-container fetch.
Safety verified against Document Server 9.4: `/cache/files` auth is an nginx `secure_link` md5 over `expires + request-path + server secret`, where the request path is the one AFTER the proxy strips the sub-path prefix — host and stripped prefix are not signed material, so a rewritten URL still returns 200. A reported URL outside the public base is fetched as-is with a warn log.
### Same-origin proxy
The plugin is proxy-only: it always reverse-proxies the Document Server's whole browser UI through the dsh web port at `/sidebar/onlyoffice/ds`. The config route hands out that mount's api.js URL, and everything the editor then loads — the iframe, sdkjs/web-apps statics, fonts, the co-authoring socket, exports and downloads — flows back through dsh web. **The browser never needs to reach the DS at all**; point `documentServerUrl` at the DS container entry and the DS can stay on a private network. This also removes the mixed-content pitfall: an https GUI proxies an http-only DS transparently.
How it cooperates with the DS (9.x; the same generation the plugin targets):
- api.js derives the DS base from its own script URL, and the editor derives the socket path from the iframe URL — both stay under the mount automatically.
- The proxy sends `X-Forwarded-Host: <browser host>/sidebar/onlyoffice/ds` (the official ONLYOFFICE "virtual path" shape), so the URLs the DS itself mints (the versioned script-caching 302s, save/download links) point back into the mount; absolute `Location` headers are additionally rewritten to mount-relative form. The same headers ride the WebSocket upgrade leg, where the DS derives the `/cache/files` document-data base from the connection.
- The editor's socket.io path is `<mount>/<version>~<hash>/doc/<key>/c`, where `<key>` is the plugin's own content-addressed document key. The proxy registers that exact WebSocket upgrade route just-in-time per opened document (disposed on the DS's status-4 close, LRU-capped, re-discovered when a DS upgrade moves the versioned prefix). A missed registration degrades softly: the editor falls back to socket.io polling over plain HTTP — editing keeps working.
- Both proxy legs sit behind the same browser-trust fence as every other plugin route (same-origin editor passes; cross-site pages are refused).
Notes and limits:
- Requires a DS of the 9.x generation (sub-path URL minting).
- Every document save changes the key (content-addressed), so upgrade routes churn per save — disposed on close, capped, invisible in practice.
- If a gateway in front of dsh web rewrites `Host` (e.g. to `127.0.0.1:3080`), URLs minted inside DS responses would carry that unreachable host: set `publicBaseUrl` to the browser-visible origin.
- Security trade-off: the DS UI runs same-origin with the DSH page, so a compromised DS could script the dsh origin. The fence still stops cross-site callers; keep the DS trusted.
- Static assets hop twice (browser → dsh web → DS); the versioned immutable caching survives the hop, but an outer gateway may need its cache map extended for `/sidebar/onlyoffice/ds/**` for best performance.
### Deployment shape
The canonical deployment: the Document Server container and dsh web share a docker network (DS container name resolvable, e.g. `onlyoffice-documentserver` — that is `documentServerUrl`), JWT enabled with a fixed secret, and `ALLOW_PRIVATE_IP_ADDRESS=true` on the DS container. The DS needs no published browser-facing port at all.
## Troubleshooting: editor reports errorCode:-4 "download failed"
`-4` means the DS could not download `document.url`. Start from the target URL in the DS container log (`error downloadFile:url=...`):
- URL points at the DS itself → `internalBaseUrl` was misconfigured to the DS address. It must be the address **the DS uses to call back into dsh web** (use the container name when both share a docker network); editing the profile's `cordis.patch.yml` hot-applies via Cordis HMR, no restart needed.
- 404 → wrong host in the URL; 403 → token expired/bad signature (reopen the file after a dsh web restart); connection refused/timeout → network unreachable (both containers must share a network; the DS needs `ALLOW_PRIVATE_IP_ADDRESS=true`).
- Quick check without a browser: fetch the config route with `Host: localhost` to get `document.url`, then `docker exec onlyoffice-documentserver curl -v <that URL>` — expect 200.
## Known limits
- Saves are whole-file overwrites: concurrent writes to the same file by the agent race (last writer wins). Live refresh narrows this window — a clean editor is pushed to the newest version within ~a second — but a dirty editor that saves after an AI edit still overwrites it; the stale banner is cleared on save (the overwrite resolved the divergence).
- Self-save suppression has a millisecond race: an external write landing between the plugin's save and its `noteSelfSave` stat can be wrongly absorbed (one missed refresh event; the next change recovers). Negligible in practice.
- `onRequestRefreshFile` requires ONLYOFFICE Docs ≥ 8.3; `refreshFile` itself was verified against Document Server 9.4 community edition.
- The Document Server must be of the 9.x generation (sub-path URL minting); the WebSocket leg depends on just-in-time route registration and silently degrades to socket.io polling when it misses (older servers or route conflicts).
- The key→file registry persists under `<DSH_HOME>/plugins/dsh-sidebar-onlyoffice/registry.json` (newest 128 entries), so a save redelivered after a dsh web restart still lands. The watch stream reconnects on its own (EventSource `retry: 3000`); a viewer left open across the restart re-fetches its config on the next interaction.
## Security
The host half only ever serves and writes files **inside a session working directory**, browser-facing routes sit behind the dsh web trust fence, and every Document-Server download is gated by a short-lived HMAC token minted per config request. The Document Server itself is user-deployed and user-configured — deploy it on a trusted network. See [SECURITY.md](./SECURITY.md) for the full stance and threat model.
## Development
```sh
pnpm install
pnpm run typecheck # type gate
pnpm test # vitest (JWT/key/tokens, route fences and callback saves, in-network save-URL rewrite, the same-origin ds proxy, settings wiring, viewer descriptors, inotify watch hub + SSE route)
pnpm run build # lib/index.js (node half) + lib/client.js (ModuleLoader-wrapped browser half)
```
Repository layout:
```
src/
├── index.ts # host entry: the four webServer routes (+ dsProxy wiring)
├── dsProxy.ts # same-origin DS reverse proxy: mount, forwarding headers, JIT WS routes
├── config.ts # schemastery schema, base-URL derivation/detection
├── settings.ts # settings-section wiring (the graphical config card's host half)
├── onlyoffice.ts # editor config, JWT payload, file tokens, save-URL rewrite
├── jwt.ts # minimal HS256 sign/verify (no dependencies)
├── registry.ts # document key → file mapping, atomic save-back, persistence
├── watch.ts # inotify watch hub (debounce, signature filter, self-save absorption)
├── trust-fence.ts # browser-request trust check (host/origin)
├── paths.ts # absolute-path + containment helpers
├── wire.ts # JSON body/error helpers
└── client/ # browser half: viewers, editor mount, i18n, settings card
```
## License
[MIT](./LICENSE)
Install
dsh plugin --profile web add github:chendefine/dsh-sidebar-onlyoffice
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-sidebar-onlyoffice from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.