Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into feat/178240-suggest-path-spec

VANELLOPE\tomoyuki-t 1 месяц назад
Родитель
Сommit
79fdbd27b5

+ 22 - 0
.claude/hooks/session-start.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+
+# Only run in remote (Claude Code on the web) environments
+if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
+  exit 0
+fi
+
+cd "$CLAUDE_PROJECT_DIR"
+
+# Install all workspace dependencies.
+# turbo (root devDependency) and all workspace packages will be installed.
+pnpm install
+
+# Install turbo globally (mirrors devcontainer postCreateCommand.sh) so it is
+# available as a bare `turbo` command in subsequent Claude tool calls.
+# Falls back to adding node_modules/.bin to PATH if the pnpm global store is
+# not yet configured in this environment.
+if ! command -v turbo &> /dev/null; then
+  pnpm install turbo --global 2>/dev/null \
+    || echo "export PATH=\"$CLAUDE_PROJECT_DIR/node_modules/.bin:\$PATH\"" >> "$CLAUDE_ENV_FILE"
+fi

+ 10 - 0
.claude/settings.json

@@ -1,5 +1,15 @@
 {
 {
   "hooks": {
   "hooks": {
+    "SessionStart": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
+          }
+        ]
+      }
+    ],
     "PostToolUse": [
     "PostToolUse": [
       {
       {
         "matcher": "Write|Edit",
         "matcher": "Write|Edit",

+ 9 - 0
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -14,6 +14,7 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
 import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+import ThreadRelationModel from '../models/thread-relation';
 import { getOpenaiService } from '../services/openai';
 import { getOpenaiService } from '../services/openai';
 import { certifyAiService } from './middlewares/certify-ai-service';
 import { certifyAiService } from './middlewares/certify-ai-service';
 
 
@@ -68,6 +69,14 @@ export const deleteThreadFactory = (crowi: Crowi): RequestHandler[] => {
       }
       }
 
 
       try {
       try {
+        const threadRelation = await ThreadRelationModel.findOne({
+          _id: threadRelationId,
+          userId: user._id,
+        });
+        if (threadRelation == null) {
+          return res.apiv3Err(new ErrorV3('Thread not found'), 404);
+        }
+
         const deletedThreadRelation =
         const deletedThreadRelation =
           await openaiService.deleteThread(threadRelationId);
           await openaiService.deleteThread(threadRelationId);
         return res.apiv3({ deletedThreadRelation });
         return res.apiv3({ deletedThreadRelation });

+ 1 - 0
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -246,6 +246,7 @@ export const postMessageToEditHandlersFactory = (
 
 
       const threadRelation = await ThreadRelationModel.findOne({
       const threadRelation = await ThreadRelationModel.findOne({
         threadId: { $eq: threadId },
         threadId: { $eq: threadId },
+        userId: user._id,
       });
       });
       if (threadRelation == null) {
       if (threadRelation == null) {
         return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
         return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);

+ 4 - 2
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -70,8 +70,10 @@ export const getThreadsFactory = (crowi: Crowi): RequestHandler[] => {
           );
           );
         }
         }
 
 
-        const threads =
-          await openaiService.getThreadsByAiAssistantId(aiAssistantId);
+        const threads = await openaiService.getThreadsByAiAssistantId(
+          aiAssistantId,
+          user._id,
+        );
 
 
         return res.apiv3({ threads });
         return res.apiv3({ threads });
       } catch (err) {
       } catch (err) {

+ 9 - 0
apps/app/src/features/openai/server/routes/message/get-messages.ts

@@ -12,6 +12,7 @@ import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import ThreadRelationModel from '../../models/thread-relation';
 import { getOpenaiService } from '../../services/openai';
 import { getOpenaiService } from '../../services/openai';
 import { certifyAiService } from '../middlewares/certify-ai-service';
 import { certifyAiService } from '../middlewares/certify-ai-service';
 
 
@@ -81,6 +82,14 @@ export const getMessagesFactory = (crowi: Crowi): RequestHandler[] => {
           );
           );
         }
         }
 
 
+        const threadRelation = await ThreadRelationModel.findOne({
+          threadId: { $eq: threadId },
+          userId: user._id,
+        });
+        if (threadRelation == null) {
+          return res.apiv3Err(new ErrorV3('Thread not found'), 404);
+        }
+
         const messages = await openaiService.getMessageData(
         const messages = await openaiService.getMessageData(
           threadId,
           threadId,
           user.lang,
           user.lang,

+ 4 - 1
apps/app/src/features/openai/server/routes/message/post-message.ts

@@ -128,7 +128,10 @@ export const postMessageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
         return res.apiv3Err(new ErrorV3('AI assistant not found'), 404);
         return res.apiv3Err(new ErrorV3('AI assistant not found'), 404);
       }
       }
 
 
-      const threadRelation = await ThreadRelationModel.findOne({ threadId });
+      const threadRelation = await ThreadRelationModel.findOne({
+        threadId: { $eq: threadId },
+        userId: user._id,
+      });
       if (threadRelation == null) {
       if (threadRelation == null) {
         return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
         return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
       }
       }

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

@@ -98,6 +98,7 @@ export interface IOpenaiService {
   ): Promise<ThreadRelationDocument>;
   ): Promise<ThreadRelationDocument>;
   getThreadsByAiAssistantId(
   getThreadsByAiAssistantId(
     aiAssistantId: string,
     aiAssistantId: string,
+    userId?: string,
   ): Promise<ThreadRelationDocument[]>;
   ): Promise<ThreadRelationDocument[]>;
   deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
