Editor embedding
Full integration examples
Complete single-file apps for Node.js Express, PHP Laravel, Python Flask and ASP.NET Core: JWT signing, file serving, callbacks, key rotation.
The four examples below do the same job, each in one file: (1) serve the source file for the gate to download, (2) render the editor page with a JWT-signed config, (3) receive callbacks, verify the HMAC signature, download the new version over the source file, and rotate document.key on closed.
Common setup#
| Variable | Meaning | DEV value |
|---|---|---|
O3O_URL | O3O address the BROWSER uses to load api.js. | http://localhost:8080 |
APP_URL_FOR_GATE | Your app address the GATE uses to download files and send callbacks. | http://host.docker.internal:3000 |
O3O_EMBED_JWT_SECRET | Same as the gate variable. Once the gate has this secret, every config must carry a token. Empty means unsigned configs (only accepted when the gate has O3O_EMBED_ALLOW_UNSIGNED=1). | string ≥ 32 characters |
O3O_EMBED_CALLBACK_SECRET | Same as the gate variable; used to verify callback signatures. | random string |
# ── online/.env (DEV machine) ─────────────────────────────────────────
O3O_FETCH_ALLOW_HOSTS=host.docker.internal # the gate may download from and call back your machine
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
# ── the machine running your app: the SAME two secrets ──────
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 — full integration: JWT-signed editor page, source file serving, callback handling and file update
// 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"; // used by the browser
const APP_URL_FOR_GATE = process.env.APP_URL_FOR_GATE || `http://host.docker.internal:${PORT}`; // used by the gate
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 grows after every "closed" callback
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; // acceptable on DEV machines only
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. the gate downloads the source file here
app.get("/files/:name", (req, res) => res.sendFile(path.join(FILES, path.basename(req.params.name))));
// 2. the page with the editor
app.get("/", (req, res) => {
const state = loadState();
const config = {
document: { url: `${APP_URL_FOR_GATE}/files/${DOC}`, title: "Sample contract.docx", fileType: "docx", key: `hopdong-r${state.rev}` },
editor: { mode: "edit", lang: "en-US", user: { id: "u-1001", name: "Jane Doe" }, 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="en"><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: verify, answer at once, then fetch the new version
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 }; // the next open uses a new key
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
// Put the source file at 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'), // used by the browser
'appUrlForGate' => env('APP_URL_FOR_GATE', 'http://host.docker.internal:3000'), // used by the gate
'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. the gate downloads the source file here
Route::get('/files/{name}', fn (string $name) => response()->file($o3o['dir'] . '/' . basename($name)));
// 2. the page with the editor
Route::get('/', function () use ($o3o, $loadState) {
$state = $loadState();
$config = [
'document' => [
'url' => $o3o['appUrlForGate'] . '/files/' . $o3o['doc'],
'title' => 'Sample contract.docx',
'fileType' => 'docx',
'key' => 'hopdong-r' . $state['rev'],
],
'editor' => [
'mode' => 'edit',
'lang' => 'en-US',
'user' => ['id' => 'u-1001', 'name' => 'Jane Doe'],
'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: verify over the raw body, fetch the new version, rotate the key on "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]; // the next open uses a new key
}
$saveState($state);
return response()->noContent();
})->withoutMiddleware([ValidateCsrfToken::class]);{{-- resources/views/o3o-editor.blade.php --}}
<!doctype html>
<html lang="en">
<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 — full integration with 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") # used by the browser
APP_URL_FOR_GATE = os.environ.get("APP_URL_FOR_GATE", f"http://host.docker.internal:{PORT}") # used by the gate
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="en"><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 # acceptable on DEV machines only
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. the gate downloads the source file here
@app.get("/files/<name>")
def files(name):
return send_from_directory(FILES, os.path.basename(name))
# 2. the page with the editor
@app.get("/")
def index():
state = load_state()
config = {
"document": {"url": f"{APP_URL_FOR_GATE}/files/{DOC}", "title": "Sample contract.docx",
"fileType": "docx", "key": f"hopdong-r{state['rev']}"},
"editor": {"mode": "edit", "lang": "en-US", "user": {"id": "u-1001", "name": "Jane Doe"},
"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: # log it; never let the background thread die silently
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} # the next open uses a new key
save_state(state)
# 3. callback: verify, answer at once, fetch the new version in the background
@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"); // used by the browser
var appUrlForGate = Env("APP_URL_FOR_GATE", "http://host.docker.internal:3000"); // used by the gate
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. the gate downloads the source file here
app.MapGet("/files/{name}", (string name) =>
Results.File(Path.Combine(filesDir, Path.GetFileName(name)), "application/octet-stream"));
// 2. the page with the editor
app.MapGet("/", () =>
{
var state = LoadState();
var config = new JsonObject
{
["document"] = new JsonObject
{
["url"] = $"{appUrlForGate}/files/{Doc}",
["title"] = "Sample contract.docx",
["fileType"] = "docx",
["key"] = $"hopdong-r{state["rev"]}"
},
["editor"] = new JsonObject
{
["mode"] = "edit",
["lang"] = "en-US",
["user"] = new JsonObject { ["id"] = "u-1001", ["name"] = "Jane Doe" },
["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(); // the default encoder already escapes <, >, & as <…
var html = $$"""
<!doctype html>
<html lang="en"><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: verify over the raw body, fetch the new version, rotate the key on "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 }; // the next open uses a new key
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));
}Check each example#
- Open
http://localhost:3000: the document shows and the console has noonError. - Type something and press Ctrl+S: the console prints
saved v1;files/hop-dong.docxis rewritten a few seconds later;files/state.jsonshowssavedVersion: 1. - Close the tab and wait about a minute: the
closedcallback arrives andrevinstate.jsonbecomes 2. - Reopen the page: new key
hopdong-r2; the gate downloads the file from your app again and shows the edited content. - Change one character of
X-O3O-Signature(e.g. replay it with curl): the app answers401.