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

Nhúng trình soạn thảo

Lưu tài liệu và callback

Cách gate lưu phiên bản, gói tin callback saved và closed, kiểm chữ ký HMAC bằng Node.js, PHP, Python, C#, thử lại và đổi key.

Trong trang này

Tài liệu được lưu theo ba đường: máy chủ soạn thảo tự lưu, người dùng bấm lưu (hoặc trang gọi save()), và lần lưu khi người cuối cùng rời đi. Cả ba đều về gate, gate ghi thành một phiên bản mới rồi báo cho bạn bằng callback.

Nguyên tắc: không bao giờ chặn lưu#

  • Gate nhận bản lưu của một phiên từng ở chế độ edit KỂ CẢ khi phiên đã quá hạn hay đã đóng, vì máy chủ soạn thảo lưu lần cuối sau khi người dùng rời đi.
  • Lưu không xét trần kết nối, không xét bản quyền.
  • Gate ghi tệp (ghi tạm rồi đổi tên), tăng version, tính sha256, trả lời máy chủ soạn thảo NGAY, rồi mới gửi callback ở nền. Callback chậm hay hỏng không ảnh hưởng thao tác lưu.
  • Gate giữ 5 phiên bản gần nhất cộng bản gốc v0, trong O3O_EMBED_RETAIN_HOURS (mặc định 24 giờ) sau khi tài liệu đóng.
  • Thân rỗng (0 byte) bị từ chối, không tạo phiên bản. Vượt O3O_EMBED_MAX_FILE_MB (mặc định 100 MB): 413.

Gói tin callback#

POST tới editor.callbackUrl, Content-Type: application/json; charset=utf-8, User-Agent: O3O-Gate/1.0. Gate không theo chuyển hướng, và URL này chịu quy tắc chống SSRF như document.url.

HTTPYêu cầu HTTP
POST /o3o/callback HTTP/1.1
Host: app.example.com
Content-Type: application/json; charset=utf-8
User-Agent: O3O-Gate/1.0
X-O3O-Event: document.saved
X-O3O-Delivery: 6f1c2b7e-2d0a-4c61-9a57-0f3b1c8d9e21
X-O3O-Timestamp: 1790086400
X-O3O-Signature: sha256=<hex HMAC_SHA256(O3O_EMBED_CALLBACK_SECRET, "1790086400." + thân nguyên văn)>

{"status": "saved", "key": "hopdong-42-v7", "version": 3, ...}
JSONThân callback saved
{
  "status": "saved",
  "key": "hopdong-42-v7",
  "version": 3,
  "url": "http://localhost:8080/o3o/embed/files/doc_9835f61009fc659dfedab8696f2d2665?v=3&exp=1790086400&sig=5f1c…",
  "fileType": "docx",
  "title": "Hợp đồng mẫu.docx",
  "size": 48890,
  "sha256": "b1946ac92492d2347c6235b4d2611184…",
  "users": [
    {
      "id": "u-1001",
      "name": "Nguyễn Văn A"
    },
    {
      "id": "u-1002",
      "name": "Trần Thị B"
    }
  ],
  "savedBy": {
    "id": "u-1001",
    "name": "Nguyễn Văn A"
  },
  "autosave": false,
  "final": false,
  "modifiedByUser": true,
  "timestamp": "2026-09-21T10:05:00Z"
}

Trường của callback

  • status"saved" | "closed"bắt buộc
    saved: có phiên bản mới, tài liệu có thể vẫn đang mở. closed: mọi người đã rời (không còn view nào trong 30 giây liên tục).
  • keystringbắt buộc
    Đúng document.key của config.
  • versionintegerbắt buộc
    Tăng dần từ 1. Với closed là phiên bản cuối, 0 nếu chưa từng lưu.
  • urlstring | nullbắt buộc
    URL tải phiên bản đó, có chữ ký, sống O3O_EMBED_RETAIN_HOURS giờ. null khi closedversion = 0.
  • fileTypestringbắt buộc
    document.fileType
  • titlestringbắt buộc
    Tên hiển thị đã chuẩn hoá.
  • sizeintegerbắt buộc
    Kích thước tính bằng byte.
  • sha256stringbắt buộc
    Băm SHA-256 dạng hex; so trước khi ghi đè bản gốc.
  • usersarraybắt buộc
    Những người đang mở tài liệu lúc gửi, dạng {id, name}. Rỗng với closed.
  • savedByobject | nullbắt buộc
    Người sở hữu phiên đã gây ra lần lưu. null với closed.
  • autosavebooleanbắt buộc
    Lần lưu do máy chủ tự làm.
  • finalbooleanbắt buộc
    true = lần lưu khi người cuối cùng rời tài liệu. Sau bản final sẽ có một callback closed.
  • modifiedByUserbooleanbắt buộc
    Có thay đổi do người dùng kể từ lần lưu trước.
  • timestampstringbắt buộc
    Thời điểm gửi, ISO 8601 UTC.