@@ -290,12 +291,21 @@ class OpenaiService implements IOpenaiService {
 
 
   async getThreadsByAiAssistantId(
   async getThreadsByAiAssistantId(
     aiAssistantId: string,
     aiAssistantId: string,
+    userId?: string,
     type: ThreadType = ThreadType.KNOWLEDGE,
     type: ThreadType = ThreadType.KNOWLEDGE,
   ): Promise<ThreadRelationDocument[]> {
   ): Promise<ThreadRelationDocument[]> {
-    const threadRelations = await ThreadRelationModel.find({
+    const query: { aiAssistant: string; type: ThreadType; userId?: string } = {
       aiAssistant: aiAssistantId,
       aiAssistant: aiAssistantId,
       type,
       type,
-    }).sort({ updatedAt: -1 });
+    };
+
+    if (userId != null) {
+      query.userId = userId;
+    }
+
+    const threadRelations = await ThreadRelationModel.find(query).sort({
+      updatedAt: -1,
+    });
     return threadRelations;
     return threadRelations;
   }
   }
 
 

+ 13 - 1
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -41,6 +41,17 @@ export const SearchForm = (props: Props): JSX.Element => {
     [searchKeyword, onSubmit],
     [searchKeyword, onSubmit],
   );
   );
 
 
+  // Prevent Downshift from intercepting Home/End keys so they move
+  // the cursor within the input field instead of navigating the list
+  const keyDownHandler = useCallback(
+    (e: React.KeyboardEvent<HTMLInputElement>) => {
+      if (e.key === 'Home' || e.key === 'End') {
+        e.nativeEvent.preventDownshiftDefault = true;
+      }
+    },
+    [],
+  );
+
   const inputOptions = useMemo(() => {
   const inputOptions = useMemo(() => {
     return getInputProps({
     return getInputProps({
       type: 'text',
       type: 'text',
@@ -49,8 +60,9 @@ export const SearchForm = (props: Props): JSX.Element => {
       ref: inputRef,
       ref: inputRef,
       value: searchKeyword,
       value: searchKeyword,
       onChange: changeSearchTextHandler,
       onChange: changeSearchTextHandler,
+      onKeyDown: keyDownHandler,
     });
     });
-  }, [getInputProps, searchKeyword, changeSearchTextHandler]);
+  }, [getInputProps, searchKeyword, changeSearchTextHandler, keyDownHandler]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (inputRef.current != null) {
     if (inputRef.current != null) {

+ 9 - 0
apps/app/src/features/search/client/interfaces/downshift.ts

@@ -1,5 +1,14 @@
 import type { ControllerStateAndHelpers } from 'downshift';
 import type { ControllerStateAndHelpers } from 'downshift';
 
 
+// Augment the global Event interface with downshift's custom property.
+// downshift checks event.nativeEvent.preventDownshiftDefault to skip
+// its default key handling. See: https://www.downshift-js.com/downshift#customizing-handlers
+declare global {
+  interface Event {
+    preventDownshiftDefault?: boolean;
+  }
+}
+
 export type DownshiftItem = { url: string };
 export type DownshiftItem = { url: string };
 
 
 export type GetItemProps =
 export type GetItemProps =

+ 150 - 0
apps/app/src/features/search/utils/disable-user-pages.spec.ts

@@ -0,0 +1,150 @@
+import type { QueryTerms } from '~/server/interfaces/search';
+
+import { excludeUserPagesFromQuery } from './disable-user-pages';
+
+describe('excludeUserPagesFromQuery()', () => {
+  it('should exclude user page strings from query prefix', () => {
+    const userString = '/user';
+    const userStringSlash = '/user/';
+    const userStringSubPath = '/user/settings';
+    const userStringSubPathDeep = '/user/profile/edit';
+    const userStringSubPathQuery = '/user/settings?ref=top';
+
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [
+        userString,
+        userStringSlash,
+        userStringSubPath,
+        userStringSubPathDeep,
+        userStringSubPathQuery,
+      ],
+      not_prefix: [],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).not.toContain(userString);
+    // Should only contain '/user'
+    expect(query.not_prefix).toContain(userString);
+
+    expect(query.prefix).not.toContain(userStringSlash);
+    expect(query.not_prefix).not.toContain(userStringSlash);
+
+    expect(query.prefix).not.toContain(userStringSubPath);
+    expect(query.not_prefix).not.toContain(userStringSubPath);
+
+    expect(query.prefix).not.toContain(userStringSubPathDeep);
+    expect(query.not_prefix).not.toContain(userStringSubPathDeep);
+
+    expect(query.prefix).not.toContain(userStringSubPathQuery);
+    expect(query.not_prefix).not.toContain(userStringSubPathQuery);
+  });
+
+  it('should not exclude strings similar to /user from query prefix', () => {
+    const userRouter = '/userouter';
+    const userRoot = '/useroot';
+    const userSettings = '/user-settings';
+    const apiUser = '/api/user';
+    const emptyString = '';
+    const rootOnly = '/';
+    const upperCase = '/USER';
+    const doubleSlashStart = '//user/new';
+    const doubleSlashSub = '/user//new';
+
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [
+        userRouter,
+        userRoot,
+        userSettings,
+        apiUser,
+        emptyString,
+        rootOnly,
+        upperCase,
+        doubleSlashStart,
+        doubleSlashSub,
+      ],
+      not_prefix: [],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).toContain(userRouter);
+    expect(query.not_prefix).not.toContain(userRouter);
+
+    expect(query.prefix).toContain(userRoot);
+    expect(query.not_prefix).not.toContain(userRoot);
+
+    expect(query.prefix).toContain(userSettings);
+    expect(query.not_prefix).not.toContain(userSettings);
+
+    expect(query.prefix).toContain(apiUser);
+    expect(query.not_prefix).not.toContain(apiUser);
+
+    expect(query.prefix).toContain(emptyString);
+    expect(query.not_prefix).not.toContain(emptyString);
+
+    expect(query.prefix).toContain(rootOnly);
+    expect(query.not_prefix).not.toContain(rootOnly);
+
+    expect(query.prefix).toContain(upperCase);
+    expect(query.not_prefix).not.toContain(upperCase);
+
+    expect(query.prefix).toContain(doubleSlashStart);
+    expect(query.not_prefix).not.toContain(doubleSlashStart);
+
+    expect(query.prefix).toContain(doubleSlashSub);
+    expect(query.not_prefix).not.toContain(doubleSlashSub);
+  });
+
+  it('should add /user to not_prefix when it is empty', () => {
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [],
+      not_prefix: [],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).toHaveLength(0);
+    expect(query.not_prefix).toContain('/user');
+    expect(query.not_prefix).toHaveLength(1);
+  });
+
+  it('should remove existing /user strings and leave not_prefix with just one /user string', () => {
+    const userString = '/user';
+
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [userString, userString],
+      not_prefix: [userString, userString],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).toHaveLength(0);
+    expect(query.not_prefix).toContain('/user');
+    expect(query.not_prefix).toHaveLength(1);
+  });
+});

