diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 25be7f1b..e269d5de 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -953,6 +953,7 @@ "Try a different search term.": "Try a different search term.", "Try again": "Try again", "Untitled chat": "Untitled chat", + "No document": "No document", "You": "You", "What can I help you with?": "What can I help you with?", "Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index ee5d320b..140c6761 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -954,6 +954,7 @@ "Try a different search term.": "Попробуйте другой поисковый запрос.", "Try again": "Попробовать снова", "Untitled chat": "Чат без названия", + "No document": "Без документа", "What can I help you with?": "Чем я могу вам помочь?", "Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}", "Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.", diff --git a/apps/client/src/features/ai-chat/components/conversation-list.tsx b/apps/client/src/features/ai-chat/components/conversation-list.tsx index 8e08fa0e..b5a2a4e5 100644 --- a/apps/client/src/features/ai-chat/components/conversation-list.tsx +++ b/apps/client/src/features/ai-chat/components/conversation-list.tsx @@ -18,8 +18,31 @@ import { useRenameAiChatMutation, } from "@/features/ai-chat/queries/ai-chat-query.ts"; import { IAiChat } from "@/features/ai-chat/types/ai-chat.types.ts"; +import { useTimeAgo } from "@/hooks/use-time-ago.tsx"; import classes from "@/features/ai-chat/components/ai-chat.module.css"; +/** + * The dimmed second line of a chat row: how long ago the chat was created and + * the document it was created in. Its own component so the self-updating + * `useTimeAgo` hook is called per row legally (hooks cannot run inside `.map()`). + */ +function ChatMetaLine({ + createdAt, + pageTitle, +}: { + createdAt: string; + pageTitle?: string | null; +}) { + const { t } = useTranslation(); + const ago = useTimeAgo(createdAt); + // e.g. "2 hours ago · Onboarding guide" / "2 hours ago · No document" + return ( + + {ago} · {pageTitle || t("No document")} + + ); +} + interface ConversationListProps { activeChatId: string | null; onSelect: (chatId: string) => void; @@ -127,16 +150,24 @@ export default function ConversationList({ } }} > - - {chat.roleName && ( - - {chat.roleEmoji || "🤖"} + + + {chat.roleName && ( + + {chat.roleEmoji || "🤖"} + + )} + + {chat.title || t("Untitled chat")} - )} - - {chat.title || t("Untitled chat")} - - + + + { {} as never, // tools {} as never, // mcpClients aiAgentRoleRepo as never, + {} as never, // pageRepo + {} as never, // pageAccess ); return { service, aiChatRepo, aiAgentRoleRepo }; } diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index e9a8590a..2f50bbcb 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; import { FastifyReply } from 'fastify'; import { streamText, @@ -14,6 +14,8 @@ import { describeProviderError } from '../../integrations/ai/ai-error.util'; import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo'; import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo'; import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PageAccessService } from '../page/page-access/page-access.service'; import { User, Workspace, @@ -126,6 +128,8 @@ export class AiChatService { private readonly tools: AiChatToolsService, private readonly mcpClients: McpClientsService, private readonly aiAgentRoleRepo: AiAgentRoleRepo, + private readonly pageRepo: PageRepo, + private readonly pageAccess: PageAccessService, ) {} /** @@ -195,12 +199,44 @@ export class AiChatService { } } if (!chatId) { + // Resolve the origin document for the history list. body.openPage.id is + // attacker-controllable, so validate it before persisting: it must be a + // real page in THIS workspace that the user is allowed to read. Anything + // else (foreign workspace, inaccessible/restricted, or non-existent) is + // dropped to null — persisting it would leak the page's title via the + // chat-list join, or violate the page_id FK on insert (this runs after + // res.hijack(), so a DB error would break the stream). + let originPageId: string | null = null; + const candidatePageId = body.openPage?.id; + if (candidatePageId) { + const page = await this.pageRepo.findById(candidatePageId); + if (page && page.workspaceId === workspace.id) { + try { + await this.pageAccess.validateCanView(page, user); + originPageId = page.id; + } catch (e) { + // Fail-closed: no provenance on any failure. A ForbiddenException is + // the expected "user cannot read this page" case; log anything else + // (e.g. a DB error) so a real fault is not masked as "no access". + if (!(e instanceof ForbiddenException)) { + this.logger.warn( + `origin page access check failed: ${ + e instanceof Error ? e.message : 'unknown error' + }`, + ); + } + originPageId = null; + } + } + } const chat = await this.aiChatRepo.insert({ creatorId: user.id, workspaceId: workspace.id, // Bind the chat to the resolved role (if any) at creation time. The role // is immutable afterwards (later turns read it from this column). roleId: role?.id ?? null, + // Validated above: a real, readable page in this workspace, else null. + pageId: originPageId, }); chatId = chat.id; isNewChat = true; diff --git a/apps/server/src/database/migrations/20260622T120000-ai-chat-page-origin.ts b/apps/server/src/database/migrations/20260622T120000-ai-chat-page-origin.ts new file mode 100644 index 00000000..db10e7ee --- /dev/null +++ b/apps/server/src/database/migrations/20260622T120000-ai-chat-page-origin.ts @@ -0,0 +1,18 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + // The document a chat was created in (the user's open page at first message). + // Informational provenance shown in the chat-history list. NULL => the chat + // was started outside any document. ON DELETE SET NULL: a hard-deleted page + // degrades the chat to "no document" instead of breaking it. + await db.schema + .alterTable('ai_chats') + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('set null'), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('ai_chats').dropColumn('page_id').execute(); +} diff --git a/apps/server/src/database/repos/ai-chat/ai-chat.repo.ts b/apps/server/src/database/repos/ai-chat/ai-chat.repo.ts index 7a783b03..143c0d19 100644 --- a/apps/server/src/database/repos/ai-chat/ai-chat.repo.ts +++ b/apps/server/src/database/repos/ai-chat/ai-chat.repo.ts @@ -43,10 +43,21 @@ export class AiChatRepo { .on('aiAgentRoles.deletedAt', 'is', null) .on('aiAgentRoles.enabled', '=', true), ) + // Left-join the origin page for its title (provenance shown in the list). + // Scoped to the chat's workspace as defense-in-depth so a page id can only + // ever surface a same-workspace title. No deletedAt filter: a soft-deleted + // page keeps showing its historical title; a hard-deleted page already + // nulls aiChats.pageId via the FK. + .leftJoin('pages', (join) => + join + .onRef('pages.id', '=', 'aiChats.pageId') + .onRef('pages.workspaceId', '=', 'aiChats.workspaceId'), + ) .selectAll('aiChats') .select([ 'aiAgentRoles.name as roleName', 'aiAgentRoles.emoji as roleEmoji', + 'pages.title as pageTitle', ]) .where('aiChats.creatorId', '=', creatorId) .where('aiChats.workspaceId', '=', workspaceId) diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index cafcee0c..26933b39 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -575,6 +575,9 @@ export interface AiChats { // chat to universal instead of breaking it. Resolved from this column on every // turn — NOT from the request body. roleId: string | null; + // The document the chat was created in (open page at first message). NULL => + // started outside any document. ON DELETE SET NULL on the page FK. + pageId: string | null; createdAt: Generated; updatedAt: Generated; deletedAt: Timestamp | null;