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ế độ
editKỂ 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ínhsha256, 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, trongO3O_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.
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, ...}{
"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ộcsaved: 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Đúngdocument.keycủa config.versionintegerbắt buộcTăng dần từ 1. Vớiclosedlà phiên bản cuối,0nếu chưa từng lưu.urlstring | nullbắt buộcURL tải phiên bản đó, có chữ ký, sốngO3O_EMBED_RETAIN_HOURSgiờ.nullkhiclosedmàversion = 0.fileTypestringbắt buộcdocument.fileTypetitlestringbắt buộcTên hiển thị đã chuẩn hoá.sizeintegerbắt buộcKích thước tính bằng byte.sha256stringbắt buộcBăm SHA-256 dạng hex; so trước khi ghi đè bản gốc.usersarraybắt buộcNhững người đang mở tài liệu lúc gửi, dạng{id, name}. Rỗng vớiclosed.savedByobject | nullbắt buộcNgười sở hữu phiên đã gây ra lần lưu.nullvớiclosed.autosavebooleanbắt buộcLần lưu do máy chủ tự làm.finalbooleanbắt buộctrue= lần lưu khi người cuối cùng rời tài liệu. Sau bảnfinalsẽ có một callbackclosed.modifiedByUserbooleanbắt buộcCó thay đổi do người dùng kể từ lần lưu trước.timestampstringbắt buộcThời điểm gửi, ISO 8601 UTC.
{
"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"
}Header#
| Header | Nội dung |
|---|---|
X-O3O-Event | document.saved hoặc document.closed |
X-O3O-Delivery | UUID của lần gửi; giữ nguyên qua các lần thử lại. Dùng để bỏ trùng. |
X-O3O-Timestamp | Giây Unix lúc ký. |
X-O3O-Signature | sha256= + 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#
- Đọ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ý.
- Tính
"sha256=" + hex(HMAC_SHA256(khoá, timestamp + "." + thân)). - So sánh bằng hàm so sánh thời gian hằng.
- Từ chối khi
X-O3O-Timestamplệch quá 300 giây so với đồng hồ của bạn.
// 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ã
2xxnà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à
onSavedvẫ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-Deliveryvà chỉ ghi khiversionlớn hơn bản bạn đã có.
Tải bản đã lư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.