فهرست منبع

Merge remote-tracking branch 'origin/master' into fix/index-rebuild-fails-on-boot

Ryu Sato 1 هفته پیش
والد
کامیت
9b2993b7a5

+ 0 - 66
.claude/rules/lsp.md

@@ -1,66 +0,0 @@
-# LSP Usage
-
-The `LSP` tool provides TypeScript-aware code intelligence. Prefer it over `grep`/`find` for symbol-level queries.
-
-## Tool availability (read before concluding LSP is unavailable)
-
-The way `LSP` is exposed differs between the main session and sub-agents. Check which context you are in before concluding it is unavailable.
-
-**Main session (this file is in your system prompt):**
-`LSP` is registered as a **deferred tool** — its schema is not loaded at session start, so it will NOT appear in the initial top-level tool list. It instead shows up by name inside the session-start `<system-reminder>` listing deferred tools.
-
-Do not conclude LSP is unavailable just because it isn't in the initial tool list. To use it:
-
-1. Confirm `LSP` appears in the deferred-tool list in the session-start system-reminder.
-2. Load its schema with `ToolSearch` using `query: "select:LSP"`.
-3. After that, call `LSP` like any other tool.
-
-Only if `LSP` is missing from the deferred-tool list AND `ToolSearch` with `select:LSP` returns no match should you treat LSP as disabled and fall back to `grep`.
-
-**Sub-agents (Explore, general-purpose, etc.):**
-`LSP` is provided directly in the initial tool list — no `ToolSearch` step needed. `ToolSearch` itself is not available in sub-agents. Just call `LSP` as a normal tool.
-
-Note: `.claude/rules/` files are NOT injected into sub-agent system prompts. A sub-agent will not know the guidance in this file unless the parent includes it in the `Agent` prompt. When delegating symbol-level research (definition lookup, caller search, type inspection) to a sub-agent, restate the key rules inline — at minimum: "prefer LSP over grep for TypeScript symbol queries; use `incomingCalls` for callers, `goToDefinition` for definitions".
-
-## Auto-start behavior
-
-The `typescript-language-server` starts automatically when the LSP tool is first invoked — no manual startup or health check is needed. If the server isn't installed, the tool returns an error; in that case fall back to `grep`.
-
-In the devcontainer, `typescript-language-server` is pre-installed globally via `postCreateCommand.sh`. It auto-detects and uses the workspace's `node_modules/typescript` at runtime.
-
-## When to use LSP (not grep)
-
-| Task | Preferred LSP operation |
-|------|------------------------|
-| Find where a function/class/type is defined | `goToDefinition` |
-| Find all call sites **including imports** | `findReferences` (see caveat below) |
-| Find which functions call a given function | `incomingCalls` ← prefer this over `findReferences` for callers |
-| Check a variable's type or JSDoc | `hover` |
-| List all exports in a file | `documentSymbol` |
-| Find what implements an interface | `goToImplementation` |
-| Trace what a function calls | `outgoingCalls` |
-
-## Decision rule
-
-- **Use LSP** when the query is about a *symbol* (function, class, type, variable) — LSP understands TypeScript semantics and won't false-match string occurrences or comments.
-- **Use grep** when searching for string literals, comments, config values, or when LSP returns no results (e.g., generated code, `.js` files without types).
-
-## `findReferences` — lazy-loading caveat
-
-TypeScript LSP loads files **on demand**. On a cold server (first query after devcontainer start), calling `findReferences` from the *definition file* may return only the definition itself because consumer files haven't been loaded yet.
-
-**Mitigation strategies (in order of preference):**
-
-1. **Prefer `incomingCalls`** over `findReferences` when you want callers. It correctly resolves cross-file call sites even on a cold server.
-2. If you need `findReferences` with full results, call it from a **known call site** (not the definition). After any file in the consumer chain is queried, the server loads it and subsequent `findReferences` calls return complete results.
-3. As a last resort, run a `hover` on an import statement in the consumer file first to warm up that file, then retry `findReferences` from the definition.
-
-## Required: line + character
-
-LSP operations require `line` and `character` (both 1-based). Read the file first to identify the exact position of the symbol, then call LSP.
-
-```
-# Example: symbol starts at col 14 on line 85
-export const useCollaborativeEditorMode = (
-             ^--- character 14
-```

+ 46 - 0
.claude/rules/mongodb-regex.md