+ 10 - 0
apps/app/src/features/search/utils/disable-user-pages.ts

@@ -0,0 +1,10 @@
+import type { QueryTerms } from '~/server/interfaces/search';
+
+export function excludeUserPagesFromQuery(terms: QueryTerms): void {
+  const userRegex: RegExp = /^\/user($|\/(?!\/))/;
+
+  terms.prefix = terms.prefix.filter((p) => !userRegex.test(p));
+  terms.not_prefix = terms.not_prefix.filter((p) => !userRegex.test(p));
+
+  terms.not_prefix.push('/user');
+}

+ 150 - 0
apps/app/src/server/service/search-query.spec.ts

@@ -0,0 +1,150 @@
+import { vi } from 'vitest';
+import { type MockProxy, mock } from 'vitest-mock-extended';
+
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager/config-manager';
+
+import type { SearchDelegator } from '../interfaces/search';
+import NamedQuery from '../models/named-query';
+import SearchService from './search';
+import type ElasticsearchDelegator from './search-delegator/elasticsearch';
+
+// Mock NamedQuery
+vi.mock('~/server/models/named-query', () => {
+  const mockModel = {
+    findOne: vi.fn(),
+  };
+  return {
+    NamedQuery: mockModel,
+    default: mockModel,
+  };
+});
+
+// Mock config manager
+vi.mock('~/server/service/config-manager/config-manager', () => {
+  return {
+    default: {
+      getConfig: vi.fn(),
+    },
+    configManager: {
+      getConfig: vi.fn(),
+    },
+  };
+});
+
+class TestSearchService extends SearchService {
+  override generateFullTextSearchDelegator(): ElasticsearchDelegator {
+    return mock<ElasticsearchDelegator>();
+  }
+
+  override generateNQDelegators(): {
+    [key in SearchDelegatorName]: SearchDelegator;
+  } {
+    return {
+      [SearchDelegatorName.DEFAULT]: mock<SearchDelegator>(),
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: mock<SearchDelegator>(),
+    };
+  }
+
+  override registerUpdateEvent(): void {}
+
+  override get isConfigured(): boolean {
+    return false;
+  }
+}
+
+describe('searchParseQuery()', () => {
+  let searchService: TestSearchService;
+  let mockCrowi: MockProxy<Crowi>;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    mockCrowi = mock<Crowi>();
+    mockCrowi.configManager = configManager;
+    searchService = new TestSearchService(mockCrowi);
+  });
+
+  it('should contain /user in the not_prefix query when user pages are disabled', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return true;
+      }
+
+      return false;
+    });
+
+    const result = await searchService.parseSearchQuery('/user/settings', null);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).toContain('/user');
+    expect(result.terms.prefix).toHaveLength(0);
+  });
+
+  it('should contain /user in the not_prefix even when search query is not a user page', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return true;
+      }
+
+      return false;
+    });
+
+    const result = await searchService.parseSearchQuery('/new-task', null);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).toContain('/user');
+    expect(result.terms.prefix).toHaveLength(0);
+  });
+
+  it('should add specific user prefixes in the query when user pages are enabled', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return false;
+      }
+
+      return true;
+    });
+
+    const result = await searchService.parseSearchQuery('/user/settings', null);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).not.toContain('/user');
+    expect(result.terms.not_prefix).not.toContain('/user/settings');
+    expect(result.terms.match).toContain('/user/settings');
+  });
+
+  it('should filter user pages even when resolved from a named query alias', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return true;
+      }
+
+      return false;
+    });
+
+    const shortcutName = 'my-shortcut';
+    const aliasPath = '/user/my-private-page';
+
+    // Mock the DB response
+    vi.mocked(NamedQuery.findOne).mockResolvedValue({
+      name: shortcutName,
+      aliasOf: aliasPath,
+    });
+
+    const result = await searchService.parseSearchQuery('dummy', shortcutName);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).toContain('/user');
+    expect(result.terms.match).toContain('/user/my-private-page');
+  });
+});

