| import { Router } from "express"; |
| import { writeFileSync, existsSync } from "fs"; |
| import { randomUUID } from "crypto"; |
| import { EventEmitter } from "events"; |
| import * as Y from "yjs"; |
| import type { Hocuspocus } from "@hocuspocus/server"; |
| import { resolveUser, extractToken } from "../auth.js"; |
| import { isHfStorageEnabled, setUserToken } from "../hf-storage.js"; |
| import { |
| publishDocument, |
| previewDocument, |
| type PublishResult, |
| type PublishStage, |
| } from "../publisher/index.js"; |
| import { docPath } from "../utils.js"; |
|
|
| const DEFAULT_DOC_NAME = "default"; |
|
|
| export interface PublishContext { |
| oauthEnabled: boolean; |
| hocuspocus: Hocuspocus; |
| } |
|
|
| |
| interface PublishLock { |
| jobId: string; |
| startedAt: number; |
| userName?: string; |
| } |
|
|
| interface PublishJob { |
| emitter: EventEmitter; |
| |
| stage?: { stage: string; detail?: Record<string, unknown> }; |
| finished?: { ok: true; result: PublishResult } | { ok: false; error: string }; |
| } |
|
|
| |
| const publishLocks = new Map<string, PublishLock>(); |
|
|
| |
| const publishJobs = new Map<string, PublishJob>(); |
|
|
| |
| const JOB_RETENTION_MS = 30_000; |
|
|
| |
| |
| |
| |
| |
| async function broadcastPublishStatus( |
| hocuspocus: Hocuspocus, |
| docName: string, |
| status: { active: boolean; userName?: string; startedAt?: number; jobId?: string } |
| ): Promise<void> { |
| try { |
| const conn = await hocuspocus.openDirectConnection(docName); |
| if (conn.document) { |
| const statusMap = conn.document.getMap("publish-status"); |
| conn.document.transact(() => { |
| statusMap.set("active", status.active); |
| statusMap.set("userName", status.userName ?? null); |
| statusMap.set("startedAt", status.startedAt ?? null); |
| statusMap.set("jobId", status.jobId ?? null); |
| }); |
| } |
| await conn.disconnect(); |
| } catch (err) { |
| console.warn("[publish] failed to broadcast publish status:", (err as Error).message); |
| } |
| } |
|
|
| export function createPublishRouter(ctx: PublishContext): Router { |
| const router = Router(); |
|
|
| |
|
|
| router.get("/api/preview/:docName", async (req, res) => { |
| const docName = req.params.docName || DEFAULT_DOC_NAME; |
|
|
| if (ctx.oauthEnabled) { |
| const token = extractToken(req.headers.cookie); |
| const user = await resolveUser(token); |
| if (!user || !user.canEdit) { |
| res.status(403).json({ error: "Unauthorized" }); |
| return; |
| } |
| } |
|
|
| try { |
| const conn = await ctx.hocuspocus.openDirectConnection(docName); |
| if (conn.document) { |
| const update = Y.encodeStateAsUpdate(conn.document); |
| writeFileSync(docPath(docName), Buffer.from(update)); |
| } |
| await conn.disconnect(); |
|
|
| const result = await previewDocument(docName); |
| if ("error" in result) { |
| res.status(404).json({ error: result.error }); |
| return; |
| } |
| res.setHeader("Content-Type", "text/html; charset=utf-8"); |
| res.send(result.html); |
| } catch (err: any) { |
| console.error("[preview] error:", err); |
| res.status(500).json({ error: err.message || "Preview failed" }); |
| } |
| }); |
|
|
| |
|
|
| router.post("/api/publish", async (req, res) => { |
| let userToken: string | undefined; |
| let userName: string | undefined; |
|
|
| if (ctx.oauthEnabled) { |
| const token = extractToken(req.headers.cookie); |
| const user = await resolveUser(token); |
| if (!user) { |
| console.warn("[publish] no valid user from token, cookie present:", !!token); |
| res.status(403).json({ error: "Unauthorized: please log in first" }); |
| return; |
| } |
| if (!user.canEdit) { |
| console.warn("[publish] user lacks write access:", user.name); |
| res.status(403).json({ error: "Unauthorized: write access required" }); |
| return; |
| } |
| userToken = token ?? undefined; |
| userName = user.name; |
| if (userToken) setUserToken(userToken); |
| } |
|
|
| const docName = DEFAULT_DOC_NAME; |
|
|
| |
| const existing = publishLocks.get(docName); |
| if (existing) { |
| res.status(409).json({ |
| error: "Publish already in progress", |
| jobId: existing.jobId, |
| startedAt: existing.startedAt, |
| userName: existing.userName ?? null, |
| }); |
| return; |
| } |
|
|
| const jobId = randomUUID(); |
| const startedAt = Date.now(); |
| const emitter = new EventEmitter(); |
| emitter.setMaxListeners(32); |
| const job: PublishJob = { emitter }; |
| publishJobs.set(jobId, job); |
| publishLocks.set(docName, { jobId, startedAt, userName }); |
|
|
| |
| |
| broadcastPublishStatus(ctx.hocuspocus, docName, { |
| active: true, |
| userName, |
| startedAt, |
| jobId, |
| }).catch(() => {}); |
|
|
| |
| res.json({ jobId }); |
|
|
| |
| try { |
| |
| |
| try { |
| const conn = await ctx.hocuspocus.openDirectConnection(docName); |
| if (conn.document) { |
| const update = Y.encodeStateAsUpdate(conn.document); |
| writeFileSync(docPath(docName), Buffer.from(update)); |
| } |
| await conn.disconnect(); |
| } catch (err) { |
| console.warn("[publish] snapshot failed:", (err as Error).message); |
| } |
|
|
| const emitStage = (stage: PublishStage | string, detail?: Record<string, unknown>) => { |
| const event = { stage, detail }; |
| job.stage = event; |
| emitter.emit("stage", event); |
| }; |
|
|
| const result = await publishDocument(docName, userToken, { onStage: emitStage }); |
|
|
| if (!result.success) { |
| job.finished = { ok: false, error: result.error || "Publish failed" }; |
| emitter.emit("done", { success: false, error: result.error || "Publish failed" }); |
| } else { |
| job.finished = { ok: true, result }; |
| emitter.emit("done", { success: true, result }); |
| } |
| } catch (err: any) { |
| console.error("[publish] error:", err); |
| const message = err?.message || "Publish failed"; |
| job.finished = { ok: false, error: message }; |
| emitter.emit("done", { success: false, error: message }); |
| } finally { |
| publishLocks.delete(docName); |
| broadcastPublishStatus(ctx.hocuspocus, docName, { active: false }).catch(() => {}); |
|
|
| |
| setTimeout(() => { |
| publishJobs.delete(jobId); |
| }, JOB_RETENTION_MS); |
| } |
| }); |
|
|
| |
|
|
| router.get("/api/publish/stream", async (req, res) => { |
| const jobId = typeof req.query.jobId === "string" ? req.query.jobId : ""; |
| const job = publishJobs.get(jobId); |
|
|
| if (ctx.oauthEnabled) { |
| const token = extractToken(req.headers.cookie); |
| const user = await resolveUser(token); |
| if (!user || !user.canEdit) { |
| res.status(403).end(); |
| return; |
| } |
| } |
|
|
| if (!job) { |
| res.status(404).end(); |
| return; |
| } |
|
|
| res.setHeader("Content-Type", "text/event-stream"); |
| res.setHeader("Cache-Control", "no-cache, no-transform"); |
| res.setHeader("Connection", "keep-alive"); |
| res.setHeader("X-Accel-Buffering", "no"); |
| res.flushHeaders?.(); |
|
|
| const write = (event: string, payload: unknown) => { |
| res.write(`event: ${event}\n`); |
| res.write(`data: ${JSON.stringify(payload)}\n\n`); |
| }; |
|
|
| |
| if (job.stage) write("stage", job.stage); |
|
|
| const onStage = (payload: { stage: string; detail?: Record<string, unknown> }) => { |
| write("stage", payload); |
| }; |
| const onDone = (payload: { success: boolean; error?: string; result?: PublishResult }) => { |
| write("done", payload); |
| cleanup(); |
| res.end(); |
| }; |
|
|
| |
| |
| if (job.finished) { |
| if (job.finished.ok) { |
| write("done", { success: true, result: job.finished.result }); |
| } else { |
| write("done", { success: false, error: job.finished.error }); |
| } |
| res.end(); |
| return; |
| } |
|
|
| job.emitter.on("stage", onStage); |
| job.emitter.on("done", onDone); |
|
|
| |
| const keepAlive = setInterval(() => { |
| res.write(`: keepalive ${Date.now()}\n\n`); |
| }, 15_000); |
|
|
| const cleanup = () => { |
| clearInterval(keepAlive); |
| job.emitter.off("stage", onStage); |
| job.emitter.off("done", onDone); |
| }; |
|
|
| req.on("close", () => { |
| cleanup(); |
| res.end(); |
| }); |
| }); |
|
|
| |
|
|
| router.get("/api/publish/status", (_req, res) => { |
| const lock = publishLocks.get(DEFAULT_DOC_NAME); |
| if (!lock) { |
| res.json({ active: false }); |
| return; |
| } |
| res.json({ |
| active: true, |
| jobId: lock.jobId, |
| startedAt: lock.startedAt, |
| userName: lock.userName ?? null, |
| }); |
| }); |
|
|
| |
|
|
| router.post("/api/admin/reset-document", async (req, res) => { |
| if (ctx.oauthEnabled) { |
| const token = extractToken(req.headers.cookie); |
| const user = await resolveUser(token); |
| if (!user || !user.canEdit) { |
| res.status(403).json({ error: "Unauthorized" }); |
| return; |
| } |
| if (token) setUserToken(token); |
| } |
|
|
| try { |
| const p = docPath(DEFAULT_DOC_NAME); |
| if (existsSync(p)) { |
| const { unlinkSync } = await import("fs"); |
| unlinkSync(p); |
| console.log("[admin] deleted local Y.Doc file:", p); |
| } |
|
|
| await ctx.hocuspocus.closeConnections(DEFAULT_DOC_NAME); |
| console.log("[admin] closed connections for", DEFAULT_DOC_NAME); |
|
|
| res.json({ success: true, message: "Document reset. Refresh the editor to start fresh." }); |
| } catch (err: any) { |
| console.error("[admin] reset error:", err); |
| res.status(500).json({ error: err.message || "Reset failed" }); |
| } |
| }); |
|
|
| return router; |
| } |
|
|