238 lines
7.0 KiB
JavaScript
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}`)
|