diff --git a/.env.example b/.env.example index b04078e3..a19fd2d7 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,16 @@ APP_URL=http://localhost:3000 PORT=3000 +# --- Security / reverse proxy --- +# The app runs with Fastify `trustProxy` ENABLED, so it derives the client IP +# (req.ip) from the `X-Forwarded-For` header. That header is client-forgeable. +# Deploy this app behind a trusted reverse proxy that SETS/OVERWRITES (not +# appends) `X-Forwarded-For` with the real client IP. Without such a proxy, any +# per-IP throttling — including the /mcp Basic brute-force limiter — can be +# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs. +# (The /mcp limiter keeps a global per-email key as an IP-independent backstop, +# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.) + # minimum of 32 characters. Generate one with: openssl rand -hex 32 APP_SECRET=REPLACE_WITH_LONG_SECRET diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 5959983e..2d81467c 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -18,7 +18,12 @@ env: IMAGE: ghcr.io/vvzvlad/gitmost jobs: + # Run the reusable test suite first so a failing test blocks the image build. + test: + uses: ./.github/workflows/test.yml + build: + needs: test runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7137d953..694df01b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,12 @@ env: IMAGE: ghcr.io/vvzvlad/gitmost jobs: + # Run the reusable test suite first so a failing test blocks the image build. + test: + uses: ./.github/workflows/test.yml + build: + needs: test strategy: matrix: include: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..955b0ac2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test + +on: + pull_request: + workflow_call: + workflow_dispatch: + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Required for the client suite, which resolves @docmost/editor-ext via its + # dist build (the server suite also rebuilds it through its own pretest). + - name: Build editor-ext + run: pnpm --filter @docmost/editor-ext build + + - name: Run tests + run: pnpm -r test diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 25ff2530..414e75b8 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -391,6 +391,13 @@ "Toggle block": "Сворачиваемый блок", "Callout": "Выноска", "Insert callout notice.": "Вставить выноску с сообщением.", + "Footnote": "Сноска", + "Insert a footnote reference.": "Вставить ссылку на сноску.", + "Footnotes": "Примечания", + "Footnote {{number}}": "Сноска {{number}}", + "Go to footnote": "Перейти к сноске", + "Back to reference": "Вернуться к ссылке", + "Empty footnote": "Пустая сноска", "Math inline": "Строчная формула", "Insert inline math equation.": "Вставить математическое выражение в строку.", "Math block": "Блок формулы", diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css index 71de2066..5758a018 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.module.css +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.module.css @@ -57,6 +57,12 @@ display: none; } +/* In the collapsed state the header expands the window on click, so hint that + it is clickable (override the drag `grab` cursor). */ +.minimized .dragBar { + cursor: pointer; +} + .dragBar { display: flex; align-items: center; diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 1b9012c5..1a150242 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -18,7 +18,7 @@ import { IconX, } from "@tabler/icons-react"; import { useAtom, useSetAtom } from "jotai"; -import { useParams } from "react-router-dom"; +import { useMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { @@ -38,6 +38,10 @@ import { import ConversationList from "@/features/ai-chat/components/conversation-list.tsx"; import ChatThread from "@/features/ai-chat/components/chat-thread.tsx"; import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts"; +import { + shouldCollapseOnOutsidePointer, + isHeaderClick, +} from "@/features/ai-chat/utils/collapse-helpers.ts"; import { useClipboard } from "@/hooks/use-clipboard"; import { notifications } from "@mantine/notifications"; import classes from "@/features/ai-chat/components/ai-chat-window.module.css"; @@ -110,6 +114,10 @@ export default function AiChatWindow() { // History section starts collapsed (matches the former panel's behavior). const [historyOpen, setHistoryOpen] = useState(false); const [minimized, setMinimized] = useState(false); + // Mirror of `minimized` for handlers wrapped in useCallback([]) (startDrag), + // which would otherwise close over a stale value. Kept in sync below. + const minimizedRef = useRef(minimized); + minimizedRef.current = minimized; const winRef = useRef(null); // Live window geometry (position + size); initialized lazily on first open so @@ -140,13 +148,16 @@ export default function AiChatWindow() { const { data: messageRows, isLoading: messagesLoading } = useAiChatMessagesQuery(activeChatId ?? undefined); - // The page the user is currently viewing, derived from the route (same - // source the breadcrumb uses). On a non-page route `pageSlug` is undefined, - // so the query is disabled and `openPage` is null. This is passed to the - // chat thread as context so the agent knows what "this page"/"the current - // page" refers to; the agent still reads/writes via its CASL-enforced page - // tools using the id. - const { pageSlug } = useParams(); + // The page the user is currently viewing. AiChatWindow lives in a pathless + // parent layout route, so useParams() can't see :pageSlug. Match the full + // pathname against the authenticated page route instead so "the current page" + // resolves regardless of where this component is mounted. On a non-page route + // the match is null, so `pageSlug` is undefined, the query is disabled and + // `openPage` is null. This is passed to the chat thread as context so the + // agent knows what "this page"/"the current page" refers to; the agent still + // reads/writes via its CASL-enforced page tools using the id. + const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug"); + const pageSlug = pageRouteMatch?.params?.pageSlug; const { data: openPageData } = usePageQuery({ pageId: extractPageSlugId(pageSlug), }); @@ -254,8 +265,31 @@ export default function AiChatWindow() { useLayoutEffect(() => { if (!windowOpen) return; setGeom((prev) => (prev ? clampGeom(prev) : computeInitialGeom())); + // Always show the window expanded on (re)open: a collapsed state from a + // previous open session must not stick. Runs before paint so the first + // frame is already expanded. The composer's autofocus is a focus INSIDE the + // window (not an outside mousedown), so it cannot self-collapse the window. + setMinimized(false); }, [windowOpen]); + // Auto-collapse the window into its header as soon as the user interacts with + // anything outside it (clicks the page/editor). Armed ONLY while the window is + // open and expanded, so it never fires repeatedly and never collapses on the + // open→reset transition. Capture phase so a page handler's stopPropagation in + // the bubble phase can't hide the event from us; the in-window/portal guards + // (shouldCollapseOnOutsidePointer) prevent false collapses from clicks inside + // the window or inside Mantine portals (kebab menu, delete-confirm modal). + useEffect(() => { + if (!windowOpen || minimized) return; + const onPointerDown = (e: MouseEvent): void => { + if (shouldCollapseOnOutsidePointer(e.target, winRef.current)) { + setMinimized(true); + } + }; + document.addEventListener("mousedown", onPointerDown, true); + return () => document.removeEventListener("mousedown", onPointerDown, true); + }, [windowOpen, minimized]); + // Persist the user's resize into state so it survives close/reopen. Skipped // while minimized so the collapsed (auto) height is never captured. The // equality guard avoids an update loop. @@ -303,10 +337,21 @@ export default function AiChatWindow() { el.style.top = `${nt}px`; }; - const up = (): void => { + const up = (ev: MouseEvent): void => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.style.userSelect = ""; + // Treat a near-zero-movement press as a click (not a drag). When the + // window is minimized, a header click expands it; nothing to persist + // because the position did not change. minimizedRef avoids the stale + // `minimized` captured by useCallback([]). + if ( + minimizedRef.current && + isHeaderClick(sx, sy, ev.clientX, ev.clientY) + ) { + setMinimized(false); + return; + } const el2 = winRef.current; // Persist the final position back into state (preserving the size) so // re-renders keep it. @@ -350,14 +395,40 @@ export default function AiChatWindow() { height: minimized ? undefined : geom.height, }} > - {/* drag bar / header */} + {/* drag bar / header. Mouse users expand a minimized window by clicking + anywhere on the bar (the click-vs-drag logic in startDrag, which + excludes the buttons). The keyboard/screen-reader Expand affordance + lives on the title element below — NOT on this container — so we never + nest the Minimize/Close