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