Tích hợp lưu trữ
Tự viết WOPI host Beta
CheckFileInfo, GetFile, PutFile, khoá, các trường bắt buộc, cách mở trình soạn thảo bằng form POST, và mã WOPI host tối thiểu bằng Python và Node.js.
Trang này mô tả tính năng đang ở giai đoạn beta: đã chạy được nhưng có thể còn thay đổi.
Trong trang này
Khi ứng dụng của bạn có kho tệp và hệ phân quyền riêng, bạn có thể tự đóng vai WOPI host: máy chủ soạn thảo O3O sẽ gọi thẳng các endpoint của bạn để đọc và ghi tệp. Trang này mô tả phần tối thiểu phải có, kèm mã chạy được bằng Python (thư viện chuẩn) và Node.js (Express). Hai ví dụ đã chạy thật với image soạn thảo DEV: mở tệp, rồi lưu về qua PutFile.
Các thao tác phải có#
| Thao tác | Yêu cầu | Bắt buộc | Ghi chú |
|---|---|---|---|
| CheckFileInfo | GET /wopi/files/{id}?access_token=… | Có | Trả JSON siêu dữ liệu và quyền của người dùng đang mở. |
| GetFile | GET /wopi/files/{id}/contents?access_token=… | Có | Trả nội dung nhị phân của tệp. |
| PutFile | POST /wopi/files/{id}/contents?access_token=…, X-WOPI-Override: PUT | Có, nếu cho sửa | Thân là toàn bộ tệp mới. Trả 200 kèm LastModifiedTime. |
| Lock / Unlock / RefreshLock / GetLock | POST /wopi/files/{id}, X-WOPI-Override: LOCK… | Không | Khai SupportsLocks: false thì máy chủ soạn thảo không gọi. |
| PutRelativeFile, RenameFile | POST /wopi/files/{id}, X-WOPI-Override: PUT_RELATIVE… | Không | Khai UserCanNotWriteRelative: true để ẩn “lưu thành tệp khác”. |
Tiền tố /wopi/files/ là quy ước; bạn đặt tuỳ ý, miễn WOPISrc trỏ đúng. {id} là mã tệp của bạn; máy chủ soạn thảo gửi lại nguyên văn như trong WOPISrc.
CheckFileInfo#
Trường của CheckFileInfo
BaseFileNamestringbắt buộcTên tệp kèm đuôi. Đuôi quyết định trình soạn thảo nào được dùng.Sizeintegerbắt buộcKích thước tính bằng byte.OwnerIdstringbắt buộcMã chủ sở hữu tệp trong hệ của bạn.UserIdstringbắt buộcMã người đang mở. Hai phiên cùng tệp khácUserIdthấy nhau trong danh sách người cùng sửa.Versionstringbắt buộcChuỗi đổi mỗi khi nội dung tệp đổi.UserFriendlyNamestringtuỳ chọnTên hiển thị của người dùng.UserCanWritebooleantuỳ chọnMặc định:falsetruemới được sửa và lưu. Phiênfalsekhông tính kết nối.UserCanNotWriteRelativebooleantuỳ chọnMặc định:falsetruenếu bạn không cài PutRelativeFile.SupportsLocksbooleantuỳ chọnMặc định:falsetruechỉ khi bạn cài đủ bốn thao tác khoá.SupportsUpdatebooleantuỳ chọnMặc định:falsetruekhi bạn cài PutFile.LastModifiedTimestringtuỳ chọnThời điểm sửa cuối, ISO 8601 UTC, ví dụ2026-09-21T10:00:00.0000000Z.PostMessageOriginstringtuỳ chọnOrigin của trang chứa iframe, để khung soạn thảo gửi thông điệppostMessagevề đúng trang.HidePrintOption, DisablePrintbooleantuỳ chọnMặc định:falseẨn và chặn lệnh in.HideExportOption, DisableExportbooleantuỳ chọnMặc định:falseẨn và chặn tải về dưới định dạng khác.HideSaveOptionbooleantuỳ chọnMặc định:falseẨn lệnh lưu (tự lưu vẫn chạy).DisableCopybooleantuỳ chọnMặc định:falseChặn chép nội dung ra ngoài khung.
{
"BaseFileName": "Hợp đồng mẫu.docx",
"Size": 48213,
"Version": "3",
"OwnerId": "owner-7",
"UserId": "u-1001",
"UserFriendlyName": "Nguyễn Văn A",
"UserCanWrite": true,
"UserCanNotWriteRelative": true,
"SupportsLocks": false,
"SupportsUpdate": true,
"LastModifiedTime": "2026-09-21T10:00:00.0000000Z",
"PostMessageOrigin": "https://app.example.com"
}PutFile#
Máy chủ soạn thảo gửi toàn bộ tệp mới trong thân yêu cầu. Ghi ra tệp tạm rồi đổi tên để không bao giờ để lại tệp dở. Ba header dưới đây cho biết vì sao có lần lưu này; bạn có thể dùng chúng để quyết định có tạo phiên bản mới trong lịch sử hay không.
| Header | Giá trị | Ý nghĩa |
|---|---|---|
X-WOPI-Override | PUT | Đánh dấu đây là PutFile. |
X-COOL-WOPI-IsModifiedByUser | true / false | Có thay đổi do người dùng kể từ lần lưu trước. |
X-COOL-WOPI-IsAutosave | true / false | Lần lưu do máy chủ tự làm. |
X-COOL-WOPI-IsExitSave | true / false | Lần lưu khi người cuối cùng rời tài liệu. |
{
"LastModifiedTime": "2026-09-21T10:05:00.0000000Z"
}Mở trình soạn thảo từ trang của bạn#
- Đọc
urlsrccủa đuôi tệp từ discovery. - Dựng
action = urlsrc + "WOPISrc=" + encodeURIComponent(URL WOPI của tệp) + "&lang=vi". - Gửi form POST vào một iframe có tên, với hai trường ẩn
access_tokenvàaccess_token_ttl(mili giây Unix lúc token hết hạn). Không đặt token lên URL.
Mã tối thiểu#
"""WOPI host tối thiểu để thử với O3O Office Online trên máy DEV.
python wopi_host.py → http://localhost:5000/open/hop-dong.docx
Đặt tệp cần mở vào thư mục ./files. Chỉ dùng thư viện chuẩn của Python 3.9 trở lên.
"""
import html
import json
import os
import secrets
import time
import urllib.request
import xml.etree.ElementTree as ET
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, unquote, urlparse
PORT = int(os.environ.get("PORT", "5000"))
O3O_URL = os.environ.get("O3O_URL", "http://localhost:8080") # O3O như trình duyệt thấy
WOPI_BASE = os.environ.get("WOPI_BASE", f"http://host.docker.internal:{PORT}") # host này như o3o-online thấy
FILES = os.environ.get("FILES_DIR", "files")
TOKENS = {} # access_token -> {"file", "user", "write", "exp_ms"}
def urlsrc_for(ext):
xml = urllib.request.urlopen(O3O_URL + "/hosting/discovery", timeout=10).read()
for action in ET.fromstring(xml).iter("action"):
if action.get("ext") == ext and action.get("name") == "edit":
return action.get("urlsrc")
raise LookupError("no edit action for ." + ext)
def iso_time(ts):
return time.strftime("%Y-%m-%dT%H:%M:%S.0000000Z", time.gmtime(ts))
class WopiHandler(BaseHTTPRequestHandler):
def reply(self, code, body=b"", ctype="application/json"):
if isinstance(body, dict):
body = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def grant(self):
"""Trả (tên tệp, quyền, phần đuôi đường dẫn) nếu access_token hợp lệ cho đúng tệp này."""
url = urlparse(self.path)
parts = url.path.strip("/").split("/")
if len(parts) not in (3, 4) or parts[:2] != ["wopi", "files"]:
return None, None, None
name = unquote(parts[2])
g = TOKENS.get(parse_qs(url.query).get("access_token", [""])[0])
if not g or g["file"] != name or g["exp_ms"] < time.time() * 1000:
return name, None, None
return name, g, parts[3:]
def do_GET(self):
if self.path.startswith("/open/"):
return self.open_editor(os.path.basename(unquote(urlparse(self.path).path[6:])))
name, g, tail = self.grant()
if g is None:
return self.reply(401 if name else 404)
path = os.path.join(FILES, name)
if tail == ["contents"]: # GetFile
with open(path, "rb") as f:
return self.reply(200, f.read(), "application/octet-stream")
st = os.stat(path) # CheckFileInfo
self.reply(200, {
"BaseFileName": name,
"Size": st.st_size,
"Version": str(st.st_mtime_ns),
"OwnerId": "demo-owner",
"UserId": g["user"],
"UserFriendlyName": g["user"],
"UserCanWrite": g["write"],
"UserCanNotWriteRelative": True,
"SupportsLocks": False,
"SupportsUpdate": True,
"LastModifiedTime": iso_time(st.st_mtime),
"PostMessageOrigin": f"http://localhost:{PORT}",
})
def do_POST(self):
name, g, tail = self.grant()
if g is None:
return self.reply(401 if name else 404)
if tail != ["contents"] or self.headers.get("X-WOPI-Override") != "PUT":
return self.reply(501) # LOCK, PUT_RELATIVE... chưa hỗ trợ
if not g["write"]:
return self.reply(401)
data = self.rfile.read(int(self.headers.get("Content-Length") or 0))
path = os.path.join(FILES, name) # PutFile
with open(path + ".tmp", "wb") as f:
f.write(data)
os.replace(path + ".tmp", path)
self.reply(200, {"LastModifiedTime": iso_time(os.stat(path).st_mtime)})
def open_editor(self, name):
if not os.path.isfile(os.path.join(FILES, name)):
return self.reply(404)
token = secrets.token_urlsafe(32)
exp_ms = int((time.time() + 8 * 3600) * 1000)
TOKENS[token] = {"file": name, "user": "u-1001", "write": True, "exp_ms": exp_ms}
wopi_src = f"{WOPI_BASE}/wopi/files/{quote(name)}"
action = urlsrc_for(name.rsplit(".", 1)[-1].lower()) + "WOPISrc=" + quote(wopi_src, safe="") + "&lang=vi"
page = f"""<!doctype html><html lang="vi"><head><meta charset="utf-8"><title>{html.escape(name)}</title>
<style>html,body{{margin:0;height:100%}}iframe{{border:0;width:100%;height:100%}}</style></head><body>
<form id="f" method="post" target="o3o" action="{html.escape(action)}">
<input type="hidden" name="access_token" value="{token}">
<input type="hidden" name="access_token_ttl" value="{exp_ms}"></form>
<iframe name="o3o" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
<script>document.getElementById("f").submit();</script></body></html>"""
self.reply(200, page.encode(), "text/html; charset=utf-8")
if __name__ == "__main__":
print(f"http://localhost:{PORT}/open/<file> (./{FILES})")
ThreadingHTTPServer(("0.0.0.0", PORT), WopiHandler).serve_forever()// wopi-host.js — WOPI host tối thiểu để thử trên máy DEV
// npm install express · node wopi-host.js → http://localhost:5000/open/hop-dong.docx
const express = require("express");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const PORT = Number(process.env.PORT || 5000);
const O3O_URL = process.env.O3O_URL || "http://localhost:8080"; // O3O như trình duyệt thấy
const WOPI_BASE = process.env.WOPI_BASE || `http://host.docker.internal:${PORT}`; // host này như o3o-online thấy
const FILES = path.resolve(process.env.FILES_DIR || "files");
const tokens = new Map(); // access_token -> { file, user, write, expMs }
const isoTime = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, ".0000000Z");
async function urlsrcFor(ext) {
const xml = await (await fetch(`${O3O_URL}/hosting/discovery`)).text();
const m = xml.match(new RegExp(`<action[^>]*ext="${ext}"[^>]*name="edit"[^>]*urlsrc="([^"]+)"`));
if (!m) throw new Error(`no edit action for .${ext}`);
return m[1];
}
function grantFor(req) {
const g = tokens.get(String(req.query.access_token || ""));
return g && g.file === req.params.id && g.expMs > Date.now() ? g : null;
}
const app = express();
app.get("/wopi/files/:id", (req, res) => { // CheckFileInfo
const g = grantFor(req);
if (!g) return res.sendStatus(401);
const st = fs.statSync(path.join(FILES, g.file));
res.json({
BaseFileName: g.file,
Size: st.size,
Version: String(Math.floor(st.mtimeMs)),
OwnerId: "demo-owner",
UserId: g.user,
UserFriendlyName: g.user,
UserCanWrite: g.write,
UserCanNotWriteRelative: true,
SupportsLocks: false,
SupportsUpdate: true,
LastModifiedTime: isoTime(st.mtimeMs),
PostMessageOrigin: `http://localhost:${PORT}`
});
});
app.get("/wopi/files/:id/contents", (req, res) => { // GetFile
const g = grantFor(req);
if (!g) return res.sendStatus(401);
res.type("application/octet-stream").sendFile(path.join(FILES, g.file));
});
app.post("/wopi/files/:id/contents", express.raw({ type: "*/*", limit: "100mb" }), (req, res) => { // PutFile
const g = grantFor(req);
if (!g || !g.write) return res.sendStatus(401);
if (req.get("X-WOPI-Override") !== "PUT") return res.sendStatus(501);
const file = path.join(FILES, g.file);
fs.writeFileSync(file + ".tmp", req.body);
fs.renameSync(file + ".tmp", file);
res.json({ LastModifiedTime: isoTime(fs.statSync(file).mtimeMs) });
});
app.post("/wopi/files/:id", (req, res) => res.sendStatus(501)); // LOCK, PUT_RELATIVE... chưa hỗ trợ
app.get("/open/:name", async (req, res) => {
const name = path.basename(req.params.name);
if (!fs.existsSync(path.join(FILES, name))) return res.sendStatus(404);
const token = crypto.randomBytes(32).toString("base64url");
const expMs = Date.now() + 8 * 3600 * 1000;
tokens.set(token, { file: name, user: "u-1001", write: true, expMs });
const wopiSrc = `${WOPI_BASE}/wopi/files/${encodeURIComponent(name)}`;
const action = (await urlsrcFor(path.extname(name).slice(1).toLowerCase()))
+ "WOPISrc=" + encodeURIComponent(wopiSrc) + "&lang=vi";
res.type("html").send(`<!doctype html><html lang="vi"><head><meta charset="utf-8"><title>${name}</title>
<style>html,body{margin:0;height:100%}iframe{border:0;width:100%;height:100%}</style></head><body>
<form id="f" method="post" target="o3o" action="${action.replace(/&/g, "&")}">
<input type="hidden" name="access_token" value="${token}">
<input type="hidden" name="access_token_ttl" value="${expMs}"></form>
<iframe name="o3o" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
<script>document.getElementById("f").submit();</script></body></html>`);
});
app.listen(PORT, "0.0.0.0", () => console.log(`http://localhost:${PORT}/open/<file> (${FILES})`));Cho máy chủ soạn thảo gọi tới host của bạn#
WOPISrc phải là địa chỉ mà container o3o-online gọi tới được và phải nằm trong một nhóm bí danh. Trên Docker Desktop, máy của bạn là host.docker.internal. Tệp compose chuẩn chỉ ánh xạ aliasgroup1 (gate) và aliasgroup2 (Nextcloud), nên thêm nhóm 3 bằng một tệp compose phụ. Host của bạn phải lắng nghe trên 0.0.0.0, không chỉ 127.0.0.1.
# online/docker/compose.wopi.yml — khai WOPI host của bạn cho máy chủ soạn thảo
# aliasgroup1 luôn dành cho gate, aliasgroup2 cho Nextcloud; dùng nhóm 3 cho host của bạn.
services:
o3o-online:
environment:
aliasgroup3: "http://host.docker.internal:5000"
o3o-gate:
environment:
# tuỳ chọn: cho gate hỏi CheckFileInfo khi đã chạm trần
O3O_WOPI_ALLOWED_HOSTS: "http://host.docker.internal:5000"cd online
docker compose --env-file .env -f docker/compose.dev.yml -f docker/compose.wopi.yml up -d
mkdir -p files && cp ~/Documents/hop-dong.docx files/
python wopi_host.py # hoặc: node wopi-host.js
# Mở trình duyệt: http://localhost:5000/open/hop-dong.docx# Thử trực tiếp WOPI host của bạn bằng một access_token vừa cấp (xem trong mã nguồn trang /open/...)
TOKEN=<access_token>
curl -s "http://localhost:5000/wopi/files/hop-dong.docx?access_token=$TOKEN" # CheckFileInfo
curl -s -o /tmp/copy.docx "http://localhost:5000/wopi/files/hop-dong.docx/contents?access_token=$TOKEN" # GetFile
curl -s -X POST -H "X-WOPI-Override: PUT" --data-binary @/tmp/copy.docx \
"http://localhost:5000/wopi/files/hop-dong.docx/contents?access_token=$TOKEN" # PutFileGiới hạn kết nối với host của bạn#
Gate đếm mọi phiên soạn thảo trên máy chủ, bất kể WOPI host nào. Phiên có UserCanWrite: true tính một kết nối; phiên chỉ đọc không tính. Khi đã chạm trần, trang soạn thảo mới được thay bằng trang “Đã đạt giới hạn kết nối” có nút mở chỉ đọc. Nếu bạn khai gốc của host vào O3O_WOPI_ALLOWED_HOSTS, gate hỏi CheckFileInfo (tối đa 3 giây) để nhận ra phiên chỉ đọc và cho qua. Chi tiết: cách đếm kết nối.
Lưu thành tệp khác (PutRelativeFile) và khoá proof để host kiểm chữ ký X-WOPI-Proof của máy chủ soạn thảo.