Add Office Collabora canvas integration
Alessandro committed
Apr 26, 2026 at 12:57 UTC
bf02987d5782310dee479d0e23c06082b8feb9d5
22 files changed
+3091
plugins/_office/api/collabora_logs.py
new
+13
@@ -0,0 +1,13 @@
1
+from __future__ import annotations
2
+
3
+from helpers.api import ApiHandler, Request
4
+from plugins._office.helpers import collabora_status
5
+
6
+
7
+class CollaboraLogs(ApiHandler):
8
+ async def process(self, input: dict, request: Request) -> dict:
9
+ return {
10
+ "ok": True,
11
+ "bootstrap": collabora_status.tail_file(collabora_status.BOOTSTRAP_LOG),
12
+ "wrapper": collabora_status.tail_file(collabora_status.WRAPPER_LOG),
13
+ }
plugins/_office/api/collabora_status.py
new
+11
@@ -0,0 +1,11 @@
1
+from __future__ import annotations
2
+
3
+from helpers.api import ApiHandler, Request
4
+from plugins._office.helpers.collabora_status import collect_status, read_status
5
+
6
+
7
+class CollaboraStatus(ApiHandler):
8
+ async def process(self, input: dict, request: Request) -> dict:
9
+ if input.get("fresh"):
10
+ return collect_status()
11
+ return read_status()
plugins/_office/api/office_session.py
new
+129
@@ -0,0 +1,129 @@
1
+from __future__ import annotations
2
+
3
+import xml.etree.ElementTree as ET
4
+from urllib.parse import quote, urlparse
5
+
6
+import httpx
7
+from helpers.api import ApiHandler, Request
8
+from plugins._office.helpers import collabora_runtime, collabora_status, wopi_store
9
+
10
+
11
+DISCOVERY_URLS = (
12
+ "http://127.0.0.1:9980/office/hosting/discovery",
13
+ "http://127.0.0.1:9980/hosting/discovery",
14
+)
15
+
16
+
17
+class OfficeSession(ApiHandler):
18
+ async def process(self, input: dict, request: Request) -> dict:
19
+ action = str(input.get("action") or "open").lower()
20
+ if action == "status":
21
+ return collabora_status.collect_status()
22
+ if action == "retry":
23
+ collabora_runtime.retry_bootstrap()
24
+ return {"ok": True, **collabora_status.read_status()}
25
+ if action == "recent":
26
+ return {"ok": True, "documents": wopi_store.get_recent_documents()}
27
+ if action == "create":
28
+ doc = wopi_store.create_document(
29
+ kind=str(input.get("kind") or "document"),
30
+ title=str(input.get("title") or "Untitled"),
31
+ fmt=str(input.get("format") or "docx"),
32
+ content=str(input.get("content") or ""),
33
+ path=str(input.get("path") or ""),
34
+ )
35
+ return await self._open_document(doc, input, request)
36
+ if action == "open":
37
+ doc = wopi_store.register_document(str(input.get("path") or ""))
38
+ return await self._open_document(doc, input, request)
39
+ return {"ok": False, "error": f"Unsupported office session action: {action}"}
40
+
41
+ async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
42
+ mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
43
+ permission = "write" if mode == "edit" else "read"
44
+ origin = self._origin(request)
45
+ session = wopi_store.create_session(
46
+ doc["file_id"],
47
+ user_id=str(input.get("user_id") or "agent-zero-user"),
48
+ permission=permission,
49
+ origin=origin,
50
+ )
51
+ discovery = await self._discover()
52
+ if not discovery.get("ok"):
53
+ return {
54
+ "ok": False,
55
+ "error": discovery.get("error") or "Collabora discovery is unavailable",
56
+ "file_id": doc["file_id"],
57
+ "title": doc["basename"],
58
+ "extension": doc["extension"],
59
+ "status": collabora_status.collect_status(),
60
+ }
61
+
62
+ action_url = self._select_action(discovery["xml"], doc["extension"], mode)
63
+ if not action_url:
64
+ return {
65
+ "ok": False,
66
+ "error": f"Collabora does not advertise {mode} support for .{doc['extension']}",
67
+ "file_id": doc["file_id"],
68
+ "title": doc["basename"],
69
+ "extension": doc["extension"],
70
+ }
71
+
72
+ wopi_src = f"http://127.0.0.1:80/wopi/files/{doc['file_id']}"
73
+ iframe_action = self._same_origin_action(action_url, wopi_src, session["session_id"])
74
+ return {
75
+ "ok": True,
76
+ "file_id": doc["file_id"],
77
+ "iframe_action": iframe_action,
78
+ "access_token": session["access_token"],
79
+ "access_token_ttl": session["access_token_ttl"],
80
+ "post_message_origin": origin,
81
+ "title": doc["basename"],
82
+ "extension": doc["extension"],
83
+ "path": doc["path"],
84
+ "version": wopi_store.item_version(doc),
85
+ }
86
+
87
+ def _origin(self, request: Request) -> str:
88
+ origin = request.headers.get("Origin") or request.host_url.rstrip("/")
89
+ return origin.rstrip("/")
90
+
91
+ async def _discover(self) -> dict:
92
+ for url in DISCOVERY_URLS:
93
+ try:
94
+ async with httpx.AsyncClient(timeout=8.0) as client:
95
+ response = await client.get(url)
96
+ if response.status_code == 200 and "wopi-discovery" in response.text.lower():
97
+ return {"ok": True, "xml": response.text}
98
+ except Exception:
99
+ continue
100
+ return {"ok": False, "error": "Collabora discovery is not reachable yet"}
101
+
102
+ def _select_action(self, discovery_xml: str, extension: str, mode: str) -> str:
103
+ root = ET.fromstring(discovery_xml)
104
+ best = ""
105
+ fallback = ""
106
+ for action in root.findall(".//{*}action"):
107
+ if action.attrib.get("ext", "").lower() != extension.lower():
108
+ continue
109
+ name = action.attrib.get("name", "").lower()
110
+ urlsrc = action.attrib.get("urlsrc", "")
111
+ if not urlsrc:
112
+ continue
113
+ if name == mode:
114
+ best = urlsrc
115
+ break
116
+ if name == "view":
117
+ fallback = urlsrc
118
+ return best or fallback
119
+
120
+ def _same_origin_action(self, urlsrc: str, wopi_src: str, session_id: str) -> str:
121
+ parsed = urlparse(urlsrc)
122
+ path = parsed.path or "/office/browser/cool.html"
123
+ if not path.startswith("/office"):
124
+ path = "/office" + path
125
+ query = parsed.query
126
+ base = path + (f"?{query}" if query else ("?" if urlsrc.endswith("?") else ""))
127
+ separator = "" if base.endswith("?") or base.endswith("&") else ("&" if "?" in base else "?")
128
+ base = f"{base}{separator}a0_session={quote(session_id, safe='')}"
129
+ return f"{base}&WOPISrc={quote(wopi_src, safe='')}"
plugins/_office/extensions/python/_functions/run_ui/init_a0/end/_20_collabora_bootstrap.py
new
+9
@@ -0,0 +1,9 @@
1
+from __future__ import annotations
2
+
3
+from helpers.extension import Extension
4
+from plugins._office.helpers.collabora_runtime import start_bootstrap_worker
5
+
6
+
7
+class CollaboraBootstrap(Extension):
8
+ def execute(self, **kwargs):
9
+ start_bootstrap_worker(force=False)
plugins/_office/extensions/python/job_loop/_20_collabora_reconcile.py
new
+9
@@ -0,0 +1,9 @@
1
+from __future__ import annotations
2
+
3
+from helpers.extension import Extension
4
+from plugins._office.helpers.collabora_runtime import reconcile
5
+
6
+
7
+class CollaboraReconcile(Extension):
8
+ async def execute(self, **kwargs):
9
+ reconcile()
plugins/_office/extensions/python/startup_migration/_20_office_routes.py
new
+9
@@ -0,0 +1,9 @@
1
+from __future__ import annotations
2
+
3
+from helpers.extension import Extension
4
+from plugins._office.helpers.route_bootstrap import install_route_hooks
5
+
6
+
7
+class OfficeRoutesStartup(Extension):
8
+ def execute(self, **kwargs):
9
+ install_route_hooks()
plugins/_office/extensions/webui/get_tool_message_handler/document-artifact-handler.js
new
+81
@@ -0,0 +1,81 @@
1
+import {
2
+ createActionButton,
3
+ copyToClipboard,
4
+} from "/components/messages/action-buttons/simple-action-buttons.js";
5
+import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
6
+import { store as speechStore } from "/components/chat/speech/speech-store.js";
7
+import {
8
+ buildDetailPayload,
9
+ cleanStepTitle,
10
+ drawProcessStep,
11
+} from "/js/messages.js";
12
+
13
+export default async function registerDocumentArtifactHandler(extData) {
14
+ if (extData?.tool_name === "document_artifact") {
15
+ extData.handler = drawDocumentArtifactTool;
16
+ }
17
+}
18
+
19
+async function openOfficeCanvas(kvps = {}) {
20
+ const canvas = globalThis.Alpine?.store?.("rightCanvas")
21
+ || (await import("/components/canvas/right-canvas-store.js")).store;
22
+ await canvas?.open?.("office", {
23
+ path: kvps.path || "",
24
+ file_id: kvps.file_id || "",
25
+ source: "tool",
26
+ });
27
+}
28
+
29
+function drawDocumentArtifactTool({
30
+ id,
31
+ type,
32
+ heading,
33
+ content,
34
+ kvps,
35
+ timestamp,
36
+ agentno = 0,
37
+ ...additional
38
+}) {
39
+ const args = arguments[0];
40
+ const title = cleanStepTitle(heading);
41
+ const displayKvps = { ...kvps };
42
+ const contentText = String(content ?? "");
43
+ const headerLabels = [
44
+ kvps?._tool_name && { label: kvps._tool_name, class: "tool-name-badge" },
45
+ kvps?.format && { label: String(kvps.format).toUpperCase(), class: "tool-name-badge" },
46
+ ].filter(Boolean);
47
+
48
+ const actionButtons = [
49
+ createActionButton("description", "Office", () => openOfficeCanvas(kvps)),
50
+ ];
51
+
52
+ if (kvps?.path) {
53
+ actionButtons.push(
54
+ createActionButton("content_copy", "Path", () => copyToClipboard(kvps.path)),
55
+ );
56
+ }
57
+
58
+ if (contentText.trim()) {
59
+ actionButtons.push(
60
+ createActionButton("history", "Versions", () =>
61
+ stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
62
+ ),
63
+ createActionButton("detail", "", () =>
64
+ stepDetailStore.showStepDetail(buildDetailPayload(args, { headerLabels })),
65
+ ),
66
+ createActionButton("speak", "", () => speechStore.speak(contentText)),
67
+ createActionButton("copy", "", () => copyToClipboard(contentText)),
68
+ );
69
+ }
70
+
71
+ return drawProcessStep({
72
+ id,
73
+ title,
74
+ code: "DOC",
75
+ classes: undefined,
76
+ kvps: displayKvps,
77
+ content,
78
+ actionButtons: actionButtons.filter(Boolean),
79
+ log: args,
80
+ });
81
+}
plugins/_office/extensions/webui/right-canvas-panels/office-panel.html
new
+8
@@ -0,0 +1,8 @@
1
+<div
2
+ class="right-canvas-surface-panel office-canvas-surface"
3
+ data-surface-id="office"
4
+ x-show="$store.rightCanvas && $store.rightCanvas.isOpen && $store.rightCanvas.activeSurfaceId === 'office'"
5
+ style="display: none;"
6
+>
7
+ <x-component path="/plugins/_office/webui/office-panel.html"></x-component>
8
+</div>
plugins/_office/extensions/webui/right_canvas_register_surfaces/register-office.js
new
+38
@@ -0,0 +1,38 @@
1
+function waitForElement(selector, timeoutMs = 3000) {
2
+ const found = document.querySelector(selector);
3
+ if (found) return Promise.resolve(found);
4
+ return new Promise((resolve) => {
5
+ const timeout = globalThis.setTimeout(() => {
6
+ observer.disconnect();
7
+ resolve(document.querySelector(selector));
8
+ }, timeoutMs);
9
+ const observer = new MutationObserver(() => {
10
+ const element = document.querySelector(selector);
11
+ if (!element) return;
12
+ globalThis.clearTimeout(timeout);
13
+ observer.disconnect();
14
+ resolve(element);
15
+ });
16
+ observer.observe(document.body, { childList: true, subtree: true });
17
+ });
18
+}
19
+
20
+export default async function registerOfficeSurface(canvas) {
21
+ canvas.registerSurface({
22
+ id: "office",
23
+ title: "Office",
24
+ icon: "description",
25
+ order: 20,
26
+ modalPath: "/plugins/_office/webui/main.html",
27
+ async open(payload = {}) {
28
+ const panel = await waitForElement('[data-surface-id="office"] .office-panel');
29
+ const office = globalThis.Alpine?.store?.("office");
30
+ await office?.onMount?.(panel, { mode: "canvas" });
31
+ await office?.onOpen?.(payload);
32
+ },
33
+ async close() {
34
+ const office = globalThis.Alpine?.store?.("office");
35
+ office?.beforeHostHidden?.();
36
+ },
37
+ });
38
+}
plugins/_office/helpers/collabora_runtime.py
new
+348
@@ -0,0 +1,348 @@
1
+from __future__ import annotations
2
+
3
+import fcntl
4
+import os
5
+import shutil
6
+import subprocess
7
+import threading
8
+import time
9
+from pathlib import Path
10
+
11
+from plugins._office.helpers import collabora_status as status
12
+
13
+
14
+LOCK_FILE = status.RUNTIME_DIR / "bootstrap.lock"
15
+WRAPPER_FILE = status.RUNTIME_DIR / "run_coolwsd.sh"
16
+SUPERVISOR_CONF = Path("/etc/supervisor/conf.d/a0_office_collabora.conf")
17
+SUPERVISOR_INCLUDE_PATTERN = "/etc/supervisor/conf.d/a0_office_*.conf"
18
+SOURCES_FILE = Path("/etc/apt/sources.list.d/collaboraonline.sources")
19
+KEYRING_FILE = Path("/etc/apt/keyrings/collaboraonline-release-keyring.gpg")
20
+
21
+_worker_lock = threading.Lock()
22
+_worker: threading.Thread | None = None
23
+
24
+
25
+def start_bootstrap_worker(force: bool = False) -> bool:
26
+ global _worker
27
+ with _worker_lock:
28
+ if _worker and _worker.is_alive():
29
+ return False
30
+ _worker = threading.Thread(target=bootstrap, kwargs={"force": force}, name="a0-office-collabora-bootstrap", daemon=True)
31
+ _worker.start()
32
+ return True
33
+
34
+
35
+def bootstrap(force: bool = False) -> None:
36
+ status.ensure_dirs()
37
+ with LOCK_FILE.open("w", encoding="utf-8") as lock:
38
+ try:
39
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
40
+ except BlockingIOError:
41
+ status.append_log("bootstrap already running")
42
+ return
43
+
44
+ try:
45
+ _bootstrap_locked(force=force)
46
+ except Exception as exc:
47
+ status.append_log(f"bootstrap failed: {exc}")
48
+ status.write_status("failed", healthy=False, installing=False, message=str(exc))
49
+
50
+
51
+def _bootstrap_locked(force: bool = False) -> None:
52
+ status.write_status("installing", healthy=False, installing=True, message="Preparing Collabora Online")
53
+ status.append_log("bootstrap start")
54
+
55
+ _write_wrapper()
56
+ _write_supervisor_conf()
57
+ _reread_supervisor()
58
+
59
+ if status.packages_installed() and not force:
60
+ status.append_log("coolwsd and code-brand already installed")
61
+ _restart_supervisor()
62
+ _finish_status()
63
+ return
64
+
65
+ if not _can_install():
66
+ status.write_status("degraded", healthy=False, installing=False, message="Container does not support automatic apt installation")
67
+ _restart_supervisor()
68
+ return
69
+
70
+ _ensure_code_repo()
71
+ _wait_for_apt_locks()
72
+ _run(["apt-get", "update"], timeout=600)
73
+ _run([
74
+ "apt-get",
75
+ "install",
76
+ "-y",
77
+ "--no-install-recommends",
78
+ "coolwsd",
79
+ "coolwsd-deprecated",
80
+ "code-brand",
81
+ ], timeout=1800, env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"})
82
+
83
+ _restart_supervisor()
84
+ _finish_status()
85
+
86
+
87
+def reconcile() -> None:
88
+ wrapper_changed = _write_wrapper()
89
+ supervisor_changed = _write_supervisor_conf()
90
+ if supervisor_changed:
91
+ _reread_supervisor()
92
+ current = status.collect_status()
93
+ if current.get("healthy"):
94
+ if wrapper_changed or supervisor_changed:
95
+ status.append_log("Collabora runtime configuration changed; restarting service")
96
+ _restart_supervisor()
97
+ time.sleep(1)
98
+ current = status.collect_status()
99
+ status.write_status("healthy", healthy=True, installed=True, installing=False, degraded=False, message="Collabora Online is healthy")
100
+ return
101
+ if current.get("installed"):
102
+ _reread_supervisor()
103
+ _restart_supervisor()
104
+ status.write_status("degraded", healthy=False, installing=False, message="Collabora is installed but not healthy")
105
+ return
106
+ start_bootstrap_worker(force=False)
107
+
108
+
109
+def retry_bootstrap() -> None:
110
+ start_bootstrap_worker(force=True)
111
+
112
+
113
+def _finish_status() -> None:
114
+ for _ in range(12):
115
+ current = status.collect_status()
116
+ if current.get("healthy"):
117
+ status.write_status("healthy", healthy=True, installed=True, installing=False, degraded=False, message="Collabora Online is healthy")
118
+ return
119
+ time.sleep(2)
120
+ current = status.collect_status()
121
+ state = "degraded" if current.get("installed") else "failed"
122
+ status.write_status(
123
+ state,
124
+ healthy=False,
125
+ installed=bool(current.get("installed")),
126
+ installing=False,
127
+ degraded=bool(current.get("installed")),
128
+ message="Collabora did not become healthy yet",
129
+ )
130
+
131
+
132
+def _can_install() -> bool:
133
+ return os.geteuid() == 0 and shutil.which("apt-get") is not None and shutil.which("dpkg") is not None
134
+
135
+
136
+def _ensure_code_repo() -> None:
137
+ KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
138
+ if not KEYRING_FILE.exists():
139
+ _run([
140
+ "wget",
141
+ "-O",
142
+ str(KEYRING_FILE),
143
+ "https://collaboraoffice.com/downloads/gpg/collaboraonline-release-keyring.gpg",
144
+ ], timeout=300)
145
+ SOURCES_FILE.write_text(
146
+ "\n".join([
147
+ "Types: deb",
148
+ "URIs: https://www.collaboraoffice.com/repos/CollaboraOnline/CODE-deb",
149
+ "Suites: ./",
150
+ f"Signed-By: {KEYRING_FILE}",
151
+ "",
152
+ ]),
153
+ encoding="utf-8",
154
+ )
155
+
156
+
157
+def _wait_for_apt_locks(timeout: int = 180) -> None:
158
+ locks = [
159
+ "/var/lib/dpkg/lock-frontend",
160
+ "/var/lib/dpkg/lock",
161
+ "/var/lib/apt/lists/lock",
162
+ "/var/cache/apt/archives/lock",
163
+ ]
164
+ deadline = time.time() + timeout
165
+ while time.time() < deadline:
166
+ busy = False
167
+ for lock_path in locks:
168
+ try:
169
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
170
+ try:
171
+ fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
172
+ fcntl.lockf(fd, fcntl.LOCK_UN)
173
+ except OSError:
174
+ busy = True
175
+ finally:
176
+ os.close(fd)
177
+ except OSError:
178
+ continue
179
+ if not busy:
180
+ return
181
+ status.append_log("waiting for apt/dpkg locks")
182
+ time.sleep(3)
183
+ raise TimeoutError("Timed out waiting for apt/dpkg locks")
184
+
185
+
186
+def _write_wrapper() -> bool:
187
+ status.ensure_dirs()
188
+ changed = _write_text_if_changed(
189
+ WRAPPER_FILE,
190
+ """#!/usr/bin/env bash
191
+set -u
192
+LOG="/a0/tmp/_office/collabora/coolwsd-wrapper.log"
193
+LOG_DIR="$(dirname "$LOG")"
194
+mkdir -p "$LOG_DIR" /opt/cool/cache /opt/cool/child-roots
195
+while true; do
196
+ if ! command -v coolwsd >/dev/null 2>&1; then
197
+ echo "$(date -u +%FT%TZ) coolwsd missing; sleeping" >> "$LOG"
198
+ sleep 20
199
+ continue
200
+ fi
201
+ if ! id cool >/dev/null 2>&1; then
202
+ echo "$(date -u +%FT%TZ) cool user missing; sleeping" >> "$LOG"
203
+ sleep 20
204
+ continue
205
+ fi
206
+ chown -R cool:cool "$LOG_DIR" /opt/cool/cache /opt/cool/child-roots 2>/dev/null || true
207
+ args=(
208
+ --o:sys_template_path=/opt/cool/systemplate
209
+ --o:child_root_path=/opt/cool/child-roots
210
+ --o:file_server_root_path=/usr/share/coolwsd
211
+ --o:cache_files.path=/opt/cool/cache
212
+ --o:ssl.enable=false
213
+ --o:ssl.termination=false
214
+ --o:net.listen=loopback
215
+ --o:net.proto=IPv4
216
+ --o:net.service_root=/office
217
+ --o:home_mode.enable=true
218
+ )
219
+ if command -v runuser >/dev/null 2>&1; then
220
+ runuser -u cool -- /usr/bin/coolwsd "${args[@]}" >> "$LOG" 2>&1 &
221
+ else
222
+ su -s /bin/bash cool -c 'exec /usr/bin/coolwsd "$@"' coolwsd "${args[@]}" >> "$LOG" 2>&1 &
223
+ fi
224
+ child=$!
225
+ trap 'kill -TERM "$child" 2>/dev/null; wait "$child" 2>/dev/null; exit 0' TERM INT
226
+ wait "$child"
227
+ code=$?
228
+ echo "$(date -u +%FT%TZ) coolwsd exited with ${code}; restarting after backoff" >> "$LOG"
229
+ sleep 5
230
+done
231
+""",
232
+ )
233
+ WRAPPER_FILE.chmod(0o755)
234
+ return changed
235
+
236
+
237
+def _write_supervisor_conf() -> bool:
238
+ include_changed = _ensure_supervisor_include()
239
+ if not os.access("/etc/supervisor/conf.d", os.W_OK):
240
+ status.append_log("supervisor conf directory is not writable")
241
+ return include_changed
242
+ conf_changed = _write_text_if_changed(
243
+ SUPERVISOR_CONF,
244
+ f"""[program:{status.SUPERVISOR_PROGRAM}]
245
+command={WRAPPER_FILE}
246
+autostart=true
247
+autorestart=true
248
+startsecs=0
249
+startretries=999999
250
+stopsignal=TERM
251
+stdout_logfile=/a0/tmp/_office/collabora/supervisor.log
252
+stderr_logfile=/a0/tmp/_office/collabora/supervisor.err.log
253
+""",
254
+ )
255
+ return include_changed or conf_changed
256
+
257
+
258
+def _ensure_supervisor_include() -> bool:
259
+ active_config = _active_supervisor_config()
260
+ if not active_config or not active_config.exists() or not os.access(active_config, os.W_OK):
261
+ return False
262
+ try:
263
+ text = active_config.read_text(encoding="utf-8")
264
+ except OSError:
265
+ return False
266
+ if SUPERVISOR_INCLUDE_PATTERN in text:
267
+ return False
268
+ if "\n[include]\n" in f"\n{text}":
269
+ updated = _append_to_include_files(text, SUPERVISOR_INCLUDE_PATTERN)
270
+ else:
271
+ updated = text.rstrip() + "\n\n[include]\nfiles = " + SUPERVISOR_INCLUDE_PATTERN + "\n"
272
+ if updated != text:
273
+ active_config.write_text(updated, encoding="utf-8")
274
+ return True
275
+ return False
276
+
277
+
278
+def _active_supervisor_config() -> Path | None:
279
+ cmdline = Path("/proc/1/cmdline")
280
+ try:
281
+ parts = [part for part in cmdline.read_text(encoding="utf-8").split("\x00") if part]
282
+ except OSError:
283
+ return None
284
+ for index, part in enumerate(parts):
285
+ if part == "-c" and index + 1 < len(parts):
286
+ return Path(parts[index + 1])
287
+ if part.startswith("-c") and len(part) > 2:
288
+ return Path(part[2:])
289
+ return Path("/etc/supervisor/supervisord.conf")
290
+
291
+
292
+def _append_to_include_files(text: str, pattern: str) -> str:
293
+ lines = text.splitlines()
294
+ in_include = False
295
+ for index, line in enumerate(lines):
296
+ stripped = line.strip()
297
+ if stripped.startswith("[") and stripped.endswith("]"):
298
+ in_include = stripped.lower() == "[include]"
299
+ continue
300
+ if in_include and stripped.startswith("files"):
301
+ separator = " " if line.rstrip().endswith("=") else " "
302
+ lines[index] = line.rstrip() + separator + pattern
303
+ return "\n".join(lines) + ("\n" if text.endswith("\n") else "")
304
+ return text.rstrip() + "\nfiles = " + pattern + "\n"
305
+
306
+
307
+def _write_text_if_changed(path: Path, text: str) -> bool:
308
+ try:
309
+ if path.exists() and path.read_text(encoding="utf-8") == text:
310
+ return False
311
+ except OSError:
312
+ pass
313
+ path.write_text(text, encoding="utf-8")
314
+ return True
315
+
316
+
317
+def _reread_supervisor() -> None:
318
+ if not shutil.which("supervisorctl"):
319
+ return
320
+ _run(["supervisorctl", "reread"], timeout=20, check=False)
321
+ _run(["supervisorctl", "update", status.SUPERVISOR_PROGRAM], timeout=30, check=False)
322
+
323
+
324
+def _restart_supervisor() -> None:
325
+ if not shutil.which("supervisorctl"):
326
+ return
327
+ _run(["supervisorctl", "restart", status.SUPERVISOR_PROGRAM], timeout=30, check=False)
328
+
329
+
330
+def _run(args: list[str], timeout: int, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
331
+ status.append_log("$ " + " ".join(args))
332
+ result = subprocess.run(
333
+ args,
334
+ text=True,
335
+ stdout=subprocess.PIPE,
336
+ stderr=subprocess.STDOUT,
337
+ timeout=timeout,
338
+ env=env,
339
+ check=False,
340
+ )
341
+ if result.stdout:
342
+ with status.BOOTSTRAP_LOG.open("a", encoding="utf-8") as handle:
343
+ handle.write(result.stdout)
344
+ if not result.stdout.endswith("\n"):
345
+ handle.write("\n")
346
+ if check and result.returncode != 0:
347
+ raise RuntimeError(f"{' '.join(args)} failed with exit {result.returncode}")
348
+ return result
plugins/_office/helpers/collabora_status.py
new
+181
@@ -0,0 +1,181 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+import os
5
+import shutil
6
+import subprocess
7
+import time
8
+from pathlib import Path
9
+from typing import Any
10
+from urllib.request import Request, urlopen
11
+
12
+from helpers import files
13
+
14
+
15
+PLUGIN_NAME = "_office"
16
+RUNTIME_DIR = Path(files.get_abs_path("tmp", PLUGIN_NAME, "collabora"))
17
+STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "collabora"))
18
+STATUS_FILE = RUNTIME_DIR / "status.json"
19
+BOOTSTRAP_LOG = RUNTIME_DIR / "bootstrap.log"
20
+WRAPPER_LOG = RUNTIME_DIR / "coolwsd-wrapper.log"
21
+SUPERVISOR_PROGRAM = "a0_office_collabora"
22
+
23
+
24
+def ensure_dirs() -> None:
25
+ RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
26
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
27
+ (STATE_DIR / "backups").mkdir(parents=True, exist_ok=True)
28
+
29
+
30
+def now_ts() -> float:
31
+ return time.time()
32
+
33
+
34
+def read_status() -> dict[str, Any]:
35
+ ensure_dirs()
36
+ if not STATUS_FILE.exists():
37
+ return default_status("idle")
38
+ try:
39
+ data = json.loads(STATUS_FILE.read_text(encoding="utf-8"))
40
+ if isinstance(data, dict):
41
+ return {**default_status("idle"), **data}
42
+ except Exception:
43
+ pass
44
+ return default_status("idle")
45
+
46
+
47
+def write_status(state: str, **extra: Any) -> dict[str, Any]:
48
+ ensure_dirs()
49
+ payload = {
50
+ **read_status(),
51
+ "state": state,
52
+ "updated_at": now_ts(),
53
+ **extra,
54
+ }
55
+ STATUS_FILE.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
56
+ return payload
57
+
58
+
59
+def default_status(state: str = "idle") -> dict[str, Any]:
60
+ return {
61
+ "plugin": PLUGIN_NAME,
62
+ "state": state,
63
+ "healthy": False,
64
+ "installed": False,
65
+ "installing": False,
66
+ "degraded": False,
67
+ "message": "",
68
+ "updated_at": 0,
69
+ "runtime_dir": str(RUNTIME_DIR),
70
+ "state_dir": str(STATE_DIR),
71
+ "status_file": str(STATUS_FILE),
72
+ "bootstrap_log": str(BOOTSTRAP_LOG),
73
+ "wrapper_log": str(WRAPPER_LOG),
74
+ }
75
+
76
+
77
+def append_log(message: str) -> None:
78
+ ensure_dirs()
79
+ line = f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} {message}\n"
80
+ with BOOTSTRAP_LOG.open("a", encoding="utf-8") as handle:
81
+ handle.write(line)
82
+
83
+
84
+def tail_file(path: Path, max_bytes: int = 16000) -> str:
85
+ try:
86
+ with path.open("rb") as handle:
87
+ handle.seek(0, os.SEEK_END)
88
+ size = handle.tell()
89
+ handle.seek(max(0, size - max_bytes))
90
+ return handle.read().decode("utf-8", errors="replace")
91
+ except FileNotFoundError:
92
+ return ""
93
+ except Exception as exc:
94
+ return f"Could not read log: {exc}"
95
+
96
+
97
+def run_command(args: list[str], timeout: int = 10) -> subprocess.CompletedProcess[str]:
98
+ return subprocess.run(
99
+ args,
100
+ text=True,
101
+ stdout=subprocess.PIPE,
102
+ stderr=subprocess.STDOUT,
103
+ timeout=timeout,
104
+ check=False,
105
+ )
106
+
107
+
108
+def command_exists(name: str) -> bool:
109
+ return shutil.which(name) is not None
110
+
111
+
112
+def package_installed(name: str) -> bool:
113
+ if not command_exists("dpkg-query"):
114
+ return False
115
+ result = run_command(["dpkg-query", "-W", "-f=${Status}", name], timeout=8)
116
+ return result.returncode == 0 and "install ok installed" in result.stdout
117
+
118
+
119
+def packages_installed() -> bool:
120
+ return (
121
+ command_exists("coolwsd")
122
+ and command_exists("coolforkit-caps")
123
+ and package_installed("coolwsd")
124
+ and package_installed("coolwsd-deprecated")
125
+ and package_installed("code-brand")
126
+ )
127
+
128
+
129
+def supervisor_status() -> str:
130
+ if not command_exists("supervisorctl"):
131
+ return "supervisorctl unavailable"
132
+ result = run_command(["supervisorctl", "status", SUPERVISOR_PROGRAM], timeout=8)
133
+ return (result.stdout or "").strip() or f"exit {result.returncode}"
134
+
135
+
136
+def process_status() -> str:
137
+ if not command_exists("pgrep"):
138
+ return ""
139
+ result = run_command(["pgrep", "-a", "coolwsd"], timeout=8)
140
+ return (result.stdout or "").strip()
141
+
142
+
143
+def discovery_ok() -> bool:
144
+ for url in (
145
+ "http://127.0.0.1:9980/office/hosting/discovery",
146
+ "http://127.0.0.1:9980/hosting/discovery",
147
+ ):
148
+ try:
149
+ request = Request(url, headers={"User-Agent": "Agent-Zero-Office/1.0"})
150
+ with urlopen(request, timeout=5) as response:
151
+ body = response.read(256)
152
+ if response.status == 200 and b"wopi-discovery" in body.lower():
153
+ return True
154
+ except Exception:
155
+ continue
156
+ return False
157
+
158
+
159
+def collect_status() -> dict[str, Any]:
160
+ ensure_dirs()
161
+ installed = packages_installed()
162
+ supervisor = supervisor_status()
163
+ process = process_status()
164
+ http_ok = discovery_ok()
165
+ healthy = installed and http_ok
166
+ saved = read_status()
167
+ installing = saved.get("state") == "installing"
168
+ state = "healthy" if healthy else ("installing" if installing else ("degraded" if installed else saved.get("state") or "idle"))
169
+ return {
170
+ **saved,
171
+ "state": state,
172
+ "healthy": healthy,
173
+ "installed": installed,
174
+ "installing": installing and not healthy,
175
+ "degraded": installed and not healthy,
176
+ "coolwsd_path": shutil.which("coolwsd") or "",
177
+ "supervisor": supervisor,
178
+ "process": process,
179
+ "discovery_ok": http_ok,
180
+ "updated_at": now_ts(),
181
+ }
plugins/_office/helpers/office_proxy.py
new
+261
@@ -0,0 +1,261 @@
1
+from __future__ import annotations
2
+
3
+import asyncio
4
+from http.cookies import SimpleCookie
5
+from urllib.parse import parse_qs, unquote
6
+
7
+import httpx
8
+from flask.sessions import SecureCookieSessionInterface
9
+from starlette.responses import PlainTextResponse, Response
10
+from starlette.types import Receive, Scope, Send
11
+from starlette.websockets import WebSocket
12
+
13
+from helpers import login
14
+from plugins._office.helpers import wopi_store
15
+
16
+
17
+UPSTREAM_HTTP = "http://127.0.0.1:9980"
18
+UPSTREAM_WS = "ws://127.0.0.1:9980"
19
+HTTP_PROXY_ATTEMPTS = 4
20
+HTTP_PROXY_RETRY_DELAYS = (0.2, 0.5, 1.0)
21
+TRANSIENT_HTTP_ERRORS = (
22
+ httpx.ConnectError,
23
+ httpx.ConnectTimeout,
24
+ httpx.ReadError,
25
+ httpx.ReadTimeout,
26
+ httpx.RemoteProtocolError,
27
+ httpx.WriteError,
28
+ httpx.WriteTimeout,
29
+)
30
+
31
+
32
+class OfficeProxy:
33
+ def __init__(self, flask_app=None) -> None:
34
+ self.flask_app = flask_app
35
+
36
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
37
+ if scope["type"] == "websocket":
38
+ await self.websocket(scope, receive, send)
39
+ return
40
+ if scope["type"] == "http":
41
+ await self.http(scope, receive, send)
42
+ return
43
+ await PlainTextResponse("Unsupported scope", status_code=500)(scope, receive, send)
44
+
45
+ def upstream_path(self, scope: Scope) -> str:
46
+ raw_path = scope.get("raw_path")
47
+ if raw_path:
48
+ path = raw_path.decode("latin-1")
49
+ else:
50
+ path = scope.get("path", "")
51
+ if not path.startswith("/office"):
52
+ path = "/office" + (path if path.startswith("/") else "/" + path)
53
+ query = scope.get("query_string", b"").decode("latin-1")
54
+ return path + (f"?{query}" if query else "")
55
+
56
+ async def http(self, scope: Scope, receive: Receive, send: Send) -> None:
57
+ if not self.is_authorized(scope):
58
+ await PlainTextResponse("Authentication required", status_code=401)(scope, receive, send)
59
+ return
60
+
61
+ body = b""
62
+ more = True
63
+ while more:
64
+ message = await receive()
65
+ if message["type"] != "http.request":
66
+ break
67
+ body += message.get("body", b"")
68
+ more = bool(message.get("more_body"))
69
+
70
+ method = scope.get("method", "GET")
71
+ headers = self.forward_headers(scope)
72
+ url = UPSTREAM_HTTP + self.upstream_path(scope)
73
+ try:
74
+ upstream, attempts = await self.request_upstream_http(method, url, body, headers)
75
+ disable_cache = self.should_disable_cache(scope, upstream.status_code)
76
+ omitted_headers = {"content-encoding", "content-length", "transfer-encoding", "connection"}
77
+ if disable_cache:
78
+ omitted_headers.update({"cache-control", "pragma", "expires"})
79
+ response_headers = {
80
+ key: value
81
+ for key, value in upstream.headers.items()
82
+ if key.lower() not in omitted_headers
83
+ }
84
+ response_headers["X-A0-Office-Proxy-Attempts"] = str(attempts)
85
+ if disable_cache:
86
+ response_headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"
87
+ response_headers["Pragma"] = "no-cache"
88
+ response_headers["Expires"] = "0"
89
+ await Response(upstream.content, status_code=upstream.status_code, headers=response_headers)(scope, receive, send)
90
+ except Exception as exc:
91
+ await PlainTextResponse(
92
+ f"Collabora is unavailable after {HTTP_PROXY_ATTEMPTS} attempts: {exc}",
93
+ status_code=503,
94
+ headers={
95
+ "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0",
96
+ "Pragma": "no-cache",
97
+ "Expires": "0",
98
+ },
99
+ )(scope, receive, send)
100
+
101
+ async def request_upstream_http(
102
+ self,
103
+ method: str,
104
+ url: str,
105
+ body: bytes,
106
+ headers: dict[str, str],
107
+ ) -> tuple[httpx.Response, int]:
108
+ for attempt in range(1, HTTP_PROXY_ATTEMPTS + 1):
109
+ try:
110
+ async with httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=36000.0), follow_redirects=False) as client:
111
+ response = await client.request(method, url, content=body, headers=headers)
112
+ return response, attempt
113
+ except TRANSIENT_HTTP_ERRORS:
114
+ if attempt >= HTTP_PROXY_ATTEMPTS:
115
+ raise
116
+ delay_index = min(attempt - 1, len(HTTP_PROXY_RETRY_DELAYS) - 1)
117
+ await asyncio.sleep(HTTP_PROXY_RETRY_DELAYS[delay_index])
118
+ raise RuntimeError("Collabora proxy retry loop exited unexpectedly")
119
+
120
+ def should_disable_cache(self, scope: Scope, status_code: int) -> bool:
121
+ path = self.upstream_path(scope).split("?", 1)[0]
122
+ return status_code >= 500 or path.endswith("/cool.html")
123
+
124
+ async def websocket(self, scope: Scope, receive: Receive, send: Send) -> None:
125
+ import websockets
126
+
127
+ websocket = WebSocket(scope, receive=receive, send=send)
128
+ if not self.is_authorized(scope):
129
+ await websocket.close(code=1008)
130
+ return
131
+
132
+ await websocket.accept()
133
+ url = self.upstream_websocket_url(scope)
134
+ headers = self.websocket_headers(scope)
135
+ origin = self.header_value(scope, b"origin", "")
136
+ try:
137
+ async with websockets.connect(
138
+ url,
139
+ host="127.0.0.1",
140
+ port=9980,
141
+ origin=origin or None,
142
+ additional_headers=headers,
143
+ open_timeout=10,
144
+ ping_interval=None,
145
+ ) as upstream:
146
+ async def browser_to_upstream():
147
+ while True:
148
+ msg = await websocket.receive()
149
+ if msg["type"] == "websocket.disconnect":
150
+ await upstream.close()
151
+ return
152
+ if "bytes" in msg and msg["bytes"] is not None:
153
+ await upstream.send(msg["bytes"])
154
+ elif "text" in msg and msg["text"] is not None:
155
+ await upstream.send(msg["text"])
156
+
157
+ async def upstream_to_browser():
158
+ async for msg in upstream:
159
+ if isinstance(msg, bytes):
160
+ await websocket.send_bytes(msg)
161
+ else:
162
+ await websocket.send_text(msg)
163
+
164
+ await asyncio.gather(browser_to_upstream(), upstream_to_browser())
165
+ except Exception:
166
+ await websocket.close(code=1011)
167
+
168
+ def forward_headers(self, scope: Scope) -> dict[str, str]:
169
+ headers: dict[str, str] = {}
170
+ for key_b, value_b in scope.get("headers", []):
171
+ key = key_b.decode("latin-1")
172
+ value = value_b.decode("latin-1")
173
+ if key.lower() in {"host", "content-length", "connection"}:
174
+ continue
175
+ headers[key] = value
176
+ host = dict(scope.get("headers", [])).get(b"host", b"localhost:32080").decode("latin-1")
177
+ headers["Host"] = host
178
+ headers["X-Forwarded-Proto"] = scope.get("scheme", "http")
179
+ return headers
180
+
181
+ def upstream_websocket_url(self, scope: Scope) -> str:
182
+ host = self.header_value(scope, b"host", "localhost:32080")
183
+ return f"ws://{host}{self.upstream_path(scope)}"
184
+
185
+ def websocket_headers(self, scope: Scope) -> list[tuple[str, str]]:
186
+ hop_by_hop = {
187
+ b"host",
188
+ b"connection",
189
+ b"upgrade",
190
+ b"origin",
191
+ b"sec-websocket-key",
192
+ b"sec-websocket-version",
193
+ b"sec-websocket-extensions",
194
+ }
195
+ headers = [
196
+ (key.decode("latin-1"), value.decode("latin-1"))
197
+ for key, value in scope.get("headers", [])
198
+ if key.lower() not in hop_by_hop
199
+ ]
200
+ return headers
201
+
202
+ def header_value(self, scope: Scope, name: bytes, default: str = "") -> str:
203
+ value = dict(scope.get("headers", [])).get(name, default.encode("latin-1"))
204
+ return value.decode("latin-1") if isinstance(value, bytes) else str(value)
205
+
206
+ def is_authorized(self, scope: Scope) -> bool:
207
+ if self._has_valid_wopi_token(scope):
208
+ return True
209
+ credentials_hash = login.get_credentials_hash()
210
+ if not credentials_hash:
211
+ return True
212
+ if not self.flask_app:
213
+ return False
214
+ serializer = SecureCookieSessionInterface().get_signing_serializer(self.flask_app)
215
+ if not serializer:
216
+ return False
217
+ cookie_header = dict(scope.get("headers", [])).get(b"cookie", b"").decode("latin-1")
218
+ if not cookie_header:
219
+ return False
220
+ cookies = SimpleCookie()
221
+ cookies.load(cookie_header)
222
+ session_cookie = cookies.get(self.flask_app.config.get("SESSION_COOKIE_NAME", "session"))
223
+ if not session_cookie:
224
+ return False
225
+ try:
226
+ session_data = serializer.loads(session_cookie.value)
227
+ except Exception:
228
+ return False
229
+ return session_data.get("authentication") == credentials_hash
230
+
231
+ def _has_valid_wopi_token(self, scope: Scope) -> bool:
232
+ if not self._is_collabora_editor_channel(scope):
233
+ return False
234
+ path = self.upstream_path(scope)
235
+ decoded = unquote(path)
236
+ marker = "/wopi/files/"
237
+ marker_index = decoded.find(marker)
238
+ if marker_index == -1:
239
+ return False
240
+ file_part = decoded[marker_index + len(marker):]
241
+ file_id, separator, query_text = file_part.partition("?")
242
+ if not separator or not file_id:
243
+ return False
244
+ file_id = file_id.strip("/")
245
+ if "/" in file_id:
246
+ file_id = file_id.split("/", 1)[0]
247
+ token = (parse_qs(query_text, keep_blank_values=True).get("access_token") or [""])[0]
248
+ if not token:
249
+ return False
250
+ try:
251
+ wopi_store.validate_token(token, file_id, require_write=False)
252
+ except Exception:
253
+ return False
254
+ return True
255
+
256
+ def _is_collabora_editor_channel(self, scope: Scope) -> bool:
257
+ path = scope.get("path", "")
258
+ raw_path = scope.get("raw_path")
259
+ if raw_path:
260
+ path = raw_path.decode("latin-1", errors="ignore")
261
+ return path.startswith("/office/cool/")
plugins/_office/helpers/route_bootstrap.py
new
+61
@@ -0,0 +1,61 @@
1
+from __future__ import annotations
2
+
3
+
4
+def install_route_hooks() -> None:
5
+ from helpers.ui_server import UiServerRuntime
6
+
7
+ if getattr(UiServerRuntime, "_a0_office_route_hooks_installed", False):
8
+ return
9
+
10
+ original_register_http_routes = UiServerRuntime.register_http_routes
11
+ original_build_asgi_app = UiServerRuntime.build_asgi_app
12
+
13
+ def register_http_routes(self):
14
+ result = original_register_http_routes(self)
15
+ from plugins._office.helpers.wopi_routes import register_wopi_routes
16
+
17
+ register_wopi_routes(self.webapp)
18
+ return result
19
+
20
+ def build_asgi_app(self, startup_monitor):
21
+ from socketio import ASGIApp
22
+ from starlette.applications import Starlette
23
+ from starlette.routing import Mount
24
+ from uvicorn.middleware.wsgi import WSGIMiddleware
25
+
26
+ from helpers import fasta2a_server, mcp_server
27
+ from plugins._office.helpers.office_proxy import OfficeProxy
28
+
29
+ with startup_monitor.stage("wsgi.middleware.create"):
30
+ wsgi_app = WSGIMiddleware(self.webapp)
31
+
32
+ with startup_monitor.stage("mcp.proxy.init"):
33
+ mcp_app = mcp_server.DynamicMcpProxy.get_instance()
34
+
35
+ with startup_monitor.stage("a2a.proxy.init"):
36
+ a2a_app = fasta2a_server.DynamicA2AProxy.get_instance()
37
+
38
+ with startup_monitor.stage("starlette.app.create"):
39
+ starlette_app = Starlette(
40
+ routes=[
41
+ Mount("/office", app=OfficeProxy(self.webapp)),
42
+ Mount("/mcp", app=mcp_app),
43
+ Mount("/a2a", app=a2a_app),
44
+ Mount("/", app=wsgi_app),
45
+ ],
46
+ lifespan=startup_monitor.lifespan(),
47
+ )
48
+
49
+ with startup_monitor.stage("socketio.asgi.create"):
50
+ return ASGIApp(self.socketio_server, other_asgi_app=starlette_app)
51
+
52
+ UiServerRuntime.register_http_routes = register_http_routes
53
+ UiServerRuntime.build_asgi_app = build_asgi_app
54
+ UiServerRuntime._a0_office_route_hooks_installed = True
55
+ UiServerRuntime._a0_office_original_build_asgi_app = original_build_asgi_app
56
+
57
+
58
+def is_installed() -> bool:
59
+ from helpers.ui_server import UiServerRuntime
60
+
61
+ return bool(getattr(UiServerRuntime, "_a0_office_route_hooks_installed", False))
plugins/_office/helpers/wopi_routes.py
new
+133
@@ -0,0 +1,133 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+import mimetypes
5
+from pathlib import Path
6
+from typing import Any
7
+
8
+from flask import Flask, Response, request, send_file
9
+
10
+from plugins._office.helpers import wopi_store
11
+
12
+
13
+def register_wopi_routes(app: Flask) -> None:
14
+ if getattr(app, "_a0_office_wopi_routes_registered", False):
15
+ return
16
+ app._a0_office_wopi_routes_registered = True
17
+
18
+ app.add_url_rule("/wopi/files/<file_id>", "office_wopi_file", wopi_file, methods=["GET", "POST"])
19
+ app.add_url_rule("/wopi/files/<file_id>/contents", "office_wopi_contents", wopi_contents, methods=["GET", "POST"])
20
+
21
+
22
+def token_from_request() -> str:
23
+ auth = request.headers.get("Authorization", "")
24
+ if auth.lower().startswith("bearer "):
25
+ return auth.split(" ", 1)[1].strip()
26
+ return request.args.get("access_token", "") or request.form.get("access_token", "")
27
+
28
+
29
+def validate(file_id: str, require_write: bool = False) -> dict[str, Any] | Response:
30
+ try:
31
+ return wopi_store.validate_token(token_from_request(), file_id, require_write=require_write)
32
+ except PermissionError as exc:
33
+ return Response(str(exc), status=401)
34
+ except Exception as exc:
35
+ return Response(str(exc), status=404)
36
+
37
+
38
+def json_response(data: dict[str, Any], status: int = 200, headers: dict[str, str] | None = None) -> Response:
39
+ return Response(
40
+ json.dumps(data, separators=(",", ":"), ensure_ascii=False),
41
+ status=status,
42
+ mimetype="application/json",
43
+ headers=headers or {},
44
+ )
45
+
46
+
47
+def conflict(current_lock: str, reason: str = "Lock mismatch") -> Response:
48
+ return Response(
49
+ "",
50
+ status=409,
51
+ headers={
52
+ "X-WOPI-Lock": current_lock or "",
53
+ "X-WOPI-LockFailureReason": reason,
54
+ },
55
+ )
56
+
57
+
58
+def wopi_file(file_id: str):
59
+ if request.method == "GET":
60
+ token_info = validate(file_id)
61
+ if isinstance(token_info, Response):
62
+ return token_info
63
+ try:
64
+ return json_response(wopi_store.check_file_info(file_id, token_info))
65
+ except FileNotFoundError:
66
+ return Response("File not found", status=404)
67
+ except Exception as exc:
68
+ return Response(str(exc), status=500)
69
+
70
+ override = request.headers.get("X-WOPI-Override", "").upper().replace("-", "_")
71
+ require_write = override in {"LOCK", "REFRESH_LOCK", "UNLOCK"}
72
+ token_info = validate(file_id, require_write=require_write)
73
+ if isinstance(token_info, Response):
74
+ return token_info
75
+
76
+ lock_value = request.headers.get("X-WOPI-Lock", "")
77
+ old_lock = request.headers.get("X-WOPI-OldLock", "")
78
+ timeout = request.headers.get("X-WOPI-LockExpirationTimeout")
79
+ session_id = (token_info.get("token") or {}).get("session_id", "")
80
+
81
+ try:
82
+ if override == "GET_LOCK":
83
+ return Response("", status=200, headers={"X-WOPI-Lock": wopi_store.get_lock(file_id)})
84
+ if override == "LOCK" and old_lock:
85
+ ok, current = wopi_store.unlock_and_relock(file_id, old_lock, lock_value, session_id, timeout)
86
+ return Response("", status=200) if ok else conflict(current)
87
+ if override == "LOCK":
88
+ ok, current = wopi_store.lock(file_id, lock_value, session_id, timeout)
89
+ return Response("", status=200) if ok else conflict(current)
90
+ if override == "REFRESH_LOCK":
91
+ ok, current = wopi_store.refresh_lock(file_id, lock_value, timeout)
92
+ return Response("", status=200) if ok else conflict(current)
93
+ if override == "UNLOCK":
94
+ ok, current = wopi_store.unlock(file_id, lock_value)
95
+ return Response("", status=200) if ok else conflict(current)
96
+ return Response("Unsupported WOPI override", status=501)
97
+ except Exception as exc:
98
+ return Response(str(exc), status=500)
99
+
100
+
101
+def wopi_contents(file_id: str):
102
+ if request.method == "GET":
103
+ token_info = validate(file_id)
104
+ if isinstance(token_info, Response):
105
+ return token_info
106
+ try:
107
+ doc = wopi_store.get_document(file_id)
108
+ path = Path(doc["path"])
109
+ mimetype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
110
+ return send_file(path, mimetype=mimetype, as_attachment=False, download_name=doc["basename"])
111
+ except FileNotFoundError:
112
+ return Response("File not found", status=404)
113
+ except Exception as exc:
114
+ return Response(str(exc), status=500)
115
+
116
+ override = request.headers.get("X-WOPI-Override", "").upper().replace("-", "_")
117
+ if override != "PUT":
118
+ return Response("Unsupported WOPI override", status=501)
119
+ token_info = validate(file_id, require_write=True)
120
+ if isinstance(token_info, Response):
121
+ return token_info
122
+
123
+ try:
124
+ version = wopi_store.put_file(file_id, request.get_data() or b"", request.headers.get("X-WOPI-Lock", ""))
125
+ return Response("", status=200, headers={"X-WOPI-ItemVersion": version})
126
+ except wopi_store.LockMismatch as exc:
127
+ return conflict(exc.current_lock)
128
+ except OverflowError as exc:
129
+ return Response(str(exc), status=413)
130
+ except FileNotFoundError:
131
+ return Response("File not found", status=404)
132
+ except Exception as exc:
133
+ return Response(str(exc), status=500)
plugins/_office/helpers/wopi_store.py
new
+552
@@ -0,0 +1,552 @@
1
+from __future__ import annotations
2
+
3
+import hashlib
4
+import json
5
+import os
6
+import secrets
7
+import shutil
8
+import sqlite3
9
+import time
10
+import uuid
11
+import zipfile
12
+from contextlib import contextmanager
13
+from pathlib import Path
14
+from typing import Any
15
+from xml.sax.saxutils import escape
16
+
17
+from helpers import files
18
+
19
+
20
+PLUGIN_NAME = "_office"
21
+SUPPORTED_EXTENSIONS = {"docx", "xlsx", "pptx", "odt", "ods", "odp"}
22
+DEFAULT_TTL_SECONDS = 8 * 60 * 60
23
+DEFAULT_LOCK_SECONDS = 30 * 60
24
+MAX_LOCK_SECONDS = 3600
25
+MIN_LOCK_SECONDS = 60
26
+MAX_SAVE_BYTES = 512 * 1024 * 1024
27
+
28
+STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "collabora"))
29
+DB_PATH = STATE_DIR / "documents.sqlite3"
30
+BACKUP_DIR = STATE_DIR / "backups"
31
+DOCUMENTS_DIR = Path(files.get_abs_path("usr", "workdir", "documents"))
32
+WORKDIR = Path(files.get_abs_path("usr", "workdir"))
33
+
34
+
35
+def now() -> float:
36
+ return time.time()
37
+
38
+
39
+def now_iso() -> str:
40
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
41
+
42
+
43
+def ensure_dirs() -> None:
44
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
45
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
46
+ DOCUMENTS_DIR.mkdir(parents=True, exist_ok=True)
47
+
48
+
49
+def sha256_bytes(data: bytes) -> str:
50
+ return hashlib.sha256(data).hexdigest()
51
+
52
+
53
+def token_hash(token: str) -> str:
54
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
55
+
56
+
57
+def safe_title(title: str, fallback: str = "Document") -> str:
58
+ cleaned = "".join(ch if ch.isalnum() or ch in " ._-" else "_" for ch in title).strip(" ._")
59
+ return cleaned or fallback
60
+
61
+
62
+def normalize_extension(value: str) -> str:
63
+ ext = value.lower().strip().lstrip(".")
64
+ if ext not in SUPPORTED_EXTENSIONS:
65
+ raise ValueError(f"Unsupported Office format: {ext}")
66
+ return ext
67
+
68
+
69
+def normalize_path(path: str | Path) -> Path:
70
+ raw = str(path)
71
+ if raw.startswith("/a0/") and not files.get_base_dir().startswith("/a0"):
72
+ raw = files.get_abs_path(raw.removeprefix("/a0/"))
73
+ candidate = Path(raw if os.path.isabs(raw) else files.get_abs_path(raw))
74
+ resolved = candidate.expanduser().resolve(strict=False)
75
+ allowed_roots = [WORKDIR.resolve(strict=False)]
76
+ if not any(os.path.commonpath([str(resolved), str(root)]) == str(root) for root in allowed_roots):
77
+ raise PermissionError("Office documents must be inside /a0/usr/workdir")
78
+ if candidate.exists():
79
+ real = candidate.resolve(strict=True)
80
+ if not any(os.path.commonpath([str(real), str(root)]) == str(root) for root in allowed_roots):
81
+ raise PermissionError("Office document symlink escapes the workdir")
82
+ return resolved
83
+
84
+
85
+@contextmanager
86
+def connect() -> Any:
87
+ ensure_dirs()
88
+ conn = sqlite3.connect(DB_PATH, timeout=30)
89
+ conn.row_factory = sqlite3.Row
90
+ conn.execute("PRAGMA journal_mode=WAL")
91
+ conn.execute("PRAGMA foreign_keys=ON")
92
+ init_db(conn)
93
+ try:
94
+ yield conn
95
+ conn.commit()
96
+ finally:
97
+ conn.close()
98
+
99
+
100
+def init_db(conn: sqlite3.Connection) -> None:
101
+ conn.executescript(
102
+ """
103
+ CREATE TABLE IF NOT EXISTS documents (
104
+ file_id TEXT PRIMARY KEY,
105
+ path TEXT NOT NULL UNIQUE,
106
+ basename TEXT NOT NULL,
107
+ extension TEXT NOT NULL,
108
+ owner_id TEXT NOT NULL,
109
+ size INTEGER NOT NULL,
110
+ version INTEGER NOT NULL,
111
+ sha256 TEXT NOT NULL,
112
+ last_modified TEXT NOT NULL,
113
+ created_at REAL NOT NULL,
114
+ updated_at REAL NOT NULL
115
+ );
116
+ CREATE TABLE IF NOT EXISTS sessions (
117
+ session_id TEXT PRIMARY KEY,
118
+ file_id TEXT NOT NULL,
119
+ user_id TEXT NOT NULL,
120
+ permission TEXT NOT NULL,
121
+ origin TEXT NOT NULL,
122
+ created_at REAL NOT NULL,
123
+ expires_at REAL NOT NULL
124
+ );
125
+ CREATE TABLE IF NOT EXISTS tokens (
126
+ token_hash TEXT PRIMARY KEY,
127
+ file_id TEXT NOT NULL,
128
+ session_id TEXT NOT NULL,
129
+ user_id TEXT NOT NULL,
130
+ permission TEXT NOT NULL,
131
+ source_path TEXT NOT NULL,
132
+ created_at REAL NOT NULL,
133
+ expires_at REAL NOT NULL
134
+ );
135
+ CREATE TABLE IF NOT EXISTS locks (
136
+ file_id TEXT PRIMARY KEY,
137
+ lock_value TEXT NOT NULL,
138
+ expires_at REAL NOT NULL,
139
+ session_id TEXT NOT NULL,
140
+ updated_at REAL NOT NULL
141
+ );
142
+ CREATE TABLE IF NOT EXISTS versions (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ file_id TEXT NOT NULL,
145
+ version TEXT NOT NULL,
146
+ path TEXT NOT NULL,
147
+ size INTEGER NOT NULL,
148
+ sha256 TEXT NOT NULL,
149
+ created_at REAL NOT NULL
150
+ );
151
+ CREATE TABLE IF NOT EXISTS events (
152
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
153
+ file_id TEXT,
154
+ event_type TEXT NOT NULL,
155
+ payload TEXT NOT NULL,
156
+ created_at REAL NOT NULL
157
+ );
158
+ """
159
+ )
160
+
161
+
162
+def register_document(path: str | Path, owner_id: str = "a0") -> dict[str, Any]:
163
+ resolved = normalize_path(path)
164
+ if not resolved.exists():
165
+ raise FileNotFoundError(str(resolved))
166
+ ext = normalize_extension(resolved.suffix.lstrip("."))
167
+ data = resolved.read_bytes()
168
+ digest = sha256_bytes(data)
169
+ stat = resolved.stat()
170
+ current_time = now()
171
+ with connect() as conn:
172
+ row = conn.execute("SELECT * FROM documents WHERE path = ?", (str(resolved),)).fetchone()
173
+ if row:
174
+ conn.execute(
175
+ """
176
+ UPDATE documents
177
+ SET basename=?, extension=?, size=?, sha256=?, last_modified=?, updated_at=?
178
+ WHERE file_id=?
179
+ """,
180
+ (resolved.name, ext, stat.st_size, digest, now_iso(), current_time, row["file_id"]),
181
+ )
182
+ return get_document(row["file_id"], conn=conn)
183
+
184
+ file_id = uuid.uuid4().hex
185
+ conn.execute(
186
+ """
187
+ INSERT INTO documents
188
+ (file_id, path, basename, extension, owner_id, size, version, sha256, last_modified, created_at, updated_at)
189
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
190
+ """,
191
+ (file_id, str(resolved), resolved.name, ext, owner_id, stat.st_size, 1, digest, now_iso(), current_time, current_time),
192
+ )
193
+ _record_version(conn, file_id, resolved, "1", data)
194
+ return get_document(file_id, conn=conn)
195
+
196
+
197
+def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[str, Any]:
198
+ def _fetch(active: sqlite3.Connection) -> dict[str, Any]:
199
+ row = active.execute("SELECT * FROM documents WHERE file_id = ?", (file_id,)).fetchone()
200
+ if not row:
201
+ raise FileNotFoundError(file_id)
202
+ return dict(row)
203
+
204
+ if conn is not None:
205
+ return _fetch(conn)
206
+ with connect() as active:
207
+ return _fetch(active)
208
+
209
+
210
+def get_recent_documents(limit: int = 12) -> list[dict[str, Any]]:
211
+ with connect() as conn:
212
+ rows = conn.execute(
213
+ "SELECT * FROM documents ORDER BY updated_at DESC LIMIT ?",
214
+ (limit,),
215
+ ).fetchall()
216
+ return [dict(row) for row in rows]
217
+
218
+
219
+def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
220
+ permission = "write" if permission == "write" else "read"
221
+ token = secrets.token_urlsafe(32)
222
+ created = now()
223
+ expires = created + ttl_seconds
224
+ doc = get_document(file_id)
225
+ session_id = uuid.uuid4().hex
226
+ with connect() as conn:
227
+ conn.execute(
228
+ "INSERT INTO sessions (session_id, file_id, user_id, permission, origin, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
229
+ (session_id, file_id, user_id, permission, origin, created, expires),
230
+ )
231
+ conn.execute(
232
+ """
233
+ INSERT INTO tokens
234
+ (token_hash, file_id, session_id, user_id, permission, source_path, created_at, expires_at)
235
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
236
+ """,
237
+ (token_hash(token), file_id, session_id, user_id, permission, doc["path"], created, expires),
238
+ )
239
+ return {
240
+ "session_id": session_id,
241
+ "file_id": file_id,
242
+ "access_token": token,
243
+ "access_token_ttl": int(expires * 1000),
244
+ "expires_at": expires,
245
+ "permission": permission,
246
+ "origin": origin,
247
+ }
248
+
249
+
250
+def validate_token(raw_token: str, file_id: str, require_write: bool = False) -> dict[str, Any]:
251
+ if not raw_token:
252
+ raise PermissionError("Missing WOPI access token")
253
+ with connect() as conn:
254
+ row = conn.execute("SELECT * FROM tokens WHERE token_hash = ?", (token_hash(raw_token),)).fetchone()
255
+ if not row or row["file_id"] != file_id:
256
+ raise PermissionError("Invalid WOPI access token")
257
+ if row["expires_at"] < now():
258
+ raise PermissionError("Expired WOPI access token")
259
+ if require_write and row["permission"] != "write":
260
+ raise PermissionError("WOPI token is read-only")
261
+ session = conn.execute("SELECT * FROM sessions WHERE session_id = ?", (row["session_id"],)).fetchone()
262
+ return {"token": dict(row), "session": dict(session) if session else {}}
263
+
264
+
265
+def check_file_info(file_id: str, token_info: dict[str, Any]) -> dict[str, Any]:
266
+ doc = get_document(file_id)
267
+ session = token_info.get("session") or {}
268
+ can_write = (token_info.get("token") or {}).get("permission") == "write"
269
+ origin = session.get("origin") or "http://localhost:32080"
270
+ info = {
271
+ "BaseFileName": doc["basename"],
272
+ "OwnerId": doc["owner_id"],
273
+ "Size": int(doc["size"]),
274
+ "Version": item_version(doc),
275
+ "UserId": session.get("user_id") or "agent-zero-user",
276
+ "UserFriendlyName": "Agent Zero",
277
+ "UserCanWrite": bool(can_write),
278
+ "ReadOnly": not bool(can_write),
279
+ "SupportsLocks": True,
280
+ "SupportsUpdate": True,
281
+ "SupportsExtendedLockLength": True,
282
+ "SupportsGetLock": True,
283
+ "UserCanNotWriteRelative": True,
284
+ "PostMessageOrigin": origin,
285
+ "ClosePostMessage": True,
286
+ "CloseUrl": origin.rstrip("/") + "/",
287
+ "LastModifiedTime": doc["last_modified"],
288
+ }
289
+ return {key: value for key, value in info.items() if value is not None}
290
+
291
+
292
+def item_version(doc: dict[str, Any]) -> str:
293
+ return f"{int(doc['version'])}-{str(doc['sha256'])[:12]}"
294
+
295
+
296
+def get_lock(file_id: str) -> str:
297
+ with connect() as conn:
298
+ _clear_expired_locks(conn)
299
+ row = conn.execute("SELECT lock_value FROM locks WHERE file_id = ?", (file_id,)).fetchone()
300
+ return row["lock_value"] if row else ""
301
+
302
+
303
+def lock(file_id: str, lock_value: str, session_id: str, timeout_seconds: int) -> tuple[bool, str]:
304
+ timeout_seconds = clamp_lock_timeout(timeout_seconds)
305
+ with connect() as conn:
306
+ _clear_expired_locks(conn)
307
+ row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
308
+ if row and row["lock_value"] != lock_value:
309
+ return False, row["lock_value"]
310
+ expires = now() + timeout_seconds
311
+ conn.execute(
312
+ """
313
+ INSERT INTO locks (file_id, lock_value, expires_at, session_id, updated_at)
314
+ VALUES (?, ?, ?, ?, ?)
315
+ ON CONFLICT(file_id) DO UPDATE SET lock_value=excluded.lock_value, expires_at=excluded.expires_at, session_id=excluded.session_id, updated_at=excluded.updated_at
316
+ """,
317
+ (file_id, lock_value, expires, session_id, now()),
318
+ )
319
+ return True, lock_value
320
+
321
+
322
+def refresh_lock(file_id: str, lock_value: str, timeout_seconds: int) -> tuple[bool, str]:
323
+ timeout_seconds = clamp_lock_timeout(timeout_seconds)
324
+ with connect() as conn:
325
+ _clear_expired_locks(conn)
326
+ row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
327
+ if not row or row["lock_value"] != lock_value:
328
+ return False, row["lock_value"] if row else ""
329
+ conn.execute(
330
+ "UPDATE locks SET expires_at = ?, updated_at = ? WHERE file_id = ?",
331
+ (now() + timeout_seconds, now(), file_id),
332
+ )
333
+ return True, lock_value
334
+
335
+
336
+def unlock(file_id: str, lock_value: str) -> tuple[bool, str]:
337
+ with connect() as conn:
338
+ _clear_expired_locks(conn)
339
+ row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
340
+ if not row:
341
+ return True, ""
342
+ if row["lock_value"] != lock_value:
343
+ return False, row["lock_value"]
344
+ conn.execute("DELETE FROM locks WHERE file_id = ?", (file_id,))
345
+ return True, ""
346
+
347
+
348
+def unlock_and_relock(file_id: str, old_lock: str, new_lock: str, session_id: str, timeout_seconds: int) -> tuple[bool, str]:
349
+ with connect() as conn:
350
+ _clear_expired_locks(conn)
351
+ row = conn.execute("SELECT * FROM locks WHERE file_id = ?", (file_id,)).fetchone()
352
+ if row and row["lock_value"] != old_lock:
353
+ return False, row["lock_value"]
354
+ expires = now() + clamp_lock_timeout(timeout_seconds)
355
+ conn.execute(
356
+ """
357
+ INSERT INTO locks (file_id, lock_value, expires_at, session_id, updated_at)
358
+ VALUES (?, ?, ?, ?, ?)
359
+ ON CONFLICT(file_id) DO UPDATE SET lock_value=excluded.lock_value, expires_at=excluded.expires_at, session_id=excluded.session_id, updated_at=excluded.updated_at
360
+ """,
361
+ (file_id, new_lock, expires, session_id, now()),
362
+ )
363
+ return True, new_lock
364
+
365
+
366
+def put_file(file_id: str, data: bytes, lock_value: str) -> str:
367
+ if len(data) > MAX_SAVE_BYTES:
368
+ raise OverflowError("Office save exceeds maximum size")
369
+ with connect() as conn:
370
+ _clear_expired_locks(conn)
371
+ doc = get_document(file_id, conn=conn)
372
+ current_lock = conn.execute("SELECT lock_value FROM locks WHERE file_id = ?", (file_id,)).fetchone()
373
+ current = current_lock["lock_value"] if current_lock else ""
374
+ path = Path(doc["path"])
375
+ if current and current != lock_value:
376
+ raise LockMismatch(current)
377
+ if not current and int(doc["size"]) > 0:
378
+ raise LockMismatch("")
379
+
380
+ previous = path.read_bytes() if path.exists() else b""
381
+ _record_version(conn, file_id, path, item_version(doc), previous)
382
+ tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
383
+ with tmp_path.open("wb") as handle:
384
+ handle.write(data)
385
+ handle.flush()
386
+ os.fsync(handle.fileno())
387
+ os.replace(tmp_path, path)
388
+ digest = sha256_bytes(data)
389
+ next_version = int(doc["version"]) + 1
390
+ conn.execute(
391
+ """
392
+ UPDATE documents
393
+ SET size=?, version=?, sha256=?, last_modified=?, updated_at=?
394
+ WHERE file_id=?
395
+ """,
396
+ (len(data), next_version, digest, now_iso(), now(), file_id),
397
+ )
398
+ return f"{next_version}-{digest[:12]}"
399
+
400
+
401
+class LockMismatch(Exception):
402
+ def __init__(self, current_lock: str) -> None:
403
+ super().__init__("WOPI lock mismatch")
404
+ self.current_lock = current_lock
405
+
406
+
407
+def clamp_lock_timeout(value: int | str | None) -> int:
408
+ try:
409
+ seconds = int(value or DEFAULT_LOCK_SECONDS)
410
+ except (TypeError, ValueError):
411
+ seconds = DEFAULT_LOCK_SECONDS
412
+ return max(MIN_LOCK_SECONDS, min(MAX_LOCK_SECONDS, seconds))
413
+
414
+
415
+def _clear_expired_locks(conn: sqlite3.Connection) -> None:
416
+ conn.execute("DELETE FROM locks WHERE expires_at < ?", (now(),))
417
+
418
+
419
+def _record_version(conn: sqlite3.Connection, file_id: str, path: Path, version: str, data: bytes) -> None:
420
+ if not data:
421
+ return
422
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
423
+ backup_path = BACKUP_DIR / f"{file_id}-{int(time.time() * 1000)}-{version.replace('/', '_')}"
424
+ backup_path.write_bytes(data)
425
+ conn.execute(
426
+ "INSERT INTO versions (file_id, version, path, size, sha256, created_at) VALUES (?, ?, ?, ?, ?, ?)",
427
+ (file_id, version, str(backup_path), len(data), sha256_bytes(data), now()),
428
+ )
429
+
430
+
431
+def version_history(file_id: str) -> list[dict[str, Any]]:
432
+ with connect() as conn:
433
+ rows = conn.execute(
434
+ "SELECT id, file_id, version, path, size, sha256, created_at FROM versions WHERE file_id = ? ORDER BY id DESC",
435
+ (file_id,),
436
+ ).fetchall()
437
+ return [dict(row) for row in rows]
438
+
439
+
440
+def restore_version(file_id: str, version_id: int) -> dict[str, Any]:
441
+ with connect() as conn:
442
+ doc = get_document(file_id, conn=conn)
443
+ row = conn.execute("SELECT * FROM versions WHERE id = ? AND file_id = ?", (version_id, file_id)).fetchone()
444
+ if not row:
445
+ raise FileNotFoundError(f"Version {version_id} not found")
446
+ data = Path(row["path"]).read_bytes()
447
+ path = Path(doc["path"])
448
+ _record_version(conn, file_id, path, item_version(doc), path.read_bytes() if path.exists() else b"")
449
+ path.write_bytes(data)
450
+ digest = sha256_bytes(data)
451
+ next_version = int(doc["version"]) + 1
452
+ conn.execute(
453
+ "UPDATE documents SET size=?, version=?, sha256=?, last_modified=?, updated_at=? WHERE file_id=?",
454
+ (len(data), next_version, digest, now_iso(), now(), file_id),
455
+ )
456
+ return get_document(file_id, conn=conn)
457
+
458
+
459
+def create_document(kind: str, title: str, fmt: str, content: str = "", path: str = "") -> dict[str, Any]:
460
+ ext = normalize_extension(fmt)
461
+ target = normalize_path(path) if path else _unique_document_path(title, ext)
462
+ target.parent.mkdir(parents=True, exist_ok=True)
463
+ if target.exists():
464
+ raise FileExistsError(str(target))
465
+ data = template_bytes(kind, ext, title, content)
466
+ target.write_bytes(data)
467
+ return register_document(target)
468
+
469
+
470
+def _unique_document_path(title: str, ext: str) -> Path:
471
+ base = safe_title(title, "Document")
472
+ candidate = DOCUMENTS_DIR / f"{base}.{ext}"
473
+ index = 2
474
+ while candidate.exists():
475
+ candidate = DOCUMENTS_DIR / f"{base} {index}.{ext}"
476
+ index += 1
477
+ return candidate.resolve(strict=False)
478
+
479
+
480
+def template_bytes(kind: str, ext: str, title: str, content: str) -> bytes:
481
+ if ext == "docx":
482
+ return _docx(title, content)
483
+ if ext == "xlsx":
484
+ return _xlsx(title, content)
485
+ if ext == "pptx":
486
+ return _pptx(title, content)
487
+ if ext in {"odt", "ods", "odp"}:
488
+ return _odf(ext, title, content)
489
+ raise ValueError(ext)
490
+
491
+
492
+def _zip_bytes(files_map: dict[str, str | bytes], stored: set[str] | None = None) -> bytes:
493
+ import io
494
+
495
+ buffer = io.BytesIO()
496
+ with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
497
+ for name, value in files_map.items():
498
+ data = value.encode("utf-8") if isinstance(value, str) else value
499
+ info = zipfile.ZipInfo(name)
500
+ info.compress_type = zipfile.ZIP_STORED if stored and name in stored else zipfile.ZIP_DEFLATED
501
+ archive.writestr(info, data)
502
+ return buffer.getvalue()
503
+
504
+
505
+def _docx(title: str, content: str) -> bytes:
506
+ lines = [title] + [line for line in content.splitlines() if line.strip()]
507
+ body = "".join(f"<w:p><w:r><w:t>{escape(line)}</w:t></w:r></w:p>" for line in lines)
508
+ return _zip_bytes({
509
+ "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>""",
510
+ "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>""",
511
+ "word/document.xml": f"""<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}<w:sectPr/></w:body></w:document>""",
512
+ })
513
+
514
+
515
+def _xlsx(title: str, content: str) -> bytes:
516
+ rows = [title] + [line for line in content.splitlines() if line.strip()]
517
+ sheet_rows = "".join(
518
+ f'<row r="{idx}"><c r="A{idx}" t="inlineStr"><is><t>{escape(line)}</t></is></c></row>'
519
+ for idx, line in enumerate(rows, start=1)
520
+ )
521
+ return _zip_bytes({
522
+ "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>""",
523
+ "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>""",
524
+ "xl/_rels/workbook.xml.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>""",
525
+ "xl/workbook.xml": """<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>""",
526
+ "xl/worksheets/sheet1.xml": f"""<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>{sheet_rows}</sheetData></worksheet>""",
527
+ })
528
+
529
+
530
+def _pptx(title: str, content: str) -> bytes:
531
+ subtitle = content.splitlines()[0] if content.splitlines() else ""
532
+ return _zip_bytes({
533
+ "[Content_Types].xml": """<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/></Types>""",
534
+ "_rels/.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>""",
535
+ "ppt/_rels/presentation.xml.rels": """<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>""",
536
+ "ppt/presentation.xml": """<?xml version="1.0" encoding="UTF-8"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="5143500"/></p:presentation>""",
537
+ "ppt/slides/slide1.xml": f"""<?xml version="1.0" encoding="UTF-8"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="Title"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>{escape(title)}</a:t></a:r></a:p><a:p><a:r><a:t>{escape(subtitle)}</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>""",
538
+ })
539
+
540
+
541
+def _odf(ext: str, title: str, content: str) -> bytes:
542
+ mime = {
543
+ "odt": "application/vnd.oasis.opendocument.text",
544
+ "ods": "application/vnd.oasis.opendocument.spreadsheet",
545
+ "odp": "application/vnd.oasis.opendocument.presentation",
546
+ }[ext]
547
+ body = f"<text:p>{escape(title)}</text:p><text:p>{escape(content)}</text:p>"
548
+ return _zip_bytes({
549
+ "mimetype": mime,
550
+ "META-INF/manifest.xml": f"""<?xml version="1.0" encoding="UTF-8"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:media-type="{mime}" manifest:full-path="/"/><manifest:file-entry manifest:media-type="text/xml" manifest:full-path="content.xml"/></manifest:manifest>""",
551
+ "content.xml": f"""<?xml version="1.0" encoding="UTF-8"?><office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.2"><office:body><office:text>{body}</office:text></office:body></office:document-content>""",
552
+ }, stored={"mimetype"})
plugins/_office/plugin.yaml
new
+8
@@ -0,0 +1,8 @@
1
+name: _office
2
+title: Office
3
+description: Universal Canvas office documents with Collabora Online, WOPI, and document artifacts.
4
+version: "0.1"
5
+settings_sections:
6
+ - developer
7
+per_project_config: false
8
+per_agent_config: false
plugins/_office/prompts/agent.system.tool.document_artifact.md
new
+37
@@ -0,0 +1,37 @@
1
+### document_artifact
2
+create/open/inspect reusable Office artifacts in the Agent Zero canvas
3
+use when producing substantial documents, spreadsheets, or presentations that should stay editable
4
+do not dump long office-style artifacts only into chat when this tool is available
5
+
6
+formats: docx xlsx pptx odt ods odp
7
+actions: create open inspect export version_history restore_version status
8
+common args: action kind title format content path file_id version_id
9
+
10
+storage:
11
+- generated files default to `/a0/usr/workdir/documents/`
12
+- existing files must be under `/a0/usr/workdir`
13
+- tool results include `canvas_surface: office`; open the Office canvas when collaborating on the artifact
14
+
15
+examples:
16
+~~~json
17
+{
18
+ "tool_name": "document_artifact",
19
+ "tool_args": {
20
+ "action": "create",
21
+ "kind": "document",
22
+ "title": "Project Brief",
23
+ "format": "docx",
24
+ "content": "Draft the brief here."
25
+ }
26
+}
27
+~~~
28
+
29
+~~~json
30
+{
31
+ "tool_name": "document_artifact",
32
+ "tool_args": {
33
+ "action": "open",
34
+ "path": "/a0/usr/workdir/documents/Project Brief.docx"
35
+ }
36
+}
37
+~~~
plugins/_office/tools/document_artifact.py
new
+115
@@ -0,0 +1,115 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+from pathlib import Path
5
+from typing import Any
6
+
7
+from helpers.tool import Response, Tool
8
+from plugins._office.helpers import collabora_status, wopi_store
9
+
10
+
11
+class DocumentArtifact(Tool):
12
+ async def execute(
13
+ self,
14
+ action: str = "",
15
+ kind: str = "document",
16
+ title: str = "Untitled",
17
+ format: str = "docx",
18
+ content: str = "",
19
+ path: str = "",
20
+ file_id: str = "",
21
+ version_id: int | str | None = None,
22
+ **kwargs: Any,
23
+ ) -> Response:
24
+ action = str(action or self.method or "status").strip().lower().replace("-", "_")
25
+ try:
26
+ if action == "create":
27
+ doc = wopi_store.create_document(kind=kind, title=title, fmt=format, content=content, path=path)
28
+ return self._document_response("Created document artifact.", doc)
29
+ if action == "open":
30
+ doc = self._document_from_input(file_id=file_id, path=path)
31
+ return self._document_response("Opened document artifact.", doc)
32
+ if action == "inspect":
33
+ doc = self._document_from_input(file_id=file_id, path=path)
34
+ return self._json_response({"ok": True, "document": self._public_doc(doc)}, doc=doc)
35
+ if action == "version_history":
36
+ doc = self._document_from_input(file_id=file_id, path=path)
37
+ versions = wopi_store.version_history(doc["file_id"])
38
+ return self._json_response({"ok": True, "versions": versions}, doc=doc)
39
+ if action == "restore_version":
40
+ if version_id is None or str(version_id).strip() == "":
41
+ return Response(message="version_id is required for restore_version.", break_loop=False)
42
+ doc = self._document_from_input(file_id=file_id, path=path)
43
+ restored = wopi_store.restore_version(doc["file_id"], int(version_id))
44
+ return self._document_response("Restored document artifact version.", restored)
45
+ if action == "export":
46
+ doc = self._document_from_input(file_id=file_id, path=path)
47
+ target_format = str(kwargs.get("target_format") or kwargs.get("export_format") or "").lower().lstrip(".")
48
+ if target_format and target_format != doc["extension"]:
49
+ return Response(
50
+ message=f"Export to .{target_format} is not available yet. The source file remains unchanged at {doc['path']}.",
51
+ break_loop=False,
52
+ additional=self._additional(doc),
53
+ )
54
+ return self._document_response("Document artifact export path is ready.", doc)
55
+ if action == "status":
56
+ return self._json_response({"ok": True, "status": collabora_status.collect_status()})
57
+ return Response(message=f"Unknown document_artifact action: {action}", break_loop=False)
58
+ except Exception as exc:
59
+ return Response(message=f"document_artifact {action} failed: {exc}", break_loop=False)
60
+
61
+ def get_log_object(self):
62
+ return self.agent.context.log.log(
63
+ type="tool",
64
+ heading=f"icon://description {self.agent.agent_name}: Using document artifact",
65
+ content="",
66
+ kvps={**self.args, "_tool_name": self.name},
67
+ _tool_name=self.name,
68
+ )
69
+
70
+ def _document_from_input(self, file_id: str = "", path: str = "") -> dict[str, Any]:
71
+ if file_id:
72
+ return wopi_store.get_document(file_id)
73
+ if path:
74
+ return wopi_store.register_document(path)
75
+ raise ValueError("file_id or path is required")
76
+
77
+ def _document_response(self, message: str, doc: dict[str, Any]) -> Response:
78
+ payload = {"ok": True, "message": message, "document": self._public_doc(doc)}
79
+ return Response(
80
+ message=json.dumps(payload, indent=2, ensure_ascii=False),
81
+ break_loop=False,
82
+ additional=self._additional(doc),
83
+ )
84
+
85
+ def _json_response(self, payload: dict[str, Any], doc: dict[str, Any] | None = None) -> Response:
86
+ return Response(
87
+ message=json.dumps(payload, indent=2, ensure_ascii=False, default=str),
88
+ break_loop=False,
89
+ additional=self._additional(doc) if doc else {"_tool_name": self.name, "canvas_surface": "office"},
90
+ )
91
+
92
+ def _additional(self, doc: dict[str, Any] | None) -> dict[str, Any]:
93
+ if not doc:
94
+ return {"_tool_name": self.name, "canvas_surface": "office"}
95
+ return {
96
+ "_tool_name": self.name,
97
+ "canvas_surface": "office",
98
+ "file_id": doc["file_id"],
99
+ "title": doc["basename"],
100
+ "format": doc["extension"],
101
+ "path": doc["path"],
102
+ "version": wopi_store.item_version(doc),
103
+ }
104
+
105
+ def _public_doc(self, doc: dict[str, Any]) -> dict[str, Any]:
106
+ return {
107
+ "file_id": doc["file_id"],
108
+ "path": doc["path"],
109
+ "basename": doc["basename"],
110
+ "extension": doc["extension"],
111
+ "size": doc["size"],
112
+ "version": wopi_store.item_version(doc),
113
+ "last_modified": doc["last_modified"],
114
+ "exists": Path(doc["path"]).exists(),
115
+ }
plugins/_office/webui/main.html
new
+14
@@ -0,0 +1,14 @@
1
+<html
2
+ class="office-modal modal-no-backdrop"
3
+ data-canvas-surface="office"
4
+ data-canvas-modal-path="/plugins/_office/webui/main.html"
5
+ data-canvas-dock-title="Open Office in canvas"
6
+ data-canvas-dock-icon="dock_to_right"
7
+>
8
+<head>
9
+ <title>Office</title>
10
+</head>
11
+<body class="office-modal-body">
12
+ <x-component path="/plugins/_office/webui/office-panel.html" mode="modal"></x-component>
13
+</body>
14
+</html>
plugins/_office/webui/office-panel.html
new
+464
@@ -0,0 +1,464 @@
1
+<html>
2
+<head>
3
+ <script type="module">
4
+ import { store } from "/plugins/_office/webui/office-store.js";
5
+ </script>
6
+</head>
7
+<body>
8
+ <div class="office-panel" x-data x-create="$store.office.onMount($el, xAttrs($el) || {})" x-destroy="$store.office.cleanup()">
9
+ <template x-if="$store.office">
10
+ <div class="office-shell">
11
+ <div class="office-toolbar">
12
+ <button type="button" class="office-button" title="Open" @click="$store.office.openPrompt()">
13
+ <span class="material-symbols-outlined">folder_open</span>
14
+ <span>Open</span>
15
+ </button>
16
+ <button type="button" class="office-button" title="New document" @click="$store.office.create('document')">
17
+ <span class="material-symbols-outlined">note_add</span>
18
+ <span>Doc</span>
19
+ </button>
20
+ <button type="button" class="office-button" title="New spreadsheet" @click="$store.office.create('spreadsheet')">
21
+ <span class="material-symbols-outlined">table</span>
22
+ <span>Sheet</span>
23
+ </button>
24
+ <button type="button" class="office-button" title="New presentation" @click="$store.office.create('presentation')">
25
+ <span class="material-symbols-outlined">slideshow</span>
26
+ <span>Presentation</span>
27
+ </button>
28
+ <span class="office-toolbar-spacer"></span>
29
+ <span
30
+ class="office-health-pill"
31
+ :class="`is-${$store.office.status?.state || 'unknown'}`"
32
+ :title="$store.office.status?.message || 'Office status'"
33
+ >
34
+ <span class="office-health-dot"></span>
35
+ <span x-text="$store.office.status?.state || 'status'"></span>
36
+ </span>
37
+ <button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
38
+ <span class="material-symbols-outlined">save</span>
39
+ </button>
40
+ <button type="button" class="office-icon-button" title="Versions" @click="$store.office.showVersions()" :disabled="!$store.office.session">
41
+ <span class="material-symbols-outlined">history</span>
42
+ </button>
43
+ <button type="button" class="office-icon-button" title="Refresh status" @click="$store.office.refresh()">
44
+ <span class="material-symbols-outlined">refresh</span>
45
+ </button>
46
+ </div>
47
+
48
+ <div class="office-status-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
49
+ <span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
50
+ <span x-text="$store.office.error || $store.office.message || 'Working...'"></span>
51
+ </div>
52
+
53
+ <div class="office-body">
54
+ <div class="office-bootstrap" x-show="!$store.office.session && (!$store.office.status || !$store.office.status.healthy)">
55
+ <div class="office-bootstrap-header">
56
+ <span class="material-symbols-outlined">description</span>
57
+ <div>
58
+ <strong x-text="$store.office.status?.state || 'Preparing Office'"></strong>
59
+ <span x-text="$store.office.status?.message || 'Collabora Online is being prepared in the background.'"></span>
60
+ </div>
61
+ </div>
62
+ <div class="office-bootstrap-actions">
63
+ <button type="button" class="office-button" @click="$store.office.retry()">
64
+ <span class="material-symbols-outlined">restart_alt</span>
65
+ <span>Retry</span>
66
+ </button>
67
+ <button type="button" class="office-button" @click="$store.office.refresh()">
68
+ <span class="material-symbols-outlined">sync</span>
69
+ <span>Status</span>
70
+ </button>
71
+ </div>
72
+ <pre class="office-log" x-text="$store.office.logs?.bootstrap || $store.office.logs?.wrapper || ''"></pre>
73
+ </div>
74
+
75
+ <div class="office-start" x-show="!$store.office.session && $store.office.status?.healthy" style="display: none;">
76
+ <div class="office-start-actions">
77
+ <button type="button" class="office-create-tile" @click="$store.office.create('document')">
78
+ <span class="material-symbols-outlined">article</span>
79
+ <span>Document</span>
80
+ </button>
81
+ <button type="button" class="office-create-tile" @click="$store.office.create('spreadsheet')">
82
+ <span class="material-symbols-outlined">table_chart</span>
83
+ <span>Spreadsheet</span>
84
+ </button>
85
+ <button type="button" class="office-create-tile" @click="$store.office.create('presentation')">
86
+ <span class="material-symbols-outlined">co_present</span>
87
+ <span>Presentation</span>
88
+ </button>
89
+ </div>
90
+ <div class="office-recent" x-show="$store.office.recent.length">
91
+ <template x-for="doc in $store.office.recent" :key="doc.file_id">
92
+ <button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
93
+ <span class="material-symbols-outlined">description</span>
94
+ <span x-text="doc.basename"></span>
95
+ </button>
96
+ </template>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="office-frame-wrap" x-show="$store.office.session" style="display: none;">
101
+ <iframe
102
+ data-office-frame
103
+ allow="clipboard-read *; clipboard-write *; fullscreen *"
104
+ allowfullscreen
105
+ ></iframe>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </template>
110
+ </div>
111
+
112
+ <style>
113
+ .office-panel,
114
+ .office-shell {
115
+ display: flex;
116
+ flex: 1 1 auto;
117
+ flex-direction: column;
118
+ width: 100%;
119
+ height: 100%;
120
+ min-width: 0;
121
+ min-height: 0;
122
+ background: var(--color-background);
123
+ }
124
+
125
+ .office-panel {
126
+ container-type: inline-size;
127
+ }
128
+
129
+ .modal-inner.office-modal {
130
+ box-sizing: border-box;
131
+ container-type: inline-size;
132
+ width: min(82vw, 1180px);
133
+ height: min(88vh, 900px);
134
+ min-width: min(340px, calc(100vw - 16px));
135
+ min-height: min(500px, calc(100vh - 16px));
136
+ max-width: calc(100vw - 16px);
137
+ max-height: calc(100vh - 16px);
138
+ resize: both;
139
+ border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
140
+ border-radius: 7px;
141
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.32);
142
+ background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
143
+ }
144
+
145
+ .modal-inner.office-modal .modal-header {
146
+ min-height: 34px;
147
+ padding: 0.35rem 0.75rem 0.35rem 1rem;
148
+ cursor: move;
149
+ user-select: none;
150
+ background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
151
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
152
+ }
153
+
154
+ .modal-inner.office-modal .modal-title {
155
+ font-size: 0.95rem;
156
+ letter-spacing: 0;
157
+ }
158
+
159
+ .modal-inner.office-modal .modal-close {
160
+ font-size: 1.35rem;
161
+ line-height: 1;
162
+ }
163
+
164
+ .modal-inner.office-modal .modal-scroll {
165
+ display: flex;
166
+ flex-direction: column;
167
+ flex: 1 1 auto;
168
+ min-height: 0;
169
+ overflow: hidden;
170
+ padding: 0;
171
+ }
172
+
173
+ .modal-inner.office-modal .modal-bd.office-modal-body {
174
+ box-sizing: border-box;
175
+ display: flex;
176
+ flex-direction: column;
177
+ flex: 1 1 auto;
178
+ width: 100%;
179
+ height: 100%;
180
+ min-height: 0;
181
+ padding: 0;
182
+ }
183
+
184
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component,
185
+ .modal-inner.office-modal .modal-bd.office-modal-body > div[x-data] {
186
+ display: flex;
187
+ flex: 1 1 auto;
188
+ width: 100%;
189
+ height: 100%;
190
+ min-height: 0;
191
+ }
192
+
193
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel {
194
+ flex: 1 1 auto;
195
+ height: 100%;
196
+ min-height: 0;
197
+ }
198
+
199
+ .office-toolbar {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 6px;
203
+ min-height: 44px;
204
+ padding: 7px 9px;
205
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
206
+ background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
207
+ overflow-x: auto;
208
+ }
209
+
210
+ .office-toolbar-spacer {
211
+ flex: 1 1 auto;
212
+ min-width: 8px;
213
+ }
214
+
215
+ .office-button,
216
+ .office-icon-button,
217
+ .office-health-pill,
218
+ .office-create-tile,
219
+ .office-recent-row {
220
+ display: inline-flex;
221
+ align-items: center;
222
+ justify-content: center;
223
+ gap: 7px;
224
+ border: 1px solid color-mix(in srgb, var(--color-border) 64%, transparent);
225
+ border-radius: 7px;
226
+ background: color-mix(in srgb, var(--color-panel) 80%, transparent);
227
+ color: var(--color-text);
228
+ font: inherit;
229
+ cursor: pointer;
230
+ }
231
+
232
+ .office-button {
233
+ min-height: 32px;
234
+ padding: 5px 9px;
235
+ font-size: 0.8rem;
236
+ white-space: nowrap;
237
+ }
238
+
239
+ .office-icon-button {
240
+ width: 32px;
241
+ height: 32px;
242
+ min-width: 32px;
243
+ padding: 0;
244
+ }
245
+
246
+ .office-health-pill {
247
+ min-height: 28px;
248
+ padding: 4px 8px;
249
+ cursor: default;
250
+ font-size: 0.75rem;
251
+ text-transform: capitalize;
252
+ white-space: nowrap;
253
+ color: var(--color-text-muted);
254
+ background: color-mix(in srgb, var(--color-panel) 64%, transparent);
255
+ }
256
+
257
+ .office-health-dot {
258
+ width: 7px;
259
+ height: 7px;
260
+ border-radius: 999px;
261
+ background: color-mix(in srgb, var(--color-text-muted) 70%, transparent);
262
+ }
263
+
264
+ .office-health-pill.is-healthy .office-health-dot {
265
+ background: #31c48d;
266
+ }
267
+
268
+ .office-health-pill.is-installing .office-health-dot {
269
+ background: #f6ad55;
270
+ }
271
+
272
+ .office-health-pill.is-degraded .office-health-dot,
273
+ .office-health-pill.is-failed .office-health-dot {
274
+ background: #f05252;
275
+ }
276
+
277
+ .office-button:hover:not(:disabled),
278
+ .office-icon-button:hover:not(:disabled),
279
+ .office-create-tile:hover,
280
+ .office-recent-row:hover {
281
+ background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
282
+ border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
283
+ }
284
+
285
+ .office-button:disabled,
286
+ .office-icon-button:disabled {
287
+ cursor: not-allowed;
288
+ opacity: 0.42;
289
+ }
290
+
291
+ .office-button .material-symbols-outlined,
292
+ .office-icon-button .material-symbols-outlined {
293
+ font-size: 18px;
294
+ }
295
+
296
+ .office-status-line {
297
+ display: flex;
298
+ align-items: center;
299
+ gap: 8px;
300
+ min-height: 32px;
301
+ padding: 5px 10px;
302
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 44%, transparent);
303
+ font-size: 0.82rem;
304
+ color: var(--color-text);
305
+ }
306
+
307
+ .office-body {
308
+ position: relative;
309
+ display: flex;
310
+ flex: 1 1 auto;
311
+ min-width: 0;
312
+ min-height: 0;
313
+ overflow: hidden;
314
+ }
315
+
316
+ .office-bootstrap,
317
+ .office-start {
318
+ display: flex;
319
+ flex: 1 1 auto;
320
+ min-width: 0;
321
+ min-height: 0;
322
+ flex-direction: column;
323
+ gap: 14px;
324
+ padding: 18px;
325
+ overflow: auto;
326
+ }
327
+
328
+ .office-bootstrap-header {
329
+ display: grid;
330
+ grid-template-columns: auto minmax(0, 1fr);
331
+ gap: 10px;
332
+ align-items: start;
333
+ max-width: 720px;
334
+ }
335
+
336
+ .office-bootstrap-header > .material-symbols-outlined {
337
+ font-size: 24px;
338
+ color: color-mix(in srgb, var(--color-primary) 70%, var(--color-text));
339
+ }
340
+
341
+ .office-bootstrap-header div {
342
+ display: flex;
343
+ min-width: 0;
344
+ flex-direction: column;
345
+ gap: 3px;
346
+ font-size: 0.9rem;
347
+ line-height: 1.35;
348
+ }
349
+
350
+ .office-bootstrap-header span {
351
+ color: var(--color-text-muted);
352
+ }
353
+
354
+ .office-bootstrap-actions,
355
+ .office-start-actions {
356
+ display: flex;
357
+ flex-wrap: wrap;
358
+ gap: 8px;
359
+ }
360
+
361
+ .office-log {
362
+ min-height: 140px;
363
+ max-height: 260px;
364
+ overflow: auto;
365
+ margin: 0;
366
+ padding: 10px;
367
+ border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
368
+ border-radius: 7px;
369
+ background: color-mix(in srgb, var(--color-panel) 78%, transparent);
370
+ color: var(--color-text-muted);
371
+ font-family: var(--font-family-code);
372
+ font-size: 0.72rem;
373
+ white-space: pre-wrap;
374
+ }
375
+
376
+ .office-create-tile {
377
+ min-width: 132px;
378
+ min-height: 88px;
379
+ flex-direction: column;
380
+ padding: 12px;
381
+ font-weight: 650;
382
+ }
383
+
384
+ .office-create-tile .material-symbols-outlined {
385
+ font-size: 28px;
386
+ }
387
+
388
+ .office-recent {
389
+ display: flex;
390
+ flex-direction: column;
391
+ gap: 6px;
392
+ max-width: 720px;
393
+ }
394
+
395
+ .office-recent-row {
396
+ justify-content: flex-start;
397
+ min-height: 36px;
398
+ padding: 7px 9px;
399
+ text-align: left;
400
+ }
401
+
402
+ .office-recent-row span:last-child {
403
+ overflow: hidden;
404
+ text-overflow: ellipsis;
405
+ white-space: nowrap;
406
+ }
407
+
408
+ .office-frame-wrap {
409
+ display: flex;
410
+ position: absolute;
411
+ inset: 0;
412
+ flex: 1 1 auto;
413
+ width: 100%;
414
+ height: 100%;
415
+ min-width: 0;
416
+ min-height: 0;
417
+ background: #fff;
418
+ }
419
+
420
+ .office-frame-wrap iframe {
421
+ flex: 1 1 auto;
422
+ width: 100%;
423
+ height: 100%;
424
+ min-width: 0;
425
+ min-height: 0;
426
+ border: 0;
427
+ background: #fff;
428
+ }
429
+
430
+ .office-panel .spinning {
431
+ display: inline-block;
432
+ animation: office-spin 0.8s linear infinite;
433
+ }
434
+
435
+ @keyframes office-spin {
436
+ to { transform: rotate(360deg); }
437
+ }
438
+
439
+ @media (max-width: 520px) {
440
+ .office-button span:last-child {
441
+ display: none;
442
+ }
443
+ .office-health-pill span:last-child {
444
+ display: none;
445
+ }
446
+ .office-create-tile {
447
+ min-width: 104px;
448
+ }
449
+ }
450
+
451
+ @container (max-width: 560px) {
452
+ .office-button span:last-child {
453
+ display: none;
454
+ }
455
+ .office-health-pill span:last-child {
456
+ display: none;
457
+ }
458
+ .office-create-tile {
459
+ min-width: 104px;
460
+ }
461
+ }
462
+ </style>
463
+</body>
464
+</html>
plugins/_office/webui/office-store.js
new
+449
@@ -0,0 +1,449 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { callJsonApi } from "/js/api.js";
3
+
4
+const FRAME_NAME_PREFIX = "a0-office-frame";
5
+
6
+function makeFrameName() {
7
+ const id = globalThis.crypto?.randomUUID?.()
8
+ || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
9
+ return `${FRAME_NAME_PREFIX}-${id}`;
10
+}
11
+
12
+function parseMessage(data) {
13
+ if (typeof data === "string") {
14
+ try {
15
+ return JSON.parse(data);
16
+ } catch {
17
+ return { MessageId: data };
18
+ }
19
+ }
20
+ return data && typeof data === "object" ? data : {};
21
+}
22
+
23
+const model = {
24
+ status: null,
25
+ logs: null,
26
+ recent: [],
27
+ session: null,
28
+ loading: false,
29
+ error: "",
30
+ message: "",
31
+ frameReady: false,
32
+ frameName: FRAME_NAME_PREFIX,
33
+ _root: null,
34
+ _messageBound: false,
35
+ _frameTimer: null,
36
+ _frameRecoveryTimer: null,
37
+ _frameAttempt: 0,
38
+ _frameRecoveryTried: false,
39
+ _frameOrigin: "",
40
+ _mode: "canvas",
41
+ _floatingCleanup: null,
42
+
43
+ async init(element = null) {
44
+ return await this.onMount(element, { mode: "canvas" });
45
+ },
46
+
47
+ async onMount(element = null, options = {}) {
48
+ if (element) this._root = element;
49
+ this.assignFrameName(element);
50
+ globalThis.requestAnimationFrame?.(() => this.assignFrameName(element));
51
+ if (!this._messageBound) {
52
+ globalThis.addEventListener("message", (event) => this.onPostMessage(event));
53
+ this._messageBound = true;
54
+ }
55
+ this._mode = options?.mode === "modal" ? "modal" : "canvas";
56
+ if (this._mode === "modal") {
57
+ this.setupFloatingModal(element);
58
+ } else {
59
+ this.setupCanvasSurface(element);
60
+ }
61
+ await this.refresh();
62
+ if (this.session && this._root) {
63
+ await this.restartFrameLoad();
64
+ }
65
+ },
66
+
67
+ async onOpen(payload = {}) {
68
+ await this.refresh();
69
+ if (payload?.path) {
70
+ await this.openPath(payload.path);
71
+ } else if (this.session && !this.frameReady) {
72
+ await this.restartFrameLoad();
73
+ }
74
+ },
75
+
76
+ cleanup() {
77
+ this._floatingCleanup?.();
78
+ this._floatingCleanup = null;
79
+ if (this._mode === "modal") {
80
+ this._root = null;
81
+ }
82
+ },
83
+
84
+ async refresh() {
85
+ try {
86
+ this.status = await callJsonApi("/plugins/_office/office_session", { action: "status" });
87
+ const recent = await callJsonApi("/plugins/_office/office_session", { action: "recent" });
88
+ this.recent = recent?.documents || [];
89
+ if (!this.status?.healthy) {
90
+ const logs = await callJsonApi("/plugins/_office/collabora_logs", {});
91
+ this.logs = logs;
92
+ }
93
+ } catch (error) {
94
+ this.error = error instanceof Error ? error.message : String(error);
95
+ }
96
+ },
97
+
98
+ async retry() {
99
+ this.message = "Retrying Collabora setup...";
100
+ this.status = await callJsonApi("/plugins/_office/office_session", { action: "retry" });
101
+ },
102
+
103
+ async create(kind = "document") {
104
+ const defaults = {
105
+ document: ["Document", "docx"],
106
+ spreadsheet: ["Spreadsheet", "xlsx"],
107
+ presentation: ["Presentation", "pptx"],
108
+ };
109
+ const [title, format] = defaults[kind] || defaults.document;
110
+ await this.openSession({
111
+ action: "create",
112
+ kind,
113
+ title,
114
+ format,
115
+ content: "",
116
+ });
117
+ },
118
+
119
+ async openPrompt() {
120
+ const path = globalThis.prompt?.("Open Office file path", "/a0/usr/workdir/documents/");
121
+ if (!path) return;
122
+ await this.openPath(path);
123
+ },
124
+
125
+ async openPath(path) {
126
+ await this.openSession({ action: "open", path, mode: "edit" });
127
+ },
128
+
129
+ async openSession(payload) {
130
+ this.loading = true;
131
+ this.error = "";
132
+ this.message = "";
133
+ try {
134
+ const response = await callJsonApi("/plugins/_office/office_session", payload);
135
+ if (!response?.ok) {
136
+ this.error = response?.error || "Office session could not be opened.";
137
+ if (response?.status) this.status = response.status;
138
+ return;
139
+ }
140
+ this.clearFrameTimers();
141
+ this.session = response;
142
+ this.frameReady = false;
143
+ this._frameOrigin = "";
144
+ this._frameAttempt = 0;
145
+ this._frameRecoveryTried = false;
146
+ await this.submitFrame();
147
+ this.scheduleFrameWatch();
148
+ await this.refresh();
149
+ } catch (error) {
150
+ this.error = error instanceof Error ? error.message : String(error);
151
+ } finally {
152
+ this.loading = false;
153
+ }
154
+ },
155
+
156
+ async submitFrame() {
157
+ await new Promise((resolve) => requestAnimationFrame(resolve));
158
+ const session = this.session;
159
+ const frame = this.activeFrame();
160
+ if (!session || !frame?.name) return;
161
+ const form = document.createElement("form");
162
+ form.method = "post";
163
+ form.action = this.frameAction(session.iframe_action);
164
+ form.target = frame.name;
165
+ form.style.display = "none";
166
+ const fields = {
167
+ access_token: session.access_token,
168
+ access_token_ttl: String(session.access_token_ttl),
169
+ ui_defaults: "UIMode=notebookbar;TextRuler=false",
170
+ };
171
+ for (const [name, value] of Object.entries(fields)) {
172
+ const input = document.createElement("input");
173
+ input.type = "hidden";
174
+ input.name = name;
175
+ input.value = value;
176
+ form.appendChild(input);
177
+ }
178
+ document.body.appendChild(form);
179
+ form.submit();
180
+ form.remove();
181
+ },
182
+
183
+ async restartFrameLoad() {
184
+ this.frameReady = false;
185
+ this._frameOrigin = "";
186
+ this._frameAttempt = 0;
187
+ this._frameRecoveryTried = false;
188
+ this.clearFrameTimers();
189
+ await this.submitFrame();
190
+ this.scheduleFrameWatch();
191
+ },
192
+
193
+ frameAction(action) {
194
+ const url = new URL(action, globalThis.location.origin);
195
+ url.searchParams.set("a0_frame_attempt", String(this._frameAttempt));
196
+ return url.pathname + url.search;
197
+ },
198
+
199
+ scheduleFrameWatch() {
200
+ this.clearFrameTimers();
201
+ this._frameTimer = setTimeout(() => {
202
+ if (this.session && !this.frameReady) {
203
+ this.message = "Still opening the editor...";
204
+ this._frameRecoveryTimer = setTimeout(() => this.recoverFrameLoad(), 3000);
205
+ }
206
+ }, 20000);
207
+ },
208
+
209
+ async recoverFrameLoad() {
210
+ if (!this.session || this.frameReady || this._frameRecoveryTried) return;
211
+ this._frameRecoveryTried = true;
212
+ this._frameAttempt += 1;
213
+ this.message = "Still opening the editor... trying a fresh editor load.";
214
+ await this.submitFrame();
215
+ this._frameTimer = setTimeout(() => {
216
+ if (this.session && !this.frameReady) {
217
+ this.message = "Still opening the editor...";
218
+ }
219
+ }, 25000);
220
+ },
221
+
222
+ clearFrameTimers() {
223
+ if (this._frameTimer) {
224
+ clearTimeout(this._frameTimer);
225
+ this._frameTimer = null;
226
+ }
227
+ if (this._frameRecoveryTimer) {
228
+ clearTimeout(this._frameRecoveryTimer);
229
+ this._frameRecoveryTimer = null;
230
+ }
231
+ },
232
+
233
+ beforeHostHidden() {
234
+ if (this.session) {
235
+ this.save();
236
+ }
237
+ this.frameReady = false;
238
+ this._frameOrigin = "";
239
+ this.clearFrameTimers();
240
+ const frame = this.activeFrame();
241
+ if (frame) {
242
+ frame.src = "about:blank";
243
+ }
244
+ },
245
+
246
+ postToFrame(message) {
247
+ const frame = this.activeFrame();
248
+ const targetOrigin = this._frameOrigin || this.session?.post_message_origin || globalThis.location.origin;
249
+ frame?.contentWindow?.postMessage(JSON.stringify(message), targetOrigin);
250
+ },
251
+
252
+ save() {
253
+ this.postToFrame({
254
+ MessageId: "Action_Save",
255
+ Values: {
256
+ DontTerminateEdit: true,
257
+ DontSaveIfUnmodified: true,
258
+ },
259
+ });
260
+ },
261
+
262
+ closeFile() {
263
+ this.save();
264
+ this.session = null;
265
+ this.frameReady = false;
266
+ this._frameOrigin = "";
267
+ this._frameAttempt = 0;
268
+ this._frameRecoveryTried = false;
269
+ this.clearFrameTimers();
270
+ },
271
+
272
+ async showVersions() {
273
+ if (!this.session?.file_id) return;
274
+ this.message = "Version history is available through the document_artifact tool.";
275
+ },
276
+
277
+ onPostMessage(event) {
278
+ if (!this.session) return;
279
+ if (!this.isAllowedFrameOrigin(event.origin)) return;
280
+ this._frameOrigin = event.origin;
281
+ const message = parseMessage(event.data);
282
+ const id = message.MessageId || message.messageId || "";
283
+ if (id === "App_LoadingStatus" && message.Values?.Status === "Frame_Ready") {
284
+ this.frameReady = true;
285
+ this.clearFrameTimers();
286
+ if (this.message === "Still opening the editor...") this.message = "";
287
+ if (this.message === "Still opening the editor... trying a fresh editor load.") this.message = "";
288
+ this.postToFrame({ MessageId: "Host_PostmessageReady" });
289
+ } else if (id === "UI_Close") {
290
+ this.session = null;
291
+ } else if (id === "Action_Save_Resp") {
292
+ this.message = message.Values?.success === false ? "Save did not complete." : "Saved";
293
+ }
294
+ },
295
+
296
+ isAllowedFrameOrigin(origin) {
297
+ const allowed = new Set([
298
+ globalThis.location.origin,
299
+ this.session?.post_message_origin,
300
+ this.loopbackCounterpart(globalThis.location.origin),
301
+ this.loopbackCounterpart(this.session?.post_message_origin),
302
+ ].filter(Boolean));
303
+ return allowed.has(origin);
304
+ },
305
+
306
+ loopbackCounterpart(origin) {
307
+ if (!origin) return "";
308
+ try {
309
+ const url = new URL(origin);
310
+ if (url.hostname === "127.0.0.1") {
311
+ url.hostname = "localhost";
312
+ return url.origin;
313
+ }
314
+ if (url.hostname === "localhost") {
315
+ url.hostname = "127.0.0.1";
316
+ return url.origin;
317
+ }
318
+ } catch {
319
+ return "";
320
+ }
321
+ return "";
322
+ },
323
+
324
+ assignFrameName(element = null) {
325
+ const root = element || this._root;
326
+ if (!root) return this.frameName || FRAME_NAME_PREFIX;
327
+ if (!root.dataset.officeFrameName) {
328
+ root.dataset.officeFrameName = makeFrameName();
329
+ }
330
+ const frame = root.querySelector?.("iframe[data-office-frame]");
331
+ if (frame) {
332
+ frame.setAttribute("name", root.dataset.officeFrameName);
333
+ frame.name = root.dataset.officeFrameName;
334
+ try {
335
+ frame.contentWindow.name = root.dataset.officeFrameName;
336
+ } catch {}
337
+ }
338
+ this.frameName = root.dataset.officeFrameName;
339
+ return this.frameName;
340
+ },
341
+
342
+ activeFrame() {
343
+ this.assignFrameName();
344
+ return this._root?.querySelector?.("iframe[data-office-frame]") || null;
345
+ },
346
+
347
+ setupFloatingModal(element = null) {
348
+ this._floatingCleanup?.();
349
+ const root = element || globalThis.document?.querySelector(".office-panel");
350
+ const modal = root?.closest?.(".modal");
351
+ const inner = modal?.querySelector?.(".modal-inner");
352
+ const body = modal?.querySelector?.(".modal-bd");
353
+ const header = modal?.querySelector?.(".modal-header");
354
+ if (!modal || !inner || !header) return;
355
+ modal.classList.add("modal-floating");
356
+ inner.classList.add("office-modal", "modal-no-backdrop");
357
+ body?.classList?.add("office-modal-body");
358
+
359
+ const rect = inner.getBoundingClientRect();
360
+ inner.style.left = `${Math.max(8, rect.left)}px`;
361
+ inner.style.top = `${Math.max(8, rect.top)}px`;
362
+ inner.style.transform = "none";
363
+
364
+ let drag = null;
365
+ let resizeObserver = null;
366
+ const viewportGap = 8;
367
+ const clampPosition = (left, top) => {
368
+ const bounds = inner.getBoundingClientRect();
369
+ const maxLeft = Math.max(viewportGap, globalThis.innerWidth - bounds.width - viewportGap);
370
+ const maxTop = Math.max(viewportGap, globalThis.innerHeight - bounds.height - viewportGap);
371
+ return {
372
+ left: Math.min(Math.max(viewportGap, left), maxLeft),
373
+ top: Math.min(Math.max(viewportGap, top), maxTop),
374
+ };
375
+ };
376
+ const clampGeometry = () => {
377
+ const bounds = inner.getBoundingClientRect();
378
+ const left = Math.max(viewportGap, bounds.left);
379
+ const top = Math.max(viewportGap, bounds.top);
380
+ const maxWidth = Math.max(340, globalThis.innerWidth - viewportGap * 2);
381
+ const maxHeight = Math.max(360, globalThis.innerHeight - viewportGap * 2);
382
+ if (bounds.width > maxWidth) inner.style.width = `${maxWidth}px`;
383
+ if (bounds.height > maxHeight) inner.style.height = `${maxHeight}px`;
384
+ const next = clampPosition(left, top);
385
+ inner.style.left = `${next.left}px`;
386
+ inner.style.top = `${next.top}px`;
387
+ inner.style.maxWidth = `${Math.max(340, globalThis.innerWidth - next.left - viewportGap)}px`;
388
+ inner.style.maxHeight = `${Math.max(360, globalThis.innerHeight - next.top - viewportGap)}px`;
389
+ };
390
+ clampGeometry();
391
+ globalThis.addEventListener("resize", clampGeometry);
392
+ if (globalThis.ResizeObserver) {
393
+ resizeObserver = new ResizeObserver(clampGeometry);
394
+ resizeObserver.observe(inner);
395
+ }
396
+
397
+ const onPointerMove = (event) => {
398
+ if (!drag) return;
399
+ const next = clampPosition(
400
+ drag.left + event.clientX - drag.x,
401
+ drag.top + event.clientY - drag.y,
402
+ );
403
+ inner.style.left = `${next.left}px`;
404
+ inner.style.top = `${next.top}px`;
405
+ clampGeometry();
406
+ };
407
+ const onPointerUp = () => {
408
+ drag = null;
409
+ globalThis.removeEventListener("pointermove", onPointerMove);
410
+ globalThis.removeEventListener("pointerup", onPointerUp);
411
+ try {
412
+ header.releasePointerCapture?.(header.__officePanelPointerId || 0);
413
+ } catch {}
414
+ };
415
+ const onPointerDown = (event) => {
416
+ if (event.button !== 0) return;
417
+ if (event.target?.closest?.("button, input, select, textarea, a")) return;
418
+ const current = inner.getBoundingClientRect();
419
+ drag = {
420
+ x: event.clientX,
421
+ y: event.clientY,
422
+ left: current.left,
423
+ top: current.top,
424
+ };
425
+ header.__officePanelPointerId = event.pointerId;
426
+ header.setPointerCapture?.(event.pointerId);
427
+ globalThis.addEventListener("pointermove", onPointerMove);
428
+ globalThis.addEventListener("pointerup", onPointerUp);
429
+ event.preventDefault();
430
+ };
431
+ header.addEventListener("pointerdown", onPointerDown);
432
+
433
+ this._floatingCleanup = () => {
434
+ header.removeEventListener("pointerdown", onPointerDown);
435
+ globalThis.removeEventListener("pointermove", onPointerMove);
436
+ globalThis.removeEventListener("pointerup", onPointerUp);
437
+ globalThis.removeEventListener("resize", clampGeometry);
438
+ resizeObserver?.disconnect?.();
439
+ };
440
+ },
441
+
442
+ setupCanvasSurface(element = null) {
443
+ this._floatingCleanup?.();
444
+ this._floatingCleanup = null;
445
+ if (element) this._root = element;
446
+ },
447
+};
448
+
449
+export const store = createStore("office", model);
tests/test_office_wopi_store.py
new
+161
@@ -0,0 +1,161 @@
1
+from __future__ import annotations
2
+
3
+import sys
4
+from pathlib import Path
5
+
6
+import pytest
7
+from flask import Flask
8
+
9
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
10
+if str(PROJECT_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(PROJECT_ROOT))
12
+
13
+from plugins._office.helpers import wopi_routes, wopi_store
14
+
15
+
16
+@pytest.fixture()
17
+def office_state(tmp_path, monkeypatch):
18
+ workdir = tmp_path / "workdir"
19
+ state = tmp_path / "state"
20
+ documents = workdir / "documents"
21
+ monkeypatch.setattr(wopi_store, "STATE_DIR", state)
22
+ monkeypatch.setattr(wopi_store, "DB_PATH", state / "documents.sqlite3")
23
+ monkeypatch.setattr(wopi_store, "BACKUP_DIR", state / "backups")
24
+ monkeypatch.setattr(wopi_store, "DOCUMENTS_DIR", documents)
25
+ monkeypatch.setattr(wopi_store, "WORKDIR", workdir)
26
+ wopi_store.ensure_dirs()
27
+ return {"workdir": workdir, "state": state, "documents": documents}
28
+
29
+
30
+def test_check_file_info_has_no_nulls_and_token_is_scoped(office_state):
31
+ doc = wopi_store.create_document("document", "Scope Test", "docx", "hello")
32
+ session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
33
+
34
+ token_info = wopi_store.validate_token(session["access_token"], doc["file_id"], require_write=True)
35
+ info = wopi_store.check_file_info(doc["file_id"], token_info)
36
+
37
+ assert all(value is not None for value in info.values())
38
+ assert info["UserCanWrite"] is True
39
+ assert info["ReadOnly"] is False
40
+ assert info["SupportsLocks"] is True
41
+
42
+ other = wopi_store.create_document("document", "Other", "docx", "")
43
+ with pytest.raises(PermissionError):
44
+ wopi_store.validate_token(session["access_token"], other["file_id"])
45
+
46
+
47
+def test_path_traversal_and_symlink_escape_are_rejected(office_state, tmp_path):
48
+ outside = tmp_path / "outside.docx"
49
+ outside.write_bytes(wopi_store.template_bytes("document", "docx", "Outside", ""))
50
+
51
+ with pytest.raises(PermissionError):
52
+ wopi_store.register_document(outside)
53
+
54
+ link = office_state["workdir"] / "escape.docx"
55
+ link.symlink_to(outside)
56
+ with pytest.raises(PermissionError):
57
+ wopi_store.register_document(link)
58
+
59
+
60
+def test_lock_conflicts_refresh_unlock_and_relock(office_state):
61
+ doc = wopi_store.create_document("document", "Lock Test", "docx", "")
62
+ session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
63
+
64
+ ok, current = wopi_store.lock(doc["file_id"], "lock-a", session["session_id"], 120)
65
+ assert ok is True
66
+ assert current == "lock-a"
67
+
68
+ ok, current = wopi_store.lock(doc["file_id"], "lock-b", session["session_id"], 120)
69
+ assert ok is False
70
+ assert current == "lock-a"
71
+
72
+ ok, current = wopi_store.refresh_lock(doc["file_id"], "lock-a", 120)
73
+ assert ok is True
74
+ assert current == "lock-a"
75
+
76
+ ok, current = wopi_store.unlock_and_relock(doc["file_id"], "lock-a", "lock-c", session["session_id"], 120)
77
+ assert ok is True
78
+ assert current == "lock-c"
79
+
80
+ ok, current = wopi_store.unlock(doc["file_id"], "lock-b")
81
+ assert ok is False
82
+ assert current == "lock-c"
83
+
84
+ ok, current = wopi_store.unlock(doc["file_id"], "lock-c")
85
+ assert ok is True
86
+ assert current == ""
87
+
88
+
89
+def test_put_file_requires_lock_and_updates_version_history(office_state):
90
+ doc = wopi_store.create_document("document", "Save Test", "docx", "before")
91
+ session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
92
+
93
+ with pytest.raises(wopi_store.LockMismatch):
94
+ wopi_store.put_file(doc["file_id"], b"after", "")
95
+
96
+ ok, _ = wopi_store.lock(doc["file_id"], "save-lock", session["session_id"], 120)
97
+ assert ok is True
98
+ next_version = wopi_store.put_file(doc["file_id"], b"after", "save-lock")
99
+ saved = wopi_store.get_document(doc["file_id"])
100
+
101
+ assert next_version == wopi_store.item_version(saved)
102
+ assert saved["size"] == len(b"after")
103
+ assert (office_state["documents"] / "Save Test.docx").read_bytes() == b"after"
104
+ assert wopi_store.version_history(doc["file_id"])
105
+
106
+
107
+def test_wopi_routes_return_conflict_lock_header(office_state):
108
+ app = Flask(__name__)
109
+ wopi_routes.register_wopi_routes(app)
110
+ doc = wopi_store.create_document("document", "Route Test", "docx", "")
111
+ session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
112
+
113
+ with app.test_client() as client:
114
+ first = client.post(
115
+ f"/wopi/files/{doc['file_id']}?access_token={session['access_token']}",
116
+ headers={"X-WOPI-Override": "LOCK", "X-WOPI-Lock": "route-lock"},
117
+ )
118
+ assert first.status_code == 200
119
+
120
+ conflict = client.post(
121
+ f"/wopi/files/{doc['file_id']}?access_token={session['access_token']}",
122
+ headers={"X-WOPI-Override": "LOCK", "X-WOPI-Lock": "other-lock"},
123
+ )
124
+ assert conflict.status_code == 409
125
+ assert conflict.headers["X-WOPI-Lock"] == "route-lock"
126
+
127
+
128
+def test_office_proxy_accepts_encoded_wopi_socket_token_without_session_cookie(office_state):
129
+ pytest.importorskip("starlette")
130
+ from plugins._office.helpers import office_proxy
131
+
132
+ doc = wopi_store.create_document("document", "Socket Token", "docx", "")
133
+ session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://127.0.0.1:32080")
134
+ encoded_wopi = (
135
+ f"http%3A%2F%2F127.0.0.1%3A80%2Fwopi%2Ffiles%2F{doc['file_id']}"
136
+ f"%3Faccess_token%3D{session['access_token']}"
137
+ f"%26access_token_ttl%3D{session['access_token_ttl']}"
138
+ )
139
+ scope = {
140
+ "type": "websocket",
141
+ "path": f"/office/cool/{encoded_wopi}/ws",
142
+ "raw_path": f"/office/cool/{encoded_wopi}/ws".encode("latin-1"),
143
+ "query_string": b"",
144
+ "headers": [],
145
+ }
146
+
147
+ proxy = office_proxy.OfficeProxy()
148
+
149
+ assert proxy._has_valid_wopi_token(scope) is True
150
+
151
+ headers = proxy.websocket_headers({
152
+ "headers": [
153
+ (b"host", b"127.0.0.1:32080"),
154
+ (b"origin", b"http://127.0.0.1:32080"),
155
+ (b"user-agent", b"qa"),
156
+ (b"sec-websocket-key", b"ignored"),
157
+ ],
158
+ })
159
+ assert proxy.upstream_websocket_url(scope).startswith("ws://127.0.0.1:32080/office/cool/")
160
+ assert all(key.lower() not in {"host", "origin", "sec-websocket-key"} for key, _ in headers)
161
+ assert ("user-agent", "qa") in headers