dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DSH web plugin: open and edit .docx/.xlsx/.pptx in the better-sidebar editor through a self-hosted ONLYOFFICE Document Server (JWT-signed config, atomic save-back, live refresh on AI edits)
A DeepSeek Harness (DSH) web plugin that opens and edits .docx / .xlsx / .pptx in the dsh-better-sidebar file sidebar through a self-hosted ONLYOFFICE Document Server — with JWT-signed configs, atomic save-back to disk, and live refresh when the AI edits an open file.
/sidebar/onlyoffice/ds: the DS can stay on a private network the browser never reaches.refreshFile, with no page reload and no api.js reload. A dirty editor is never auto-refreshed (a banner offers a manual reload instead).documentServerUrl (container-to-container) instead of hairpinning through the public reverse proxy.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.| 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)
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).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).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:
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.onRequestRefreshFile (fired on reconnect / same-key saves, only while NOT modified) rides the same refresh path.noteSelfSave records the just-written signature), so a user save doesn't bounce a refresh back and reset the cursor.removed notice; re-creation pushes a change.dsh web), Node.js ≥ 20, with the dsh-better-sidebar plugin installed.jwtSecret must equal the DS JWT_SECRET.ALLOW_PRIVATE_IP_ADDRESS=true when document.url / callbackUrl are private addresses (the DS refuses them by default).From the npm registry (prebuilt — no build permission needed):
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):
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.
All keys are optional; the profile's cordis.patch.yml layer carries them:
- 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.
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).
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.
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):
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.<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.Notes and limits:
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./sidebar/onlyoffice/ds/** for best performance.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.
-4 means the DS could not download document.url. Start from the target URL in the DS container log (error downloadFile:url=...):
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.ALLOW_PRIVATE_IP_ADDRESS=true).Host: localhost to get document.url, then docker exec onlyoffice-documentserver curl -v <that URL> — expect 200.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.<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.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 for the full stance and threat model.
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