Bỏ qua, tới nội dung
Tài liệu APIv1

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ácYêu cầuBắt buộcGhi chú
CheckFileInfoGET /wopi/files/{id}?access_token=…Trả JSON siêu dữ liệu và quyền của người dùng đang mở.
GetFileGET /wopi/files/{id}/contents?access_token=…Trả nội dung nhị phân của tệp.
PutFilePOST /wopi/files/{id}/contents?access_token=…, X-WOPI-Override: PUTCó, nếu cho sửaThân là toàn bộ tệp mới. Trả 200 kèm LastModifiedTime.
Lock / Unlock / RefreshLock / GetLockPOST /wopi/files/{id}, X-WOPI-Override: LOCKKhôngKhai SupportsLocks: false thì máy chủ soạn thảo không gọi.
PutRelativeFile, RenameFilePOST /wopi/files/{id}, X-WOPI-Override: PUT_RELATIVEKhôngKhai 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ộc
    Tê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ộc
    Kích thước tính bằng byte.
  • OwnerIdstringbắt buộc
    Mã chủ sở hữu tệp trong hệ của bạn.
  • UserIdstringbắt buộc
    Mã người đang mở. Hai phiên cùng tệp khác UserId thấy nhau trong danh sách người cùng sửa.
  • Versionstringbắt buộc
    Chuỗi đổi mỗi khi nội dung tệp đổi.
  • UserFriendlyNamestringtuỳ chọn
    Tên hiển thị của người dùng.
  • UserCanWritebooleantuỳ chọnMặc định: false
    true mới được sửa và lưu. Phiên false không tính kết nối.
  • UserCanNotWriteRelativebooleantuỳ chọnMặc định: false
    true nếu bạn không cài PutRelativeFile.
  • SupportsLocksbooleantuỳ chọnMặc định: false
    true chỉ khi bạn cài đủ bốn thao tác khoá.
  • SupportsUpdatebooleantuỳ chọnMặc định: false
    true khi bạn cài PutFile.
  • LastModifiedTimestringtuỳ chọn
    Thời điểm sửa cuối, ISO 8601 UTC, ví dụ 2026-09-21T10:00:00.0000000Z.
  • PostMessageOriginstringtuỳ chọn
    Origin của trang chứa iframe, để khung soạn thảo gửi thông điệp postMessage về đú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: false
    Chặn chép nội dung ra ngoài khung.
200Ví dụ trả lời CheckFileInfo
{
  "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.

HeaderGiá trịÝ nghĩa
X-WOPI-OverridePUTĐánh dấu đây là PutFile.
X-COOL-WOPI-IsModifiedByUsertrue / falseCó thay đổi do người dùng kể từ lần lưu trước.
X-COOL-WOPI-IsAutosavetrue / falseLần lưu do máy chủ tự làm.
X-COOL-WOPI-IsExitSavetrue / falseLần lưu khi người cuối cùng rời tài liệu.
200Trả lời PutFile
{
  "LastModifiedTime": "2026-09-21T10:05:00.0000000Z"
}

Mở trình soạn thảo từ trang của bạn#

  1. Đọc urlsrc của đuôi tệp từ discovery.
  2. Dựng action = urlsrc + "WOPISrc=" + encodeURIComponent(URL WOPI của tệp) + "&lang=vi".
  3. Gửi form POST vào một iframe có tên, với hai trường ẩn access_tokenaccess_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.py · wopi-host.js
"""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, "&amp;")}">
<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.

YAMLcompose.wopi.yml
# 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"
BashChạy thử
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
BashGọi thẳng ba thao tác bằng curl
# 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"               # PutFile

Giớ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.

Sắp có

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.