tfrere HF Staff commited on
Commit
e371833
·
1 Parent(s): 1c21ac9

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 CHANGED
@@ -1,6 +1,6 @@
1
  import { whoAmI, type WhoAmIUser } from "@huggingface/hub";
2
  import { randomBytes } from "crypto";
3
- import type { Request, Response, NextFunction } from "express";
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 = join(__dirname, "..", "..", "data");
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, dirname } from "path";
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", DEFAULT_DOC_NAME.replace(/[^a-zA-Z0-9_-]/g, "_"), "index.html");
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
- ws.on("message", (data: Buffer, isBinary: boolean) => {
209
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as any);
210
- console.log(`[ws-debug] msg ${buf.length}B binary=${isBinary} first20=${buf.slice(0, 20).toString("hex")}`);
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/:doc`);
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
- // Graceful shutdown: flush pending docs to HF
465
- process.on("SIGTERM", async () => {
466
- console.log("[server] shutting down, flushing to HF...");
467
  await flushAll();
468
- server.close();
469
- });
 
 
 
 
 
 
 
470
 
471
- process.on("SIGINT", async () => {
472
- console.log("[server] shutting down, flushing to HF...");
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
- } else {
209
- const { from, to } = editor.state.selection;
210
- if (from !== to) {
211
- editor.chain().focus().insertContent(newText).run();
212
- }
213
  }
214
- return "Selection replaced successfully";
 
 
 
 
 
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();