+ 25 - 21
apps/app/src/server/service/search.ts

@@ -8,6 +8,7 @@ import {
   isIncludeAiMenthion,
   isIncludeAiMenthion,
   removeAiMenthion,
   removeAiMenthion,
 } from '~/features/search/utils/ai';
 } from '~/features/search/utils/ai';
+import { excludeUserPagesFromQuery } from '~/features/search/utils/disable-user-pages';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type {
 import type {
   IFormattedSearchResult,
   IFormattedSearchResult,
@@ -328,34 +329,37 @@ class SearchService implements SearchQueryParser, SearchResolver {
     _queryString: string,
     _queryString: string,
     nqName: string | null,
     nqName: string | null,
   ): Promise<ParsedQuery> {
   ): Promise<ParsedQuery> {
+    const disableUserPages = configManager.getConfig(
+      'security:disableUserPages',
+    );
     const queryString = normalizeQueryString(_queryString);
     const queryString = normalizeQueryString(_queryString);
-
     const terms = this.parseQueryString(queryString);
     const terms = this.parseQueryString(queryString);
 
 
-    if (nqName == null) {
-      return { queryString, terms };
-    }
+    let parsedQuery: ParsedQuery = { queryString, terms };
 
 
-    const nq = await NamedQuery.findOne({ name: normalizeNQName(nqName) });
+    if (nqName != null) {
+      const nq = await NamedQuery.findOne({ name: normalizeNQName(nqName) });
 
 
-    // will delegate to full-text search
-    if (nq == null) {
-      logger.debug(
-        `Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`,
-      );
-      return { queryString, terms };
-    }
+      if (nq != null) {
+        const { aliasOf, delegatorName } = nq;
 
 
-    const { aliasOf, delegatorName } = nq;
+        if (aliasOf != null) {
+          parsedQuery = {
+            queryString: normalizeQueryString(aliasOf),
+            terms: this.parseQueryString(aliasOf),
+          };
+        } else {
+          parsedQuery = { queryString, terms, delegatorName };
+        }
+      } else {
+        logger.debug(
+          `Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`,
+        );
+      }
+    }
 
 
-    let parsedQuery: ParsedQuery;
-    if (aliasOf != null) {
-      parsedQuery = {
-        queryString: normalizeQueryString(aliasOf),
-        terms: this.parseQueryString(aliasOf),
-      };
-    } else {
-      parsedQuery = { queryString, terms, delegatorName };
+    if (disableUserPages) {
+      excludeUserPagesFromQuery(parsedQuery.terms);
     }
     }
 
 
     return parsedQuery;
     return parsedQuery;