JSONThân callback closed
{
  "status": "closed",
  "key": "hopdong-42-v7",
  "version": 4,
  "url": "http://localhost:8080/o3o/embed/files/doc_9835f61009fc659dfedab8696f2d2665?v=4&exp=1790090000&sig=9a0e…",
  "fileType": "docx",
  "title": "Hợp đồng mẫu.docx",
  "size": 49102,
  "sha256": "7d865e959b2466918c9863afca942d0f…",
  "users": [],
  "savedBy": null,
  "autosave": false,
  "final": false,
  "modifiedByUser": false,
  "timestamp": "2026-09-21T10:31:00Z"
}
HeaderNội dung
X-O3O-Eventdocument.saved hoặc document.closed
X-O3O-DeliveryUUID của lần gửi; giữ nguyên qua các lần thử lại. Dùng để bỏ trùng.
X-O3O-TimestampGiây Unix lúc ký.
X-O3O-Signaturesha256= + hex của HMAC_SHA256(O3O_EMBED_CALLBACK_SECRET, X-O3O-Timestamp + "." + thân_nguyên_văn). Vắng mặt khi máy chủ chưa đặt khoá.

Kiểm chữ ký HMAC#

  1. Đọc thân NGUYÊN VĂN dưới dạng byte, trước khi phân tích JSON. Phân tích rồi tuần tự hoá lại sẽ làm lệch chữ ký.
  2. Tính "sha256=" + hex(HMAC_SHA256(khoá, timestamp + "." + thân)).
  3. So sánh bằng hàm so sánh thời gian hằng.
  4. Từ chối khi X-O3O-Timestamp lệch quá 300 giây so với đồng hồ của bạn.
Kiểm chữ ký và nhận callback
// npm install express
const crypto = require("crypto");
const express = require("express");

