ultisuite-backend/services/hocuspocus/server.mjs
R3D347HR4Y 2bdd16fa37
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
wrappages
2026-06-10 12:48:11 +02:00

238 lines
7.0 KiB
JavaScript

import { Server } from "@hocuspocus/server"
import { TiptapTransformer } from "@hocuspocus/transformer"
import * as Y from "yjs"
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
const { Node } = require("@tiptap/core")
const StarterKit = require("@tiptap/starter-kit").default
const Underline = require("@tiptap/extension-underline").default
const Link = require("@tiptap/extension-link").default
const { TextStyle } = require("@tiptap/extension-text-style")
const Highlight = require("@tiptap/extension-highlight").default
const TextAlign = require("@tiptap/extension-text-align").default
const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table")
const Image = require("@tiptap/extension-image").default
const graphicAttributes = {
graphicType: { default: "image" },
src: { default: null },
alt: { default: "" },
shapeType: { default: "rect" },
fill: { default: "#4285f4" },
stroke: { default: "#1a73e8" },
strokeWidth: { default: 2 },
gradientCss: { default: "" },
gradientAngle: { default: 180 },
gradientColor1: { default: "#4285f4" },
gradientColor2: { default: "#34a853" },
width: { default: 240 },
height: { default: 160 },
placement: { default: "block" },
wrap: { default: "square" },
floatSide: { default: "left" },
x: { default: 0 },
y: { default: 0 },
rotationDeg: { default: 0 },
zIndex: { default: 0 },
cropX: { default: 0 },
cropY: { default: 0 },
cropWidth: { default: 1 },
cropHeight: { default: 1 },
cropShape: { default: "rect" },
assetId: { default: null },
opacity: { default: 1 },
shadow: { default: "" },
}
const DocsGraphic = Node.create({
name: "docsGraphic",
group: "block",
atom: true,
addAttributes() {
return graphicAttributes
},
})
const DocsInlineGraphic = Node.create({
name: "docsInlineGraphic",
group: "inline",
inline: true,
atom: true,
addAttributes() {
return graphicAttributes
},
})
/** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */
const transformerExtensions = [
StarterKit.configure({ undoRedo: false, link: false, underline: false }),
Underline,
Link.configure({ openOnClick: false }),
TextStyle,
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Table.configure({ resizable: true }),
TableRow,
TableCell,
TableHeader,
Image.configure({ inline: true, allowBase64: true }),
DocsGraphic,
DocsInlineGraphic,
]
const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
const SECRET = process.env.HOCUSPOCUS_SECRET || ""
const ULTID_URL = (process.env.ULTID_INTERNAL_URL || "http://127.0.0.1").replace(/\/$/, "")
function decodeJwtPayload(token) {
const parts = token.split(".")
if (parts.length < 2) return null
try {
const json = Buffer.from(parts[1], "base64url").toString("utf8")
return JSON.parse(json)
} catch {
return null
}
}
function hookContext(payload) {
return payload?.lastContext ?? payload?.context ?? {}
}
function tipTapContentHasText(content) {
if (!content || typeof content !== "object") return false
const walk = (node) => {
if (!node || typeof node !== "object") return false
if (typeof node.text === "string" && node.text.trim()) return true
if (Array.isArray(node.content)) {
return node.content.some(walk)
}
return false
}
return walk(content)
}
function tipTapContentHasBody(content) {
if (!content || typeof content !== "object") return false
if (tipTapContentHasText(content)) return true
const walk = (node) => {
if (!node || typeof node !== "object") return false
const type = node.type
if (type && type !== "doc" && type !== "paragraph") return true
if (Array.isArray(node.content)) {
if (node.content.length === 0) return false
if (node.content.length > 1) return true
return walk(node.content[0])
}
return false
}
return walk(content)
}
async function loadFromUltid(context) {
if (!context?.path || !context?.user) return null
const params = new URLSearchParams({ user: context.user, path: context.path })
const res = await fetch(`${ULTID_URL}/api/v1/richtext/internal/document?${params}`, {
headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {},
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`load failed: ${res.status}`)
const raw = await res.text()
if (!raw.trim()) return null
try {
const doc = JSON.parse(raw)
const hasStoredBody = tipTapContentHasBody(doc.content)
if (doc.content && hasStoredBody) {
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
}
if (doc.yjsState && !hasStoredBody) {
return Buffer.from(doc.yjsState, "base64")
}
} catch (err) {
console.error("[onLoadDocument] parse", err)
}
return null
}
async function storeToUltid(context, document) {
if (!context?.path || !context?.user) {
throw new Error("store missing path or user in context")
}
const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
let tiptapJson = null
try {
tiptapJson = TiptapTransformer.fromYdoc(document, "default", transformerExtensions)
} catch {
/* optional */
}
const body = {
room: context.room ?? context.path,
path: context.path,
user: context.user,
sub: context.sub ?? "",
yjsState: state,
document: tiptapJson,
}
const res = await fetch(`${ULTID_URL}/api/v1/richtext/hooks/store`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}),
},
body: JSON.stringify(body),
})
if (!res.ok) {
const detail = await res.text().catch(() => "")
throw new Error(`store failed: ${res.status}${detail ? ` ${detail}` : ""}`)
}
}
const server = new Server({
port: PORT,
debounce: 3000,
maxDebounce: 10000,
async onAuthenticate(data) {
const { token, documentName } = data
if (!SECRET) return {}
const payload = decodeJwtPayload(token)
if (!payload?.room || payload.room !== documentName) {
console.error("[onAuthenticate] forbidden", { documentName, room: payload?.room })
throw new Error("forbidden")
}
if (payload.exp && payload.exp * 1000 < Date.now()) {
console.error("[onAuthenticate] token expired", { documentName })
throw new Error("token expired")
}
return {
room: payload.room,
path: payload.path,
user: payload.user,
sub: payload.sub,
name: payload.name,
mode: payload.mode,
}
},
async onLoadDocument(data) {
const ctx = hookContext(data)
return await loadFromUltid(ctx)
},
async onStoreDocument(data) {
const ctx = hookContext(data)
if (ctx.mode === "view") return
try {
await storeToUltid(ctx, data.document)
} catch (err) {
console.error("[onStoreDocument]", err)
}
},
})
server.listen()
console.log(`Hocuspocus listening on :${PORT}`)