Nhúng trình soạn thảo
Ví dụ tích hợp đầy đủ
Ứng dụng hoàn chỉnh một tệp cho Node.js Express, PHP Laravel, Python Flask và ASP.NET Core: ký JWT, phục vụ tệp, nhận callback, đổi key.
Trong trang này
Bốn ví dụ dưới đây làm cùng một việc, mỗi cái trong một tệp: (1) phục vụ tệp gốc để gate tải, (2) dựng trang có trình soạn thảo với config đã ký JWT, (3) nhận callback, kiểm chữ ký HMAC, tải phiên bản mới về ghi đè tệp gốc, và đổi document.key khi nhận closed.
Chuẩn bị chung#
| Biến | Ý nghĩa | Giá trị trên máy DEV |
|---|---|---|
O3O_URL | Địa chỉ O3O mà TRÌNH DUYỆT dùng để nạp api.js. | http://localhost:8080 |
APP_URL_FOR_GATE | Địa chỉ ứng dụng của bạn mà GATE dùng để tải tệp và gửi callback. | http://host.docker.internal:3000 |
O3O_EMBED_JWT_SECRET | Trùng với biến cùng tên của gate. Khi gate có khoá này, mọi config phải kèm token. Để trống thì trang gửi config không ký (chỉ được khi gate bật O3O_EMBED_ALLOW_UNSIGNED=1). | chuỗi ≥ 32 ký tự |
O3O_EMBED_CALLBACK_SECRET | Trùng với biến cùng tên của gate; dùng để kiểm chữ ký callback. | chuỗi ngẫu nhiên |
# ── online/.env (máy DEV) ─────────────────────────────────────────
O3O_FETCH_ALLOW_HOSTS=host.docker.internal # gate được tải tệp và gửi callback về máy bạn
O3O_EMBED_JWT_SECRET=O3O_DEMO_KEY_jwt_0123456789abcdef0123456789
O3O_EMBED_CALLBACK_SECRET=O3O_DEMO_KEY_callback_0123456789abcdef
# cd online && docker compose --env-file .env -f docker/compose.dev.yml up -d o3o-gate
# ── máy chạy ứng dụng của bạn: CÙNG hai khoá ──────
export O3O_URL=http://localhost:8080
export APP_URL_FOR_GATE=http://host.docker.internal:3000
export O3O_EMBED_JWT_SECRET=O3O_DEMO_KEY_jwt_0123456789abcdef0123456789
export O3O_EMBED_CALLBACK_SECRET=O3O_DEMO_KEY_callback_0123456789abcdef
mkdir -p files && cp ~/Documents/hop-dong.docx files/Node.js + Express#
// server.js — tích hợp đầy đủ: trang soạn thảo ký JWT, phục vụ tệp gốc, nhận callback và cập nhật tệp
// npm install express jsonwebtoken · node server.js · http://localhost:3000
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const express = require("express");
const jwt = require("jsonwebtoken");
const PORT = Number(process.env.PORT || 3000);
const O3O_URL = process.env.O3O_URL || "http://localhost:8080"; // trình duyệt dùng
const APP_URL_FOR_GATE = process.env.APP_URL_FOR_GATE || `http://host.docker.internal:${PORT}`; // gate dùng
const JWT_SECRET = process.env.O3O_EMBED_JWT_SECRET || "";
const CALLBACK_SECRET = process.env.O3O_EMBED_CALLBACK_SECRET || "";
const FILES = path.join(__dirname, "files");
const DOC = "hop-dong.docx";
const STATE = path.join(FILES, "state.json"); // { rev, savedVersion }: rev tăng sau mỗi callback "closed"
const loadState = () => { try { return JSON.parse(fs.readFileSync(STATE, "utf8")); } catch { return { rev: 1, savedVersion: 0 }; } };
const saveState = (s) => fs.writeFileSync(STATE, JSON.stringify(s));
function verifySignature(rawBody, timestamp, signature) {
if (!CALLBACK_SECRET) return true; // chỉ chấp nhận trên máy DEV
const ts = Number(timestamp);
if (!signature || !Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = "sha256=" + crypto.createHmac("sha256", CALLBACK_SECRET).update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(String(signature));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// 1. gate tải tệp gốc tại đây
app.get("/files/:name", (req, res) => res.sendFile(path.join(FILES, path.basename(req.params.name))));
// 2. trang có trình soạn thảo
app.get("/", (req, res) => {
const state = loadState();
const config = {
document: { url: `${APP_URL_FOR_GATE}/files/${DOC}`, title: "Hợp đồng mẫu.docx", fileType: "docx", key: `hopdong-r${state.rev}` },
editor: { mode: "edit", lang: "vi", user: { id: "u-1001", name: "Nguyễn Văn A" }, callbackUrl: `${APP_URL_FOR_GATE}/o3o/callback` },
ui: { closeButton: true }
};
const browserConfig = JWT_SECRET
? { ...config, token: jwt.sign(config, JWT_SECRET, { algorithm: "HS256", expiresIn: "1h" }) }
: config;
const json = JSON.stringify(browserConfig).replace(/</g, "\\u003c");
res.type("html").send(`<!doctype html>
<html lang="vi"><head><meta charset="utf-8"><title>O3O Editor</title>
<style>html,body{margin:0;height:100%}#o3o-editor{height:100vh}</style></head>
<body><div id="o3o-editor"></div>
<script src="${O3O_URL}/o3o/api.js"></script>
<script>
const config = ${json};
config.events = {
onSaved: (e) => console.log("saved v" + e.version),
onLimitReached: (e) => console.warn(e.message),
onError: (e) => console.error(e.code, e.message)
};
new O3O.Editor("o3o-editor", config);
</script></body></html>`);
});
// 3. callback: kiểm chữ ký, trả lời ngay, rồi tải phiên bản mới
app.post("/o3o/callback", express.raw({ type: "*/*", limit: "1mb" }), async (req, res) => {
if (!verifySignature(req.body, req.get("X-O3O-Timestamp"), req.get("X-O3O-Signature"))) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(204);
try {
let state = loadState();
if (event.url && event.version > state.savedVersion) {
const r = await fetch(event.url);
if (!r.ok) throw new Error("download " + r.status);
const data = Buffer.from(await r.arrayBuffer());
if (event.sha256 && crypto.createHash("sha256").update(data).digest("hex") !== event.sha256) throw new Error("sha256 mismatch");
fs.writeFileSync(path.join(FILES, DOC + ".tmp"), data);
fs.renameSync(path.join(FILES, DOC + ".tmp"), path.join(FILES, DOC));
state.savedVersion = event.version;
}
if (event.status === "closed") state = { rev: state.rev + 1, savedVersion: 0 }; // lần mở sau dùng key mới
saveState(state);
} catch (err) {
console.error("callback", err);
}
});
app.listen(PORT, "0.0.0.0", () => console.log(`http://localhost:${PORT}`));PHP + Laravel#
<?php
// routes/web.php — Laravel 11+
// composer require firebase/php-jwt · php artisan serve --host=0.0.0.0 --port=3000
// Tệp gốc đặt ở storage/app/o3o/hop-dong.docx
use Firebase\JWT\JWT;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
$o3o = [
'url' => env('O3O_URL', 'http://localhost:8080'), // trình duyệt dùng
'appUrlForGate' => env('APP_URL_FOR_GATE', 'http://host.docker.internal:3000'), // gate dùng
'jwtSecret' => (string) env('O3O_EMBED_JWT_SECRET', ''),
'callbackSecret' => (string) env('O3O_EMBED_CALLBACK_SECRET', ''),
'dir' => storage_path('app/o3o'),
'doc' => 'hop-dong.docx',
];
$loadState = function () use ($o3o): array {
$raw = @file_get_contents($o3o['dir'] . '/state.json');
return $raw ? json_decode($raw, true) : ['rev' => 1, 'savedVersion' => 0];
};
$saveState = fn (array $s) => file_put_contents($o3o['dir'] . '/state.json', json_encode($s));
// 1. gate tải tệp gốc tại đây
Route::get('/files/{name}', fn (string $name) => response()->file($o3o['dir'] . '/' . basename($name)));
// 2. trang có trình soạn thảo
Route::get('/', function () use ($o3o, $loadState) {
$state = $loadState();
$config = [
'document' => [
'url' => $o3o['appUrlForGate'] . '/files/' . $o3o['doc'],
'title' => 'Hợp đồng mẫu.docx',
'fileType' => 'docx',
'key' => 'hopdong-r' . $state['rev'],
],
'editor' => [
'mode' => 'edit',
'lang' => 'vi',
'user' => ['id' => 'u-1001', 'name' => 'Nguyễn Văn A'],
'callbackUrl' => $o3o['appUrlForGate'] . '/o3o/callback',
],
'ui' => ['closeButton' => true],
];
if ($o3o['jwtSecret'] !== '') {
$config['token'] = JWT::encode($config + ['iat' => time(), 'exp' => time() + 3600], $o3o['jwtSecret'], 'HS256');
}
return view('o3o-editor', ['o3oUrl' => $o3o['url'], 'config' => $config]);
});
// 3. callback: kiểm chữ ký trên thân nguyên văn, tải phiên bản mới, đổi key khi "closed"
Route::post('/o3o/callback', function (Request $request) use ($o3o, $loadState, $saveState) {
$raw = $request->getContent();
$ts = (string) $request->header('X-O3O-Timestamp', '');
$sig = (string) $request->header('X-O3O-Signature', '');
if ($o3o['callbackSecret'] !== '') {
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $o3o['callbackSecret']);
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($expected, $sig)) {
abort(401);
}
}
$event = json_decode($raw, true);
$state = $loadState();
if (!empty($event['url']) && $event['version'] > $state['savedVersion']) {
$data = Http::timeout(60)->get($event['url'])->throw()->body();
if (empty($event['sha256']) || hash('sha256', $data) === $event['sha256']) {
file_put_contents($o3o['dir'] . '/' . $o3o['doc'] . '.tmp', $data);
rename($o3o['dir'] . '/' . $o3o['doc'] . '.tmp', $o3o['dir'] . '/' . $o3o['doc']);
$state['savedVersion'] = $event['version'];
}
}
if (($event['status'] ?? '') === 'closed') {
$state = ['rev' => $state['rev'] + 1, 'savedVersion' => 0]; // lần mở sau dùng key mới
}
$saveState($state);
return response()->noContent();
})->withoutMiddleware([ValidateCsrfToken::class]);{{-- resources/views/o3o-editor.blade.php --}}
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<title>O3O Editor</title>
<style>html, body { margin: 0; height: 100%; } #o3o-editor { height: 100vh; }</style>
</head>
<body>
<div id="o3o-editor"></div>
<script src="{{ $o3oUrl }}/o3o/api.js"></script>
<script>
const config = @json($config);
config.events = {
onSaved: (e) => console.log("saved v" + e.version),
onLimitReached: (e) => console.warn(e.message),
onError: (e) => console.error(e.code, e.message)
};
new O3O.Editor("o3o-editor", config);
</script>
</body>
</html>Python + Flask#
# app.py — tích hợp đầy đủ với Flask
# pip install flask pyjwt requests · python app.py · http://localhost:3000
import hashlib
import hmac
import json
import os
import threading
import time
import jwt
import requests
from flask import Flask, Response, abort, request, send_from_directory
PORT = int(os.environ.get("PORT", "3000"))
O3O_URL = os.environ.get("O3O_URL", "http://localhost:8080") # trình duyệt dùng
APP_URL_FOR_GATE = os.environ.get("APP_URL_FOR_GATE", f"http://host.docker.internal:{PORT}") # gate dùng
JWT_SECRET = os.environ.get("O3O_EMBED_JWT_SECRET", "")
CALLBACK_SECRET = os.environ.get("O3O_EMBED_CALLBACK_SECRET", "")
FILES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files")
DOC = "hop-dong.docx"
STATE = os.path.join(FILES, "state.json")
LOCK = threading.Lock()
PAGE = """<!doctype html>
<html lang="vi"><head><meta charset="utf-8"><title>O3O Editor</title>
<style>html,body{margin:0;height:100%}#o3o-editor{height:100vh}</style></head>
<body><div id="o3o-editor"></div>
<script src="__O3O_URL__/o3o/api.js"></script>
<script>
const config = __CONFIG__;
config.events = {
onSaved: (e) => console.log("saved v" + e.version),
onLimitReached: (e) => console.warn(e.message),
onError: (e) => console.error(e.code, e.message)
};
new O3O.Editor("o3o-editor", config);
</script></body></html>"""
app = Flask(__name__)
def load_state():
try:
with open(STATE, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {"rev": 1, "savedVersion": 0}
def save_state(state):
with open(STATE, "w", encoding="utf-8") as f:
json.dump(state, f)
def verify(raw, timestamp, signature):
if not CALLBACK_SECRET:
return True # chỉ chấp nhận trên máy DEV
if not signature or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
expected = "sha256=" + hmac.new(CALLBACK_SECRET.encode(), timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# 1. gate tải tệp gốc tại đây
@app.get("/files/<name>")
def files(name):
return send_from_directory(FILES, os.path.basename(name))
# 2. trang có trình soạn thảo
@app.get("/")
def index():
state = load_state()
config = {
"document": {"url": f"{APP_URL_FOR_GATE}/files/{DOC}", "title": "Hợp đồng mẫu.docx",
"fileType": "docx", "key": f"hopdong-r{state['rev']}"},
"editor": {"mode": "edit", "lang": "vi", "user": {"id": "u-1001", "name": "Nguyễn Văn A"},
"callbackUrl": f"{APP_URL_FOR_GATE}/o3o/callback"},
"ui": {"closeButton": True},
}
browser_config = dict(config)
if JWT_SECRET:
now = int(time.time())
browser_config["token"] = jwt.encode({**config, "iat": now, "exp": now + 3600}, JWT_SECRET, algorithm="HS256")
data = json.dumps(browser_config, ensure_ascii=False).replace("<", "\\u003c")
return Response(PAGE.replace("__O3O_URL__", O3O_URL).replace("__CONFIG__", data), mimetype="text/html")
def handle(event):
try:
apply_event(event)
except Exception: # ghi nhật ký, không để luồng nền chết lặng lẽ
app.logger.exception("O3O callback")
def apply_event(event):
with LOCK:
state = load_state()
if event.get("url") and event["version"] > state["savedVersion"]:
r = requests.get(event["url"], timeout=60)
r.raise_for_status()
if event.get("sha256") and hashlib.sha256(r.content).hexdigest() != event["sha256"]:
raise ValueError("sha256 mismatch")
tmp = os.path.join(FILES, DOC + ".tmp")
with open(tmp, "wb") as f:
f.write(r.content)
os.replace(tmp, os.path.join(FILES, DOC))
state["savedVersion"] = event["version"]
if event["status"] == "closed":
state = {"rev": state["rev"] + 1, "savedVersion": 0} # lần mở sau dùng key mới
save_state(state)
# 3. callback: kiểm chữ ký, trả lời ngay, tải phiên bản mới ở luồng nền
@app.post("/o3o/callback")
def callback():
raw = request.get_data()
if not verify(raw, request.headers.get("X-O3O-Timestamp", ""), request.headers.get("X-O3O-Signature", "")):
abort(401)
threading.Thread(target=handle, args=(json.loads(raw),), daemon=True).start()
return "", 204
if __name__ == "__main__":
app.run(host="0.0.0.0", port=PORT)ASP.NET Core#
// Program.cs — ASP.NET Core minimal API (.NET 8)
// dotnet new web -n O3OEmbed && cd O3OEmbed
// dotnet add package Microsoft.IdentityModel.JsonWebTokens
// dotnet run --urls http://0.0.0.0:3000
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();
var app = builder.Build();
string Env(string name, string fallback) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback;
var o3oUrl = Env("O3O_URL", "http://localhost:8080"); // trình duyệt dùng
var appUrlForGate = Env("APP_URL_FOR_GATE", "http://host.docker.internal:3000"); // gate dùng
var jwtSecret = Env("O3O_EMBED_JWT_SECRET", "");
var callbackSecret = Env("O3O_EMBED_CALLBACK_SECRET", "");
var filesDir = Path.Combine(builder.Environment.ContentRootPath, "files");
const string Doc = "hop-dong.docx";
var statePath = Path.Combine(filesDir, "state.json");
var stateLock = new SemaphoreSlim(1, 1);
JsonObject LoadState() => File.Exists(statePath)
? JsonNode.Parse(File.ReadAllText(statePath))!.AsObject()
: new JsonObject { ["rev"] = 1, ["savedVersion"] = 0 };
void SaveState(JsonObject s) => File.WriteAllText(statePath, s.ToJsonString());
// 1. gate tải tệp gốc tại đây
app.MapGet("/files/{name}", (string name) =>
Results.File(Path.Combine(filesDir, Path.GetFileName(name)), "application/octet-stream"));
// 2. trang có trình soạn thảo
app.MapGet("/", () =>
{
var state = LoadState();
var config = new JsonObject
{
["document"] = new JsonObject
{
["url"] = $"{appUrlForGate}/files/{Doc}",
["title"] = "Hợp đồng mẫu.docx",
["fileType"] = "docx",
["key"] = $"hopdong-r{state["rev"]}"
},
["editor"] = new JsonObject
{
["mode"] = "edit",
["lang"] = "vi",
["user"] = new JsonObject { ["id"] = "u-1001", ["name"] = "Nguyễn Văn A" },
["callbackUrl"] = $"{appUrlForGate}/o3o/callback"
},
["ui"] = new JsonObject { ["closeButton"] = true }
};
if (jwtSecret.Length > 0)
{
var payload = config.DeepClone().AsObject();
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
payload["iat"] = now;
payload["exp"] = now + 3600;
var creds = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), SecurityAlgorithms.HmacSha256);
config["token"] = new JsonWebTokenHandler().CreateToken(payload.ToJsonString(), creds);
}
var json = config.ToJsonString(); // mặc định đã thoát <, >, & thành <…
var html = $$"""
<!doctype html>
<html lang="vi"><head><meta charset="utf-8"><title>O3O Editor</title>
<style>html,body{margin:0;height:100%}#o3o-editor{height:100vh}</style></head>
<body><div id="o3o-editor"></div>
<script src="{{o3oUrl}}/o3o/api.js"></script>
<script>
const config = {{json}};
config.events = {
onSaved: (e) => console.log("saved v" + e.version),
onLimitReached: (e) => console.warn(e.message),
onError: (e) => console.error(e.code, e.message)
};
new O3O.Editor("o3o-editor", config);
</script></body></html>
""";
return Results.Content(html, "text/html; charset=utf-8");
});
// 3. callback: kiểm chữ ký trên thân nguyên văn, tải phiên bản mới, đổi key khi "closed"
app.MapPost("/o3o/callback", async (HttpRequest req, IHttpClientFactory http) =>
{
using var buffer = new MemoryStream();
await req.Body.CopyToAsync(buffer);
var raw = buffer.ToArray();
if (callbackSecret.Length > 0 &&
!VerifySignature(callbackSecret, req.Headers["X-O3O-Timestamp"].ToString(), raw, req.Headers["X-O3O-Signature"].ToString()))
return Results.Unauthorized();
var ev = JsonNode.Parse(raw)!.AsObject();
await stateLock.WaitAsync();
try
{
var state = LoadState();
var version = (int)ev["version"]!;
var url = (string?)ev["url"];
if (url is not null && version > (int)state["savedVersion"]!)
{
var data = await http.CreateClient().GetByteArrayAsync(url);
var sha = Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
if (ev["sha256"] is null || sha == (string?)ev["sha256"])
{
var tmp = Path.Combine(filesDir, Doc + ".tmp");
await File.WriteAllBytesAsync(tmp, data);
File.Move(tmp, Path.Combine(filesDir, Doc), overwrite: true);
state["savedVersion"] = version;
}
}
if ((string?)ev["status"] == "closed")
state = new JsonObject { ["rev"] = (int)state["rev"]! + 1, ["savedVersion"] = 0 }; // lần mở sau dùng key mới
SaveState(state);
}
finally
{
stateLock.Release();
}
return Results.NoContent();
});
app.Run();
static bool VerifySignature(string secret, string timestamp, byte[] rawBody, string signature)
{
if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) 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));
}Kiểm tra từng ví dụ#
- Mở
http://localhost:3000: tài liệu hiện, console không cóonError. - Gõ vài chữ, bấm Ctrl+S: console in
saved v1;files/hop-dong.docxđược ghi lại vài giây sau;files/state.jsoncósavedVersion: 1. - Đóng thẻ, đợi khoảng một phút: callback
closedtới,revtrongstate.jsontăng lên 2. - Mở lại trang: key mới
hopdong-r2, gate tải lại tệp từ ứng dụng của bạn và thấy nội dung đã sửa. - Sửa một ký tự trong
X-O3O-Signature(ví dụ gửi lại bằng curl): ứng dụng trả401.