// rawBody là Buffer của thân yêu cầu NGUYÊN VĂN, chưa phân tích JSON
function verifyO3OSignature(secret, timestamp, rawBody, signature, toleranceSeconds = 300) {
  const ts = Number(timestamp);
  if (!signature || !Number.isInteger(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(String(signature));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
// express.raw giữ thân dạng Buffer; đừng để express.json() chạy trước route này
app.post("/o3o/callback", express.raw({ type: "*/*", limit: "1mb" }), (req, res) => {
  const ok = verifyO3OSignature(process.env.O3O_EMBED_CALLBACK_SECRET, req.get("X-O3O-Timestamp"), req.body, req.get("X-O3O-Signature"));
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204);                                   // trả lời trong 10 giây, việc nặng làm sau
  console.log(req.get("X-O3O-Delivery"), event.status, event.key, event.version);
});
app.listen(3000);

module.exports = { verifyO3OSignature };
<?php
// PHP thuần; trong Laravel dùng $request->getContent() và $request->header(...)
function verify_o3o_signature(string $secret, string $timestamp, string $rawBody, string $signature, int $tolerance = 300): bool
{
    if ($signature === '' || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

$raw = file_get_contents('php://input');                // thân nguyên văn
$ok = verify_o3o_signature(
    getenv('O3O_EMBED_CALLBACK_SECRET') ?: '',
    $_SERVER['HTTP_X_O3O_TIMESTAMP'] ?? '',
    $raw,
    $_SERVER['HTTP_X_O3O_SIGNATURE'] ?? ''
);
if (!$ok) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);
error_log(($_SERVER['HTTP_X_O3O_DELIVERY'] ?? '') . ' ' . $event['status'] . ' v' . $event['version']);
http_response_code(204);
# pip install flask
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request


def verify_o3o_signature(secret: str, timestamp: str, raw_body: bytes, signature: str, tolerance: int = 300) -> bool:
    if not signature or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = "sha256=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


app = Flask(__name__)


@app.post("/o3o/callback")
def o3o_callback():
    raw = request.get_data()                            # thân nguyên văn, trước khi phân tích JSON
    ok = verify_o3o_signature(
        os.environ.get("O3O_EMBED_CALLBACK_SECRET", ""),
        request.headers.get("X-O3O-Timestamp", ""),
        raw,
        request.headers.get("X-O3O-Signature", ""),
    )
    if not ok:
        abort(401)
    event = json.loads(raw)
    print(request.headers.get("X-O3O-Delivery"), event["status"], event["key"], event["version"])
    return "", 204


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3000)
// Program.cs — ASP.NET Core (.NET 8)
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var app = WebApplication.CreateBuilder(args).Build();

app.MapPost("/o3o/callback", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);                // thân nguyên văn
    var raw = buffer.ToArray();
    var ok = VerifyO3OSignature(
        Environment.GetEnvironmentVariable("O3O_EMBED_CALLBACK_SECRET") ?? "",
        request.Headers["X-O3O-Timestamp"].ToString(),
        raw,
        request.Headers["X-O3O-Signature"].ToString());
    if (!ok) return Results.Unauthorized();

    using var ev = JsonDocument.Parse(raw);
    Console.WriteLine($"{request.Headers["X-O3O-Delivery"]} {ev.RootElement.GetProperty("status").GetString()} v{ev.RootElement.GetProperty("version").GetInt32()}");
    return Results.NoContent();
});

app.Run("http://0.0.0.0:3000");

static bool VerifyO3OSignature(string secret, string timestamp, byte[] rawBody, string signature, int toleranceSeconds = 300)
{
    if (!long.TryParse(timestamp, out var ts)) return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds) return false;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
    hmac.TransformBlock(prefix, 0, prefix.Length, null, 0);
    hmac.TransformFinalBlock(rawBody, 0, rawBody.Length);
    var expected = "sha256=" + Convert.ToHexString(hmac.Hash!).ToLowerInvariant();
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature));
}

Trả lời và thử lại#

  • Trả bất kỳ mã 2xx nào trong 10 giây; thân trả lời bị bỏ qua. Làm việc nặng (tải tệp, ghi kho) sau khi đã trả lời.
  • Mã khác hoặc quá 10 giây là thất bại. Gate thử lại sau 10 giây rồi sau 60 giây (tổng 3 lần), sau đó ghi nhật ký mức ERROR và bỏ.
  • Callback hỏng không ảnh hưởng người đang sửa; tệp vẫn nằm ở gate trong thời gian giữ và onSaved vẫn phát.
  • Callback có thể tới trùng (thử lại) hoặc lệch thứ tự: bỏ trùng theo X-O3O-Delivery và chỉ ghi khi version lớn hơn bản bạn đã có.

Tải bản đã lưu#

BashTải và đối chiếu
# URL trong callback đã có chữ ký: tải thẳng, không cần khoá
curl -f -o hop-dong-v3.docx "http://localhost:8080/o3o/embed/files/doc_9835f61009fc659dfedab8696f2d2665?v=3&exp=1790086400&sig=<sig>"
# So với trường sha256 của callback trước khi ghi đè bản gốc
sha256sum hop-dong-v3.docx

Đóng tài liệu và đổi key#

Callback closed nghĩa là không còn ai mở tài liệu. Từ đây, lần mở kế tiếp PHẢI dùng document.key mới để gate tải lại tệp từ document.url (lúc này đã là bản bạn vừa cất). Mẫu hay dùng: giữ một bộ đếm cho mỗi tệp, key = "hopdong-r" + bộ_đếm, tăng bộ đếm khi nhận closed. Các ví dụ tích hợp làm đúng như vậy.