ultisuite-backend/services/hocuspocus/server.mjs
R3D347HR4Y 0466a1c169
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
wow
2026-06-11 01:22:52 +02:00

370 lines
11 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
/** Keep in sync with lib/drive/extensions/docs-graphic.ts graphicAttributes */
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 },
positionMode: { default: "move-with-text" },
anchorPos: { default: -1 },
pageIndex: { default: 0 },
pageX: { default: 0 },
pageY: { default: 0 },
wrapMarginMm: { default: 3 },
rotationDeg: { default: 0 },
zIndex: { default: 0 },
cropX: { default: 0 },
cropY: { default: 0 },
cropWidth: { default: 1 },
cropHeight: { default: 1 },
cropShape: { default: "rect" },
lockAspectRatio: { default: true },
imageFit: { default: "contain" },
imageFitAnchorH: { default: 0.5 },
imageFitAnchorV: { default: 0.5 },
assetId: { default: null },
opacity: { default: 1 },
shadow: { default: "" },
brightness: { default: 0 },
contrast: { default: 0 },
recolor: { default: "" },
altTitle: { default: "" },
drawScene: { default: null },
}
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)
}
function yjsToExcalidrawElements(yArray) {
if (!yArray || yArray.length === 0) return []
return yArray
.toArray()
.sort((a, b) => {
const key1 = a.get("pos")
const key2 = b.get("pos")
return key1 > key2 ? 1 : key1 < key2 ? -1 : 0
})
.map((x) => x.get("el"))
.filter((el) => el && typeof el.id === "string" && typeof el.type === "string")
}
function exportUltidrawScene(ydoc) {
const yElements = ydoc.getArray("elements")
const yAssets = ydoc.getMap("assets")
const elements = yjsToExcalidrawElements(yElements)
const files = {}
yAssets.forEach((value, key) => {
files[key] = value
})
return {
elements,
appState: { gridSize: null, viewBackgroundColor: "#ffffff" },
files,
}
}
function seedYdocFromJson(ydoc, elements, files, generateNKeysBetween) {
const yElements = ydoc.getArray("elements")
const yAssets = ydoc.getMap("assets")
if (!Array.isArray(elements) || elements.length === 0 || yElements.length > 0) return
const keys = generateNKeysBetween(null, null, elements.length)
ydoc.transact(() => {
for (let i = 0; i < elements.length; i++) {
const el = elements[i]
if (!el || typeof el.id !== "string") continue
yElements.push([
new Y.Map(Object.entries({ pos: keys[i], el: { ...el } })),
])
}
if (files && typeof files === "object") {
for (const [id, asset] of Object.entries(files)) {
yAssets.set(id, asset)
}
}
})
}
async function loadFromUltidraw(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/ultidraw/internal/document?${params}`, {
headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {},
})
if (res.status === 404) return null
if (!res.ok) throw new Error(`ultidraw load failed: ${res.status}`)
const raw = await res.text()
if (!raw.trim()) return null
try {
const doc = JSON.parse(raw)
const ydoc = new Y.Doc()
if (doc.yjsState) {
Y.applyUpdate(ydoc, Buffer.from(doc.yjsState, "base64"))
}
const { generateNKeysBetween } = await import("fractional-indexing")
seedYdocFromJson(ydoc, doc.elements, doc.files, generateNKeysBetween)
if (ydoc.getArray("elements").length === 0) {
return null
}
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
} catch (err) {
console.error("[onLoadDocument] ultidraw parse", err)
}
return null
}
async function storeToUltidraw(context, document) {
if (!context?.path || !context?.user) {
throw new Error("ultidraw store missing path or user in context")
}
const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
const scene = exportUltidrawScene(document)
const body = {
room: context.room ?? context.path,
path: context.path,
user: context.user,
sub: context.sub ?? "",
yjsState: state,
document: scene,
}
const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/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(`ultidraw store failed: ${res.status}${detail ? ` ${detail}` : ""}`)
}
}
function isDrawRoom(name) {
return typeof name === "string" && name.startsWith("draw:")
}
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)
if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) {
return await loadFromUltidraw(ctx)
}
return await loadFromUltid(ctx)
},
async onStoreDocument(data) {
const ctx = hookContext(data)
if (ctx.mode === "view") return
try {
if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) {
await storeToUltidraw(ctx, data.document)
} else {
await storeToUltid(ctx, data.document)
}
} catch (err) {
console.error("[onStoreDocument]", err)
}
},
})
server.listen()
console.log(`Hocuspocus listening on :${PORT}`)