@@ -0,0 +1,46 @@
+# MongoDB Regex Escaping
+
+## RegExp.escape() must not be used for MongoDB-bound regex patterns
+
+Node.js 24's built-in `RegExp.escape()` escapes non-ASCII whitespace (code points
+≥ U+0100, e.g. U+3000 IDEOGRAPHIC SPACE) into `\uXXXX` form. MongoDB's PCRE2 engine
+does **not** support `\u`, so such a pattern throws:
+
+```
+Regular expression is invalid: PCRE2 does not support \L, \l, \N{name}, \U, or \u
+  code: 51091
+```
+
+This breaks page creation, v5 page migration, page listing, etc. for any path that
+contains those characters. (`escape-string-regexp`, used before the v7.5.0 refactor,
+passed non-ASCII characters through literally and did not have this problem.)
+
+## The Rule
+
+When a regex is sent to **MongoDB** — used as a `$regex` value, or wrapped in
+`new RegExp(...)` and assigned to a query field (`path`, `name`, …) in a Mongoose
+`find` / `updateMany` / `aggregate` / `count` / `bulkWrite` — escape the dynamic part
+with **`escapeStringForMongoRegex()`** from `@growi/core/dist/utils`, never `RegExp.escape()`.
+
+`escapeStringForMongoRegex()` escapes only regex metacharacters and passes every other
+character through literally (equivalent to `escape-string-regexp` v5), so its output
+never contains `\u` and is safe for PCRE2.
+
+```typescript
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
+
+// ❌ WRONG — pattern goes to MongoDB
+Page.find({ path: new RegExp(`^${RegExp.escape(path)}`) });
+
+// ✅ CORRECT
+Page.find({ path: new RegExp(`^${escapeStringForMongoRegex(path)}`) });
+```
+
+## Exception: in-process JS regex is fine
+
+`RegExp.escape()` is acceptable for regexes evaluated **in-process by V8** — i.e.
+`.test()` / `.replace()` / `.match()` on local strings that are never sent to MongoDB.
+V8 interprets `\uXXXX` correctly, so there is no need to change those call sites.
+
+See `escapeStringForMongoRegex` (`packages/core/src/utils/escape-string-for-regex.ts`)
+and issue #11235 for background.

+ 0 - 27
.claude/settings.json

@@ -1,31 +1,6 @@
 {
 {
   "permissions": {
   "permissions": {
     "allow": [
     "allow": [
-      "Bash(node --version)",
-      "Bash(npm --version)",
-      "Bash(npm view *)",
-      "Bash(pnpm --version)",
-      "Bash(turbo --version)",
-      "Bash(turbo run build)",
-      "Bash(turbo run lint)",
-      "Bash(pnpm run lint:*)",
-      "Bash(pnpm vitest run *)",
-      "Bash(pnpm biome check *)",
-      "Bash(pnpm ls *)",
-      "Bash(pnpm why *)",
-      "Bash(cat *)",
-      "Bash(echo *)",
-      "Bash(find *)",
-      "Bash(grep *)",
-      "Bash(git diff *)",
-      "Bash(gh issue view *)",
-      "Bash(gh pr view *)",
-      "Bash(gh pr diff *)",
-      "Bash(ls *)",
-      "WebFetch(domain:github.com)",
-      "mcp__plugin_context7_*",
-      "WebSearch",
-      "WebFetch"
     ]
     ]
   },
   },
   "enableAllProjectMcpServers": true,
   "enableAllProjectMcpServers": true,
@@ -54,8 +29,6 @@
     ]
     ]
   },
   },
   "enabledPlugins": {
   "enabledPlugins": {
-    "context7@claude-plugins-official": true,
-    "typescript-lsp@claude-plugins-official": true,
     "figma@claude-plugins-official": true,
     "figma@claude-plugins-official": true,
     "mcp-client-skills@growi-mcp-tools": true
     "mcp-client-skills@growi-mcp-tools": true
   }
   }

+ 1 - 1
.devcontainer/app/postCreateCommand.sh

@@ -26,7 +26,7 @@ mkdir -p "$PNPM_HOME"
 # (overlay FS) and the workspace (bind mount) are on different filesystems.
 # (overlay FS) and the workspace (bind mount) are on different filesystems.
 pnpm config set store-dir /workspace/.pnpm-store
 pnpm config set store-dir /workspace/.pnpm-store
 
 
-pnpm install --global turbo typescript-language-server typescript
+pnpm install --global turbo
 
 
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap

+ 0 - 5
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -20,11 +20,6 @@ pnpm i -g pnpm
 # Install turbo
 # Install turbo
 pnpm install turbo --global
 pnpm install turbo --global
 
 
-# Install typescript-language-server for Claude Code LSP plugin
-# Use `npm -g` (not `pnpm --global`) so the binary lands in nvm's node bin, which is on the default PATH.
-# pnpm's global bin requires PNPM_HOME from ~/.bashrc, which the Claude Code extension's shell doesn't source.
-npm install -g typescript-language-server typescript
-
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap
 
 

+ 1 - 0
AGENTS.md

@@ -27,6 +27,7 @@ GROWI is a team collaboration wiki platform using Markdown, featuring hierarchic
 | **github-cli** | **CRITICAL**: gh CLI auth required; stop immediately if unauthenticated |
 | **github-cli** | **CRITICAL**: gh CLI auth required; stop immediately if unauthenticated |
 
 
 | **testing** | Test commands, pnpm vitest usage |
 | **testing** | Test commands, pnpm vitest usage |
+| **mongodb-regex** | `RegExp.escape()` breaks MongoDB PCRE2 for non-ASCII whitespace; use `escapeStringForMongoRegex` for query-bound patterns |
 
 
 ### On-Demand Skills
 ### On-Demand Skills
 
 

+ 23 - 1
CHANGELOG.md

