fix: XSS escaping, DRY utils, agent undo batch, graceful shutdown
Browse files- Escape meta.licence in published HTML to prevent XSS
- Extract shared sanitizeName/docPath/DATA_DIR into utils.ts (DRY)
- Disable WS debug logging in production
- Fix startup log message (was /collab/:doc, now /collab)
- Remove unused NextFunction import in auth.ts
- Remove duplicate JSDoc in publisher/index.ts
- Add timeout to graceful shutdown (SIGTERM/SIGINT)
- Fix agent undo batch: close batch on stream finish (onFinish)
- Fix replaceSelection: return error when no text selected
Made-with: Cursor
- backend/src/auth.ts +1 -1
- backend/src/hf-storage.ts +1 -4
- backend/src/publisher/html-renderer.ts +1 -1
- backend/src/publisher/index.ts +2 -10
- backend/src/server.ts +23 -24
- backend/src/utils.ts +14 -0
- frontend/src/hooks/useAgentChat.ts +11 -6
backend/src/auth.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { whoAmI, type WhoAmIUser } from "@huggingface/hub";
|
| 2 |
import { randomBytes } from "crypto";
|
| 3 |
-
import type { Request, Response
|
| 4 |
|
| 5 |
export interface AuthUser {
|
| 6 |
name: string;
|
|
|
|
| 1 |
import { whoAmI, type WhoAmIUser } from "@huggingface/hub";
|
| 2 |
import { randomBytes } from "crypto";
|
| 3 |
+
import type { Request, Response } from "express";
|
| 4 |
|
| 5 |
export interface AuthUser {
|
| 6 |
name: string;
|
backend/src/hf-storage.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { uploadFile, downloadFile, createRepo, commit, type CommitFile, type RepoDesignation } from "@huggingface/hub";
|
|
|
|
| 2 |
|
| 3 |
const SPACE_ID = process.env.SPACE_ID || "";
|
| 4 |
|
|
@@ -57,10 +58,6 @@ export async function ensureDatasetExists(token?: string): Promise<void> {
|
|
| 57 |
_datasetReady = true;
|
| 58 |
}
|
| 59 |
|
| 60 |
-
function sanitizeName(name: string): string {
|
| 61 |
-
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
// ---------- Images ----------
|
| 65 |
|
| 66 |
export async function uploadImageToHf(
|
|
|
|
| 1 |
import { uploadFile, downloadFile, createRepo, commit, type CommitFile, type RepoDesignation } from "@huggingface/hub";
|
| 2 |
+
import { sanitizeName } from "./utils.js";
|
| 3 |
|
| 4 |
const SPACE_ID = process.env.SPACE_ID || "";
|
| 5 |
|
|
|
|
| 58 |
_datasetReady = true;
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
// ---------- Images ----------
|
| 62 |
|
| 63 |
export async function uploadImageToHf(
|
backend/src/publisher/html-renderer.ts
CHANGED
|
@@ -992,7 +992,7 @@ function renderFooter(meta: PublishMeta): string {
|
|
| 992 |
sections += `
|
| 993 |
<section class="reuse-block">
|
| 994 |
<p class="footer-heading" role="heading" aria-level="2">Reuse</p>
|
| 995 |
-
<p>${meta.licence}</p>
|
| 996 |
</section>`;
|
| 997 |
}
|
| 998 |
|
|
|
|
| 992 |
sections += `
|
| 993 |
<section class="reuse-block">
|
| 994 |
<p class="footer-heading" role="heading" aria-level="2">Reuse</p>
|
| 995 |
+
<p>${escapeHtml(meta.licence)}</p>
|
| 996 |
</section>`;
|
| 997 |
}
|
| 998 |
|
backend/src/publisher/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { readFileSync, existsSync } from "fs";
|
|
| 8 |
import { join, dirname } from "path";
|
| 9 |
import { fileURLToPath } from "url";
|
| 10 |
import * as Y from "yjs";
|
|
|
|
| 11 |
import { TiptapTransformer } from "@hocuspocus/transformer";
|
| 12 |
import { renderArticleHTML, type PublishMeta } from "./html-renderer.js";
|
| 13 |
import { getServerExtensions } from "./extensions.js";
|
|
@@ -19,11 +20,7 @@ import {
|
|
| 19 |
} from "../hf-storage.js";
|
| 20 |
|
| 21 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 22 |
-
const DATA_DIR =
|
| 23 |
-
|
| 24 |
-
function docPath(name: string) {
|
| 25 |
-
return join(DATA_DIR, `${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.yjs`);
|
| 26 |
-
}
|
| 27 |
|
| 28 |
export interface PublishCSS {
|
| 29 |
variables: string;
|
|
@@ -36,11 +33,6 @@ export interface PublishCSS {
|
|
| 36 |
components: string;
|
| 37 |
}
|
| 38 |
|
| 39 |
-
/**
|
| 40 |
-
* Load CSS files for injection into the published HTML.
|
| 41 |
-
* Reads the template CSS foundation + editor extensions.
|
| 42 |
-
* Tries multiple paths: dev source, Docker copy, relative fallback.
|
| 43 |
-
*/
|
| 44 |
/**
|
| 45 |
* Resolve @custom-media at-rules so the CSS works in browsers.
|
| 46 |
* PostCSS handles this in the Vite build, but the publisher reads raw source files.
|
|
|
|
| 8 |
import { join, dirname } from "path";
|
| 9 |
import { fileURLToPath } from "url";
|
| 10 |
import * as Y from "yjs";
|
| 11 |
+
import { DATA_DIR as SHARED_DATA_DIR, docPath } from "../utils.js";
|
| 12 |
import { TiptapTransformer } from "@hocuspocus/transformer";
|
| 13 |
import { renderArticleHTML, type PublishMeta } from "./html-renderer.js";
|
| 14 |
import { getServerExtensions } from "./extensions.js";
|
|
|
|
| 20 |
} from "../hf-storage.js";
|
| 21 |
|
| 22 |
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 23 |
+
const DATA_DIR = SHARED_DATA_DIR;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
export interface PublishCSS {
|
| 26 |
variables: string;
|
|
|
|
| 33 |
components: string;
|
| 34 |
}
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
/**
|
| 37 |
* Resolve @custom-media at-rules so the CSS works in browsers.
|
| 38 |
* PostCSS handles this in the Vite build, but the publisher reads raw source files.
|
backend/src/server.ts
CHANGED
|
@@ -5,8 +5,7 @@ import { WebSocketServer } from "ws";
|
|
| 5 |
import { Hocuspocus } from "@hocuspocus/server";
|
| 6 |
import { Database } from "@hocuspocus/extension-database";
|
| 7 |
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
| 8 |
-
import { join
|
| 9 |
-
import { fileURLToPath } from "url";
|
| 10 |
import { randomUUID } from "crypto";
|
| 11 |
import multer from "multer";
|
| 12 |
import * as Y from "yjs";
|
|
@@ -25,9 +24,8 @@ import {
|
|
| 25 |
} from "./hf-storage.js";
|
| 26 |
import { resolveUser, extractToken, isOAuthEnabled, handleOAuthAuthorize, handleOAuthCallback } from "./auth.js";
|
| 27 |
import { publishDocument } from "./publisher/index.js";
|
|
|
|
| 28 |
|
| 29 |
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 30 |
-
const DATA_DIR = join(__dirname, "..", "data");
|
| 31 |
const PORT = parseInt(process.env.PORT || "8080", 10);
|
| 32 |
|
| 33 |
/** Single collab document; URL `?doc=` is not supported. */
|
|
@@ -45,7 +43,7 @@ async function ensurePublishedRestored(token?: string): Promise<void> {
|
|
| 45 |
if (_publishedRestored) return;
|
| 46 |
if (!getDatasetId()) return;
|
| 47 |
|
| 48 |
-
const publishedPath = join(DATA_DIR, "published",
|
| 49 |
if (existsSync(publishedPath)) {
|
| 50 |
_publishedRestored = true;
|
| 51 |
return;
|
|
@@ -78,10 +76,6 @@ async function ensurePublishedRestored(token?: string): Promise<void> {
|
|
| 78 |
|
| 79 |
mkdirSync(DATA_DIR, { recursive: true });
|
| 80 |
|
| 81 |
-
function docPath(name: string) {
|
| 82 |
-
return join(DATA_DIR, `${name.replace(/[^a-zA-Z0-9_-]/g, "_")}.yjs`);
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
if (isHfStorageEnabled()) {
|
| 86 |
console.log("[server] HF Dataset persistence enabled");
|
| 87 |
} else if (getDatasetId()) {
|
|
@@ -205,10 +199,12 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
| 205 |
ws.on("error", (error) => {
|
| 206 |
console.error("[ws] socket error:", error.message);
|
| 207 |
});
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
const token = extractToken(req.headers.cookie);
|
| 213 |
hocuspocus.handleConnection(ws, req, { token });
|
| 214 |
});
|
|
@@ -454,22 +450,25 @@ if (existsSync(staticDir)) {
|
|
| 454 |
|
| 455 |
const server = httpServer.listen(PORT, async () => {
|
| 456 |
console.log(`[server] running on http://localhost:${PORT}`);
|
| 457 |
-
console.log(`[server] collab websocket at ws://localhost:${PORT}/collab
|
| 458 |
console.log(`[server] SPACE_ID=${process.env.SPACE_ID || "(not set)"} HF_DATASET_ID=${getDatasetId() || "(empty)"} HF_TOKEN=${process.env.HF_TOKEN ? "set" : "not set"}`);
|
| 459 |
|
| 460 |
// Eager restore: dataset is public, works without token
|
| 461 |
await ensurePublishedRestored();
|
| 462 |
});
|
| 463 |
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
console.log("[server] shutting down, flushing to HF...");
|
| 467 |
await flushAll();
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
|
| 471 |
-
process.on("
|
| 472 |
-
|
| 473 |
-
await flushAll();
|
| 474 |
-
server.close();
|
| 475 |
-
});
|
|
|
|
| 5 |
import { Hocuspocus } from "@hocuspocus/server";
|
| 6 |
import { Database } from "@hocuspocus/extension-database";
|
| 7 |
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
| 8 |
+
import { join } from "path";
|
|
|
|
| 9 |
import { randomUUID } from "crypto";
|
| 10 |
import multer from "multer";
|
| 11 |
import * as Y from "yjs";
|
|
|
|
| 24 |
} from "./hf-storage.js";
|
| 25 |
import { resolveUser, extractToken, isOAuthEnabled, handleOAuthAuthorize, handleOAuthCallback } from "./auth.js";
|
| 26 |
import { publishDocument } from "./publisher/index.js";
|
| 27 |
+
import { DATA_DIR, docPath, sanitizeName } from "./utils.js";
|
| 28 |
|
|
|
|
|
|
|
| 29 |
const PORT = parseInt(process.env.PORT || "8080", 10);
|
| 30 |
|
| 31 |
/** Single collab document; URL `?doc=` is not supported. */
|
|
|
|
| 43 |
if (_publishedRestored) return;
|
| 44 |
if (!getDatasetId()) return;
|
| 45 |
|
| 46 |
+
const publishedPath = join(DATA_DIR, "published", sanitizeName(DEFAULT_DOC_NAME), "index.html");
|
| 47 |
if (existsSync(publishedPath)) {
|
| 48 |
_publishedRestored = true;
|
| 49 |
return;
|
|
|
|
| 76 |
|
| 77 |
mkdirSync(DATA_DIR, { recursive: true });
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
if (isHfStorageEnabled()) {
|
| 80 |
console.log("[server] HF Dataset persistence enabled");
|
| 81 |
} else if (getDatasetId()) {
|
|
|
|
| 199 |
ws.on("error", (error) => {
|
| 200 |
console.error("[ws] socket error:", error.message);
|
| 201 |
});
|
| 202 |
+
if (process.env.NODE_ENV !== "production") {
|
| 203 |
+
ws.on("message", (data: Buffer, isBinary: boolean) => {
|
| 204 |
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as any);
|
| 205 |
+
console.log(`[ws-debug] msg ${buf.length}B binary=${isBinary} first20=${buf.slice(0, 20).toString("hex")}`);
|
| 206 |
+
});
|
| 207 |
+
}
|
| 208 |
const token = extractToken(req.headers.cookie);
|
| 209 |
hocuspocus.handleConnection(ws, req, { token });
|
| 210 |
});
|
|
|
|
| 450 |
|
| 451 |
const server = httpServer.listen(PORT, async () => {
|
| 452 |
console.log(`[server] running on http://localhost:${PORT}`);
|
| 453 |
+
console.log(`[server] collab websocket at ws://localhost:${PORT}/collab`);
|
| 454 |
console.log(`[server] SPACE_ID=${process.env.SPACE_ID || "(not set)"} HF_DATASET_ID=${getDatasetId() || "(empty)"} HF_TOKEN=${process.env.HF_TOKEN ? "set" : "not set"}`);
|
| 455 |
|
| 456 |
// Eager restore: dataset is public, works without token
|
| 457 |
await ensurePublishedRestored();
|
| 458 |
});
|
| 459 |
|
| 460 |
+
async function gracefulShutdown(signal: string) {
|
| 461 |
+
console.log(`[server] ${signal} received, flushing to HF...`);
|
|
|
|
| 462 |
await flushAll();
|
| 463 |
+
await new Promise<void>((resolve) => {
|
| 464 |
+
server.close(() => resolve());
|
| 465 |
+
setTimeout(() => {
|
| 466 |
+
console.warn("[server] force-closing after timeout");
|
| 467 |
+
resolve();
|
| 468 |
+
}, 5000);
|
| 469 |
+
});
|
| 470 |
+
process.exit(0);
|
| 471 |
+
}
|
| 472 |
|
| 473 |
+
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
| 474 |
+
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
|
|
|
|
|
|
|
backend/src/utils.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { join, dirname } from "path";
|
| 2 |
+
import { fileURLToPath } from "url";
|
| 3 |
+
|
| 4 |
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 5 |
+
|
| 6 |
+
export const DATA_DIR = join(__dirname, "..", "data");
|
| 7 |
+
|
| 8 |
+
export function sanitizeName(name: string): string {
|
| 9 |
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function docPath(name: string): string {
|
| 13 |
+
return join(DATA_DIR, `${sanitizeName(name)}.yjs`);
|
| 14 |
+
}
|
frontend/src/hooks/useAgentChat.ts
CHANGED
|
@@ -205,13 +205,14 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
|
|
| 205 |
.insertContent(newText)
|
| 206 |
.run();
|
| 207 |
pendingSelectionRef.current = null;
|
| 208 |
-
|
| 209 |
-
const { from, to } = editor.state.selection;
|
| 210 |
-
if (from !== to) {
|
| 211 |
-
editor.chain().focus().insertContent(newText).run();
|
| 212 |
-
}
|
| 213 |
}
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
|
| 217 |
case "insertAtCursor": {
|
|
@@ -271,6 +272,10 @@ export function useAgentChat({ editor, undoManager, frontmatterStore }: UseAgent
|
|
| 271 |
});
|
| 272 |
},
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
onError(error) {
|
| 275 |
console.error("[agent] chat error:", error);
|
| 276 |
endAgentBatch();
|
|
|
|
| 205 |
.insertContent(newText)
|
| 206 |
.run();
|
| 207 |
pendingSelectionRef.current = null;
|
| 208 |
+
return "Selection replaced successfully";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
}
|
| 210 |
+
const { from, to } = editor.state.selection;
|
| 211 |
+
if (from !== to) {
|
| 212 |
+
editor.chain().focus().insertContent(newText).run();
|
| 213 |
+
return "Selection replaced successfully";
|
| 214 |
+
}
|
| 215 |
+
return "No text selected to replace";
|
| 216 |
}
|
| 217 |
|
| 218 |
case "insertAtCursor": {
|
|
|
|
| 272 |
});
|
| 273 |
},
|
| 274 |
|
| 275 |
+
onFinish() {
|
| 276 |
+
endAgentBatch();
|
| 277 |
+
},
|
| 278 |
+
|
| 279 |
onError(error) {
|
| 280 |
console.error("[agent] chat error:", error);
|
| 281 |
endAgentBatch();
|