@@ -1,9 +1,31 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/growilabs/compare/v7.5.3...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.5.4...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.5.4](https://github.com/growilabs/compare/v7.5.3...v7.5.4) - 2026-05-27
+
+### 💎 Features
+
+* feat: Retrieve GROWI news (#10986) @ryotaro-nagahara
+* feat: Editor guide (#10847) @yuki-takei
+* feat(otel): Add growi_installed_at metrics (#11214) @ryotaro-nagahara
+* feat(otel): add yjs docs count and mongoose connection pool metrics (v7 backport) (#11218) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: UserPicture in order to show tooltip in production builds (#11192) @yuki-takei
+* fix: Correct parent grant value in /grant-data endpoint (#11181) @yuki-takei
+* fix: Permit GRANT_RESTRICTED child under any parent grant (#11182) @yuki-takei
+* fix(editor): Fix cursor stuck on wrapped lines by upgrading @codemirror/view to ^6.42.1 (#11153) @yuki-takei
+* fix(bookmark): Add owner authorization checks to bookmark folder api (#11178) @Ryosei-Fukushima
+* fix(bulk-export): Set completedAt on all bulk export completion paths (#11195) @tomoyuki-t-weseek
+* fix(admin): Prompt reload after toggling page bulk export setting (#11180) @tomoyuki-t-weseek
+* fix(drawio): draw.io stencil URLs for local instances (#11196) @yuki-takei
+* fix(bulk-export): Show bulk-export restart modal on duplicate-job error (#11186) @tomoyuki-t-weseek
+* fix(admin): stop infinite render loop on G2G data transfer page (#11166) @miya
+
 ## [v7.5.3](https://github.com/growilabs/compare/v7.5.2...v7.5.3) - 2026-05-14
 ## [v7.5.3](https://github.com/growilabs/compare/v7.5.2...v7.5.3) - 2026-05-14
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`7.5.3`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.5.3/apps/app/docker/Dockerfile)
+* [`7.5.4`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.5.4/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 
 

+ 3 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.5.4-RC.0",
+  "version": "7.5.5-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
@@ -175,7 +175,7 @@
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
     "jotai": "^2.12.3",
     "jotai": "^2.12.3",
-    "js-cookie": "^3.0.5",
+    "js-cookie": "^3.0.7",
     "js-tiktoken": "^1.0.15",
     "js-tiktoken": "^1.0.15",
     "js-yaml": "^4.1.1",
     "js-yaml": "^4.1.1",
     "jsonrepair": "^3.12.0",
     "jsonrepair": "^3.12.0",
@@ -223,7 +223,7 @@
     "pathe": "^2.0.3",
     "pathe": "^2.0.3",
     "pretty-bytes": "^6.1.1",
     "pretty-bytes": "^6.1.1",
     "prop-types": "^15.8.1",
     "prop-types": "^15.8.1",
-    "qs": "^6.14.2",
+    "qs": "^6.15.2",
     "rate-limiter-flexible": "^2.3.7",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
     "react-bootstrap-typeahead": "^6.3.2",

+ 4 - 2
apps/app/src/features/openai/server/services/openai.ts

@@ -11,7 +11,7 @@ import {
   isPopulated,
   isPopulated,
   PageGrant,
   PageGrant,
 } from '@growi/core';
 } from '@growi/core';
-import { deepEquals } from '@growi/core/dist/utils';
+import { deepEquals, escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import { isGlobPatternPath } from '@growi/core/dist/utils/page-path-utils';
 import createError from 'http-errors';
 import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
@@ -77,7 +77,9 @@ const convertPathPatternsToRegExp = (
   return pagePathPatterns.map((pagePathPattern) => {
   return pagePathPatterns.map((pagePathPattern) => {
     if (isGlobPatternPath(pagePathPattern)) {
     if (isGlobPatternPath(pagePathPattern)) {
       const trimedPagePathPattern = pagePathPattern.replace('/*', '');
       const trimedPagePathPattern = pagePathPattern.replace('/*', '');
-      const escapedPagePathPattern = RegExp.escape(trimedPagePathPattern);
+      const escapedPagePathPattern = escapeStringForMongoRegex(
+        trimedPagePathPattern,
+      );
       // https://regex101.com/r/x5KIZL/1
       // https://regex101.com/r/x5KIZL/1
       return new RegExp(`^${escapedPagePathPattern}($|/)`);
       return new RegExp(`^${escapedPagePathPattern}($|/)`);
     }
     }

+ 2 - 1
apps/app/src/server/models/obsolete-page.js

@@ -1,5 +1,6 @@
 import { GroupType, Origin } from '@growi/core';
 import { GroupType, Origin } from '@growi/core';
 import {
 import {
+  escapeStringForMongoRegex,
   pagePathUtils,
   pagePathUtils,
   pathUtils,
   pathUtils,
   templateChecker,
   templateChecker,
@@ -687,7 +688,7 @@ export const getPageSchema = (crowi) => {
     const regexpList = pathList.map((path) => {
     const regexpList = pathList.map((path) => {
       const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
       const pathWithTrailingSlash = pathUtils.addTrailingSlash(path);
       return new RegExp(
       return new RegExp(
-        `^${RegExp.escape(pathWithTrailingSlash)}_{1,2}template$`,
+        `^${escapeStringForMongoRegex(pathWithTrailingSlash)}_{1,2}template$`,
       );
       );
     });
     });
 
 

+ 8 - 7
apps/app/src/server/models/page.ts

@@ -4,6 +4,7 @@ import type {
   IUserHasId,
   IUserHasId,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
 import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
 import { getIdForRef, isPopulated } from '@growi/core/dist/interfaces';
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import { hasSlash, isTopPage } from '@growi/core/dist/utils/page-path-utils';
 import { hasSlash, isTopPage } from '@growi/core/dist/utils/page-path-utils';
 import {
 import {
   addTrailingSlash,
   addTrailingSlash,
@@ -347,7 +348,7 @@ export class PageQueryBuilder {
     const pathNormalized = normalizePath(path);
     const pathNormalized = normalizePath(path);
     const pathWithTrailingSlash = addTrailingSlash(path);
     const pathWithTrailingSlash = addTrailingSlash(path);
 
 
-    const startsPattern = RegExp.escape(pathWithTrailingSlash);
+    const startsPattern = escapeStringForMongoRegex(pathWithTrailingSlash);
 
 
     this.query = this.query.and({
     this.query = this.query.and({
       $or: [
       $or: [
@@ -372,7 +373,7 @@ export class PageQueryBuilder {
 
 
     const pathWithTrailingSlash = addTrailingSlash(path);
     const pathWithTrailingSlash = addTrailingSlash(path);
 
 
-    const startsPattern = RegExp.escape(pathWithTrailingSlash);
+    const startsPattern = escapeStringForMongoRegex(pathWithTrailingSlash);
 
 
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
 
 
@@ -408,7 +409,7 @@ export class PageQueryBuilder {
       return this;
       return this;
     }
     }
 
 
-    const startsPattern = RegExp.escape(path);
+    const startsPattern = escapeStringForMongoRegex(path);
 
 
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
     this.query = this.query.and({ path: new RegExp(`^${startsPattern}`) });
 
 
@@ -423,7 +424,7 @@ export class PageQueryBuilder {
       return this;
       return this;
     }
     }
 
 
-    const startsPattern = RegExp.escape(str);
+    const startsPattern = escapeStringForMongoRegex(str);
 
 
     this.query = this.query.and({
     this.query = this.query.and({
       path: new RegExp(`^(?!${startsPattern}).*$`),
       path: new RegExp(`^(?!${startsPattern}).*$`),
@@ -439,7 +440,7 @@ export class PageQueryBuilder {
       return this;
       return this;
     }
     }
 
 
-    const startsPattern = RegExp.escape(path);
+    const startsPattern = escapeStringForMongoRegex(path);
 
 
     this.query = this.query.and({
     this.query = this.query.and({
       path: { $not: new RegExp(`^${startsPattern}(/|$)`) },
       path: { $not: new RegExp(`^${startsPattern}(/|$)`) },
@@ -454,7 +455,7 @@ export class PageQueryBuilder {
       return this;
       return this;
     }
     }
 
 
-    const match = RegExp.escape(str);
+    const match = escapeStringForMongoRegex(str);
 
 
     this.query = this.query.and({ path: new RegExp(`^(?=.*${match}).*$`) });
     this.query = this.query.and({ path: new RegExp(`^(?=.*${match}).*$`) });
 
 
@@ -467,7 +468,7 @@ export class PageQueryBuilder {
       return this;
       return this;
     }
     }
 
 
-    const match = RegExp.escape(str);
+    const match = escapeStringForMongoRegex(str);
 
 
     this.query = this.query.and({ path: new RegExp(`^(?!.*${match}).*$`) });
     this.query = this.query.and({ path: new RegExp(`^(?!.*${match}).*$`) });
 
 

+ 2 - 1
apps/app/src/server/routes/apiv3/users.js

@@ -1,6 +1,7 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import express from 'express';
 import express from 'express';
 import { body, query } from 'express-validator';
 import { body, query } from 'express-validator';
@@ -340,7 +341,7 @@ module.exports = (crowi) => {
 
 
       // Search from input
       // Search from input
       const searchText = req.query.searchText || '';
       const searchText = req.query.searchText || '';
-      const searchWord = new RegExp(RegExp.escape(searchText));
+      const searchWord = new RegExp(escapeStringForMongoRegex(searchText));
       // Sort
       // Sort
       const { sort, sortOrder } = req.query;
       const { sort, sortOrder } = req.query;
       const sortOutput = {
       const sortOutput = {

+ 10 - 2
apps/app/src/server/service/page-grant.ts

@@ -5,7 +5,12 @@ import {
   type IGrantedGroup,
   type IGrantedGroup,
   PageGrant,
   PageGrant,
 } from '@growi/core';
 } from '@growi/core';
-import { pagePathUtils, pageUtils, pathUtils } from '@growi/core/dist/utils';
+import {
+  escapeStringForMongoRegex,
+  pagePathUtils,
+  pageUtils,
+  pathUtils,
+} from '@growi/core/dist/utils';
 import mongoose, { type HydratedDocument } from 'mongoose';
 import mongoose, { type HydratedDocument } from 'mongoose';
 
 
 import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
 import type { ExternalGroupProviderType } from '~/features/external-user-group/interfaces/external-user-group';
@@ -595,7 +600,10 @@ class PageGrantService implements IPageGrantService {
     };
     };
 
 
     const commonCondition = {
     const commonCondition = {
-      path: new RegExp(`^${RegExp.escape(addTrailingSlash(targetPath))}`, 'i'),
+      path: new RegExp(
+        `^${escapeStringForMongoRegex(addTrailingSlash(targetPath))}`,
+        'i',
+      ),
       isEmpty: false,
       isEmpty: false,
     };
     };
 
 

+ 12 - 5
apps/app/src/server/service/page/index.ts

@@ -17,7 +17,11 @@ import type {
   Ref,
   Ref,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
 import { PageGrant } from '@growi/core/dist/interfaces';
 import { PageGrant } from '@growi/core/dist/interfaces';
-import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import {
+  escapeStringForMongoRegex,
+  pagePathUtils,
+  pathUtils,
+} from '@growi/core/dist/utils';
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 import type { Cursor, HydratedDocument } from 'mongoose';
 import type { Cursor, HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
@@ -3961,7 +3965,8 @@ class PageService implements IPageService {
     const ancestorPaths = paths.flatMap((p) => collectAncestorPaths(p, []));
     const ancestorPaths = paths.flatMap((p) => collectAncestorPaths(p, []));
     // targets' descendants
     // targets' descendants
     const pathAndRegExpsToNormalize: (RegExp | string)[] = paths.map(
     const pathAndRegExpsToNormalize: (RegExp | string)[] = paths.map(
-      (p) => new RegExp(`^${RegExp.escape(addTrailingSlash(p))}`, 'i'),
+      (p) =>
+        new RegExp(`^${escapeStringForMongoRegex(addTrailingSlash(p))}`, 'i'),
     );
     );
     // include targets' path
     // include targets' path
     pathAndRegExpsToNormalize.push(...paths);
     pathAndRegExpsToNormalize.push(...paths);
@@ -4172,7 +4177,7 @@ class PageService implements IPageService {
           const parentId = parent._id;
           const parentId = parent._id;
 
 
           // Build filter
           // Build filter
-          const parentPathEscaped = RegExp.escape(
+          const parentPathEscaped = escapeStringForMongoRegex(
             parent.path === '/' ? '' : parent.path,
             parent.path === '/' ? '' : parent.path,
           ); // adjust the path for RegExp
           ); // adjust the path for RegExp
           const filter: any = {
           const filter: any = {
@@ -5138,7 +5143,9 @@ class PageService implements IPageService {
     const wasOnTree = exPage.parent != null || isTopPage(exPage.path);
     const wasOnTree = exPage.parent != null || isTopPage(exPage.path);
     const shouldBeOnTree = currentPage.grant !== PageGrant.GRANT_RESTRICTED;
     const shouldBeOnTree = currentPage.grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({
     const isChildrenExist = await Page.count({
-      path: new RegExp(`^${RegExp.escape(addTrailingSlash(currentPage.path))}`),
+      path: new RegExp(
+        `^${escapeStringForMongoRegex(addTrailingSlash(currentPage.path))}`,
+      ),
       parent: { $ne: null },
       parent: { $ne: null },
     });
     });
 
 
@@ -5270,7 +5277,7 @@ class PageService implements IPageService {
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
     const shouldBeOnTree = grant !== PageGrant.GRANT_RESTRICTED;
     const isChildrenExist = await Page.count({
     const isChildrenExist = await Page.count({
       path: new RegExp(
       path: new RegExp(
-        `^${RegExp.escape(addTrailingSlash(clonedPageData.path))}`,
+        `^${escapeStringForMongoRegex(addTrailingSlash(clonedPageData.path))}`,
       ),
       ),
       parent: { $ne: null },
       parent: { $ne: null },
     });
     });

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/slackbot-proxy",
   "name": "@growi/slackbot-proxy",
-  "version": "7.5.4-slackbot-proxy.0",
+  "version": "7.5.5-slackbot-proxy.0",
   "license": "MIT",
   "license": "MIT",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "7.5.4-RC.0",
+  "version": "7.5.5-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "license": "MIT",
   "private": true,
   "private": true,
@@ -78,7 +78,7 @@
     "ts-patch": "^3.3.0",
     "ts-patch": "^3.3.0",
     "tsconfig-paths": "^4.2.0",
     "tsconfig-paths": "^4.2.0",
     "tspc": "^1.1.2",
     "tspc": "^1.1.2",
-    "turbo": "^2.1.3",
+    "turbo": "^2.9.14",
     "typescript": "^5.9.3",
     "typescript": "^5.9.3",
     "typescript-transform-paths": "^3.5.6",
     "typescript-transform-paths": "^3.5.6",
     "vite": "^6.4.2",
     "vite": "^6.4.2",

+ 8 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,13 @@
 # @growi/core
 # @growi/core
 
 
+## 2.3.1
+
+### Patch Changes
+
+- [#11236](https://github.com/growilabs/growi/pull/11236) [`bd28252`](https://github.com/growilabs/growi/commit/bd28252c1a6e7f76f9bdadbdc3a07690f6bc0573) Thanks [@yuki-takei](https://github.com/yuki-takei)! - Fix page operations and v5 page migration failing for page paths that contain non-ASCII whitespace (e.g. U+3000 IDEOGRAPHIC SPACE)
+
+  Node.js 24's `RegExp.escape()` escapes non-ASCII whitespace (code points >= U+0100, such as U+3000) into `\uXXXX` form, which MongoDB's PCRE2 engine does not support (error 51091). Added `escapeStringForMongoRegex()`, which escapes only regex metacharacters and passes other characters through literally, and used it wherever the resulting pattern is sent to MongoDB.
+
 ## 2.3.0
 ## 2.3.0
 
 
 ### Minor Changes
 ### Minor Changes

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/core",
   "name": "@growi/core",
-  "version": "2.3.0",
+  "version": "2.3.1",
   "description": "GROWI Core Libraries",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "license": "MIT",
   "repository": {
   "repository": {

+ 61 - 0
packages/core/src/utils/escape-string-for-regex.spec.ts

@@ -0,0 +1,61 @@
+import { describe, expect, test } from 'vitest';
+
+import { escapeStringForMongoRegex } from './escape-string-for-regex';
+
+describe('escapeStringForMongoRegex', () => {
+  test('escapes regex metacharacters', () => {
+    expect(escapeStringForMongoRegex('a.b*c+d?e')).toBe('a\\.b\\*c\\+d\\?e');
+    expect(escapeStringForMongoRegex('(group)[set]{n}')).toBe(
+      '\\(group\\)\\[set\\]\\{n\\}',
+    );
+    expect(escapeStringForMongoRegex('^start$ | end\\')).toBe(
+      '\\^start\\$ \\| end\\\\',
+    );
+  });
+
+  test('escapes hyphen as \\x2d (escape-string-regexp v5 behavior)', () => {
+    expect(escapeStringForMongoRegex('a-b')).toBe('a\\x2db');
+  });
+
+  test('does NOT escape forward slash or ASCII space', () => {
+    // The .source getter still renders "/" as "\/", but the escaped string itself keeps "/" literal.
+    expect(escapeStringForMongoRegex('/parent/child')).toBe('/parent/child');
+    expect(escapeStringForMongoRegex('a b')).toBe('a b');
+  });
+
+  // Core property of the fix: unlike RegExp.escape(), this must NOT emit \uXXXX,
+  // because MongoDB's PCRE2 engine rejects \u (error 51091).
+  test('passes non-ASCII whitespace through literally (no \\u escape)', () => {
+    const ideographicSpace = ' '; // full-width space
+    const escaped = escapeStringForMongoRegex(`/page${ideographicSpace}title`);
+    expect(escaped).toContain(ideographicSpace);
+    expect(escaped).not.toContain('\\u');
+  });
+
+  test.each([
+    ' ',
+    ' ',
+    ' ',
+    ' ',
+    ' ',
+    '
',
+    '
',
+    ' ',
+    ' ',
+    ' ',
+  ])('does not emit \\u for whitespace char %j', (ws) => {
+    expect(escapeStringForMongoRegex(`x${ws}y`)).not.toContain('\\u');
+  });
+
+  test('produces a pattern that literally matches the original string', () => {
+    for (const s of [
+      '/parent/全角 space', // U+3000
+      '/a.b+c?(d)[e]',
+      '/path-with-hyphen',
+      '/nbsp here',
+    ]) {
+      const re = new RegExp(`^${escapeStringForMongoRegex(s)}$`);
+      expect(re.test(s)).toBe(true);
+    }
+  });
+});

+ 22 - 0
packages/core/src/utils/escape-string-for-regex.ts

@@ -0,0 +1,22 @@
+/**
+ * Escape a string for safe use inside a regular expression that is sent to MongoDB
+ * (`$regex` / `new RegExp(...)` used in a query). MongoDB evaluates regular expressions
+ * with the PCRE2 engine.
+ *
+ * Why not `RegExp.escape()`:
+ *   Node.js 24's built-in `RegExp.escape()` escapes non-ASCII whitespace
+ *   (code points >= U+0100, e.g. U+3000 IDEOGRAPHIC SPACE) into `\uXXXX` form.
+ *   PCRE2 does not support `\u`, so such a pattern makes MongoDB throw
+ *   "Regular expression is invalid: PCRE2 does not support ... \u" (error 51091).
+ *
+ * This helper instead escapes only regex metacharacters and passes every other
+ * character through literally — behaviourally identical to `escape-string-regexp` v5,
+ * which is what GROWI used before the v7.5.0 refactor. The output never contains `\u`,
+ * so it is safe to hand to MongoDB.
+ *
+ * Use this (not `RegExp.escape`) whenever the resulting pattern is sent to MongoDB.
+ * For in-process JS regex (`.test()` / `.replace()`), `RegExp.escape` is fine.
+ */
+export const escapeStringForMongoRegex = (str: string): string => {
+  return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d');
+};

+ 1 - 0
packages/core/src/utils/index.ts

@@ -4,6 +4,7 @@ import * as _envUtils from './env-utils';
 export const envUtils = _envUtils;
 export const envUtils = _envUtils;
 
 
 export * from './browser-utils';
 export * from './browser-utils';
+export * from './escape-string-for-regex';
 export * from './global-event-target';
 export * from './global-event-target';
 export * from './growi-theme-metadata';
 export * from './growi-theme-metadata';
 export * as deepEquals from './is-deep-equals';
 export * as deepEquals from './is-deep-equals';

+ 12 - 2
packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts

@@ -17,8 +17,10 @@ describe('generateChildrenRegExp', () => {
       invalidPaths: ['/parent', '/parent/child/grandchild', '/other/path'],
       invalidPaths: ['/parent', '/parent/child/grandchild', '/other/path'],
     },
     },
     {
     {
+      // escapeStringForMongoRegex does not escape ASCII space (it is PCRE-safe as-is),
+      // unlike RegExp.escape which would emit \x20.
       path: '/parent (with brackets)',
       path: '/parent (with brackets)',
-      expected: '^\\/parent\\x20\\(with\\x20brackets\\)(\\/[^/]+)\\/?$',
+      expected: '^\\/parent \\(with brackets\\)(\\/[^/]+)\\/?$',
       validPaths: [
       validPaths: [
         '/parent (with brackets)/child',
         '/parent (with brackets)/child',
         '/parent (with brackets)/test',
         '/parent (with brackets)/test',
@@ -30,13 +32,21 @@ describe('generateChildrenRegExp', () => {
     },
     },
     {
     {
       path: '/parent[with square]',
       path: '/parent[with square]',
-      expected: '^\\/parent\\[with\\x20square\\](\\/[^/]+)\\/?$',
+      expected: '^\\/parent\\[with square\\](\\/[^/]+)\\/?$',
       validPaths: ['/parent[with square]/child', '/parent[with square]/test'],
       validPaths: ['/parent[with square]/child', '/parent[with square]/test'],
       invalidPaths: [
       invalidPaths: [
         '/parent[with square]',
         '/parent[with square]',
         '/parent[with square]/child/grandchild',
         '/parent[with square]/child/grandchild',
       ],
       ],
     },
     },
+    {
+      // Regression for #11235: a path containing U+3000 (full-width space) must NOT be
+      // escaped to   — MongoDB's PCRE2 rejects \u (error 51091). The char passes through literally.
+      path: '/親 ページ',
+      expected: '^\\/親 ページ(\\/[^/]+)\\/?$',
+      validPaths: ['/親 ページ/child', '/親 ページ/テスト'],
+      invalidPaths: ['/親 ページ', '/親 ページ/child/grandchild'],
+    },
     {
     {
       path: '/parent*with+special?chars',
       path: '/parent*with+special?chars',
       expected: '^\\/parent\\*with\\+special\\?chars(\\/[^/]+)\\/?$',
       expected: '^\\/parent\\*with\\+special\\?chars(\\/[^/]+)\\/?$',

+ 5 - 1
packages/core/src/utils/page-path-utils/generate-children-regexp.ts

@@ -1,3 +1,4 @@
+import { escapeStringForMongoRegex } from '../escape-string-for-regex';
 import { isTopPage } from './is-top-page';
 import { isTopPage } from './is-top-page';
 
 
 /**
 /**
@@ -10,5 +11,8 @@ export const generateChildrenRegExp = (path: string): RegExp => {
 
 
   // https://regex101.com/r/mrDJrx/1
   // https://regex101.com/r/mrDJrx/1
   // ex. /parent/any_child OR /any_level1
   // ex. /parent/any_child OR /any_level1
-  return new RegExp(`^${RegExp.escape(path)}(\\/[^/]+)\\/?$`);
+  // NOTE: use escapeStringForMongoRegex (not RegExp.escape) because this pattern is sent to
+  // MongoDB ($regex). RegExp.escape would emit \uXXXX for non-ASCII whitespace (e.g. U+3000),
+  // which PCRE2 rejects (error 51091).
+  return new RegExp(`^${escapeStringForMongoRegex(path)}(\\/[^/]+)\\/?$`);
 };
 };

+ 7 - 0
packages/pluginkit/CHANGELOG.md

@@ -1,5 +1,12 @@
 # @growi/pluginkit
 # @growi/pluginkit
 
 
+## 1.2.4
+
+### Patch Changes
+
+- Updated dependencies [[`bd28252`](https://github.com/growilabs/growi/commit/bd28252c1a6e7f76f9bdadbdc3a07690f6bc0573)]:
+  - @growi/core@2.3.1
+
 ## 1.2.3
 ## 1.2.3
 
 
 ### Patch Changes
 ### Patch Changes

+ 1 - 1
packages/pluginkit/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/pluginkit",
   "name": "@growi/pluginkit",
-  "version": "1.2.3",
+  "version": "1.2.4",
   "description": "Plugin kit for GROWI",
   "description": "Plugin kit for GROWI",
   "license": "MIT",
   "license": "MIT",
   "repository": {
   "repository": {

+ 5 - 4
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -1,4 +1,5 @@
 import type { IPageHasId, IUser } from '@growi/core';
 import type { IPageHasId, IUser } from '@growi/core';
+import { escapeStringForMongoRegex } from '@growi/core/dist/utils';
 import type { Request, Response } from 'express';
 import type { Request, Response } from 'express';
 import createError from 'http-errors';
 import createError from 'http-errors';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
@@ -187,7 +188,7 @@ describe('listPages', () => {
       const pagePath = '/parent';
       const pagePath = '/parent';
       const optionsFilter = '^child';
       const optionsFilter = '^child';
       const expectedRegex = new RegExp(
       const expectedRegex = new RegExp(
-        `^${RegExp.escape('/parent/')}${RegExp.escape('child')}`,
+        `^${escapeStringForMongoRegex('/parent/')}${escapeStringForMongoRegex('child')}`,
       );
       );
 
 
       // when
       // when
@@ -202,7 +203,7 @@ describe('listPages', () => {
       const pagePath = '/parent';
       const pagePath = '/parent';
       const optionsFilter = 'child';
       const optionsFilter = 'child';
       const expectedRegex = new RegExp(
       const expectedRegex = new RegExp(
-        `^${RegExp.escape('/parent/')}.*${RegExp.escape('child')}`,
+        `^${escapeStringForMongoRegex('/parent/')}.*${escapeStringForMongoRegex('child')}`,
       );
       );
 
 
       // when
       // when
@@ -230,7 +231,7 @@ describe('listPages', () => {
       const pagePath = '/parent';
       const pagePath = '/parent';
       const optionsFilter = 'child';
       const optionsFilter = 'child';
       const expectedRegex = new RegExp(
       const expectedRegex = new RegExp(
-        `^${RegExp.escape('/parent/')}.*${RegExp.escape('child')}`,
+        `^${escapeStringForMongoRegex('/parent/')}.*${escapeStringForMongoRegex('child')}`,
       );
       );
 
 
       // when
       // when
@@ -320,7 +321,7 @@ describe('when excludedPaths is handled', () => {
 
 
     // check if the logic generates the correct regex: ^\/(user|tmp)(\/|$)
     // check if the logic generates the correct regex: ^\/(user|tmp)(\/|$)
     const expectedRegex = new RegExp(
     const expectedRegex = new RegExp(
-      `^\\/(${RegExp.escape('user')}|${RegExp.escape('tmp')})(\\/|$)`,
+      `^\\/(${escapeStringForMongoRegex('user')}|${escapeStringForMongoRegex('tmp')})(\\/|$)`,
     );
     );
     expect(queryMock.and).toHaveBeenCalledWith([
     expect(queryMock.and).toHaveBeenCalledWith([
       {
       {

+ 7 - 5
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -1,6 +1,6 @@
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
-import { pathUtils } from '@growi/core/dist/utils';
+import { escapeStringForMongoRegex, pathUtils } from '@growi/core/dist/utils';
 import { loggerFactory } from '@growi/logger';
 import { loggerFactory } from '@growi/logger';
 import type { Request, Response } from 'express';
 import type { Request, Response } from 'express';
 import createError, { isHttpError } from 'http-errors';
 import createError, { isHttpError } from 'http-errors';
@@ -33,16 +33,18 @@ export function addFilterCondition(
     );
     );
   }
   }
 
 
-  const pagePathForRegexp = RegExp.escape(addTrailingSlash(pagePath));
+  const pagePathForRegexp = escapeStringForMongoRegex(
+    addTrailingSlash(pagePath),
+  );
 
 
   let filterPath: RegExp;
   let filterPath: RegExp;
   try {
   try {
     if (optionsFilter.charAt(0) === '^') {
     if (optionsFilter.charAt(0) === '^') {
       // move '^' to the first of path
       // move '^' to the first of path
-      const escapedFilter = RegExp.escape(optionsFilter.slice(1));
+      const escapedFilter = escapeStringForMongoRegex(optionsFilter.slice(1));
       filterPath = new RegExp(`^${pagePathForRegexp}${escapedFilter}`);
       filterPath = new RegExp(`^${pagePathForRegexp}${escapedFilter}`);
     } else {
     } else {
-      const escapedFilter = RegExp.escape(optionsFilter);
+      const escapedFilter = escapeStringForMongoRegex(optionsFilter);
       filterPath = new RegExp(`^${pagePathForRegexp}.*${escapedFilter}`);
       filterPath = new RegExp(`^${pagePathForRegexp}.*${escapedFilter}`);
     }
     }
   } catch (err) {
   } catch (err) {
@@ -101,7 +103,7 @@ export const listPages = ({
       if (excludedPaths.length > 0) {
       if (excludedPaths.length > 0) {
         const escapedPaths = excludedPaths.map((p) => {
         const escapedPaths = excludedPaths.map((p) => {
           const cleanPath = p.startsWith('/') ? p.substring(1) : p;
           const cleanPath = p.startsWith('/') ? p.substring(1) : p;
-          return RegExp.escape(cleanPath);
+          return escapeStringForMongoRegex(cleanPath);
         });
         });
 
 
         const regex = new RegExp(`^\\/(${escapedPaths.join('|')})(\\/|$)`);
         const regex = new RegExp(`^\\/(${escapedPaths.join('|')})(\\/|$)`);

+ 1 - 1
packages/slack/package.json

@@ -59,7 +59,7 @@
     "date-fns": "^3.6.0",
     "date-fns": "^3.6.0",
     "extensible-custom-error": "^0.0.7",
     "extensible-custom-error": "^0.0.7",
     "http-errors": "^2.0.0",
     "http-errors": "^2.0.0",
-    "qs": "^6.14.2",
+    "qs": "^6.15.2",
     "url-join": "^4.0.0"
     "url-join": "^4.0.0"
   },
   },
   "devDependencies": {
   "devDependencies": {

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 93 - 302
pnpm-lock.yaml


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است