diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..565e85f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,27 @@ +name: Bug report +description: Report a reproducible Modex defect +title: "bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: For security issues, use the private process in SECURITY.md. + - type: input + id: version + attributes: + label: Version + placeholder: v1.2.3 or commit SHA + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction + description: Include minimal steps, expected behavior, and actual behavior. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment and logs + description: Remove tokens, cookies, credentials, and personal data. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7c77bbc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,42 @@ +name: Feature request +description: Suggest an improvement or new capability for Modex +title: "feat: " +labels: [enhancement] +body: + - type: markdown + attributes: + value: For security issues, use the private process in SECURITY.md. + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what gets in the way today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the behavior or API you would like. Sketches and examples help. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Workarounds you have tried or other approaches you weighed. + - type: dropdown + id: area + attributes: + label: Area + options: + - Backend / API + - Frontend / portal + - Admin console + - Search / embeddings + - Deploy / docsctl + - MCP / skills + - Docs / ops + - Other + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..10f1e02 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## Summary + +## Verification + +- [ ] Go tests/vet pass for affected modules +- [ ] Frontend type check, i18n check, and build pass +- [ ] User-facing changes include focused tests +- [ ] Changelog/docs updated when needed +- [ ] No secrets or local configuration are included + +## Compatibility and rollout + +Describe configuration, schema, deployment, or rollback impact. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ef3ff46 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: /backend + schedule: + interval: weekly + - package-ecosystem: gomod + directory: /mcp + schedule: + interval: weekly + - package-ecosystem: gomod + directory: /tools/docsctl + schedule: + interval: weekly + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: docker + directory: /backend + schedule: + interval: weekly + - package-ecosystem: docker + directory: /frontend + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..189a66d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + go: + name: Go Tests + runs-on: ubuntu-latest + strategy: + matrix: + module: + - backend + - tools/docsctl + - mcp + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + cache: true + + - name: Test ${{ matrix.module }} + working-directory: ${{ matrix.module }} + run: go test -race -cover ./... + + lint: + name: Go Lint + runs-on: ubuntu-latest + strategy: + matrix: + module: + - backend + - tools/docsctl + - mcp + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + cache: true + + - name: golangci-lint ${{ matrix.module }} + uses: golangci/golangci-lint-action@v9 + with: + version: v2.7.2 + working-directory: ${{ matrix.module }} + + frontend: + name: Frontend + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Type check + working-directory: frontend + run: npm run lint + + - name: Check translation catalogs + working-directory: frontend + run: npm run i18n:check + + - name: Build + working-directory: frontend + run: npm run build + + - name: Install Playwright browser + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: E2E smoke tests + working-directory: frontend + run: npm run e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 574ebf3..5faac95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,8 @@ permissions: contents: write jobs: - build-go: - name: Build Go Binaries + build-docsctl: + name: Build docsctl Binaries runs-on: ubuntu-latest strategy: matrix: @@ -22,31 +22,17 @@ jobs: goarch: arm64 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version: '1.23' cache: true - name: Prepare dist directory run: mkdir -p dist - # 编译 backend (modex-api) - - name: Build modex-api - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: 0 - run: | - BINARY_NAME="modex-api-${{ matrix.goos }}-${{ matrix.goarch }}" - if [ "${{ matrix.goos }}" = "windows" ]; then - BINARY_NAME="${BINARY_NAME}.exe" - fi - cd backend - go build -ldflags="-s -w" -o "../dist/${BINARY_NAME}" ./cmd/modex-api - # 编译 tools/docsctl(被文档仓库 CI 通过 MODEX_DOCSCTL_URL 下载使用) - name: Build docsctl env: @@ -61,24 +47,10 @@ jobs: cd tools/docsctl go build -ldflags="-s -w -X main.docsctlVersion=${{ github.ref_name }}" -o "../../dist/${BINARY_NAME}" ./cmd/docsctl - # 编译 mcp (docs-mcp-server) - - name: Build docs-mcp-server - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: 0 - run: | - BINARY_NAME="docs-mcp-server-${{ matrix.goos }}-${{ matrix.goarch }}" - if [ "${{ matrix.goos }}" = "windows" ]; then - BINARY_NAME="${BINARY_NAME}.exe" - fi - cd mcp - go build -ldflags="-s -w" -o "../dist/${BINARY_NAME}" ./cmd/docs-mcp-server - - - name: Upload Go Binaries + - name: Upload docsctl Binaries uses: actions/upload-artifact@v4 with: - name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} + name: docsctl-${{ matrix.goos }}-${{ matrix.goarch }} path: dist/* build-frontend: @@ -86,12 +58,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: frontend/package-lock.json @@ -105,45 +77,123 @@ jobs: cd frontend npm run build - - name: Archive Frontend Output - run: | - cd frontend - zip -r ../modex-frontend.zip .next public package.json package-lock.json -x "node_modules/*" ".git/*" + - name: Package MCP npm distribution + run: npm pack ./mcp/npx --pack-destination . - - name: Upload Frontend Artifact + - name: Upload MCP npm package uses: actions/upload-artifact@v4 with: - name: frontend-zip - path: modex-frontend.zip + name: mcp-npm-package + path: modex-mcp-*.tgz + + publish-images: + name: Publish ${{ matrix.name }} image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - name: api + context: backend + file: backend/Dockerfile + - name: frontend + context: frontend + file: frontend/Dockerfile + - name: mcp + context: mcp + file: mcp/Dockerfile + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }}/${{ matrix.name }} + tags: | + type=ref,event=tag + type=ref,event=branch + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + - uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.file }} + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: mode=max + sbom: true create-release: name: Create GitHub Release - needs: [build-go, build-frontend] + needs: [build-docsctl, build-frontend, publish-images] runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Download all artifacts + - name: Download docsctl artifacts uses: actions/download-artifact@v4 with: + pattern: docsctl-* path: release-artifacts + - name: Download MCP npm package + uses: actions/download-artifact@v4 + with: + name: mcp-npm-package + path: release-artifacts/mcp-npm-package + - name: Organize release files run: | mkdir -p final-release - # 提取所有的 Go 编译产物 - find release-artifacts -type f -name "modex-api-*" -exec cp {} final-release/ \; + # 提取 docsctl 二进制(前后端和 MCP 以 GHCR 镜像发布) find release-artifacts -type f -name "docsctl-*" -exec cp {} final-release/ \; - find release-artifacts -type f -name "docs-mcp-server-*" -exec cp {} final-release/ \; - # 提取前端打包产物 - find release-artifacts -type f -name "modex-frontend.zip" -exec cp {} final-release/ \; + # 提取 MCP npm 分发包,供仍需本地 stdio MCP 的客户端使用 + find release-artifacts -type f -name "modex-mcp-*.tgz" -exec cp {} final-release/ \; ls -l final-release + - name: Install Syft + uses: anchore/sbom-action/download-syft@v0 + + - name: Generate SPDX SBOM and checksums + run: | + syft dir:final-release -o spdx-json=/tmp/modex-release.spdx.json + mv /tmp/modex-release.spdx.json final-release/modex-release.spdx.json + cd final-release + sha256sum * > SHA256SUMS + + - name: Install Cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign release artifacts with Sigstore + working-directory: final-release + run: | + for file in *; do + cosign sign-blob --yes --bundle "${file}.sigstore.json" "${file}" + done + + - name: Attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: final-release/* + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: final-release/* generate_release_notes: true + tag_name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('manual-{0}', github.run_number) }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 4ba35af..c913d25 100644 --- a/.gitignore +++ b/.gitignore @@ -26,11 +26,17 @@ deploy/.env # docsctl build output **/.modex/ +/tools/docsctl/docsctl + +# Serena tool metadata +.serena/ # Node / Next.js node_modules/ frontend/.next/ frontend/out/ +frontend/playwright-report/ +frontend/test-results/ frontend/next-env.d.ts *.tsbuildinfo npm-debug.log* @@ -43,3 +49,4 @@ yarn-error.log* # Local app config (may contain secrets); copy from config.example.yaml deploy/config.yaml config.yaml +/dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..97b6fc1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,46 @@ +# Shared lint config for the backend, tools/docsctl, and mcp Go modules. +# Conservative on purpose: the default vet-grade linters plus a few high-signal +# ones, so CI stays green without churn while still catching real mistakes. +version: "2" + +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - unconvert + settings: + errcheck: + exclude-functions: + # Deferred rollback after a successful commit is a no-op; ignoring its + # error is the standard pgx idiom. + - (github.com/jackc/pgx/v5.Tx).Rollback + staticcheck: + checks: + - all + # Keep the v1 lint baseline; these are optional style rewrites. + - -QF1001 + - -QF1002 + - -QF1003 + - -ST1000 + - -ST1020 + exclusions: + rules: + # Test files routinely ignore errors on best-effort setup calls. + - path: _test\.go + linters: + - errcheck + # Closing an already-consumed response/object is best-effort. + - text: Error return value of `.*\.Close` is not checked + linters: + - errcheck + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9179f83 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and uses semantic version tags. + +## Unreleased + +### Security + +- Added verified OIDC ID tokens, nonce validation, PKCE, persistent Redis + sessions, request limits, and hardened HTTP server timeouts. + +### Changed + +- Upgraded the project toolchain to Go 1.23. +- Split the API and in-memory store into responsibility-focused files. +- Completed English catalog coverage for existing frontend UI copy. +- Added checksums, SBOMs, signatures, provenance, and container publication to + the release workflow. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c9abee5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,12 @@ +# Code of Conduct + +Modex follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +We are committed to a welcoming, harassment-free community. Be respectful, +assume good intent, accept constructive feedback, and focus disagreement on the +work. Harassment, threats, discriminatory language, deliberate disruption, and +publication of private information are not acceptable. + +Report conduct concerns privately through the repository owner's GitHub contact +channel. Maintainers may edit or remove contributions and temporarily or +permanently restrict participation when necessary to protect the community. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..37f542d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing to Modex + +Thank you for improving Modex. By participating, you agree to follow the +[Code of Conduct](CODE_OF_CONDUCT.md). + +## Development setup + +Requirements: Go 1.23, Node.js 20+, Docker with Compose, and Chromium for the +Playwright suite. + +```bash +cp deploy/.env.example deploy/.env +docker compose -f deploy/docker-compose.yml up --build +``` + +Run the fast checks before opening a pull request: + +```bash +cd backend && go test ./... && go vet ./... +cd ../mcp && go test ./... && go vet ./... +cd ../tools/docsctl && go test ./... && go vet ./... +cd ../../frontend && npm ci && npm run i18n:check && npm run lint && npm run build +``` + +Use `npm run e2e` for user-facing changes. Add focused tests for behavior you +change. PostgreSQL integration tests require `TEST_DATABASE_URL`. + +## Pull requests + +- Keep changes scoped and explain user-visible behavior and migration impact. +- Do not commit credentials, generated `.env` files, or local configuration. +- Add Chinese source messages and English translations together. Run + `npm run i18n:extract` after changing UI copy. +- Update `CHANGELOG.md` for notable behavior, security, or compatibility changes. +- Use Conventional Commit style where practical, for example `fix:`, `feat:`, + `docs:`, or `refactor:`. + +Report security issues privately as described in [SECURITY.md](SECURITY.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 084d103..caa970d 100644 --- a/README.md +++ b/README.md @@ -1,414 +1,215 @@ # Modex -Modex is an internal Module Documentation Experience platform MVP. - -## Structure +[![GitHub stars](https://img.shields.io/github/stars/songkwon/modex?style=social)](https://github.com/songkwon/modex/stargazers) +[![CI](https://github.com/songkwon/modex/actions/workflows/ci.yml/badge.svg)](https://github.com/songkwon/modex/actions/workflows/ci.yml) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) +[![Go](https://img.shields.io/badge/Go-1.23-00ADD8?logo=go&logoColor=white)](backend/go.mod) +[![Next.js](https://img.shields.io/badge/Next.js-16-black?logo=nextdotjs)](frontend/package.json) +[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=111)](frontend/package.json) +[![Playwright](https://img.shields.io/badge/E2E-Playwright-45ba4b?logo=playwright)](frontend/playwright.config.ts) +[![MCP](https://img.shields.io/badge/MCP-streamable_HTTP-6f42c1)](mcp/) +[![i18n](https://img.shields.io/badge/i18n-ready-2f80ed)](docs/i18n-weblate.md) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/songkwon/modex) + +**Language:** English | [中文](README.zh-CN.md) + +Modex is a documentation experience platform for teams, enterprises, and open-source communities. It brings engineering documentation from many repositories, frameworks, and versions into one governed portal, with publishing, search, reading analytics, permissions, and MCP access for AI coding tools. + +Use Modex to build an internal engineering docs hub, module knowledge base, architecture/API handbook portal, AI-searchable documentation platform, or CI-driven documentation publishing system. + +## Highlights + +- **Unified documentation portal**: a Next.js frontend with home, categories, docs reader, personal workspace, and admin console. +- **Multi-source publishing**: `docsctl` supports `validate`, `build`, `package`, and `deploy` for Markdown, VuePress, VitePress, Fumadocs, and static sites. +- **CI-driven sync**: documentation repositories build in their own CI and push standard artifacts to Modex for archiving, indexing, and rendering. +- **Search and AI answers**: keyword, semantic, and hybrid search with configurable chat, embedding, and rerank providers. +- **MCP access**: a streamable HTTP MCP server for hosted deployments, plus an `npx` stdio wrapper for clients that only support local MCP commands. +- **Skill package**: a Modex Skill is shipped in the repository and can be installed separately by clients that support skills. +- **Permissions and teams**: mock login for local development, OIDC/Keycloak for production, users, teams, category ownership, super admins, and scoped platform management. +- **Deploy diagnostics**: `/api/deploy` returns staged deploy results for artifact parsing, authentication, asset upload, embedding cleanup, and metadata ingest. +- **Operational health**: `/healthz` exposes a lightweight snapshot of repository, object storage, search/vector state, embedding count, and registry counts. +- **Internationalization-ready**: frontend copy uses JSON message catalogs, consistency checks, and Weblate setup notes. +- **Tested delivery**: Go tests, frontend type checks, production build, and Playwright E2E smoke tests are wired into CI. + +## Repository Layout + +```text +modex/ + backend/ Go REST API, auth, analytics, deploy ingest, search, persistence + frontend/ Next.js portal, admin console, reader, i18n, Playwright tests + tools/docsctl/ Documentation CLI for validate/build/package/deploy + mcp/ streamable HTTP MCP server, npx stdio wrapper, client skill + deploy/ Docker Compose, PostgreSQL/pgvector migration, env templates + docs/ Operator docs, examples, CI templates, i18n/testing guides +``` -- `backend/`: Go REST API with mock registry data, search, embedding provider abstraction, analytics placeholders. -- `frontend/`: Next.js portal with home, module cards, info drawer, search, docs reading pages, and admin placeholders. -- `tools/docsctl/`: Go CLI for `validate`, `build`, `package`, and `deploy`. -- `mcp/`: stdio MCP server that calls the backend API. -- `deploy/`: Docker Compose and PostgreSQL migration. -- `docs/`: product and pipeline docs plus sample Markdown source. +## Quick Start -## Start Locally +Start the full local stack with Docker Compose: ```bash cd deploy cp .env.example .env -docker-compose up --build +docker compose up --build ``` -Open: +Default endpoints: -- Frontend: +- Frontend: - Backend health: -- MinIO console: -- Meilisearch: +- MinIO Console: +- PostgreSQL: `localhost:5432` +- Redis: `localhost:6379` -## Run Without Docker +The MCP server is optional in local Compose. Enable it with the `mcp` profile: ```bash -cd backend -go run ./cmd/modex-api -``` - -```bash -cd frontend -npm install -npm run dev +cd deploy +docker compose --profile mcp up --build ``` -## Keycloak / OAuth2 Login +Then point streamable HTTP MCP clients at: -Local development defaults to `AUTH_MODE=mock`. For the company Keycloak deployment, create a Keycloak client for Modex and set these values in `deploy/.env` or the production environment. `AUTH_MODE=keycloak` is accepted as an alias of `oidc`. - -```env -AUTH_MODE=oidc -APP_BASE_URL=https://modex-api.example.com -FRONTEND_BASE_URL=https://modex.example.com -NEXT_PUBLIC_API_BASE_URL=https://modex-api.example.com -CORS_ALLOW_ORIGINS=https://modex.example.com - -KEYCLOAK_BASE_URL=https://keycloak.example.com -KEYCLOAK_REALM=your-realm -OIDC_CLIENT_ID=modex -OIDC_CLIENT_SECRET=replace-with-keycloak-secret -OIDC_REDIRECT_URL=https://modex-api.example.com/api/auth/callback -OIDC_SCOPES=openid profile email - -COOKIE_DOMAIN=.example.com -COOKIE_SAME_SITE=lax -COOKIE_SECURE=true +```text +http://localhost:8787/mcp ``` -Keycloak client settings: - -- Valid redirect URI: `https://modex-api.example.com/api/auth/callback` -- Web origin: `https://modex.example.com` -- Access type: confidential, if using `OIDC_CLIENT_SECRET` - -If your Keycloak endpoints are non-standard, override `OIDC_ISSUER_URL` or the explicit endpoint variables: - -- `OIDC_AUTH_URL` -- `OIDC_TOKEN_URL` -- `OIDC_USERINFO_URL` -- `OIDC_END_SESSION_URL` - -### Configuration philosophy: Environment variables vs config file - -We follow a pragmatic split: - -- **Environment variables** — for infrastructure, secrets, and deployment-specific wiring: - - `AUTH_MODE`, `KEYCLOAK_*`, all `OIDC_*` endpoint / client / redirect settings - - `COOKIE_*`, `SUPER_ADMIN_USERS` - - Database, MinIO, Meilisearch, embedding provider URLs and keys - - `PORT`, `DATA_DIR`, CORS origins, etc. - -- **Application config file (YAML)** — for higher-level, semantic configuration that describes *how the app should interpret data from external systems*. These are good to keep in a version-controlled file (with comments) so changes are reviewable. +To run backend and frontend separately, make sure PostgreSQL, Redis, and MinIO are available, then start: - Currently the main candidate is **OIDC user attribute mapping**. - -#### OIDC user attribute mapping - -Different Keycloak realms and mappers expose user profile data under different claim names. - -You can configure the mapping in two ways (they combine with clear precedence): - -1. **Recommended for teams**: Put it in a config file (`config.yaml` or similar). -2. **Quick override / CI / one-off**: Use the `OIDC_CLAIM_*` environment variables. - -**Precedence (lowest to highest):** -1. Hardcoded defaults in the code (`email`, `picture`, `name`, `department`) -2. Values from the YAML config file -3. Explicit `OIDC_CLAIM_*` environment variables (these always win) - -##### Using a config file - -Set the `CONFIG_FILE` environment variable, or place the file in one of the conventional locations: - -- `config.yaml` / `config.yml` (next to the working directory) -- `configs/config.yaml` -- `/etc/modex/config.yaml` - -Example (`deploy/config.example.yaml`): - -```yaml -auth: - user_mapping: - unique_id_claim: email # company convention: email is the stable user key - avatar_claim: picture # or wxPhotoURL, avatar, etc. - display_name_claim: name - secondary_info_claim: department -``` - -In docker / k8s you typically mount the file: - -```yaml -volumes: - - ./config.yaml:/app/config.yaml:ro -environment: - CONFIG_FILE: /app/config.yaml +```bash +cd backend +go run ./cmd/modex-api ``` -##### Environment variable overrides (still supported) - -You can continue to (or temporarily) use only environment variables: - -```env -OIDC_CLAIM_UNIQUE_ID=email -OIDC_CLAIM_AVATAR=wxPhotoURL -OIDC_CLAIM_DISPLAY_NAME=name -OIDC_CLAIM_SECONDARY_INFO=department +```bash +cd frontend +npm install +npm run dev ``` -These take priority over anything in the config file. - -The backend merges claims from **both the ID token and the userinfo endpoint** (ID token is especially useful for custom protocol mappers). - -When `unique_id_claim` is set to `email`, the email value becomes the internal `User.ID`. Only use email as the unique key if emails are guaranteed to be stable in your organization. - -The backend exposes: - -- `GET /api/auth/login`: redirects to Keycloak. -- `GET /api/auth/callback`: exchanges the OAuth2 code, reads userinfo, creates the Modex session cookie, and syncs the user + groups into the directory. On failure it redirects to the portal with `?login_error=...` and logs the detail server-side. -- `GET /api/auth/me`: returns the current session user (401 when not logged in). -- `POST /api/auth/logout`: clears the Modex session cookie. -- `GET /api/config`: returns the frontend-facing auth mode and login URL (empty when OIDC is not fully configured). - -### Login model - -Login is a real, cookie-backed action in **both** modes — the backend no longer -silently impersonates a seeded user. Anonymous visitors can still browse the -portal; `GET /api/auth/me` simply returns 401 until they log in. - -- `AUTH_MODE=oidc` (or `keycloak`): the portal "登录" button sends the browser to - `/api/auth/login` → Keycloak → `/api/auth/callback`, which sets the session cookie. -- `AUTH_MODE=mock` (local dev): `POST /api/auth/mock-login` creates a real session - cookie. Pass `{"username":"alice"}` to log in as a specific seeded user. - -### Why Keycloak login can fail - -If `AUTH_MODE=oidc` is set but login does not work, check, in order: - -1. **`GET /api/config` shows `oidc_login_enabled: false`** → the issuer/endpoints or - `OIDC_CLIENT_ID` are missing. Set `KEYCLOAK_BASE_URL` + `KEYCLOAK_REALM` (or - `OIDC_ISSUER_URL`) and `OIDC_CLIENT_ID`. -2. **Redirect URI mismatch** → `OIDC_REDIRECT_URL` must exactly equal the value - registered in the Keycloak client (`{APP_BASE_URL}/api/auth/callback`). -3. **`login_url` points at the wrong host** → set `APP_BASE_URL` to the URL the - browser uses to reach the backend (e.g. `http://localhost:8671` locally). -4. **CORS / cookies** → `CORS_ALLOW_ORIGINS` must include the frontend origin; for - cross-subdomain prod set `COOKIE_DOMAIN=.example.com`, `COOKIE_SECURE=true`. -5. Otherwise read the backend log — callback errors (including provider - `error_description`) are logged and echoed to the portal via `?login_error=`. - -## User, Group & Permission Management - -The directory is managed from `/admin/users` (super-admin only): - -- `GET /api/admin/users` (optional `?keyword=`), `POST /api/admin/users` -- `GET|PUT|DELETE /api/admin/users/{id}` -- `GET /api/admin/groups`, `POST /api/admin/groups` - -OIDC logins upsert the user into this directory and refresh their groups and -last-login timestamp. Groups referenced by a user are auto-registered. - -### Roles & platform permissions - -- **Super admin**: configured via `SUPER_ADMIN_USERS` (comma-separated - usernames/emails). Matched on login (mock or OIDC), granted the `admin` role, - and may manage every platform plus users/permissions. `GET /api/auth/me` - reports `is_super_admin`. -- **Platform admin**: a user with the `admin` role and `managed_categories` - set to the platform (category) IDs they govern. A managed platform covers its - sub-platforms (e.g. `engineering` covers `engineering.cbb`). - -Platform-scoped writes are enforced server-side: - -- Create/update/delete categories, modules, versions, entries require manage - rights on the relevant platform (super admins bypass). -- Top-level platform creation and all user/group/permission management are - super-admin only. -- Search/embedding reindex requires an admin session. -- `POST /api/admin/modules/{key}/migrate` reassigns a module to other - platform(s) and requires manage rights on **both** source and target - (super admins bypass). +## Publish Documentation -## AI Search - -The home page has a centered search (ChatGPT-style). As you type it shows live -results with an entry-type icon, platform breadcrumb, title, a context snippet, -and highlighted matched keywords. The sparkle **询问 AI** action calls -`POST /api/ask`, which retrieves the top documents and either forwards them to an -external LLM (`ASK_HTTP_URL`) or returns an extractive answer with cited sources. - -## Deployment Configuration - -`deploy/.env.example` keeps deploy-time values out of code. The important groups are: - -- Public domains and ports: `APP_BASE_URL`, `FRONTEND_BASE_URL`, `BACKEND_PORT`, `FRONTEND_PORT`, `CORS_ALLOW_ORIGINS` -- Frontend API routing: `NEXT_PUBLIC_API_BASE_URL` is used by browser-side requests; `INTERNAL_API_BASE_URL` is used by Next.js server rendering inside Docker. In Compose, keep it as `http://backend:8671` unless you change `BACKEND_PORT`. -- PostgreSQL: `DATABASE_URL`, `POSTGRES_*` -- MinIO: `MINIO_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `MINIO_*` -- Meilisearch: `MEILISEARCH_URL`, `MEILISEARCH_PUBLIC_URL`, `MEILI_*` -- Embedding: `EMBEDDING_PROVIDER`, `EMBEDDING_HTTP_URL`, `EMBEDDING_HTTP_API_KEY`, `EMBEDDING_DIM` -- MCP: `MCP_ENABLED`, `MCP_TOKEN` - -## docsctl Examples +Build and package documentation: ```bash cd tools/docsctl -DOCS_SOURCE_DIR=/path/to/vuepress-project go run ./cmd/docsctl init -DOCS_SOURCE_DIR=/path/to/wiki-root go run ./cmd/docsctl discover -DOCS_SOURCE_DIR=/path/to/wiki-root DOCS_DISCOVER_WRITE=true go run ./cmd/docsctl discover -go run ./cmd/docsctl validate -DOCS_SOURCE_DIR=../../docs/examples/markdown go run ./cmd/docsctl build -DOCS_SOURCE_DIR=../../docs/examples/markdown go run ./cmd/docsctl package -DOCS_DEPLOY_URL=http://localhost:8671/api/deploy DOCS_ARTIFACT=../../docs/examples/markdown/.modex/docs-artifact.zip go run ./cmd/docsctl deploy +DOCS_SOURCE_DIR=/path/to/docs go run ./cmd/docsctl validate +DOCS_SOURCE_DIR=/path/to/docs go run ./cmd/docsctl build +DOCS_SOURCE_DIR=/path/to/docs go run ./cmd/docsctl package ``` -Additional examples: +Deploy it to Modex: ```bash -DOCS_SOURCE_DIR=../../docs/examples/vuepress go run ./cmd/docsctl package -DOCS_SOURCE_DIR=../../docs/examples/fumadocs go run ./cmd/docsctl package +DOCS_DEPLOY_URL=http://localhost:8671/api/deploy \ +DOCS_DEPLOY_TOKEN=your-token \ +DOCS_ARTIFACT=/path/to/docs/.modex/docs-artifact.zip \ +go run ./cmd/docsctl deploy ``` -The artifact is written to `docs/examples/markdown/.modex/docs-artifact.zip` and includes: +Generate the Deploy Token from the Modex admin console. In production CI, store it in GitLab/GitHub secret variables and never commit it. -- `site/` -- `manifest.json` -- `metadata.json` -- `nav.json` -- `documents.jsonl` -- `llms.txt` -- optional `llms-full.txt` -- `assets/` +For GitLab CI, include [deploy/ci/modex-docs.gitlab-ci.yml](deploy/ci/modex-docs.gitlab-ci.yml). The in-app usage guide shows the deployment URL, `docsctl` download URL, and Runner tag for the current Modex instance. -The VuePress and Fumadocs examples use small local build scripts so the packaging path can be tested without installing full framework dependencies. Real projects should replace those scripts with commands such as `pnpm docs:build`, `npm run build`, or the team-standard build command. +## AI Tool Access -For existing RD/VuePress sites, see `docs/vuepress-migration.md`. +For hosted deployments, run the MCP image and expose the streamable HTTP endpoint: -`docsctl discover` recursively scans existing documentation roots, detects -VuePress, Fumadocs, static HTML, and Markdown projects, and can create -`docs.yaml` in place when `DOCS_DISCOVER_WRITE=true` is set. Use -`DOCS_DISCOVER_DEPTH` to control traversal depth. - -## MCP - -Modex ships an MCP server so AI clients (Claude Code, Cursor, …) can search and -read your docs. Three ways to run it: +```text +https://modex.example.com/mcp +``` -### 1. npx (recommended for developers) +The MCP server proxies tool calls to the Modex backend. Set `MODEX_API_BASE_URL` to the backend URL and pass a user MCP token with `MODEX_MCP_TOKEN` when the deployment needs authenticated access. -The `mcp/npx` package (`modex-docs-mcp`) is a zero-dependency stdio server. Add it -to your client pointed at a Modex deployment: +For clients that only support local stdio MCP servers, install the package served by a deployed Modex backend: ```bash -claude mcp add modex-docs \ +claude mcp add modex \ --env MODEX_API_BASE_URL=https://modex.example.com \ --env MODEX_MCP_TOKEN=your-token \ - -- npx -y modex-docs-mcp + -- npx -y https://modex.example.com/api/mcp/dist/modex-mcp.tgz ``` -See [mcp/npx/README.md](mcp/npx/README.md) for Cursor/Windsurf config. - -### 2. Docker (builds with the stack) - -The Go MCP server builds alongside the compose stack and runs on demand (stdio, -not a served port): +For clients that support skills: ```bash -cd deploy && docker compose --profile mcp run --rm mcp +npx skills add https://modex.example.com ``` -### 3. From source +The repository-hosted skill can also be installed from [mcp/skill](mcp/skill): ```bash -cd mcp -DOCS_API_BASE_URL=http://localhost:8671 MCP_TOKEN=dev-token go run ./cmd/docs-mcp-server +npx skills add https://github.com/songkwon/modex/tree/main/mcp/skill ``` -Send JSON-RPC lines on stdin: +See [mcp/npx/README.md](mcp/npx/README.md) for more client examples. -```json -{"jsonrpc":"2.0","id":1,"method":"tools/list"} -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"构建缓存怎么清理","mode":"hybrid","limit":5}}} -``` - -## Analytics & Admin APIs - -Reading statistics are tracked in the backend and surfaced in the admin portal: - -- `POST /api/analytics/page-view`: records a page view (`doc_id`, `session_id`). -- `POST /api/analytics/read-progress`: updates dwell time and scroll depth. -- `GET /api/admin/analytics/pages`: aggregated PV / UV / 7-day / 30-day reads per page. - -The docs reading page records views automatically via the `PageViewTracker` -client component, and `frontend/lib/analytics.ts` holds the PostHog init and -`capture()` event helpers (enabled by setting `NEXT_PUBLIC_POSTHOG_KEY`). +## Release Artifacts -Admin registry mutations are implemented against the in-memory store: +Tagged releases publish: -- Categories: `POST /api/admin/categories`, `PUT|DELETE /api/admin/categories/{id}` -- Modules: `POST /api/admin/modules`, `PUT /api/admin/modules/{module_key}` -- Versions: `POST /api/admin/modules/{module_key}/versions`, `PUT .../versions/{docs_version}` -- Entries: `POST .../versions/{docs_version}/entries`, `PUT|DELETE /api/admin/entries/{entry_id}` -- Releases: `GET /api/admin/releases/{release_id}`, `POST /api/admin/releases/{release_id}/rollback` +- GHCR images for the API, frontend, and MCP server: + - `ghcr.io/songkwon/modex/api` + - `ghcr.io/songkwon/modex/frontend` + - `ghcr.io/songkwon/modex/mcp` +- `docsctl-*` binaries in GitHub Releases for Linux, macOS, and Windows. +- `modex-mcp-*.tgz` in GitHub Releases for local stdio MCP clients. +- Checksums, SBOM, Sigstore bundles, and build provenance for release files. -## Search Index Maintenance +`docsctl` is intentionally distributed as a binary for CI jobs. The API, frontend, and hosted MCP server are distributed as container images. -Semantic and hybrid search use embeddings produced by the configured provider -and cached per document. Rebuild the cache after publishing new content: +## Configuration and Deployment -- `POST /api/embeddings/reindex`: (re)embeds every page and returns the count. -- `POST /api/search/reindex`: rebuilds the index and reports document counts. +Common configuration files: -Both are also available from the **索引维护** panel on `/admin`. Embeddings are -otherwise computed lazily on first semantic/hybrid query and cached; keyword-only -search never calls the embedding provider. Publishing a new artifact invalidates -the cached vectors for that module/version automatically. +- [deploy/.env.example](deploy/.env.example): the unified environment template. Copy it for local development, and replace all secrets and public URLs for production. +- [deploy/config.example.yaml](deploy/config.example.yaml): application-level config such as OIDC claim mapping. +- [deploy/docker-compose.yml](deploy/docker-compose.yml): local and single-node deployment stack. -## Durable Store Snapshots +Production recommendations: -Set `DATA_DIR` to make the registry survive restarts. On boot the backend loads -`${DATA_DIR}/modex-store.json` (or seeds fresh data when absent), saves -periodically (`DATA_SAVE_INTERVAL_SECONDS`, default 60), and writes a final -snapshot on graceful shutdown (SIGINT/SIGTERM). Writes are atomic (temp file + -rename). In Docker Compose this is a `backend-data` named volume mounted at -`/data`, so `docker compose restart` keeps modules, users, analytics, and search -indexes. Leave `DATA_DIR` empty for a pure in-memory store. +- Use OIDC/Keycloak for login and configure the `KEYCLOAK_*` or `OIDC_*` environment variables. +- Set `COOKIE_SECURE=true`, a production `COOKIE_DOMAIN`, and exact CORS origins. +- Replace all PostgreSQL, MinIO, OIDC, PostHog, cookie, and deploy-token secrets. +- Configure real chat, embedding, and rerank providers, then run embedding reindex. +- Use an internal Kroki deployment if diagram source must stay on-prem. +- Treat `deploy/.env`, `deploy/config.yaml`, and `docker compose config` output as secret-bearing material. -## Branding +## Testing -The Modex mark lives at `frontend/app/icon.svg` (auto-served favicon), -`frontend/app/apple-icon.png`, and `frontend/public/logo.svg` (header). PNG icon -sizes and the web manifest are generated from the SVG; regenerate with -`rsvg-convert -w -h app/icon.svg -o .png`. - -## MVP Notes - -The full product loop is functional end-to-end: `docsctl deploy` uploads an -artifact to `POST /api/deploy`, which parses the zip and ingests modules, -versions, entries, pages, nav, built HTML, and site assets so they immediately -appear in the portal, search, and MCP. Mock + Keycloak/OIDC login, user/group -management, analytics, admin CRUD, real search/embedding reindexing, and durable -snapshot persistence are all implemented. +```bash +cd backend && go test ./... +cd tools/docsctl && go test ./... +cd mcp && go test ./... -The next infrastructure iteration replaces the snapshot store with managed -services: PostgreSQL (source of truth), MinIO (artifact/site bytes), Meilisearch -(keyword index), and pgvector (embeddings). Configuration, the `001_init.sql` -migration, and the provider seams are already in place for that work. +cd frontend +npm run lint +npm run build +npm run e2e +``` -## GitLab 集成(参考 Mintlify) +See [docs/testing.md](docs/testing.md). -我们采用**与 Mintlify 类似的 CI 驱动方式**实现 GitLab 对接: +## Internationalization / Weblate -- **推荐流程**(编译发生在源仓库): - 1. 在 modex 后台为 Module 配置 `repo_url`、`source_type: "gitlab"`、`gitlab_branch`、`gitlab_path`(仓库内 docs 子目录)。 - 2. 生成该 Module 的 **Deploy Token**(通过 admin API `PUT /api/admin/modules/{key}` 设置 `deploy_token`,或未来 UI)。 - 3. 在**文档仓库**(例如你的 rd-doc)的 `.gitlab-ci.yml` 中: - - 运行你的原生构建(`npm run build` for VitePress/VuePress 等)。 - - 使用 `docsctl package`(或 `build` + `package`)生成标准 artifact(包含预构建的 site/ HTML、nav、documents.jsonl 等)。 - - `docsctl deploy`(或直接 curl)把 zip POST 到 modex 的 `/api/deploy`,带 `X-Modex-Deploy-Token` 头。 - 4. 推送后自动同步。文档归属到该 Module 关联的“领域”(Category 树中的指定位置)。 +Frontend catalogs: -- **一个仓库映射到多个位置**(rd-doc 例子): - rd-doc 仓库有 `docs/standard/`(规范)、`docs/tools/version-control/` 等。 - 你可以用不同 CI job(或 matrix)分别设置 `DOCS_MODULE=rd-standard` + `DOCS_SOURCE_DIR=docs/standard`, - 部署为不同的 Module,然后在 modex 管理后台把它们分配到不同领域(标准规范、工具规范…)。 +- [frontend/messages/zh-CN.json](frontend/messages/zh-CN.json) +- [frontend/messages/en-US.json](frontend/messages/en-US.json) -- **为什么推荐“同步后编译”**: - - 编译使用仓库自己的环境(正确 Node 版本、依赖、主题、插件)。 - - modex 接收的是**预构建 artifact**(HTML + 静态资源 + 结构化内容 + nav),无需在 modex 里实现各种渲染器。 - - 纯 Markdown 仓库也可以用 `DOCS_BUILDER=markdown` 轻量打包(docsctl 支持)。 +`zh-CN` is the source language. Add new keys there first, mirror them in every locale file, and use `useI18n().t(...)` in components. Weblate setup notes are in [docs/i18n-weblate.md](docs/i18n-weblate.md). -- **文档内容与 MinIO**: - 是的。Artifact 中的 `site/`(构建后的 HTML、JS、CSS、图片等静态资源)在生产环境应持久化到 MinIO(当前 MVP 用内存 + snapshot 存储,便于开发和快照恢复;`StorageURI` 字段已预留 `minio://` 路径)。搜索元数据和文本内容进入 store / Meilisearch。 +## More Documentation -示例 pipeline 见 `docs/pipeline/docs-deploy.example.yml` 和 `tools/docsctl`。 +- [Project Guide (MDX)](frontend/content/modex-guide.mdx) +- [Testing Guide](docs/testing.md) +- [Internationalization and Weblate](docs/i18n-weblate.md) +- [VuePress Migration](docs/vuepress-migration.md) +- [GitLab CI Template](deploy/ci/modex-docs.gitlab-ci.yml) +- [Production upgrades and rollback](docs/operations.md) +- [Contributing](CONTRIBUTING.md) +- [Security policy](SECURITY.md) -部署鉴权已在 `/api/deploy` 实现(支持全局 `DOCS_DEPLOY_TOKEN` 或 per-module token)。 +## License -这使得 rd-doc 这样的外部仓库可以持续、结构化地同步到 modex 的指定领域,同时保持构建的完整性。 +Modex is released under the [GNU General Public License v3.0](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..db4e9a9 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,215 @@ +# Modex + +[![GitHub stars](https://img.shields.io/github/stars/songkwon/modex?style=social)](https://github.com/songkwon/modex/stargazers) +[![CI](https://github.com/songkwon/modex/actions/workflows/ci.yml/badge.svg)](https://github.com/songkwon/modex/actions/workflows/ci.yml) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) +[![Go](https://img.shields.io/badge/Go-1.23-00ADD8?logo=go&logoColor=white)](backend/go.mod) +[![Next.js](https://img.shields.io/badge/Next.js-16-black?logo=nextdotjs)](frontend/package.json) +[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=111)](frontend/package.json) +[![Playwright](https://img.shields.io/badge/E2E-Playwright-45ba4b?logo=playwright)](frontend/playwright.config.ts) +[![MCP](https://img.shields.io/badge/MCP-streamable_HTTP-6f42c1)](mcp/) +[![i18n](https://img.shields.io/badge/i18n-ready-2f80ed)](docs/i18n-weblate.md) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/songkwon/modex) + +**语言:** [English](README.md) | 中文 + +Modex 是一个面向团队、企业和开源社区的文档体验平台。它把分散在不同仓库、不同文档框架和不同版本里的工程文档统一接入、发布、检索、阅读和授权,并通过 MCP 让 AI 编程工具可以读取团队的实时文档。 + +它适合用来建设内部研发文档中心、模块知识库、API/架构手册门户、AI 可检索的工程知识平台,以及需要把 Git 仓库文档自动发布到统一站点的团队级文档系统。 + +## 核心能力 + +- **统一文档门户**:Next.js 前端提供首页、分类、文档阅读、个人中心和管理控制台。 +- **多来源发布**:`docsctl` 支持 `validate`、`build`、`package`、`deploy`,可接入 Markdown、VuePress、VitePress、Fumadocs 和静态站点。 +- **CI 驱动同步**:文档仓库在自己的 CI 中构建并推送标准 artifact 到 Modex,平台侧负责归档、索引和展示。 +- **搜索与 AI 问答**:支持关键词、语义、混合搜索;可配置 Chat/Embedding/Rerank 提供商。 +- **MCP 访问**:托管部署使用 streamable HTTP MCP server;只支持本地命令的客户端可以继续使用 `npx` stdio wrapper。 +- **Skill 包**:Modex Skill 随仓库分发,支持 skill 的客户端可单独安装。 +- **权限与组织模型**:支持本地 mock 登录、生产 OIDC/Keycloak、用户、团队、分类责任人、超级管理员和平台级管理权限。 +- **发布诊断**:`/api/deploy` 返回阶段化发布结果,方便 CI 排查 artifact 解析、鉴权、资源上传、索引清理和入库问题。 +- **运维快照**:`/healthz` 返回 repository、对象存储、搜索/vector、embedding count 和 registry counts。 +- **国际化准备**:前端使用 JSON 消息目录,提供一致性检查和 Weblate 接入说明。 +- **可测试交付**:Go 单测、前端类型检查、生产构建和 Playwright E2E smoke tests 已接入 CI。 + +## 仓库结构 + +```text +modex/ + backend/ Go REST API, auth, analytics, deploy ingest, search, persistence + frontend/ Next.js portal, admin console, reader, i18n, Playwright tests + tools/docsctl/ Documentation CLI for validate/build/package/deploy + mcp/ streamable HTTP MCP server, npx stdio wrapper, client skill + deploy/ Docker Compose, PostgreSQL/pgvector migration, env templates + docs/ Operator docs, examples, CI templates, i18n/testing guides +``` + +## 快速开始 + +使用 Docker Compose 启动完整依赖: + +```bash +cd deploy +cp .env.example .env +docker compose up --build +``` + +默认地址: + +- 前端: +- 后端健康检查: +- MinIO Console: +- PostgreSQL:`localhost:5432` +- Redis:`localhost:6379` + +本地 Compose 中 MCP 是可选 profile。需要启动 MCP 时: + +```bash +cd deploy +docker compose --profile mcp up --build +``` + +streamable HTTP MCP 地址为: + +```text +http://localhost:8787/mcp +``` + +不使用 Docker 启动应用时,需要先准备 PostgreSQL、Redis 和 MinIO,然后分别启动后端和前端: + +```bash +cd backend +go run ./cmd/modex-api +``` + +```bash +cd frontend +npm install +npm run dev +``` + +## 发布一份文档 + +构建并打包文档: + +```bash +cd tools/docsctl +DOCS_SOURCE_DIR=/path/to/docs go run ./cmd/docsctl validate +DOCS_SOURCE_DIR=/path/to/docs go run ./cmd/docsctl build +DOCS_SOURCE_DIR=/path/to/docs go run ./cmd/docsctl package +``` + +发布到 Modex: + +```bash +DOCS_DEPLOY_URL=http://localhost:8671/api/deploy \ +DOCS_DEPLOY_TOKEN=your-token \ +DOCS_ARTIFACT=/path/to/docs/.modex/docs-artifact.zip \ +go run ./cmd/docsctl deploy +``` + +Deploy Token 可在管理后台为文档源生成。生产 CI 中请把 token 放到 GitLab/GitHub 的 secret variables,不要提交到仓库。 + +GitLab CI 可 include [deploy/ci/modex-docs.gitlab-ci.yml](deploy/ci/modex-docs.gitlab-ci.yml)。页面内「使用指南」会按当前 Modex 实例展示部署地址、`docsctl` 下载地址和 Runner tag。 + +## AI 工具接入 + +托管部署推荐暴露 streamable HTTP MCP endpoint: + +```text +https://modex.example.com/mcp +``` + +MCP server 会把工具调用代理到 Modex 后端。需要鉴权时,为部署设置 `MODEX_API_BASE_URL`,并通过 `MODEX_MCP_TOKEN` 传入用户自己的 MCP token。 + +如果客户端只支持启动本地 stdio MCP server,可以安装已部署 Modex 后端提供的分发包: + +```bash +claude mcp add modex \ + --env MODEX_API_BASE_URL=https://modex.example.com \ + --env MODEX_MCP_TOKEN=your-token \ + -- npx -y https://modex.example.com/api/mcp/dist/modex-mcp.tgz +``` + +支持 Skill 的客户端可以安装 Modex Skill: + +```bash +npx skills add https://modex.example.com +``` + +仓库里的 skill 也可以直接安装: + +```bash +npx skills add https://github.com/songkwon/modex/tree/main/mcp/skill +``` + +更多说明见 [mcp/npx/README.md](mcp/npx/README.md)。 + +## 发布产物 + +打 tag 发布时会产出: + +- GHCR 镜像: + - `ghcr.io/songkwon/modex/api` + - `ghcr.io/songkwon/modex/frontend` + - `ghcr.io/songkwon/modex/mcp` +- GitHub Release 中的 `docsctl-*` 二进制,覆盖 Linux、macOS 和 Windows。 +- GitHub Release 中的 `modex-mcp-*.tgz`,供仍需本地 stdio MCP 的客户端使用。 +- Release 文件的 checksums、SBOM、Sigstore bundles 和 build provenance。 + +`docsctl` 面向文档仓库 CI,优先发二进制;API、前端和托管 MCP server 以容器镜像分发。 + +## 配置与部署 + +常用配置文件: + +- [deploy/.env.example](deploy/.env.example):统一环境变量模板,本地开发可直接复制,生产部署请替换所有 secret 和公网 URL。 +- [deploy/config.example.yaml](deploy/config.example.yaml):应用级配置示例,例如 OIDC claim 映射。 +- [deploy/docker-compose.yml](deploy/docker-compose.yml):本地/单机部署编排。 + +生产部署建议: + +- 登录统一走 OIDC/Keycloak,并配置好 `KEYCLOAK_*` 或 `OIDC_*` 环境变量。 +- 设置 `COOKIE_SECURE=true`、生产 `COOKIE_DOMAIN` 和精确 CORS origins。 +- 替换 PostgreSQL、MinIO、OIDC、PostHog、cookie、deploy token 等所有 secret。 +- 配置真实 Chat/Embedding/Rerank provider 后执行 embedding reindex。 +- 如果图表源码不能离开内网,使用内部 Kroki 部署。 +- `deploy/.env`、`deploy/config.yaml` 和 `docker compose config` 输出都可能包含 secret,不要公开。 + +## 测试 + +```bash +cd backend && go test ./... +cd tools/docsctl && go test ./... +cd mcp && go test ./... + +cd frontend +npm run lint +npm run build +npm run e2e +``` + +详见 [docs/testing.md](docs/testing.md)。 + +## 国际化 / Weblate + +前端翻译资源位于: + +- [frontend/messages/zh-CN.json](frontend/messages/zh-CN.json) +- [frontend/messages/en-US.json](frontend/messages/en-US.json) + +`zh-CN` 是源语言。新增文案应先写入 `zh-CN.json`,再同步到其他语言文件,并通过 `useI18n().t(...)` 使用。Weblate 接入说明见 [docs/i18n-weblate.md](docs/i18n-weblate.md)。 + +## 更多文档 + +- [项目指南(MDX)](frontend/content/modex-guide.mdx) +- [测试指南](docs/testing.md) +- [国际化与 Weblate](docs/i18n-weblate.md) +- [VuePress 迁移指南](docs/vuepress-migration.md) +- [GitLab CI 模板](deploy/ci/modex-docs.gitlab-ci.yml) +- [生产升级与回滚](docs/operations.md) +- [贡献指南](CONTRIBUTING.md) +- [安全策略](SECURITY.md) + +## 许可协议 + +Modex 使用 [GNU General Public License v3.0](LICENSE) 发布。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..350bbe5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported versions + +Security fixes are provided for the latest tagged release. Operators should +upgrade promptly; older releases may receive a fix only when a supported +migration path requires it. + +## Reporting a vulnerability + +Do not open a public issue. Use GitHub's **Report a vulnerability** flow in the +repository Security tab. Include affected versions, reproduction steps, impact, +and any suggested mitigation. + +We aim to acknowledge a report within 3 business days, provide an initial +assessment within 7 business days, and coordinate disclosure after a fix is +available. Please allow reasonable remediation time before publishing details. + +Release artifacts include SHA-256 checksums, SPDX SBOMs, Sigstore bundles, and +GitHub build-provenance attestations. Verify these before production rollout. diff --git a/backend/Dockerfile b/backend/Dockerfile index 8a94de6..794c190 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM golang:1.22-alpine AS build +FROM golang:1.23-alpine AS build WORKDIR /src # IMPORTANT: On your development machine (with good internet), run ONCE: @@ -7,9 +7,9 @@ WORKDIR /src # This populates go.sum with checksums for all deps (including minio-go/v7 for real MinIO uploads of site files). # Commit the updated go.sum. Then Docker builds will be reliable. -# Optional: pass a fast proxy at build time if default is slow -# docker build --build-arg GOPROXY=https://goproxy.cn,direct ... -ARG GOPROXY=https://proxy.golang.org,direct +# Optional: override the proxy at build time, e.g. outside China: +# docker build --build-arg GOPROXY=https://proxy.golang.org,direct ... +ARG GOPROXY=https://goproxy.cn,direct ENV GOPROXY=${GOPROXY} # Copy go.mod + go.sum first for better layer caching of dependencies @@ -23,7 +23,9 @@ RUN --mount=type=cache,target=/go/pkg/mod \ # Copy source and build (static binary for alpine) COPY cmd ./cmd COPY internal ./internal -RUN CGO_ENABLED=0 GOOS=linux go build -o /out/modex-api ./cmd/modex-api +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -o /out/modex-api ./cmd/modex-api FROM alpine:3.20 WORKDIR /app diff --git a/backend/cmd/modex-api/main.go b/backend/cmd/modex-api/main.go index 795ff8a..37a8db3 100644 --- a/backend/cmd/modex-api/main.go +++ b/backend/cmd/modex-api/main.go @@ -6,28 +6,64 @@ import ( "net/http" "os" "os/signal" - "path/filepath" + "strconv" "syscall" "time" "modex/backend/internal/api" - "modex/backend/internal/store" + "modex/backend/internal/application" + "modex/backend/internal/config" + "modex/backend/internal/dburl" + "modex/backend/internal/repository" + "modex/backend/internal/vectorstore" ) +func analyticsSource() string { + if api.PosthogConfigured() { + return "built-in + PostHog (project_id=" + os.Getenv("POSTHOG_PROJECT_ID") + ", host=" + api.PosthogHost() + ")" + } + return "built-in" +} + func main() { + if _, err := config.Load(); err != nil { + log.Fatalf("load application config: %v", err) + } addr := ":" + env("PORT", "8671") - st, snapshotPath := loadStore() - srv := api.New(st) + databaseURL := dburl.FromEnv() + repository := openRepository(databaseURL) - httpServer := &http.Server{Addr: addr, Handler: srv.Handler()} + vectorCtx, vectorCancel := context.WithTimeout(context.Background(), 10*time.Second) + vectors, err := vectorstore.Open(vectorCtx, databaseURL) + vectorCancel() + if err != nil { + log.Fatalf("open PostgreSQL vector store: %v", err) + } + defer vectors.Close() + log.Printf("embedding store: PostgreSQL/pgvector") + + appSvc, err := application.NewConfigured(repository, vectors, repository) + if err != nil { + log.Fatalf("initialize application: %v", err) + } + defer appSvc.Close() + srv := api.NewWithApplication(appSvc) + log.Printf("analytics source: %s", analyticsSource()) + + handler := srv.Handler() + httpServer := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: envDuration("HTTP_READ_HEADER_TIMEOUT", 5*time.Second), + ReadTimeout: envDuration("HTTP_READ_TIMEOUT", 30*time.Second), + WriteTimeout: envDuration("HTTP_WRITE_TIMEOUT", 60*time.Second), + IdleTimeout: envDuration("HTTP_IDLE_TIMEOUT", 2*time.Minute), + MaxHeaderBytes: envInt("HTTP_MAX_HEADER_BYTES", 1<<20), + } - // Periodic + graceful-shutdown persistence when DATA_DIR is configured. stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - if snapshotPath != "" { - go autosave(st, snapshotPath, stop) - } go func() { log.Printf("modex-api listening on %s", addr) @@ -37,61 +73,43 @@ func main() { }() <-stop - if snapshotPath != "" { - if err := st.Save(snapshotPath); err != nil { - log.Printf("final snapshot save failed: %v", err) - } else { - log.Printf("saved store snapshot to %s", snapshotPath) - } - } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = httpServer.Shutdown(ctx) } -// loadStore returns the store and the snapshot path (empty when persistence is -// disabled). When DATA_DIR is set it loads an existing snapshot or falls back to -// seeded data. -func loadStore() (*store.Store, string) { - dataDir := os.Getenv("DATA_DIR") - if dataDir == "" { - log.Printf("DATA_DIR not set; starting with empty store (no demo data)") - return store.New(), "" - } - path := filepath.Join(dataDir, "modex-store.json") - if st, err := store.Load(path); err == nil { - log.Printf("loaded store snapshot from %s", path) - return st, path - } else { - log.Printf("no usable snapshot at %s (%v); starting from clean empty store", path, err) - return store.New(), path +func openRepository(databaseURL string) *repository.PostgresRepository { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + repo, err := repository.OpenPostgres(ctx, databaseURL) + if err != nil { + log.Fatalf("open PostgreSQL business store: %v", err) } + log.Printf("business store: PostgreSQL (request-level reads and writes)") + return repo } -func autosave(st *store.Store, path string, stop <-chan os.Signal) { - interval := 60 * time.Second - if v := os.Getenv("DATA_SAVE_INTERVAL_SECONDS"); v != "" { - if d, err := time.ParseDuration(v + "s"); err == nil && d > 0 { - interval = d - } +func env(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v } - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - if err := st.Save(path); err != nil { - log.Printf("snapshot save failed: %v", err) - } - case <-stop: - return + return fallback +} + +func envDuration(key string, fallback time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 { + return parsed } } + return fallback } -func env(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v +func envInt(key string, fallback int) int { + if value := os.Getenv(key); value != "" { + if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 { + return parsed + } } return fallback } diff --git a/backend/go.mod b/backend/go.mod index c138789..f8fc768 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,24 +1,40 @@ module modex/backend -go 1.22 +go 1.25.0 require ( - github.com/minio/minio-go/v7 v7.0.70 + github.com/coreos/go-oidc/v3 v3.15.0 + github.com/jackc/pgx/v5 v5.5.5 + github.com/minio/minio-go/v7 v7.2.1 + github.com/redis/go-redis/v9 v9.9.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.17.6 // indirect - github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rs/xid v1.5.0 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/net v0.23.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 5c942ce..609af05 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,38 +1,96 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= +github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= -github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= -github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.70 h1:1u9NtMgfK1U42kUxcsl5v0yj6TEOPR497OAQxpJnn2g= -github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo= +github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= +github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM= +github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/authorization_admin.go b/backend/internal/api/authorization_admin.go new file mode 100644 index 0000000..66a73ac --- /dev/null +++ b/backend/internal/api/authorization_admin.go @@ -0,0 +1,847 @@ +package api + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "time" + + "modex/backend/internal/store" +) + +func (s *Server) currentUser(r *http.Request) (store.User, bool) { + if user, ok := s.app.Auth().CurrentUser(r); ok { + fresh, err := s.app.Store().UserByID(user.ID) + return fresh, err == nil + } + if tok := bearerToken(r); tok != "" { + if user, _, _, err := s.app.Store().UserByOAuthAccessToken(tok); err == nil { + return user, true + } + } + return store.User{}, false +} + +func isAdmin(u store.User) bool { + for _, r := range u.Roles { + if r == "admin" { + return true + } + } + return false +} + +// canManageCategory reports whether the user may manage the given platform. +// A managed category id covers its descendants (e.g. "engineering" covers +// "engineering.cbb"). +func canManageCategory(u store.User, categoryID string) bool { + for _, m := range u.ManagedCategories { + if m == categoryID || strings.HasPrefix(categoryID, m+".") { + return true + } + } + return false +} + +func canManageCategories(u store.User, categoryIDs []string) bool { + for _, id := range categoryIDs { + if canManageCategory(u, id) { + return true + } + } + return false +} + +// isTeamLeader checks if the user is the designated leader of the team. +func (s *Server) isTeamLeader(u store.User, teamKey string) bool { + if teamKey == "" { + return false + } + t, err := s.app.Store().Team(teamKey) + if err != nil { + return false + } + for _, l := range t.Leaders { + if strings.EqualFold(l, u.Username) || strings.EqualFold(l, u.ID) { + return true + } + } + return false +} + +// teamMembers returns usernames/ids in the team (for ownership checks). +func (s *Server) teamMembers(teamKey string) []string { + return s.app.Store().TeamMembers(teamKey) +} + +// canManageViaResponsibleTeam allows members (incl. leader) of a category's responsible team +// to manage that category's resources (generic domain ownership). +func (s *Server) canManageViaResponsibleTeam(u store.User, categoryIDs []string) bool { + for _, cid := range categoryIDs { + // Check direct; for hierarchy the responsible on parent covers subs conceptually, + // but we also check the specific id's assignment. + resp := s.categoryResponsible(cid) + if resp == "" { + continue + } + for _, m := range s.teamMembers(resp) { + if strings.EqualFold(m, u.Username) || strings.EqualFold(m, u.ID) { + return true + } + } + } + return false +} + +func (s *Server) categoryResponsible(id string) string { + // Walk the tree (small data) to find assignment for id or nearest ancestor with one. + tree := s.app.Store().CategoryTree() + var find func([]store.Category) string + find = func(cats []store.Category) string { + for _, c := range cats { + if c.ID == id { + if c.ResponsibleTeam != "" { + return c.ResponsibleTeam + } + // inherit from parent? caller walks up if needed; here return what we have at leaf. + return "" + } + if hit := find(c.Children); hit != "" { + return hit + } + } + return "" + } + // Also try ancestor match for sub-ids (e.g. "standards.foo" covered by "standards" team) + for _, c := range tree { + if c.ID == id || strings.HasPrefix(id, c.ID+".") { + if c.ResponsibleTeam != "" { + return c.ResponsibleTeam + } + } + for _, ch := range c.Children { + if ch.ID == id || strings.HasPrefix(id, ch.ID+".") { + if ch.ResponsibleTeam != "" { + return ch.ResponsibleTeam + } + } + } + } + return find(tree) +} + +// accessibleCategoryIDs returns the set of category IDs a user may see/manage in +// the admin console. Super admins get (nil, true) meaning "everything". A team +// member gets the categories their team(s) own (Category.ResponsibleTeam) plus +// all descendants. A user with no team gets an empty set. +func (s *Server) accessibleCategoryIDs(u store.User) (set map[string]bool, all bool) { + if s.app.Auth().IsSuperAdmin(u) { + return nil, true + } + teamKeys := map[string]bool{} + for _, k := range s.app.Store().TeamKeysForUser(u) { + teamKeys[strings.ToLower(strings.TrimSpace(k))] = true + } + set = map[string]bool{} + if len(teamKeys) == 0 { + return set, false + } + cats := s.app.Store().AllCategories() + for _, c := range cats { + if c.ResponsibleTeam != "" && teamKeys[strings.ToLower(c.ResponsibleTeam)] { + set[c.ID] = true + } + } + // Expand to all descendants via ParentID closure (a child without its own + // ResponsibleTeam is still owned through its parent). + for { + added := false + for _, c := range cats { + if !set[c.ID] && c.ParentID != "" && set[c.ParentID] { + set[c.ID] = true + added = true + } + } + if !added { + break + } + } + return set, false +} + +// hasConsoleAccess reports whether the user may enter the admin console at all +// (super admin, or a member/leader of any team). +func (s *Server) hasConsoleAccess(u store.User) bool { + return s.app.Auth().IsSuperAdmin(u) || len(s.app.Store().TeamKeysForUser(u)) > 0 +} + +// isTeamAdmin reports a non-super-admin who belongs to at least one team (the +// team-scoped admin tier). +func (s *Server) isTeamAdmin(u store.User) bool { + return !s.app.Auth().IsSuperAdmin(u) && len(s.app.Store().TeamKeysForUser(u)) > 0 +} + +// requireConsole gates console-scoped reads (logs, releases, module list). Super +// admins and team members pass; everyone else gets 403. +func (s *Server) requireConsole(w http.ResponseWriter, r *http.Request) (store.User, bool) { + user, ok := s.app.Auth().CurrentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "login required") + return store.User{}, false + } + if !s.hasConsoleAccess(user) { + writeError(w, http.StatusForbidden, "forbidden", "console access required") + return store.User{}, false + } + return user, true +} + +// docCategoryIDs resolves a document id to the categories of its module. +func (s *Server) docCategoryIDs(docID string) []string { + if strings.TrimSpace(docID) == "" { + return nil + } + p, err := s.app.Store().Page(docID) + if err != nil { + return nil + } + return s.moduleCategories(p.ModuleKey) +} + +// mcpLogCategoryIDs best-effort resolves an MCP log to categories by parsing its +// free-form input JSON for a doc id or module key. MCP logs carry no structured +// module field, so this is heuristic; unresolved logs return nil (hidden from +// team admins, shown to super admins who bypass scoping). +func (s *Server) mcpLogCategoryIDs(inputJSON string) []string { + if strings.TrimSpace(inputJSON) == "" { + return nil + } + var m map[string]any + if err := json.Unmarshal([]byte(inputJSON), &m); err != nil { + return nil + } + str := func(keys ...string) string { + for _, k := range keys { + if v, ok := m[k].(string); ok && v != "" { + return v + } + } + return "" + } + if doc := str("doc_id", "docID", "docId"); doc != "" { + if cats := s.docCategoryIDs(doc); len(cats) > 0 { + return cats + } + } + if mod := str("module_key", "moduleKey", "module"); mod != "" { + return s.moduleCategories(mod) + } + return nil +} + +// categoriesIntersect reports whether any of the ids is in the accessible set. +func categoriesIntersect(ids []string, set map[string]bool) bool { + for _, id := range ids { + if set[id] { + return true + } + } + return false +} + +// requireUser writes 401 and returns false when no valid session is present. +func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (store.User, bool) { + user, ok := s.app.Auth().CurrentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "login required") + return store.User{}, false + } + return user, true +} + +// requireSuperAdmin gates super-admin-only actions (e.g. user/permission mgmt). +func (s *Server) requireSuperAdmin(w http.ResponseWriter, r *http.Request) (store.User, bool) { + user, ok := s.requireUser(w, r) + if !ok { + return store.User{}, false + } + if !s.app.Auth().IsSuperAdmin(user) { + writeError(w, http.StatusForbidden, "forbidden", "super admin required") + return store.User{}, false + } + return user, true +} + +// requirePlatform gates platform-scoped writes: super admins pass; otherwise the +// user must have management rights on at least one of the target categories. +// Team responsible for the domain (via Category.ResponsibleTeam) also grants access +// to its members/leaders (generic for OSS doc maintenance teams owning 领域). +func (s *Server) requirePlatform(w http.ResponseWriter, r *http.Request, categoryIDs []string) (store.User, bool) { + user, ok := s.requireUser(w, r) + if !ok { + return store.User{}, false + } + if s.app.Auth().IsSuperAdmin(user) { + return user, true + } + if isAdmin(user) && canManageCategories(user, categoryIDs) { + return user, true + } + if s.canManageViaResponsibleTeam(user, categoryIDs) { + return user, true + } + writeError(w, http.StatusForbidden, "forbidden", "no management permission for this platform") + return store.User{}, false +} + +func (s *Server) canAccessModule(user store.User, module store.Module) bool { + if s.app.Auth().IsSuperAdmin(user) { + return true + } + if module.CreatedBy != "" && (module.CreatedBy == user.ID || strings.EqualFold(module.CreatedBy, user.Username)) { + return true + } + set, all := s.accessibleCategoryIDs(user) + return all || categoriesIntersect(module.CategoryIDs, set) +} + +func (s *Server) requireModuleAccess(w http.ResponseWriter, r *http.Request, moduleKey string) (store.User, store.Module, bool) { + user, ok := s.requireUser(w, r) + if !ok { + return store.User{}, store.Module{}, false + } + module, err := s.app.Store().Module(moduleKey) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "module not found") + return store.User{}, store.Module{}, false + } + if !s.canAccessModule(user, module) { + writeError(w, http.StatusForbidden, "forbidden", "no management permission for this document source") + return store.User{}, store.Module{}, false + } + return user, module, true +} + +// moduleCategories resolves the category IDs attached to a module key. +func (s *Server) moduleCategories(moduleKey string) []string { + if m, err := s.app.Store().Module(moduleKey); err == nil { + return m.CategoryIDs + } + return nil +} + +func (s *Server) handleAdminCategories(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusOK, s.app.Store().CategoryTree()) + return + } + var c store.Category + if err := decodeBody(r, &c); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + // Top-level platforms are super-admin only; sub-platforms can be created by + // a manager of the parent platform. + if c.ParentID == "" { + if _, ok := s.requireSuperAdmin(w, r); !ok { + return + } + } else if _, ok := s.requirePlatform(w, r, []string{c.ParentID}); !ok { + return + } + created, err := s.app.Store().CreateCategory(c) + s.writeMutation(w, created, http.StatusCreated, err) +} + +func (s *Server) handleAdminCategoryByID(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/admin/categories/") + // Drag-and-drop move: POST /api/admin/categories/{id}/move {parent_id, index}. + if strings.HasSuffix(id, "/move") { + id = strings.TrimSuffix(id, "/move") + if _, ok := s.requireSuperAdmin(w, r); !ok { + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + return + } + var body struct { + ParentID string `json:"parent_id"` + Index int `json:"index"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + moved, err := s.app.Store().MoveCategory(id, body.ParentID, body.Index) + s.writeMutation(w, moved, http.StatusOK, err) + return + } + if _, ok := s.requirePlatform(w, r, []string{id}); !ok { + return + } + switch r.Method { + case http.MethodPut: + var c store.Category + if err := decodeBody(r, &c); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + updated, err := s.app.Store().UpdateCategory(id, c) + s.writeMutation(w, updated, http.StatusOK, err) + case http.MethodDelete: + if err := s.app.Store().DeleteCategory(id); err != nil { + writeResult(w, nil, err) + return + } + s.writeMutation(w, map[string]any{"status": "deleted", "id": id}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use PUT or DELETE") + } +} + +func (s *Server) handleAdminModules(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + user, ok := s.requireConsole(w, r) + if !ok { + return + } + modules := s.app.Store().Modules("", "") + // Team admins only see modules attached to a category they own. + if set, all := s.accessibleCategoryIDs(user); !all { + scoped := modules[:0:0] + for _, m := range modules { + if categoriesIntersect(m.CategoryIDs, set) || (m.CreatedBy != "" && (m.CreatedBy == user.ID || strings.EqualFold(m.CreatedBy, user.Username))) { + scoped = append(scoped, m) + } + } + modules = scoped + } + if kw := keywordOf(r); kw != "" { + filtered := modules[:0:0] + for _, m := range modules { + if containsFold(m.Name, kw) || containsFold(m.ModuleKey, kw) || containsFold(m.RepoURL, kw) || containsFold(m.Description, kw) { + filtered = append(filtered, m) + } + } + modules = filtered + } + if wantsPage(r) { + page, limit := pageParams(r) + writeJSON(w, http.StatusOK, paginate(modules, page, limit)) + return + } + writeJSON(w, http.StatusOK, modules) + return + } + var m store.Module + if err := decodeBody(r, &m); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + // Every doc source must be filed under at least one category. + if len(m.CategoryIDs) == 0 { + writeError(w, http.StatusBadRequest, "category_required", "文档源必须关联至少一个分类") + return + } + if _, ok := s.requirePlatform(w, r, m.CategoryIDs); !ok { + return + } + user, _ := s.currentUser(r) + m.CreatedBy = user.ID + created, err := s.app.Store().CreateModule(m) + s.writeMutation(w, created, http.StatusCreated, err) +} + +func (s *Server) handleAdminModuleRoutes(w http.ResponseWriter, r *http.Request) { + parts := splitPath(strings.TrimPrefix(r.URL.Path, "/api/admin/modules/")) + if len(parts) == 0 { + writeError(w, http.StatusNotFound, "not_found", "admin module route not found") + return + } + moduleKey := parts[0] + // Migration (reassign platform/owner) has its own dual-platform permission + // check, so handle it before the generic platform gate. + if len(parts) == 2 && parts[1] == "migrate" && r.Method == http.MethodPost { + s.handleMigrateModule(w, r, moduleKey) + return + } + user, module, ok := s.requireModuleAccess(w, r, moduleKey) + if !ok { + return + } + // Reveal / rotate the CI deploy token (kept out of normal serialization). + if len(parts) == 2 && parts[1] == "deploy-token" { + if r.Method == http.MethodPost { // rotate + token := "mdx_" + strconv.FormatInt(time.Now().UnixNano(), 36) + if _, err := s.app.Store().UpdateModule(moduleKey, store.Module{DeployToken: token}); err != nil { + writeResult(w, nil, err) + return + } + module.DeployToken = token + } else if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "deploy_token": module.DeployToken, + "deploy_url": firstNonEmptyStr(os.Getenv("APP_BASE_URL"), "") + "/api/deploy", + "module_key": moduleKey, + }) + return + } + switch { + case len(parts) == 1 && r.Method == http.MethodPut: + var m store.Module + if err := decodeBody(r, &m); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + // A doc source must keep at least one category; reject an explicit clear. + if m.CategoryIDs != nil && len(m.CategoryIDs) == 0 { + writeError(w, http.StatusBadRequest, "category_required", "文档源必须关联至少一个分类") + return + } + // Team admins may only file modules under categories they own. + if len(m.CategoryIDs) > 0 { + if set, all := s.accessibleCategoryIDs(user); !all { + for _, cid := range m.CategoryIDs { + if !set[cid] { + writeError(w, http.StatusForbidden, "forbidden", "只能选择本团队负责的分类") + return + } + } + } + } + updated, err := s.app.Store().UpdateModule(moduleKey, m) + s.writeMutation(w, updated, http.StatusOK, err) + case len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodPost: + var v store.Version + if err := decodeBody(r, &v); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + created, err := s.app.Store().CreateVersion(moduleKey, v) + s.writeMutation(w, created, http.StatusCreated, err) + case len(parts) == 3 && parts[1] == "versions" && r.Method == http.MethodPut: + var v store.Version + if err := decodeBody(r, &v); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + updated, err := s.app.Store().UpdateVersion(moduleKey, parts[2], v) + s.writeMutation(w, updated, http.StatusOK, err) + case len(parts) == 4 && parts[1] == "versions" && parts[3] == "entries" && r.Method == http.MethodPost: + var e store.Entry + if err := decodeBody(r, &e); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + created, err := s.app.Store().CreateEntry(moduleKey, parts[2], e) + s.writeMutation(w, created, http.StatusCreated, err) + default: + writeError(w, http.StatusNotFound, "not_found", "admin module route not found") + } +} + +// handleMigrateModule reassigns a module to different platform(s) (and +// optionally a new owner). The caller must be able to manage both the source +// and destination platforms (super admins bypass the check). +func (s *Server) handleMigrateModule(w http.ResponseWriter, r *http.Request, moduleKey string) { + user, ok := s.requireUser(w, r) + if !ok { + return + } + var req struct { + CategoryIDs []string `json:"category_ids"` + OwnerGroup string `json:"owner_group"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if len(req.CategoryIDs) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "category_ids is required") + return + } + if !s.app.Auth().IsSuperAdmin(user) { + source := s.moduleCategories(moduleKey) + if !isAdmin(user) || !canManageCategories(user, source) || !canManageCategories(user, req.CategoryIDs) { + writeError(w, http.StatusForbidden, "forbidden", "need management permission on both source and target platforms") + return + } + } + names := make([]string, 0, len(req.CategoryIDs)) + for _, id := range req.CategoryIDs { + names = append(names, s.app.Store().CategoryName(id)) + } + updated, err := s.app.Store().UpdateModule(moduleKey, store.Module{ + CategoryIDs: req.CategoryIDs, + CategoryPath: strings.Join(names, " / "), + OwnerGroup: req.OwnerGroup, + }) + s.writeMutation(w, updated, http.StatusOK, err) +} + +func (s *Server) handleAdminEntryByID(w http.ResponseWriter, r *http.Request) { + entryID := strings.TrimPrefix(r.URL.Path, "/api/admin/entries/") + if moduleKey, ok := s.app.Store().EntryModuleKey(entryID); ok { + if _, ok := s.requirePlatform(w, r, s.moduleCategories(moduleKey)); !ok { + return + } + } else if _, ok := s.requireUser(w, r); !ok { + return + } + switch r.Method { + case http.MethodPut: + var e store.Entry + if err := decodeBody(r, &e); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + updated, err := s.app.Store().UpdateEntry(entryID, e) + s.writeMutation(w, updated, http.StatusOK, err) + case http.MethodDelete: + if err := s.app.Store().DeleteEntry(entryID); err != nil { + writeResult(w, nil, err) + return + } + s.writeMutation(w, map[string]any{"status": "deleted", "id": entryID}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use PUT or DELETE") + } +} + +func (s *Server) handleReleaseRoutes(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireConsole(w, r) + if !ok { + return + } + parts := splitPath(strings.TrimPrefix(r.URL.Path, "/api/admin/releases/")) + if len(parts) == 0 { + writeError(w, http.StatusNotFound, "not_found", "release route not found") + return + } + releaseID := parts[0] + rel, err := s.app.Store().Release(releaseID) + if err != nil { + writeResult(w, rel, err) + return + } + if set, all := s.accessibleCategoryIDs(user); !all && !categoriesIntersect(s.moduleCategories(rel.ModuleKey), set) { + writeError(w, http.StatusForbidden, "forbidden", "no access to this release") + return + } + if len(parts) == 2 && parts[1] == "rollback" && r.Method == http.MethodPost { + rel, err = s.app.Store().RollbackRelease(releaseID) + s.writeMutation(w, rel, http.StatusOK, err) + return + } + if len(parts) == 1 && r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, rel) + return + } + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST /rollback") +} + +func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { + return + } + switch r.Method { + case http.MethodGet: + users := s.app.Store().Users(r.URL.Query().Get("keyword")) + // Enrich with the effective super admin status (persisted flag OR env SUPER_ADMIN_USERS). + for i := range users { + users[i].SuperAdmin = s.app.Auth().IsSuperAdmin(users[i]) + } + if wantsPage(r) { + page, limit := pageParams(r) + writeJSON(w, http.StatusOK, paginate(users, page, limit)) + return + } + writeJSON(w, http.StatusOK, users) + case http.MethodPost: + var u store.User + if err := decodeBody(r, &u); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + created, err := s.app.Store().CreateUser(u) + if err == nil { + created.SuperAdmin = s.app.Auth().IsSuperAdmin(created) + } + s.writeMutation(w, created, http.StatusCreated, err) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + } +} + +func (s *Server) handleAdminUserByID(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/admin/users/") + switch r.Method { + case http.MethodGet: + u, err := s.app.Store().UserByID(id) + if err == nil { + u.SuperAdmin = s.app.Auth().IsSuperAdmin(u) + } + writeResult(w, u, err) + case http.MethodPut: + var u store.User + if err := decodeBody(r, &u); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + updated, err := s.app.Store().UpdateUser(id, u) + if err == nil { + updated.SuperAdmin = s.app.Auth().IsSuperAdmin(updated) + } + s.writeMutation(w, updated, http.StatusOK, err) + case http.MethodDelete: + if err := s.app.Store().DeleteUser(id); err != nil { + writeResult(w, nil, err) + return + } + s.writeMutation(w, map[string]any{"status": "deleted", "id": id}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET, PUT or DELETE") + } +} + +func (s *Server) handleAdminTeams(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { + return + } + switch r.Method { + case http.MethodGet: + teams := s.app.Store().Teams() + if kw := keywordOf(r); kw != "" { + filtered := teams[:0:0] + for _, t := range teams { + if containsFold(t.Name, kw) || containsFold(t.Key, kw) || containsFold(strings.Join(t.Leaders, " "), kw) || containsFold(t.Description, kw) || containsFold(strings.Join(t.Members, " "), kw) { + filtered = append(filtered, t) + } + } + teams = filtered + } + if wantsPage(r) { + page, limit := pageParams(r) + writeJSON(w, http.StatusOK, paginate(teams, page, limit)) + return + } + writeJSON(w, http.StatusOK, teams) + case http.MethodPost: + var t store.Team + if err := decodeBody(r, &t); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + created, err := s.app.Store().CreateTeam(t) + s.writeMutation(w, created, http.StatusCreated, err) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + } +} + +func (s *Server) handleAdminTeamRoutes(w http.ResponseWriter, r *http.Request) { + parts := splitPath(strings.TrimPrefix(r.URL.Path, "/api/admin/teams/")) + if len(parts) == 0 { + writeError(w, http.StatusNotFound, "not_found", "team route not found") + return + } + key := parts[0] + switch { + case len(parts) == 1: + // View: super or any member of the team. Mutations: leader or super. + user, ok := s.requireUser(w, r) + if !ok { + return + } + tm, err := s.app.Store().Team(key) + if err != nil { + writeResult(w, tm, err) + return + } + isMember := false + for _, m := range tm.Members { + if strings.EqualFold(m, user.Username) || strings.EqualFold(m, user.ID) { + isMember = true + break + } + } + if !s.app.Auth().IsSuperAdmin(user) && !isMember { + writeError(w, http.StatusForbidden, "forbidden", "team membership or super required") + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, tm) + case http.MethodPut: + if !s.app.Auth().IsSuperAdmin(user) && !s.isTeamLeader(user, key) { + writeError(w, http.StatusForbidden, "forbidden", "only leader or super can update team") + return + } + var t store.Team + if err := decodeBody(r, &t); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + updated, err := s.app.Store().UpdateTeam(key, t) + s.writeMutation(w, updated, http.StatusOK, err) + case http.MethodDelete: + if _, ok := s.requireSuperAdmin(w, r); !ok { + return + } + if err := s.app.Store().DeleteTeam(key); err != nil { + writeResult(w, nil, err) + return + } + s.writeMutation(w, map[string]any{"status": "deleted", "key": key}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET/PUT/DELETE") + } + case len(parts) == 2 && parts[1] == "members" && r.Method == http.MethodPost: + // Leader or super can pull (add) members. + user, ok := s.requireUser(w, r) + if !ok { + return + } + if !s.app.Auth().IsSuperAdmin(user) && !s.isTeamLeader(user, key) { + writeError(w, http.StatusForbidden, "forbidden", "only team leader or super can add members") + return + } + var req struct { + Username string `json:"username"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if strings.TrimSpace(req.Username) == "" { + writeError(w, http.StatusBadRequest, "invalid_input", "username required") + return + } + updated, err := s.app.Store().AddTeamMember(key, req.Username) + s.writeMutation(w, updated, http.StatusOK, err) + case len(parts) == 3 && parts[1] == "members" && r.Method == http.MethodDelete: + // Leader or super can remove member. + user, ok := s.requireUser(w, r) + if !ok { + return + } + if !s.app.Auth().IsSuperAdmin(user) && !s.isTeamLeader(user, key) { + writeError(w, http.StatusForbidden, "forbidden", "only team leader or super can remove members") + return + } + member := parts[2] + updated, err := s.app.Store().RemoveTeamMember(key, member) + s.writeMutation(w, updated, http.StatusOK, err) + default: + writeError(w, http.StatusNotFound, "not_found", "team route not found") + } +} diff --git a/backend/internal/api/deploy_analytics.go b/backend/internal/api/deploy_analytics.go new file mode 100644 index 0000000..fb33e51 --- /dev/null +++ b/backend/internal/api/deploy_analytics.go @@ -0,0 +1,879 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "modex/backend/internal/deploy" + "modex/backend/internal/store" +) + +// acquireDeploySlot blocks until an ingest slot is free, the wait budget runs +// out, or the client disconnects. The returned release frees the slot and must +// be deferred by the caller; ok is false when the slot could not be acquired. +func (s *Server) acquireDeploySlot(ctx context.Context) (release func(), ok bool) { + if s.deploy == nil { + return func() {}, true + } + return s.deploy.acquire(ctx) +} + +func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + return + } + // Bound concurrent ingests so simultaneous large artifacts can't exhaust the + // instance. Wait briefly (CI tolerates a short queue), then shed load with 503. + if release, ok := s.acquireDeploySlot(r.Context()); ok { + defer release() + } else { + w.Header().Set("Retry-After", strconv.Itoa(envPositiveInt("DEPLOY_BUSY_RETRY_SECONDS", 30))) + writeError(w, http.StatusServiceUnavailable, "deploy_busy", "too many concurrent deployments; retry shortly") + return + } + report := newDeployReport() + artifact, err := deploy.ParseZip(r.Body, envInt64("DOCS_DEPLOY_MAX_BYTES", 100*1024*1024)) + if err != nil { + report.fail("parse_artifact", err) + writeDeployError(w, http.StatusBadRequest, "invalid_artifact", err.Error(), report) + return + } + report.ok("parse_artifact") + + // Deploy auth (GitLab CI / docsctl integration). Each document source owns + // an independent token, and the token selects the target document source. + // Token can be sent as X-Modex-Deploy-Token header or Authorization: Bearer + provided := r.Header.Get("X-Modex-Deploy-Token") + if provided == "" { + provided = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + } + provided = strings.TrimSpace(provided) + if provided == "" { + report.fail("authenticate", errors.New("deploy token is required")) + writeDeployError(w, http.StatusForbidden, "invalid_deploy_token", "deploy token required or invalid", report) + return + } + m, moduleErr := s.app.Store().ModuleByDeployToken(provided) + if moduleErr != nil { + report.fail("authenticate", moduleErr) + writeDeployError(w, http.StatusForbidden, "invalid_deploy_token", "deploy token required or invalid", report) + return + } + artifact = canonicalizeDeployArtifact(artifact, m) + moduleKey := m.ModuleKey + report.ok("authenticate") + + uploadedSiteFiles := artifact.SiteFiles + if s.minioClient != nil { + if err := s.uploadSiteFilesToMinIO(r.Context(), artifact, moduleKey, artifact.Metadata.DocsVersion); err != nil { + report.fail("upload_assets", err) + writeDeployError(w, http.StatusBadGateway, "site_upload_failed", err.Error(), report) + return + } + report.ok("upload_assets") + // MinIO is the source of truth for static site assets when configured; + // avoid duplicating the same bytes in PostgreSQL. + artifact.SiteFiles = nil + artifact.SiteHTML = nil + } else { + report.skip("upload_assets", "object storage is not configured; storing assets in PostgreSQL") + } + if err := s.app.Search().DeleteModuleVersionEmbeddings(r.Context(), moduleKey, artifact.Metadata.DocsVersion); err != nil { + report.fail("clear_embeddings", err) + if s.minioClient != nil { + s.cleanupUploadedSiteFiles(moduleKey, artifact.Metadata.DocsVersion, uploadedSiteFiles) + } + writeDeployError(w, http.StatusBadGateway, "embedding_cleanup_failed", err.Error(), report) + return + } + report.ok("clear_embeddings") + + storeArtifact := toStoreArtifact(artifact) + storeArtifact.SourceIP = clientIP(r) + storeArtifact.TriggerType = deployTriggerType(r) + result, err := s.app.Store().IngestArtifact(storeArtifact) + if err != nil { + report.fail("ingest_metadata", err) + if s.minioClient != nil { + s.cleanupUploadedSiteFiles(moduleKey, artifact.Metadata.DocsVersion, uploadedSiteFiles) + } + writeDeployError(w, http.StatusBadRequest, "deploy_failed", err.Error(), report) + return + } + report.ok("ingest_metadata") + if count, err := s.app.Search().ReindexModuleVersion(r.Context(), moduleKey, artifact.Metadata.DocsVersion); err != nil { + report.fail("rebuild_embeddings", err) + if s.minioClient != nil { + s.cleanupUploadedSiteFiles(moduleKey, artifact.Metadata.DocsVersion, uploadedSiteFiles) + } + writeDeployError(w, http.StatusBadGateway, "embedding_rebuild_failed", err.Error(), report) + return + } else if count > 0 { + report.ok("rebuild_embeddings") + } else { + report.skip("rebuild_embeddings", "no indexable document chunks") + } + s.writeMutation(w, map[string]any{"status": "published", "result": result, "deploy": report}, http.StatusAccepted, nil) +} + +func canonicalizeDeployArtifact(artifact deploy.Artifact, module store.Module) deploy.Artifact { + artifactModuleKey := strings.TrimSpace(artifact.Metadata.ModuleKey) + artifactDocsVersion := firstNonEmptyStr(artifact.Metadata.DocsVersion, "latest") + moduleKey := module.ModuleKey + moduleName := firstNonEmptyStr(module.Name, module.ModuleKey) + docsVersion := firstNonEmptyStr(artifact.Metadata.DocsVersion, module.DefaultVersion, "latest") + artifact.Metadata.ModuleKey = moduleKey + artifact.Metadata.ModuleName = moduleName + artifact.Metadata.DocsVersion = docsVersion + if artifact.Metadata.Description == "" { + artifact.Metadata.Description = module.Description + } + artifact = applyModuleMount(artifact, module) + // docsctl builds without knowing the target module (the deploy token selects + // it), so resource URLs in document content are baked with the placeholder + // module/version. Rewrite them to the resolved module so embedded images and + // attachments resolve under the real /api/docs/// path. + rewriteBase := artifactModuleKey != "" && (artifactModuleKey != moduleKey || artifactDocsVersion != docsVersion) + for i := range artifact.Documents { + entryKey := firstNonEmptyStr(artifact.Documents[i].EntryKey, entryKeyFromDocID(artifact.Documents[i].DocID)) + artifact.Documents[i].ModuleKey = moduleKey + artifact.Documents[i].ModuleName = moduleName + artifact.Documents[i].DocsVersion = docsVersion + if artifact.Documents[i].DocID != "" { + artifact.Documents[i].DocID = rewriteDocIDModuleVersion(artifact.Documents[i].DocID, artifactModuleKey, moduleKey, artifactDocsVersion, docsVersion) + } else if entryKey != "" { + artifact.Documents[i].DocID = moduleKey + ":" + docsVersion + ":" + entryKey + } + if rewriteBase { + artifact.Documents[i].Content = rewriteDeployAssetBaseString(artifact.Documents[i].Content, artifactModuleKey, moduleKey, artifactDocsVersion, docsVersion) + artifact.Documents[i].ContentMD = rewriteDeployAssetBaseString(artifact.Documents[i].ContentMD, artifactModuleKey, moduleKey, artifactDocsVersion, docsVersion) + } + } + artifact.SiteHTML = rewriteDeployAssetBases(artifact.SiteHTML, artifactModuleKey, moduleKey, artifactDocsVersion, docsVersion) + for name, content := range artifact.SiteFiles { + rewritten := rewriteDeployAssetBaseBytes(content, artifactModuleKey, moduleKey, artifactDocsVersion, docsVersion) + if rewritten != nil { + artifact.SiteFiles[name] = rewritten + } + } + return artifact +} + +func rewriteDocIDModuleVersion(docID, fromModule, toModule, fromVersion, toVersion string) string { + parts := strings.SplitN(docID, ":", 3) + if len(parts) != 3 { + return docID + } + if fromModule != "" && !strings.EqualFold(parts[0], fromModule) { + return docID + } + if fromVersion != "" && parts[1] != fromVersion { + return docID + } + return toModule + ":" + toVersion + ":" + parts[2] +} + +func applyModuleMount(artifact deploy.Artifact, module store.Module) deploy.Artifact { + if !strings.EqualFold(strings.TrimSpace(module.Mount), "split") || !moduleUsesMarkdown(module, artifact) || len(artifact.Documents) <= 1 { + return artifact + } + return splitMarkdownArtifactByTopLevel(artifact) +} + +func moduleUsesMarkdown(module store.Module, artifact deploy.Artifact) bool { + if strings.EqualFold(strings.TrimSpace(module.DocType), "markdown") { + return true + } + for _, entry := range artifact.Manifest.Entries { + if !strings.EqualFold(strings.TrimSpace(entry.Type), "markdown") { + return false + } + } + return len(artifact.Manifest.Entries) > 0 +} + +func splitMarkdownArtifactByTopLevel(artifact deploy.Artifact) deploy.Artifact { + prefix := commonSourcePrefix(artifact.Documents) + type group struct { + key string + title string + source string + docs []deploy.DocumentRecord + } + groups := map[string]*group{} + var order []string + for _, doc := range artifact.Documents { + rel := trimSourcePrefix(doc.SourceFile, prefix) + key, title, source := splitGroupForSource(rel, doc) + g := groups[key] + if g == nil { + g = &group{key: key, title: title, source: source} + groups[key] = g + order = append(order, key) + } + g.docs = append(g.docs, doc) + } + sort.SliceStable(order, func(i, j int) bool { + if order[i] == "guide" { + return true + } + if order[j] == "guide" { + return false + } + return order[i] < order[j] + }) + + entries := make([]deploy.Entry, 0, len(order)) + nav := make([]deploy.NavItem, 0, len(order)) + documents := make([]deploy.DocumentRecord, 0, len(order)) + for _, key := range order { + g := groups[key] + entries = append(entries, deploy.Entry{Key: g.key, Title: g.title, Type: "markdown", Source: g.source}) + nav = append(nav, deploy.NavItem{Title: g.title, Path: "/" + g.key}) + documents = append(documents, mergeMarkdownGroup(artifact, *g)) + } + artifact.Manifest.Entries = entries + artifact.Nav = nav + artifact.Documents = documents + return artifact +} + +func mergeMarkdownGroup(artifact deploy.Artifact, g struct { + key string + title string + source string + docs []deploy.DocumentRecord +}) deploy.DocumentRecord { + var content strings.Builder + var contentMD strings.Builder + for _, doc := range g.docs { + title := firstNonEmptyStr(doc.Title, doc.SourceFile) + content.WriteString("# " + title + "\n\n" + strings.TrimSpace(doc.Content) + "\n\n") + md := firstNonEmptyStr(doc.ContentMD, doc.Content) + contentMD.WriteString("# " + title + "\n\n" + strings.TrimSpace(md) + "\n\n") + } + text := strings.TrimSpace(content.String()) + desc := text + if len(desc) > 140 { + desc = desc[:140] + } + return deploy.DocumentRecord{ + DocID: artifact.Metadata.ModuleKey + ":" + artifact.Metadata.DocsVersion + ":" + g.key, + ModuleKey: artifact.Metadata.ModuleKey, + ModuleName: artifact.Metadata.ModuleName, + DocsVersion: artifact.Metadata.DocsVersion, + PackageVersion: artifact.Metadata.PackageVersion, + EntryKey: g.key, + EntryType: "markdown", + Title: g.title, + Description: desc, + Content: text, + ContentMD: strings.TrimSpace(contentMD.String()), + Path: "/" + g.key, + SourceFile: g.source, + Status: "active", + Keywords: artifact.Metadata.Keywords, + } +} + +func commonSourcePrefix(docs []deploy.DocumentRecord) string { + counts := map[string]int{} + for _, doc := range docs { + parts := splitSourcePath(doc.SourceFile) + if len(parts) > 1 { + counts[parts[0]]++ + } + } + for first, count := range counts { + if count == len(docs) { + return first + } + } + return "" +} + +func trimSourcePrefix(source, prefix string) string { + source = strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/") + if prefix != "" && (source == prefix || strings.HasPrefix(source, prefix+"/")) { + return strings.TrimPrefix(strings.TrimPrefix(source, prefix), "/") + } + return source +} + +func splitGroupForSource(rel string, doc deploy.DocumentRecord) (string, string, string) { + parts := splitSourcePath(rel) + if len(parts) == 0 { + return firstNonEmptyStr(doc.EntryKey, "guide"), firstNonEmptyStr(doc.Title, "Guide"), doc.SourceFile + } + if len(parts) == 1 { + name := strings.TrimSuffix(parts[0], sourceExt(parts[0])) + if strings.EqualFold(name, "readme") || strings.EqualFold(name, "index") { + return "guide", "Guide", doc.SourceFile + } + key := slugForMount(name) + return key, firstNonEmptyStr(doc.Title, name), doc.SourceFile + } + key := slugForMount(parts[0]) + return key, titleForMount(parts[0]), strings.Trim(strings.TrimSuffix(doc.SourceFile, strings.Join(parts[1:], "/")), "/") +} + +func splitSourcePath(source string) []string { + source = strings.Trim(strings.ReplaceAll(source, "\\", "/"), "/") + if source == "" { + return nil + } + return strings.Split(source, "/") +} + +func sourceExt(name string) string { + lower := strings.ToLower(name) + for _, ext := range []string{".mdx", ".md"} { + if strings.HasSuffix(lower, ext) { + return name[len(name)-len(ext):] + } + } + return "" +} + +func slugForMount(s string) string { + var b strings.Builder + lastDash := false + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "guide" + } + return out +} + +func titleForMount(s string) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "-", " ")) + if s == "" { + return "Guide" + } + runes := []rune(s) + runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] + return string(runes) +} + +func rewriteDeployAssetBases(files map[string]string, fromModule, toModule, fromVersion, toVersion string) map[string]string { + if len(files) == 0 || fromModule == "" || toModule == "" || (fromModule == toModule && fromVersion == toVersion) { + return files + } + out := make(map[string]string, len(files)) + for name, content := range files { + out[name] = rewriteDeployAssetBaseString(content, fromModule, toModule, fromVersion, toVersion) + } + return out +} + +func rewriteDeployAssetBaseBytes(content []byte, fromModule, toModule, fromVersion, toVersion string) []byte { + if len(content) == 0 || fromModule == "" || toModule == "" || (fromModule == toModule && fromVersion == toVersion) { + return nil + } + rewritten := rewriteDeployAssetBaseString(string(content), fromModule, toModule, fromVersion, toVersion) + if rewritten == string(content) { + return nil + } + return []byte(rewritten) +} + +func rewriteDeployAssetBaseString(content, fromModule, toModule, fromVersion, toVersion string) string { + from := "/api/docs/" + fromModule + "/" + fromVersion + "/" + to := "/api/docs/" + toModule + "/" + toVersion + "/" + return strings.ReplaceAll(content, from, to) +} + +func entryKeyFromDocID(docID string) string { + parts := strings.Split(docID, ":") + if len(parts) >= 3 { + return strings.Join(parts[2:], ":") + } + return "" +} + +func deployTriggerType(r *http.Request) string { + for _, name := range []string{ + "X-Gitlab-Event", + "X-GitHub-Event", + "X-Circleci-Event-Type", + "X-Jenkins", + "X-Buildkite-Event", + } { + if strings.TrimSpace(r.Header.Get(name)) != "" { + return "pipeline" + } + } + if strings.TrimSpace(r.Header.Get("X-Modex-Deploy-Trigger")) == "pipeline" || + strings.TrimSpace(r.Header.Get("X-CI")) != "" { + return "pipeline" + } + return "manual" +} + +func (s *Server) handleReleases(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireConsole(w, r) + if !ok { + return + } + releases := s.app.Store().Releases() + if set, all := s.accessibleCategoryIDs(user); !all { + scoped := releases[:0:0] + for _, rel := range releases { + if categoriesIntersect(s.moduleCategories(rel.ModuleKey), set) { + scoped = append(scoped, rel) + } + } + releases = scoped + } + if kw := keywordOf(r); kw != "" { + filtered := releases[:0:0] + for _, rel := range releases { + if containsFold(rel.ModuleKey, kw) || + containsFold(rel.DocsVersion, kw) || + containsFold(rel.PackageVersion, kw) || + containsFold(rel.CommitSHA, kw) || + containsFold(rel.Branch, kw) || + containsFold(rel.SourceIP, kw) || + containsFold(rel.TriggerType, kw) || + containsFold(rel.ReleaseID, kw) { + filtered = append(filtered, rel) + } + } + releases = filtered + } + if wantsPage(r) { + page, limit := pageParams(r) + writeJSON(w, http.StatusOK, paginate(releases, page, limit)) + return + } + writeJSON(w, http.StatusOK, releases) +} + +func (s *Server) handlePageView(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + return + } + var req struct { + DocID string `json:"doc_id"` + SessionID string `json:"session_id"` + ReadID string `json:"read_id"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + req.DocID = strings.TrimSpace(req.DocID) + if req.DocID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "doc_id is required") + return + } + user, _ := s.currentUser(r) + pv := s.app.Store().RecordPageView(store.PageView{DocID: req.DocID, UserID: user.ID, SessionID: req.SessionID, ReadID: req.ReadID}) + s.writeMutation(w, map[string]any{"status": "recorded", "page_view": pv}, http.StatusAccepted, nil) +} + +func (s *Server) handleReadProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + return + } + var req struct { + DocID string `json:"doc_id"` + SessionID string `json:"session_id"` + ReadID string `json:"read_id"` + DurationSeconds int `json:"duration_seconds"` + ScrollDepth float64 `json:"scroll_depth"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + req.DocID = strings.TrimSpace(req.DocID) + if req.DocID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "doc_id is required") + return + } + pv := s.app.Store().RecordReadProgress(req.DocID, req.SessionID, req.ReadID, req.DurationSeconds, req.ScrollDepth) + s.writeMutation(w, map[string]any{"status": "recorded", "page_view": pv}, http.StatusAccepted, nil) +} + +// handleDocAnalytics powers the doc-page "eye" popover: a daily read trend and +// a per-reader breakdown for one document. PostHog is preferred when configured; +// otherwise the built-in first-party page-view store returns the same shape. +func (s *Server) handleDocAnalytics(w http.ResponseWriter, r *http.Request) { + docID := strings.TrimSpace(r.URL.Query().Get("doc_id")) + if docID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "doc_id is required") + return + } + days := 30 + if v := r.URL.Query().Get("days"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 180 { + days = n + } + } + stats, err := posthogDocStats(docID, days) + if err != nil { + if errors.Is(err, errPosthogNotConfigured) { + writeJSON(w, http.StatusOK, map[string]any{"source": "builtin", "stats": s.app.Store().PageReadStats(docID, days)}) + return + } + writeError(w, http.StatusBadGateway, "posthog_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"source": "posthog", "stats": stats}) +} + +func (s *Server) handleMeFavorites(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireUser(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"favorites": s.app.Store().UserFavorites(user.ID)}) + case http.MethodPost: + var req struct { + ModuleKey string `json:"module_key"` + Favorite bool `json:"favorite"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + favorites, err := s.app.Store().SetUserFavorite(user.ID, req.ModuleKey, req.Favorite) + s.writeMutation(w, map[string]any{"favorites": favorites}, http.StatusOK, err) + case http.MethodDelete: + var req struct { + ModuleKey string `json:"module_key"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + favorites, err := s.app.Store().SetUserFavorite(user.ID, req.ModuleKey, false) + s.writeMutation(w, map[string]any{"favorites": favorites}, http.StatusOK, err) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET, POST or DELETE") + } +} + +func (s *Server) handleMeRecent(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireUser(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"recent": s.app.Store().UserRecentDocs(user.ID, 30)}) + case http.MethodPost: + var req store.UserRecentDoc + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + recent, err := s.app.Store().RecordUserRecentDoc(user.ID, req) + s.writeMutation(w, map[string]any{"recent": recent}, http.StatusAccepted, err) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + } +} + +func (s *Server) handleDocFeedback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + return + } + var req struct { + DocID string `json:"doc_id"` + Rating string `json:"rating"` + Comment string `json:"comment"` + SessionID string `json:"session_id"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + req.DocID = strings.TrimSpace(req.DocID) + req.Rating = strings.TrimSpace(req.Rating) + req.Comment = strings.TrimSpace(req.Comment) + if req.DocID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "doc_id is required") + return + } + if req.Rating != "good" && req.Rating != "bad" { + writeError(w, http.StatusBadRequest, "bad_request", "rating must be good or bad") + return + } + user, _ := s.currentUser(r) + f := s.app.Store().AddDocFeedback(store.DocFeedback{ + DocID: req.DocID, Rating: req.Rating, Comment: req.Comment, UserID: user.ID, SessionID: req.SessionID, + }) + s.writeMutation(w, map[string]any{"status": "recorded", "feedback": f}, http.StatusAccepted, nil) +} + +func (s *Server) handleDocFeedbackLogs(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireConsole(w, r) + if !ok { + return + } + logs := s.app.Store().DocFeedbacks() + type out struct { + store.DocFeedback + DisplayName string `json:"display_name"` + } + res := make([]out, 0, len(logs)) + set, all := s.accessibleCategoryIDs(user) + for _, log := range logs { + if !all && !categoriesIntersect(s.docCategoryIDs(log.DocID), set) { + continue + } + dn := "" + if log.UserID != "" { + if u, err := s.app.Store().UserByID(log.UserID); err == nil { + dn = u.DisplayName + if dn == "" { + dn = u.Username + } + } + } + res = append(res, out{DocFeedback: log, DisplayName: dn}) + } + if kw := keywordOf(r); kw != "" { + filtered := res[:0:0] + for _, l := range res { + if containsFold(l.DocID, kw) || containsFold(l.Title, kw) || containsFold(l.ModuleKey, kw) || containsFold(l.Rating, kw) || containsFold(l.Comment, kw) || containsFold(l.DisplayName, kw) || containsFold(l.UserID, kw) { + filtered = append(filtered, l) + } + } + res = filtered + } + if wantsPage(r) { + page, limit := pageParams(r) + writeJSON(w, http.StatusOK, paginate(res, page, limit)) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) handleSearchLogs(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireConsole(w, r) + if !ok { + return + } + logs := s.app.Store().SearchLogs() + type out struct { + store.SearchLog + DisplayName string `json:"display_name"` + } + res := make([]out, 0, len(logs)) + set, all := s.accessibleCategoryIDs(user) + for _, log := range logs { + // Team admins see searches scoped to, or clicked within, owned categories. + if !all && !categoriesIntersect(s.searchLogCategoryIDs(log), set) { + continue + } + dn := "" + if log.UserID != "" { + if u, err := s.app.Store().UserByID(log.UserID); err == nil { + dn = u.DisplayName + if dn == "" { + dn = u.Username + } + } + } + res = append(res, out{SearchLog: log, DisplayName: dn}) + } + if kw := keywordOf(r); kw != "" { + filtered := res[:0:0] + for _, l := range res { + if containsFold(l.Query, kw) || containsFold(l.DisplayName, kw) || containsFold(l.UserID, kw) || containsFold(l.IPAddress, kw) { + filtered = append(filtered, l) + } + } + res = filtered + } + if wantsPage(r) { + page, limit := pageParams(r) + writeJSON(w, http.StatusOK, paginate(res, page, limit)) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) searchLogCategoryIDs(log store.SearchLog) []string { + if cats := s.docCategoryIDs(log.ClickedDocID); len(cats) > 0 { + return cats + } + if strings.TrimSpace(log.FiltersJSON) == "" { + return nil + } + var filters struct { + CategoryIDs []string `json:"category_ids"` + Modules []string `json:"modules"` + } + if err := json.Unmarshal([]byte(log.FiltersJSON), &filters); err != nil { + return nil + } + out := append([]string{}, filters.CategoryIDs...) + for _, moduleKey := range filters.Modules { + out = append(out, s.moduleCategories(moduleKey)...) + } + return out +} + +func (s *Server) handleMCPLogs(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireConsole(w, r) + if !ok { + return + } + logs := s.app.Store().MCPLogs() + type out struct { + store.MCPLog + DisplayName string `json:"display_name"` + } + res := make([]out, 0, len(logs)) + set, all := s.accessibleCategoryIDs(user) + for _, log := range logs { + // Team admins only see MCP calls resolvable to an owned category. + if !all && !categoriesIntersect(s.mcpLogCategoryIDs(log.InputJSON), set) { + continue + } + dn := "" + if log.UserID != "" { + if u, err := s.app.Store().UserByID(log.UserID); err == nil { + dn = u.DisplayName + if dn == "" { + dn = u.Username + } + } + } + res = append(res, out{MCPLog: log, DisplayName: dn}) + } + if kw := keywordOf(r); kw != "" { + filtered := res[:0:0] + for _, l := range res { + if containsFold(l.ToolName, kw) || containsFold(l.Query, kw) || containsFold(l.DisplayName, kw) || containsFold(l.UserID, kw) { + filtered = append(filtered, l) + } + } + res = filtered + } + if wantsPage(r) { + page, limit := pageParams(r) + writeJSON(w, http.StatusOK, paginate(res, page, limit)) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) handleMCPLog(w http.ResponseWriter, r *http.Request) { + user, ok := s.mcpLogUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "MCP log requires a session, OAuth access token, or personal MCP token") + return + } + var req struct { + ToolName string `json:"tool_name"` + Query string `json:"query"` + InputJSON string `json:"input_json"` + ResultCount int `json:"result_count"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + s.app.Store().AddMCPLog(store.MCPLog{ID: fmt.Sprintf("ml-%d", time.Now().UnixNano()), ToolName: req.ToolName, UserID: user.ID, Query: req.Query, InputJSON: req.InputJSON, ResultCount: req.ResultCount, CreatedAt: time.Now().UTC()}) + s.writeMutation(w, map[string]any{"status": "logged"}, http.StatusAccepted, nil) +} + +func (s *Server) mcpLogUser(r *http.Request) (store.User, bool) { + if user, ok := s.currentUser(r); ok { + return user, true + } + if tok := bearerToken(r); tok != "" { + if user, err := s.app.Store().UserByMCPToken(tok); err == nil { + return user, true + } + } + return store.User{}, false +} + +func (s *Server) handleMCPTokenInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + token := bearerToken(r) + if token == "" { + writeError(w, http.StatusUnauthorized, "invalid_token", "bearer token is required") + return + } + if user, _, grant, err := s.app.Store().UserByOAuthAccessToken(token); err == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": user.ID, + "scopes": grant.Scopes, + "expires_at": grant.AccessExpiresAt, + }) + return + } + if user, err := s.app.Store().UserByMCPToken(token); err == nil { + writeJSON(w, http.StatusOK, map[string]any{ + "user_id": user.ID, + "scopes": []string{"modex:mcp:read", "modex:docs:read"}, + "expires_at": time.Now().UTC().Add(24 * time.Hour), + }) + return + } + writeError(w, http.StatusUnauthorized, "invalid_token", "bearer token is invalid or expired") +} + +func (s *Server) handleMeMCPToken(w http.ResponseWriter, r *http.Request) { + user, ok := s.currentUser(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized", "not logged in") + return + } + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]string{"mcp_token": user.MCPToken}) + case http.MethodPost: + tok, err := randomToken(32) + if err != nil { + writeError(w, http.StatusInternalServerError, "token_gen_failed", err.Error()) + return + } + updated, err := s.app.Store().SetUserMCPToken(user.ID, tok) + if err != nil { + writeError(w, http.StatusInternalServerError, "update_failed", err.Error()) + return + } + s.writeMutation(w, map[string]string{"mcp_token": updated.MCPToken}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + } +} + +// currentUser returns the authenticated user from the session cookie. Login is +// a real, cookie-backed OIDC action, so there is no silent impersonation; +// anonymous callers simply get ok == false. diff --git a/backend/internal/api/deploy_limiter.go b/backend/internal/api/deploy_limiter.go new file mode 100644 index 0000000..5b63c84 --- /dev/null +++ b/backend/internal/api/deploy_limiter.go @@ -0,0 +1,148 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "log" + "time" + + "github.com/redis/go-redis/v9" +) + +// deployLimiter bounds how many artifacts are ingested concurrently. acquire +// blocks until a slot is free, the wait budget runs out, or the client +// disconnects; the returned release frees the slot and must be deferred by the +// caller. ok is false when no slot could be acquired. +type deployLimiter interface { + acquire(ctx context.Context) (release func(), ok bool) +} + +// newDeployLimiter returns a Redis-backed distributed limiter when a shared +// client is available so the bound is global across replicas; otherwise it +// returns a per-process limiter. A non-positive max disables limiting. +func newDeployLimiter(client *redis.Client) deployLimiter { + max := envPositiveInt("DEPLOY_MAX_CONCURRENT", 2) + if max <= 0 { + return nil + } + if client != nil { + log.Printf("deploy limiter: using shared Redis backend (global max %d)", max) + return &redisDeployLimiter{client: client, key: "modex:deploysem", max: max} + } + return &localDeployLimiter{sem: make(chan struct{}, max)} +} + +func deployWait() time.Duration { + return time.Duration(envPositiveInt("DEPLOY_QUEUE_WAIT_SECONDS", 15)) * time.Second +} + +// localDeployLimiter bounds concurrency within a single process via a buffered +// channel acting as a semaphore. +type localDeployLimiter struct { + sem chan struct{} +} + +func (l *localDeployLimiter) acquire(ctx context.Context) (func(), bool) { + timer := time.NewTimer(deployWait()) + defer timer.Stop() + select { + case l.sem <- struct{}{}: + return func() { <-l.sem }, true + case <-ctx.Done(): + return nil, false + case <-timer.C: + return nil, false + } +} + +// redisDeployLimiter is a distributed semaphore backed by a Redis sorted set. +// Each holder is a unique member scored by its acquisition time. Acquisition +// first evicts holders older than the lease TTL, so a replica that crashes +// mid-deploy never holds a slot forever; release removes the member. +type redisDeployLimiter struct { + client *redis.Client + key string + max int +} + +// acquireScript atomically reclaims expired holders and, if there is room, +// admits the caller. Returns 1 when admitted, 0 when full. +// +// KEYS[1] = sorted set key +// ARGV[1] = now (unix ms) ARGV[2] = lease TTL (ms) +// ARGV[3] = max holders ARGV[4] = caller's unique member token +var deployAcquireScript = redis.NewScript(` +local now = tonumber(ARGV[1]) +local ttl = tonumber(ARGV[2]) +local max = tonumber(ARGV[3]) +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - ttl) +if redis.call('ZCARD', KEYS[1]) < max then + redis.call('ZADD', KEYS[1], now, ARGV[4]) + redis.call('PEXPIRE', KEYS[1], ttl) + return 1 +end +return 0 +`) + +func (l *redisDeployLimiter) leaseTTL() time.Duration { + // Must exceed the longest possible deploy so an in-flight holder is not + // evicted while still working (which would over-admit). Defaults to 10m. + return time.Duration(envPositiveInt("DEPLOY_SLOT_TTL_SECONDS", 600)) * time.Second +} + +func (l *redisDeployLimiter) acquire(ctx context.Context) (func(), bool) { + member := deployMemberToken() + ttl := l.leaseTTL() + deadline := time.Now().Add(deployWait()) + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for { + if l.tryAcquire(ctx, member, ttl) { + return func() { l.release(member) }, true + } + if time.Now().After(deadline) { + return nil, false + } + select { + case <-ctx.Done(): + return nil, false + case <-ticker.C: + } + } +} + +func (l *redisDeployLimiter) tryAcquire(ctx context.Context, member string, ttl time.Duration) bool { + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + now := time.Now().UnixMilli() + admitted, err := deployAcquireScript.Run(ctx, l.client, []string{l.key}, + now, ttl.Milliseconds(), l.max, member).Int64() + if err != nil { + // Fail open: Redis trouble should not block deploys entirely. The local + // HTTP timeouts and per-instance request limits still cap load. + log.Printf("deploy limiter: redis acquire failed (%v); admitting", err) + return true + } + return admitted == 1 +} + +func (l *redisDeployLimiter) release(member string) { + // Detached from the request context, which is already cancelled by the time + // the deferred release runs. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := l.client.ZRem(ctx, l.key, member).Err(); err != nil { + // The lease TTL reclaims the slot anyway; just record the slower path. + log.Printf("deploy limiter: redis release failed (%v); slot reclaims on TTL", err) + } +} + +func deployMemberToken() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + // Fall back to a timestamp; collisions only risk a transient miscount. + return "t-" + time.Now().Format("20060102150405.000000000") + } + return hex.EncodeToString(buf) +} diff --git a/backend/internal/api/llm.go b/backend/internal/api/llm.go new file mode 100644 index 0000000..5c2a417 --- /dev/null +++ b/backend/internal/api/llm.go @@ -0,0 +1,456 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "modex/backend/internal/store" +) + +// LLM API formats supported for the AI-ask feature. The frontend exposes these +// as selectable "API 格式" options so any mainstream provider can be wired in. +const ( + protoOpenAIChat = "openai-chat" // POST /chat/completions (OpenAI & all compatible vendors) + protoOpenAIResponses = "openai-responses" // POST /responses (OpenAI Responses API) + protoAnthropic = "anthropic" // POST /v1/messages (Anthropic Messages) + protoGemini = "gemini" // POST /v1beta/models/{m}:generateContent (Google Gemini) +) + +func normalizeProtocol(p string) string { + switch strings.TrimSpace(strings.ToLower(p)) { + case protoOpenAIResponses: + return protoOpenAIResponses + case protoAnthropic: + return protoAnthropic + case protoGemini: + return protoGemini + default: + return protoOpenAIChat + } +} + +// Engine defaults applied when the admin leaves the fields unset. max_tokens is +// generous so long answers are not truncated (Anthropic *requires* the field); +// temperature is low for grounded, deterministic doc answers. +const ( + defaultAskMaxTokens = 4096 + defaultAskTemperature = 0.2 +) + +func maxTokensOf(ai store.AISettings) int { + if ai.AskMaxTokens > 0 { + return ai.AskMaxTokens + } + return defaultAskMaxTokens +} + +func temperatureOf(ai store.AISettings) float64 { + if ai.AskTemperature != nil { + return *ai.AskTemperature + } + return defaultAskTemperature +} + +// chatComplete sends a single-turn (system + user) completion request using the +// protocol configured in settings and returns the assistant's text. +func chatComplete(ctx context.Context, ai store.AISettings, system, user string) (string, error) { + base := strings.TrimRight(strings.TrimSpace(ai.AskBaseURL), "/") + temp := temperatureOf(ai) + switch normalizeProtocol(ai.AskProtocol) { + case protoAnthropic: + // max_tokens is required by Anthropic; the others default to the model max. + return chatAnthropic(ctx, base, ai.AskAPIKey, ai.AskModel, maxTokensOf(ai), temp, system, user) + case protoGemini: + return chatGemini(ctx, base, ai.AskAPIKey, ai.AskModel, temp, system, user) + case protoOpenAIResponses: + return chatOpenAIResponses(ctx, base, ai.AskAPIKey, ai.AskModel, temp, system, user) + default: + return chatOpenAIChat(ctx, base, ai.AskAPIKey, ai.AskModel, temp, system, user) + } +} + +func chatCompleteStream(ctx context.Context, ai store.AISettings, system, user string, onDelta func(string) bool) error { + if normalizeProtocol(ai.AskProtocol) != protoOpenAIChat { + return fmt.Errorf("streaming is only supported for OpenAI Chat compatible protocol") + } + base := strings.TrimRight(strings.TrimSpace(ai.AskBaseURL), "/") + return chatOpenAIChatStream(ctx, base, ai.AskAPIKey, ai.AskModel, temperatureOf(ai), system, user, onDelta) +} + +// listModels fetches available model ids from the provider for the given +// protocol so the admin never has to type a model name by hand. +func listModels(ctx context.Context, protocol, base, key string) ([]string, error) { + base = strings.TrimRight(strings.TrimSpace(base), "/") + switch normalizeProtocol(protocol) { + case protoAnthropic: + return modelsAnthropic(ctx, base, key) + case protoGemini: + return modelsGemini(ctx, base, key) + default: // openai-chat & openai-responses share the /models listing + return modelsOpenAI(ctx, base, key) + } +} + +func httpJSON(ctx context.Context, method, url string, headers map[string]string, body any) ([]byte, int, error) { + var reader io.Reader + if body != nil { + raw, _ := json.Marshal(body) + reader = strings.NewReader(string(raw)) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return nil, 0, err + } + for k, v := range headers { + if v != "" { + req.Header.Set(k, v) + } + } + resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + return raw, resp.StatusCode, nil +} + +func chatOpenAIChatStream(ctx context.Context, base, key, model string, temperature float64, system, user string, onDelta func(string) bool) error { + raw, _ := json.Marshal(map[string]any{ + "model": model, + "messages": []map[string]string{ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + }, + "temperature": temperature, + "stream": true, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", strings.NewReader(string(raw))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if key != "" { + req.Header.Set("Authorization", bearer(key)) + } + resp, err := (&http.Client{Timeout: 0}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("chat endpoint %d: %s", resp.StatusCode, string(body)) + } + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, ":") { + continue + } + line = strings.TrimPrefix(line, "data:") + line = strings.TrimSpace(line) + if line == "[DONE]" { + return nil + } + var event struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(line), &event); err != nil { + continue + } + for _, choice := range event.Choices { + if choice.Delta.Content != "" && !onDelta(choice.Delta.Content) { + return nil + } + } + } + return scanner.Err() +} + +// ---- OpenAI Chat Completions ------------------------------------------------ + +func chatOpenAIChat(ctx context.Context, base, key, model string, temperature float64, system, user string) (string, error) { + raw, code, err := httpJSON(ctx, http.MethodPost, base+"/chat/completions", + map[string]string{"Content-Type": "application/json", "Authorization": bearer(key)}, + map[string]any{ + "model": model, + "messages": []map[string]string{ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + }, + "temperature": temperature, + "stream": false, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("chat endpoint %d: %s", code, string(raw)) + } + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + if len(out.Choices) == 0 { + return "", fmt.Errorf("chat endpoint returned no choices") + } + return out.Choices[0].Message.Content, nil +} + +// ---- OpenAI Responses API --------------------------------------------------- + +func chatOpenAIResponses(ctx context.Context, base, key, model string, temperature float64, system, user string) (string, error) { + raw, code, err := httpJSON(ctx, http.MethodPost, base+"/responses", + map[string]string{"Content-Type": "application/json", "Authorization": bearer(key)}, + map[string]any{ + "model": model, + "instructions": system, + "input": user, + "temperature": temperature, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("responses endpoint %d: %s", code, string(raw)) + } + // Prefer the flattened convenience field; fall back to walking output[]. + var out struct { + OutputText string `json:"output_text"` + Output []struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + if strings.TrimSpace(out.OutputText) != "" { + return out.OutputText, nil + } + for _, o := range out.Output { + for _, c := range o.Content { + if c.Text != "" { + return c.Text, nil + } + } + } + return "", fmt.Errorf("responses endpoint returned no text") +} + +// ---- Anthropic Messages ----------------------------------------------------- + +// anthropicBase normalizes a base URL to the host root (Messages lives at +// /v1/messages), tolerating a base entered with or without a trailing /v1. +func anthropicBase(base string) string { + return strings.TrimSuffix(strings.TrimRight(base, "/"), "/v1") +} + +func chatAnthropic(ctx context.Context, base, key, model string, maxTokens int, temperature float64, system, user string) (string, error) { + headers := map[string]string{ + "Content-Type": "application/json", + "x-api-key": key, + "anthropic-version": "2023-06-01", + } + raw, code, err := httpJSON(ctx, http.MethodPost, anthropicBase(base)+"/v1/messages", headers, + map[string]any{ + "model": model, + "max_tokens": maxTokens, + "temperature": temperature, + "system": system, + "messages": []map[string]string{ + {"role": "user", "content": user}, + }, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("anthropic endpoint %d: %s", code, string(raw)) + } + var out struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + for _, c := range out.Content { + if c.Type == "text" && c.Text != "" { + return c.Text, nil + } + } + return "", fmt.Errorf("anthropic endpoint returned no text") +} + +// ---- Google Gemini ---------------------------------------------------------- + +// geminiBase normalizes to the API root; generateContent lives under +// /v1beta/models/{model}:generateContent. +func geminiBase(base string) string { + b := strings.TrimRight(base, "/") + b = strings.TrimSuffix(b, "/v1beta") + b = strings.TrimSuffix(b, "/v1") + return b +} + +func chatGemini(ctx context.Context, base, key, model string, temperature float64, system, user string) (string, error) { + // Pass the key via the x-goog-api-key header rather than a ?key= query param + // so it does not end up in proxy/gateway access logs. + url := fmt.Sprintf("%s/v1beta/models/%s:generateContent", geminiBase(base), model) + raw, code, err := httpJSON(ctx, http.MethodPost, url, + map[string]string{"Content-Type": "application/json", "x-goog-api-key": key}, + map[string]any{ + "systemInstruction": map[string]any{"parts": []map[string]string{{"text": system}}}, + "contents": []map[string]any{ + {"role": "user", "parts": []map[string]string{{"text": user}}}, + }, + "generationConfig": map[string]any{"temperature": temperature}, + }) + if err != nil { + return "", err + } + if code >= 300 { + return "", fmt.Errorf("gemini endpoint %d: %s", code, string(raw)) + } + var out struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + for _, c := range out.Candidates { + for _, p := range c.Content.Parts { + if p.Text != "" { + return p.Text, nil + } + } + } + return "", fmt.Errorf("gemini endpoint returned no text") +} + +// ---- Model listing per protocol --------------------------------------------- + +func modelsOpenAI(ctx context.Context, base, key string) ([]string, error) { + raw, code, err := httpJSON(ctx, http.MethodGet, base+"/models", + map[string]string{"Authorization": bearer(key)}, nil) + if err != nil { + return nil, err + } + if code >= 300 { + return nil, fmt.Errorf("%d: %s", code, string(raw)) + } + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + _ = json.Unmarshal(raw, &parsed) + ids := make([]string, 0, len(parsed.Data)) + for _, m := range parsed.Data { + if m.ID != "" { + ids = append(ids, m.ID) + } + } + return ids, nil +} + +func modelsAnthropic(ctx context.Context, base, key string) ([]string, error) { + headers := map[string]string{"x-api-key": key, "anthropic-version": "2023-06-01"} + root := anthropicBase(base) + ids := make([]string, 0, 16) + afterID := "" + // The models list is paginated; follow has_more / last_id so every model is + // returned, not just the first page. Cap the loop defensively. + for page := 0; page < 20; page++ { + url := root + "/v1/models?limit=1000" + if afterID != "" { + url += "&after_id=" + afterID + } + raw, code, err := httpJSON(ctx, http.MethodGet, url, headers, nil) + if err != nil { + return nil, err + } + if code >= 300 { + return nil, fmt.Errorf("%d: %s", code, string(raw)) + } + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + HasMore bool `json:"has_more"` + LastID string `json:"last_id"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, err + } + for _, m := range parsed.Data { + if m.ID != "" { + ids = append(ids, m.ID) + } + } + if !parsed.HasMore || parsed.LastID == "" { + break + } + afterID = parsed.LastID + } + return ids, nil +} + +func modelsGemini(ctx context.Context, base, key string) ([]string, error) { + raw, code, err := httpJSON(ctx, http.MethodGet, + geminiBase(base)+"/v1beta/models?pageSize=1000", + map[string]string{"x-goog-api-key": key}, nil) + if err != nil { + return nil, err + } + if code >= 300 { + return nil, fmt.Errorf("%d: %s", code, string(raw)) + } + var parsed struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + _ = json.Unmarshal(raw, &parsed) + ids := make([]string, 0, len(parsed.Models)) + for _, m := range parsed.Models { + ids = append(ids, strings.TrimPrefix(m.Name, "models/")) + } + return ids, nil +} + +func bearer(key string) string { + if key == "" { + return "" + } + return "Bearer " + key +} diff --git a/backend/internal/api/mcpdist.go b/backend/internal/api/mcpdist.go new file mode 100644 index 0000000..6b17f66 --- /dev/null +++ b/backend/internal/api/mcpdist.go @@ -0,0 +1,238 @@ +package api + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" +) + +// MCP tool distribution: serve the zero-dependency npx package (mcp/npx) straight +// from this deployment so users can install the MCP server without public npm +// (intranet-friendly). The per-user MCP token still gates real API access. + +var ( + mcpTgzOnce sync.Once + mcpTgzData []byte + mcpTgzErr error + skillTgzOnce sync.Once + skillTgzData []byte + skillTgzErr error +) + +// mcpDistDir resolves the directory holding the npx package. Configured via +// MCP_DIST_DIR (set in Docker); falls back to common dev locations. +func mcpDistDir() string { + if d := strings.TrimSpace(os.Getenv("MCP_DIST_DIR")); d != "" { + return d + } + for _, c := range []string{"../mcp/npx", "mcp/npx", "../../mcp/npx"} { + if st, err := os.Stat(filepath.Join(c, "package.json")); err == nil && !st.IsDir() { + return c + } + } + return "" +} + +// skillDistDir resolves the optional Modex Skill directory served for users who +// install client-side guidance with `npx skills add`. +func skillDistDir() string { + if d := strings.TrimSpace(os.Getenv("MODEX_SKILL_DIST_DIR")); d != "" { + return d + } + for _, c := range []string{"../mcp/skill", "mcp/skill", "../../mcp/skill", "../../../mcp/skill"} { + if st, err := os.Stat(filepath.Join(c, "SKILL.md")); err == nil && !st.IsDir() { + return c + } + } + return "" +} + +// mcpDistFiles is the fixed allowlist of files we expose (no traversal). +func mcpDistFiles(dir string) []string { + out := []string{} + for _, n := range []string{"index.mjs", "package.json", "README.md"} { + if st, err := os.Stat(filepath.Join(dir, n)); err == nil && !st.IsDir() { + out = append(out, n) + } + } + return out +} + +// handleMcpDist serves the package listing, individual files, and an npm-style +// tarball. Public (the tool source is not secret). +func (s *Server) handleMcpDist(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + rest := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/api/mcp/dist"), "/") + + if rest == "modex-skill.tgz" { + data, err := skillTarball() + if err != nil || len(data) == 0 { + writeError(w, http.StatusInternalServerError, "tarball_failed", "无法生成 Modex Skill 压缩包") + return + } + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", `attachment; filename="modex-skill.tgz"`) + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(data) + return + } + + dir := mcpDistDir() + if dir == "" { + writeError(w, http.StatusServiceUnavailable, "unavailable", "MCP 工具产物未配置(设置 MCP_DIST_DIR)") + return + } + + if rest == "" { + writeJSON(w, http.StatusOK, map[string]any{ + "package": "modex-mcp", + "files": mcpDistFiles(dir), + "tarball": "/api/mcp/dist/modex-mcp.tgz", + "skill_tarball": "/api/mcp/dist/modex-skill.tgz", + }) + return + } + + if rest == "modex-mcp.tgz" || rest == "modex-docs-mcp.tgz" { + data, err := mcpTarball(dir) + if err != nil || len(data) == 0 { + writeError(w, http.StatusInternalServerError, "tarball_failed", "无法生成 MCP 工具压缩包") + return + } + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", `attachment; filename="modex-mcp.tgz"`) + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(data) + return + } + + // Single file from the allowlist; reject any path tricks. + if strings.ContainsAny(rest, "/\\") || strings.Contains(rest, "..") { + writeError(w, http.StatusBadRequest, "bad_request", "invalid file name") + return + } + allowed := false + for _, n := range mcpDistFiles(dir) { + if n == rest { + allowed = true + break + } + } + if !allowed { + writeError(w, http.StatusNotFound, "not_found", "file not found") + return + } + b, err := os.ReadFile(filepath.Join(dir, rest)) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "file not found") + return + } + w.Header().Set("Content-Type", contentTypeForName(rest, b)) + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(b) +} + +func (s *Server) handleSkillDiscovery(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + data, err := skillTarball() + if err != nil || len(data) == 0 { + writeError(w, http.StatusServiceUnavailable, "unavailable", "Modex Skill 产物未配置(设置 MODEX_SKILL_DIST_DIR)") + return + } + sum := sha256.Sum256(data) + writeJSON(w, http.StatusOK, map[string]any{ + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": []map[string]any{ + { + "name": "modex", + "description": "Use Modex MCP to search and read a team's live Modex documentation portal before answering module, API, release, architecture, or platform-specific questions.", + "type": "archive", + "url": "/api/mcp/dist/modex-skill.tgz", + "digest": fmt.Sprintf("sha256:%x", sum[:]), + }, + }, + }) +} + +// mcpTarball builds (once) an npm-installable gzip tarball: a gzipped tar whose +// entries are prefixed with "package/", which `npx -y ` accepts. +func mcpTarball(dir string) ([]byte, error) { + mcpTgzOnce.Do(func() { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, n := range mcpDistFiles(dir) { + b, err := os.ReadFile(filepath.Join(dir, n)) + if err != nil { + mcpTgzErr = err + return + } + if err := tw.WriteHeader(&tar.Header{Name: "package/" + n, Mode: 0o644, Size: int64(len(b))}); err != nil { + mcpTgzErr = err + return + } + if _, err := tw.Write(b); err != nil { + mcpTgzErr = err + return + } + } + if err := tw.Close(); err != nil { + mcpTgzErr = err + return + } + if err := gz.Close(); err != nil { + mcpTgzErr = err + return + } + mcpTgzData = buf.Bytes() + }) + return mcpTgzData, mcpTgzErr +} + +func skillTarball() ([]byte, error) { + skillTgzOnce.Do(func() { + dir := skillDistDir() + if dir == "" { + return + } + b, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + if err != nil { + skillTgzErr = err + return + } + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: "SKILL.md", Mode: 0o644, Size: int64(len(b))}); err != nil { + skillTgzErr = err + return + } + if _, err := tw.Write(b); err != nil { + skillTgzErr = err + return + } + if err := tw.Close(); err != nil { + skillTgzErr = err + return + } + if err := gz.Close(); err != nil { + skillTgzErr = err + return + } + skillTgzData = buf.Bytes() + }) + return skillTgzData, skillTgzErr +} diff --git a/backend/internal/api/mcpdist_test.go b/backend/internal/api/mcpdist_test.go new file mode 100644 index 0000000..9b5f0ec --- /dev/null +++ b/backend/internal/api/mcpdist_test.go @@ -0,0 +1,49 @@ +package api + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "modex/backend/internal/store" +) + +func TestSkillDiscoveryIndexMatchesTarballDigest(t *testing.T) { + srv := New(store.NewTestStore()) + + idxReq := httptest.NewRequest(http.MethodGet, "/.well-known/agent-skills/index.json", nil) + idxRec := httptest.NewRecorder() + srv.Handler().ServeHTTP(idxRec, idxReq) + if idxRec.Code != http.StatusOK { + t.Fatalf("index status = %d, body = %s", idxRec.Code, idxRec.Body.String()) + } + + var idx struct { + Skills []struct { + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Digest string `json:"digest"` + } `json:"skills"` + } + if err := json.Unmarshal(idxRec.Body.Bytes(), &idx); err != nil { + t.Fatalf("decode index: %v", err) + } + if len(idx.Skills) != 1 || idx.Skills[0].Name != "modex" || idx.Skills[0].Type != "archive" { + t.Fatalf("unexpected skills index: %+v", idx.Skills) + } + + tgzReq := httptest.NewRequest(http.MethodGet, idx.Skills[0].URL, nil) + tgzRec := httptest.NewRecorder() + srv.Handler().ServeHTTP(tgzRec, tgzReq) + if tgzRec.Code != http.StatusOK { + t.Fatalf("tarball status = %d, body = %s", tgzRec.Code, tgzRec.Body.String()) + } + sum := sha256.Sum256(tgzRec.Body.Bytes()) + if got, want := fmt.Sprintf("sha256:%x", sum[:]), idx.Skills[0].Digest; got != want { + t.Fatalf("digest = %s, want %s", got, want) + } +} diff --git a/backend/internal/api/oauth.go b/backend/internal/api/oauth.go new file mode 100644 index 0000000..b5a2c23 --- /dev/null +++ b/backend/internal/api/oauth.go @@ -0,0 +1,387 @@ +package api + +import ( + "encoding/base64" + "fmt" + "html" + "net/http" + "net/url" + "strings" + "time" + + "modex/backend/internal/store" +) + +const ( + oauthCodeTTL = 10 * time.Minute + oauthAccessTTL = 60 * time.Minute + oauthRefreshTTL = 90 * 24 * time.Hour +) + +type connectedAppResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + RedirectURIs []string `json:"redirect_uris"` + Scopes []string `json:"scopes"` + Trusted bool `json:"trusted"` + Enabled bool `json:"enabled"` + CreatedBy string `json:"created_by,omitempty"` + LastUsedAt time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func connectedAppOut(app store.ConnectedApp, secret string) connectedAppResponse { + return connectedAppResponse{ + ID: app.ID, Name: app.Name, Description: app.Description, ClientID: app.ClientID, ClientSecret: secret, + RedirectURIs: app.RedirectURIs, Scopes: app.Scopes, Trusted: app.Trusted, Enabled: app.Enabled, + CreatedBy: app.CreatedBy, LastUsedAt: app.LastUsedAt, CreatedAt: app.CreatedAt, UpdatedAt: app.UpdatedAt, + } +} + +func (s *Server) handleOAuthMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + base := strings.TrimRight(s.app.Auth().Config().AppBaseURL, "/") + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": base, + "authorization_endpoint": base + "/oauth/authorize", + "token_endpoint": base + "/oauth/token", + "revocation_endpoint": base + "/oauth/revoke", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"}, + "scopes_supported": []string{"modex:mcp:read", "modex:docs:read"}, + }) +} + +func (s *Server) handleAdminConnectedApps(w http.ResponseWriter, r *http.Request) { + user, ok := s.requireSuperAdmin(w, r) + if !ok { + return + } + switch r.Method { + case http.MethodGet: + apps := s.app.Store().ConnectedApps() + out := make([]connectedAppResponse, 0, len(apps)) + for _, app := range apps { + out = append(out, connectedAppOut(app, "")) + } + writeJSON(w, http.StatusOK, map[string]any{"apps": out}) + case http.MethodPost: + var req struct { + Name string `json:"name"` + Description string `json:"description"` + ClientID string `json:"client_id"` + RedirectURIs []string `json:"redirect_uris"` + Scopes []string `json:"scopes"` + Trusted bool `json:"trusted"` + Enabled *bool `json:"enabled"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + clientID := strings.TrimSpace(req.ClientID) + if clientID == "" { + suffix, err := randomToken(12) + if err != nil { + writeError(w, http.StatusInternalServerError, "token_gen_failed", err.Error()) + return + } + clientID = "modex_" + suffix + } + secret, err := randomToken(32) + if err != nil { + writeError(w, http.StatusInternalServerError, "token_gen_failed", err.Error()) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + app, err := s.app.Store().CreateConnectedApp(store.ConnectedApp{ + Name: req.Name, Description: req.Description, ClientID: clientID, RedirectURIs: req.RedirectURIs, + Scopes: req.Scopes, Trusted: req.Trusted, Enabled: enabled, CreatedBy: user.ID, + }, secret) + s.writeMutation(w, connectedAppOut(app, secret), http.StatusCreated, err) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + } +} + +func (s *Server) handleAdminConnectedAppByID(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/admin/connected-apps/") + if id == "" { + writeError(w, http.StatusNotFound, "not_found", "connected app not found") + return + } + switch r.Method { + case http.MethodPut: + var req struct { + Name string `json:"name"` + Description string `json:"description"` + RedirectURIs []string `json:"redirect_uris"` + Scopes []string `json:"scopes"` + Trusted bool `json:"trusted"` + Enabled bool `json:"enabled"` + } + if err := decodeBody(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + app, err := s.app.Store().UpdateConnectedApp(id, store.ConnectedApp{ + Name: req.Name, Description: req.Description, RedirectURIs: req.RedirectURIs, + Scopes: req.Scopes, Trusted: req.Trusted, Enabled: req.Enabled, + }) + s.writeMutation(w, connectedAppOut(app, ""), http.StatusOK, err) + case http.MethodDelete: + s.writeMutation(w, map[string]string{"status": "deleted"}, http.StatusOK, s.app.Store().DeleteConnectedApp(id)) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use PUT or DELETE") + } +} + +func (s *Server) handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + return + } + user, ok := s.app.Auth().CurrentUser(r) + if !ok { + login := s.app.Auth().Config().AppBaseURL + "/api/auth/login?next=" + url.QueryEscape(r.URL.RequestURI()) + http.Redirect(w, r, login, http.StatusFound) + return + } + q := r.URL.Query() + clientID := q.Get("client_id") + redirectURI := q.Get("redirect_uri") + state := q.Get("state") + if q.Get("response_type") != "code" { + oauthRedirectError(w, r, redirectURI, state, "unsupported_response_type") + return + } + app, err := s.app.Store().ConnectedAppByClientID(clientID) + if err != nil || !app.Enabled || !redirectURIAllowed(app.RedirectURIs, redirectURI) { + oauthRedirectError(w, r, redirectURI, state, "invalid_client") + return + } + scopes, ok := requestedScopes(q.Get("scope"), app.Scopes) + if !ok { + oauthRedirectError(w, r, redirectURI, state, "invalid_scope") + return + } + if r.Method == http.MethodGet && !app.Trusted && q.Get("approve") != "1" { + writeOAuthConsentPage(w, r, app, scopes) + return + } + code, err := randomToken(32) + if err != nil { + oauthRedirectError(w, r, redirectURI, state, "server_error") + return + } + if _, err := s.app.Store().CreateOAuthCode(app.ID, user.ID, redirectURI, scopes, code, oauthCodeTTL); err != nil { + oauthRedirectError(w, r, redirectURI, state, "server_error") + return + } + u, _ := url.Parse(redirectURI) + out := u.Query() + out.Set("code", code) + if state != "" { + out.Set("state", state) + } + u.RawQuery = out.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (s *Server) handleOAuthToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeOAuthTokenError(w, http.StatusMethodNotAllowed, "invalid_request", "use POST") + return + } + if err := r.ParseForm(); err != nil { + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + clientID, secret := oauthClientCredentials(r) + app, err := s.authenticateOAuthClient(clientID, secret) + if err != nil { + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") + return + } + access, err := randomToken(32) + if err != nil { + writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) + return + } + refresh, err := randomToken(32) + if err != nil { + writeOAuthTokenError(w, http.StatusInternalServerError, "server_error", err.Error()) + return + } + switch r.Form.Get("grant_type") { + case "authorization_code": + grant, _, _, err := s.app.Store().RedeemOAuthCode(app.ClientID, r.Form.Get("code"), r.Form.Get("redirect_uri"), access, refresh, oauthAccessTTL, oauthRefreshTTL) + if err != nil { + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "authorization code is invalid or expired") + return + } + writeOAuthTokenResponse(w, access, refresh, grant.Scopes) + case "refresh_token": + grant, _, _, err := s.app.Store().RefreshOAuthToken(app.ClientID, r.Form.Get("refresh_token"), access, refresh, oauthAccessTTL, oauthRefreshTTL) + if err != nil { + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token is invalid or expired") + return + } + writeOAuthTokenResponse(w, access, refresh, grant.Scopes) + default: + writeOAuthTokenError(w, http.StatusBadRequest, "unsupported_grant_type", "grant_type must be authorization_code or refresh_token") + } +} + +func (s *Server) handleOAuthRevoke(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeOAuthTokenError(w, http.StatusMethodNotAllowed, "invalid_request", "use POST") + return + } + if err := r.ParseForm(); err != nil { + writeOAuthTokenError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + clientID, secret := oauthClientCredentials(r) + if _, err := s.authenticateOAuthClient(clientID, secret); err != nil { + writeOAuthTokenError(w, http.StatusUnauthorized, "invalid_client", "client authentication failed") + return + } + s.app.Store().RevokeOAuthToken(clientID, r.Form.Get("token")) + writeJSON(w, http.StatusOK, map[string]any{"revoked": true}) +} + +func (s *Server) authenticateOAuthClient(clientID, secret string) (store.ConnectedApp, error) { + if strings.TrimSpace(secret) != "" { + return s.app.Store().VerifyConnectedAppSecret(clientID, secret) + } + app, err := s.app.Store().ConnectedAppByClientID(clientID) + if err != nil { + return store.ConnectedApp{}, err + } + if !app.Enabled || app.ClientSecretHash != "" { + return store.ConnectedApp{}, store.ErrNotFound + } + return app, nil +} + +func oauthClientCredentials(r *http.Request) (string, string) { + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Basic ") { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.TrimPrefix(auth, "Basic "))) + if err == nil { + parts := strings.SplitN(string(raw), ":", 2) + if len(parts) == 2 { + id, _ := url.QueryUnescape(parts[0]) + secret, _ := url.QueryUnescape(parts[1]) + return id, secret + } + } + } + return r.Form.Get("client_id"), r.Form.Get("client_secret") +} + +func requestedScopes(raw string, allowed []string) ([]string, bool) { + if strings.TrimSpace(raw) == "" { + return allowed, true + } + allowedSet := map[string]struct{}{} + for _, s := range allowed { + allowedSet[s] = struct{}{} + } + var out []string + for _, scope := range strings.Fields(raw) { + if _, ok := allowedSet[scope]; !ok { + return nil, false + } + out = append(out, scope) + } + return out, true +} + +func redirectURIAllowed(allowed []string, redirectURI string) bool { + parsed, err := url.Parse(redirectURI) + for _, u := range allowed { + if u == redirectURI { + return true + } + if err == nil && isLoopbackRedirectBase(u) && parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()) { + return true + } + } + return false +} + +func isLoopbackRedirectBase(raw string) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == "http" && u.Path == "" && u.RawQuery == "" && isLoopbackHost(u.Hostname()) +} + +func isLoopbackHost(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func writeOAuthConsentPage(w http.ResponseWriter, r *http.Request, app store.ConnectedApp, scopes []string) { + q := r.URL.Query() + q.Set("approve", "1") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, `Authorize %s

Authorize %s

This app wants to access Modex with these scopes:

%s
`, html.EscapeString(app.Name), html.EscapeString(app.Name), html.EscapeString(strings.Join(scopes, "\n")), html.EscapeString(r.URL.Path)) + for key, vals := range q { + for _, val := range vals { + _, _ = fmt.Fprintf(w, ``, html.EscapeString(key), html.EscapeString(val)) + } + } + _, _ = fmt.Fprint(w, `
`) +} + +func oauthRedirectError(w http.ResponseWriter, r *http.Request, redirectURI, state, code string) { + if redirectURI == "" { + writeError(w, http.StatusBadRequest, code, code) + return + } + u, err := url.Parse(redirectURI) + if err != nil { + writeError(w, http.StatusBadRequest, code, code) + return + } + q := u.Query() + q.Set("error", code) + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func writeOAuthTokenResponse(w http.ResponseWriter, access, refresh string, scopes []string) { + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": access, + "token_type": "Bearer", + "expires_in": int(oauthAccessTTL.Seconds()), + "refresh_token": refresh, + "scope": strings.Join(scopes, " "), + }) +} + +func writeOAuthTokenError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]any{"error": code, "error_description": desc}) +} diff --git a/backend/internal/api/oauth_test.go b/backend/internal/api/oauth_test.go new file mode 100644 index 0000000..b655feb --- /dev/null +++ b/backend/internal/api/oauth_test.go @@ -0,0 +1,187 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "modex/backend/internal/store" +) + +func TestConnectedAppOAuthAuthorizationCodeFlow(t *testing.T) { + t.Setenv("SUPER_ADMIN_USERS", "dev") + t.Setenv("APP_BASE_URL", "http://modex.test") + + srv := New(store.NewSeededTestStore()) + handler := srv.Handler() + + // OIDC is the only login path, so mint a session cookie directly instead of + // driving a full provider round-trip. SUPER_ADMIN_USERS promotes "dev". + user := srv.app.Store().UpsertUser(store.User{ + ID: "u-dev", Username: "dev", DisplayName: "Dev User", + Email: "dev@example.com", Status: "active", + }) + login := httptest.NewRecorder() + if err := srv.app.Auth().CreateSession(login, user); err != nil { + t.Fatalf("create session: %v", err) + } + cookies := login.Result().Cookies() + + createBody := `{"name":"External MCP Client","redirect_uris":["https://client.example.com/oauth/modex/callback"],"scopes":["modex:mcp:read","modex:docs:read"],"trusted":true}` + create := httptest.NewRecorder() + createReq := httptest.NewRequest(http.MethodPost, "/api/admin/connected-apps", strings.NewReader(createBody)) + createReq.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + createReq.AddCookie(c) + } + handler.ServeHTTP(create, createReq) + if create.Code != http.StatusCreated { + t.Fatalf("create app status = %d, body=%s", create.Code, create.Body.String()) + } + var app connectedAppResponse + if err := json.Unmarshal(create.Body.Bytes(), &app); err != nil { + t.Fatal(err) + } + if app.ClientID == "" || app.ClientSecret == "" { + t.Fatalf("missing client credentials: %+v", app) + } + + authURL := "/oauth/authorize?response_type=code&client_id=" + url.QueryEscape(app.ClientID) + + "&redirect_uri=" + url.QueryEscape("https://client.example.com/oauth/modex/callback") + + "&scope=" + url.QueryEscape("modex:mcp:read modex:docs:read") + + "&state=s1" + auth := httptest.NewRecorder() + authReq := httptest.NewRequest(http.MethodGet, authURL, nil) + for _, c := range cookies { + authReq.AddCookie(c) + } + handler.ServeHTTP(auth, authReq) + if auth.Code != http.StatusFound { + t.Fatalf("authorize status = %d, body=%s", auth.Code, auth.Body.String()) + } + loc, err := url.Parse(auth.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + code := loc.Query().Get("code") + if code == "" || loc.Query().Get("state") != "s1" { + t.Fatalf("bad authorize redirect: %s", loc.String()) + } + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("redirect_uri", "https://client.example.com/oauth/modex/callback") + token := httptest.NewRecorder() + tokenReq := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenReq.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(url.QueryEscape(app.ClientID)+":"+url.QueryEscape(app.ClientSecret)))) + handler.ServeHTTP(token, tokenReq) + if token.Code != http.StatusOK { + t.Fatalf("token status = %d, body=%s", token.Code, token.Body.String()) + } + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + } + if err := json.Unmarshal(token.Body.Bytes(), &tok); err != nil { + t.Fatal(err) + } + if tok.AccessToken == "" || tok.RefreshToken == "" || !strings.Contains(tok.Scope, "modex:mcp:read") { + t.Fatalf("bad token response: %+v", tok) + } + + me := httptest.NewRecorder() + meReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + meReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + handler.ServeHTTP(me, meReq) + if me.Code != http.StatusOK || !strings.Contains(me.Body.String(), `"username":"dev"`) { + t.Fatalf("bearer me status = %d, body=%s", me.Code, me.Body.String()) + } + + info := httptest.NewRecorder() + infoReq := httptest.NewRequest(http.MethodGet, "/api/mcp/token-info", nil) + infoReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + handler.ServeHTTP(info, infoReq) + if info.Code != http.StatusOK || !strings.Contains(info.Body.String(), `"modex:mcp:read"`) { + t.Fatalf("token info status = %d, body=%s", info.Code, info.Body.String()) + } + + admin := httptest.NewRecorder() + adminReq := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil) + adminReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) + handler.ServeHTTP(admin, adminReq) + if admin.Code != http.StatusUnauthorized { + t.Fatalf("oauth bearer should not enter admin APIs, status = %d, body=%s", admin.Code, admin.Body.String()) + } +} + +func TestCodexOAuthPublicClientAuthorizationCodeFlow(t *testing.T) { + t.Setenv("SUPER_ADMIN_USERS", "dev") + t.Setenv("APP_BASE_URL", "http://modex.test") + + srv := New(store.NewSeededTestStore()) + handler := srv.Handler() + + user := srv.app.Store().UpsertUser(store.User{ + ID: "u-dev", Username: "dev", DisplayName: "Dev User", + Email: "dev@example.com", Status: "active", + }) + login := httptest.NewRecorder() + if err := srv.app.Auth().CreateSession(login, user); err != nil { + t.Fatalf("create session: %v", err) + } + cookies := login.Result().Cookies() + + redirectURI := "http://127.0.0.1:49152/callback/modex" + authURL := "/oauth/authorize?response_type=code&client_id=" + url.QueryEscape(store.CodexOAuthClientID) + + "&redirect_uri=" + url.QueryEscape(redirectURI) + + "&scope=" + url.QueryEscape("modex:mcp:read modex:docs:read") + + "&state=codex-state" + auth := httptest.NewRecorder() + authReq := httptest.NewRequest(http.MethodGet, authURL, nil) + for _, c := range cookies { + authReq.AddCookie(c) + } + handler.ServeHTTP(auth, authReq) + if auth.Code != http.StatusFound { + t.Fatalf("authorize status = %d, body=%s", auth.Code, auth.Body.String()) + } + loc, err := url.Parse(auth.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + code := loc.Query().Get("code") + if code == "" || loc.Query().Get("state") != "codex-state" { + t.Fatalf("bad authorize redirect: %s", loc.String()) + } + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", store.CodexOAuthClientID) + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + token := httptest.NewRecorder() + tokenReq := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + handler.ServeHTTP(token, tokenReq) + if token.Code != http.StatusOK { + t.Fatalf("token status = %d, body=%s", token.Code, token.Body.String()) + } + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + } + if err := json.Unmarshal(token.Body.Bytes(), &tok); err != nil { + t.Fatal(err) + } + if tok.AccessToken == "" || tok.RefreshToken == "" || !strings.Contains(tok.Scope, "modex:mcp:read") { + t.Fatalf("bad token response: %+v", tok) + } +} diff --git a/backend/internal/api/pagination.go b/backend/internal/api/pagination.go new file mode 100644 index 0000000..0107761 --- /dev/null +++ b/backend/internal/api/pagination.go @@ -0,0 +1,73 @@ +package api + +import ( + "net/http" + "strconv" + "strings" +) + +// pageResult is the envelope returned by admin list endpoints when the client +// opts into server-side pagination via ?page= / ?limit=. Endpoints stay +// backward-compatible: without those params they return the plain array so +// existing callers (dropdowns, homepage, identity derivation) keep working. +type pageResult[T any] struct { + Items []T `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// wantsPage reports whether the request opted into pagination. +func wantsPage(r *http.Request) bool { + q := r.URL.Query() + return q.Has("page") || q.Has("limit") +} + +func pageParams(r *http.Request) (page, limit int) { + page = atoiOr(r.URL.Query().Get("page"), 1) + limit = atoiOr(r.URL.Query().Get("limit"), 20) + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + return page, limit +} + +func atoiOr(s string, def int) int { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + return def +} + +// paginate slices items for the given 1-based page and returns the envelope. +func paginate[T any](items []T, page, limit int) pageResult[T] { + total := len(items) + start := (page - 1) * limit + if start > total { + start = total + } + end := start + limit + if end > total { + end = total + } + out := append([]T{}, items[start:end]...) + return pageResult[T]{Items: out, Total: total, Page: page, Limit: limit} +} + +// keywordOf returns the normalized (trimmed, lowercased) keyword query param. +func keywordOf(r *http.Request) string { + return strings.ToLower(strings.TrimSpace(r.URL.Query().Get("keyword"))) +} + +func containsFold(haystack, needleLower string) bool { + if needleLower == "" { + return true + } + return strings.Contains(strings.ToLower(haystack), needleLower) +} diff --git a/backend/internal/api/posthog.go b/backend/internal/api/posthog.go new file mode 100644 index 0000000..54fc882 --- /dev/null +++ b/backend/internal/api/posthog.go @@ -0,0 +1,175 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "modex/backend/internal/store" +) + +// errPosthogNotConfigured signals that PostHog query credentials are absent. +// The API falls back to the built-in analytics store in this case. +var errPosthogNotConfigured = errors.New("posthog not configured") + +// posthogHost returns the configured PostHog API host or the default. +func posthogHost() string { + host := strings.TrimRight(os.Getenv("POSTHOG_HOST"), "/") + if host == "" { + return "https://app.posthog.com" + } + return host +} + +// posthogConfigured reports whether the server-side PostHog query credentials +// are present. This is distinct from the frontend NEXT_PUBLIC_POSTHOG_KEY +// (capture key); querying read stats needs a personal/project API key. +func posthogConfigured() bool { + return os.Getenv("POSTHOG_PERSONAL_API_KEY") != "" && os.Getenv("POSTHOG_PROJECT_ID") != "" +} + +// PosthogConfigured and PosthogHost are exported wrappers used by package main +// (startup logging). +func PosthogConfigured() bool { return posthogConfigured() } +func PosthogHost() string { return posthogHost() } + +// posthogDocStats queries PostHog (HogQL) for the daily read trend and per-user +// reading totals/duration of one document. It returns an error when configured but +// the query fails, so callers can surface the problem instead of silently +// falling back. The event/property names match what the frontend captures: +// a "docs_page_view" event carrying a "doc_id" property. +func posthogDocStats(docID string, days int) (store.PageReadStats, error) { + if !posthogConfigured() { + return store.PageReadStats{}, errPosthogNotConfigured + } + host := posthogHost() + projectID := os.Getenv("POSTHOG_PROJECT_ID") + apiKey := os.Getenv("POSTHOG_PERSONAL_API_KEY") + + query := func(hogql string) ([][]any, error) { + body, _ := json.Marshal(map[string]any{ + "query": map[string]any{"kind": "HogQLQuery", "query": hogql}, + }) + url := fmt.Sprintf("%s/api/projects/%s/query/", host, projectID) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("posthog returned %d", resp.StatusCode) + } + var out struct { + Results [][]any `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out.Results, nil + } + + esc := strings.ReplaceAll(docID, "'", "\\'") + dailyHogQL := fmt.Sprintf( + "SELECT toDate(timestamp) AS d, count() AS c FROM events "+ + "WHERE event = 'docs_page_view' AND properties.doc_id = '%s' "+ + "AND timestamp >= now() - INTERVAL %d DAY GROUP BY d ORDER BY d", + esc, days) + readersHogQL := fmt.Sprintf( + "SELECT reader, user_id, count() AS c, avg(duration) AS avg_duration, max(last) AS last_read "+ + "FROM (SELECT coalesce(person.properties.name, person.properties.email, distinct_id) AS reader, "+ + "distinct_id AS user_id, properties.read_id AS read_id, "+ + "max(toFloat64OrZero(toString(properties.duration_seconds))) AS duration, max(timestamp) AS last "+ + "FROM events WHERE event = 'docs_page_read' AND properties.doc_id = '%s' "+ + "AND timestamp >= now() - INTERVAL %d DAY GROUP BY reader, user_id, read_id) "+ + "GROUP BY reader, user_id ORDER BY c DESC LIMIT 200", + esc, days) + + dailyRows, err1 := query(dailyHogQL) + readerRows, err2 := query(readersHogQL) + if err1 != nil { + return store.PageReadStats{}, fmt.Errorf("posthog daily query failed: %w", err1) + } + if err2 != nil { + return store.PageReadStats{}, fmt.Errorf("posthog readers query failed: %w", err2) + } + + // Build a zero-filled day window so the chart has no gaps. + today := time.Now().UTC().Truncate(24 * time.Hour) + idx := map[string]int{} + daily := make([]store.DailyReadPoint, days) + for i := 0; i < days; i++ { + key := today.AddDate(0, 0, -(days - 1 - i)).Format("2006-01-02") + daily[i] = store.DailyReadPoint{Date: key, Count: 0} + idx[key] = i + } + total := 0 + for _, row := range dailyRows { + if len(row) < 2 { + continue + } + date := fmt.Sprintf("%v", row[0]) + if len(date) > 10 { + date = date[:10] + } + c := toInt(row[1]) + if i, ok := idx[date]; ok { + daily[i].Count = c + } + total += c + } + + readers := make([]store.ReaderStat, 0, len(readerRows)) + totalDuration := 0 + totalTimedReads := 0 + for _, row := range readerRows { + if len(row) < 5 { + continue + } + name := fmt.Sprintf("%v", row[0]) + if name == "" || name == "" { + name = "匿名" + } + count := toInt(row[2]) + avgDuration := toInt(row[3]) + last, _ := time.Parse(time.RFC3339, fmt.Sprintf("%v", row[4])) + readers = append(readers, store.ReaderStat{ + Reader: name, UserID: fmt.Sprintf("%v", row[1]), Count: count, + AvgDurationSec: avgDuration, LastReadAt: last, + }) + totalDuration += count * avgDuration + totalTimedReads += count + } + avgDuration := 0 + if totalTimedReads > 0 { + avgDuration = totalDuration / totalTimedReads + } + return store.PageReadStats{ + DocID: docID, Total: total, AvgDurationSec: avgDuration, + Daily: daily, Readers: readers, + }, nil +} + +func toInt(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case json.Number: + i, _ := n.Int64() + return int(i) + } + return 0 +} diff --git a/backend/internal/api/posthog_test.go b/backend/internal/api/posthog_test.go new file mode 100644 index 0000000..151d63e --- /dev/null +++ b/backend/internal/api/posthog_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +func TestPosthogDocStatsIncludesRangeAndReaderDuration(t *testing.T) { + var mu sync.Mutex + queries := make([]string, 0, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Query struct { + Query string `json:"query"` + } `json:"query"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + mu.Lock() + queries = append(queries, body.Query.Query) + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + if strings.Contains(body.Query.Query, "docs_page_read") { + _, _ = w.Write([]byte(`{"results":[["Alice","user-1",2,75.5,"2026-06-19T10:00:00Z"]]}`)) + return + } + _, _ = w.Write([]byte(`{"results":[["2026-06-19",3]]}`)) + })) + defer server.Close() + + t.Setenv("POSTHOG_HOST", server.URL) + t.Setenv("POSTHOG_PERSONAL_API_KEY", "secret") + t.Setenv("POSTHOG_PROJECT_ID", "42") + + stats, err := posthogDocStats("guide", 30) + if err != nil { + t.Fatalf("posthogDocStats: %v", err) + } + if stats.Total != 3 || stats.AvgDurationSec != 75 { + t.Fatalf("stats totals = %+v", stats) + } + if len(stats.Readers) != 1 || stats.Readers[0].Count != 2 || stats.Readers[0].AvgDurationSec != 75 { + t.Fatalf("reader stats = %+v", stats.Readers) + } + + mu.Lock() + defer mu.Unlock() + if len(queries) != 2 { + t.Fatalf("queries = %d, want 2", len(queries)) + } + for _, query := range queries { + if !strings.Contains(query, "INTERVAL 30 DAY") { + t.Errorf("query missing selected range: %s", query) + } + } + readerQuery := queries[1] + if !strings.Contains(readerQuery, "GROUP BY reader, user_id, read_id") || !strings.Contains(readerQuery, "avg(duration)") { + t.Errorf("reader query does not deduplicate reads and average duration: %s", readerQuery) + } +} diff --git a/backend/internal/api/request_guard.go b/backend/internal/api/request_guard.go new file mode 100644 index 0000000..70ee148 --- /dev/null +++ b/backend/internal/api/request_guard.go @@ -0,0 +1,181 @@ +package api + +import ( + "context" + "log" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +type limitPolicy struct { + requests int + window time.Duration +} + +// rateLimiter is the abstraction the request guards depend on. The in-memory +// implementation is per-process; the Redis implementation shares counters +// across replicas so a horizontally scaled deployment enforces one global +// limit instead of (limit × replicas). +type rateLimiter interface { + permit(ctx context.Context, key string, policy limitPolicy) bool +} + +// newRateLimiter returns a Redis-backed limiter when a shared client is +// available, otherwise falls back to the in-memory limiter. Falling back keeps +// single-node and local deployments working without Redis. +func newRateLimiter(client *redis.Client) rateLimiter { + if client != nil { + log.Printf("rate limiter: using shared Redis backend") + return &redisRateLimiter{client: client} + } + return newRequestLimiter() +} + +type limitWindow struct { + started time.Time + count int +} + +type requestLimiter struct { + mu sync.Mutex + windows map[string]limitWindow + lastGC time.Time +} + +func newRequestLimiter() *requestLimiter { + return &requestLimiter{windows: map[string]limitWindow{}, lastGC: time.Now()} +} + +func (l *requestLimiter) permit(_ context.Context, key string, policy limitPolicy) bool { + return l.allow(key, policy, time.Now()) +} + +func (l *requestLimiter) allow(key string, policy limitPolicy, now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + window := l.windows[key] + if window.started.IsZero() || now.Sub(window.started) >= policy.window { + window = limitWindow{started: now} + } + if window.count >= policy.requests { + return false + } + window.count++ + l.windows[key] = window + if now.Sub(l.lastGC) > 10*time.Minute { + for candidate, item := range l.windows { + if now.Sub(item.started) > 2*time.Hour { + delete(l.windows, candidate) + } + } + l.lastGC = now + } + return true +} + +// rateLimitScript implements an atomic fixed-window counter: the first request +// in a window sets the expiry, and every request returns the running count. +// Running INCR and PEXPIRE as one script avoids a leaked key if the process +// dies between the two commands. +var rateLimitScript = redis.NewScript(` +local current = redis.call('INCR', KEYS[1]) +if current == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +return current +`) + +type redisRateLimiter struct { + client *redis.Client +} + +func (l *redisRateLimiter) permit(ctx context.Context, key string, policy limitPolicy) bool { + // Cap the time the limiter can add to a request: a slow or unreachable Redis + // must not become a latency tax on every guarded endpoint. + ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) + defer cancel() + count, err := rateLimitScript.Run(ctx, l.client, []string{"modex:rl:" + key}, policy.window.Milliseconds()).Int64() + if err != nil { + // Fail open: a rate limiter is best-effort protection, never a hard + // dependency that can take the API down when Redis hiccups. + return true + } + return count <= int64(policy.requests) +} + +func (s *Server) requestGuards(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if policy, class := requestPolicy(r.URL.Path); policy.requests > 0 { + key := clientIP(r) + ":" + class + if !s.limiter.permit(r.Context(), key, policy) { + w.Header().Set("Retry-After", strconv.Itoa(int(policy.window.Seconds()))) + writeError(w, http.StatusTooManyRequests, "rate_limited", "too many requests") + return + } + } + if r.Body != nil { + limit := int64(envPositiveInt("HTTP_MAX_BODY_BYTES", 2<<20)) + if r.URL.Path == "/api/deploy" { + limit = int64(envPositiveInt("DOCS_DEPLOY_MAX_BYTES", 512<<20)) + } + r.Body = http.MaxBytesReader(w, r.Body, limit) + } + next.ServeHTTP(w, r) + }) +} + +func requestPolicy(path string) (limitPolicy, string) { + switch { + case path == "/api/auth/login": + return limitPolicy{requests: envPositiveInt("RATE_LIMIT_AUTH_PER_MINUTE", 10), window: time.Minute}, "auth" + case path == "/oauth/token": + return limitPolicy{requests: envPositiveInt("RATE_LIMIT_TOKEN_PER_MINUTE", 30), window: time.Minute}, "token" + case path == "/api/search": + return limitPolicy{requests: envPositiveInt("RATE_LIMIT_SEARCH_PER_MINUTE", 60), window: time.Minute}, "search" + case path == "/api/ask": + return limitPolicy{requests: envPositiveInt("RATE_LIMIT_AI_PER_MINUTE", 20), window: time.Minute}, "ai" + case path == "/api/deploy": + return limitPolicy{requests: envPositiveInt("RATE_LIMIT_DEPLOY_PER_MINUTE", 10), window: time.Minute}, "deploy" + default: + return limitPolicy{}, "" + } +} + +func clientIP(r *http.Request) string { + if envBool("TRUST_PROXY_HEADERS", false) { + if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" { + return forwarded + } + if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" { + return realIP + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func envPositiveInt(key string, fallback int) int { + value, err := strconv.Atoi(os.Getenv(key)) + if err != nil || value <= 0 { + return fallback + } + return value +} + +func envBool(key string, fallback bool) bool { + value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if value == "" { + return fallback + } + return value == "1" || value == "true" || value == "yes" +} diff --git a/backend/internal/api/request_guard_test.go b/backend/internal/api/request_guard_test.go new file mode 100644 index 0000000..92d7278 --- /dev/null +++ b/backend/internal/api/request_guard_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRequestLimiterPermitMatchesAllow(t *testing.T) { + limiter := newRequestLimiter() + policy := limitPolicy{requests: 1, window: time.Minute} + if !limiter.permit(context.Background(), "client", policy) { + t.Fatal("first request should be permitted") + } + if limiter.permit(context.Background(), "client", policy) { + t.Fatal("second request in window should be denied") + } +} + +func TestAcquireDeploySlotBoundsConcurrency(t *testing.T) { + s := &Server{deploy: &localDeployLimiter{sem: make(chan struct{}, 1)}} + release, ok := s.acquireDeploySlot(context.Background()) + if !ok { + t.Fatal("first slot should be acquired") + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, ok := s.acquireDeploySlot(cancelled); ok { + t.Fatal("slot should not be acquired while the only slot is held") + } + release() + again, ok := s.acquireDeploySlot(context.Background()) + if !ok { + t.Fatal("slot should be reusable after release") + } + again() +} + +func TestAcquireDeploySlotUnboundedWhenDisabled(t *testing.T) { + s := &Server{} + if _, ok := s.acquireDeploySlot(context.Background()); !ok { + t.Fatal("nil semaphore should always grant a slot") + } +} + +func TestRequestLimiterResetsAfterWindow(t *testing.T) { + limiter := newRequestLimiter() + policy := limitPolicy{requests: 2, window: time.Minute} + now := time.Now() + if !limiter.allow("client", policy, now) { + t.Fatal("first request should be allowed") + } + if !limiter.allow("client", policy, now) { + t.Fatal("second request should be allowed") + } + if limiter.allow("client", policy, now) { + t.Fatal("request above limit should be denied") + } + if !limiter.allow("client", policy, now.Add(time.Minute)) { + t.Fatal("request after window should be allowed") + } +} + +func TestClientIPOnlyTrustsForwardedHeaderWhenConfigured(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "192.0.2.10:1234" + r.Header.Set("X-Forwarded-For", "198.51.100.2") + t.Setenv("TRUST_PROXY_HEADERS", "false") + if got := clientIP(r); got != "192.0.2.10" { + t.Fatalf("clientIP = %q", got) + } + t.Setenv("TRUST_PROXY_HEADERS", "true") + if got := clientIP(r); got != "198.51.100.2" { + t.Fatalf("trusted clientIP = %q", got) + } +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 1eb3a5d..a3b5886 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -1,72 +1,118 @@ package api import ( - "bytes" "context" "encoding/json" - "errors" "fmt" "io" "log" - "mime" + "math" "net/http" "net/url" "os" - "path/filepath" - "strconv" + "path" + "regexp" "strings" "time" - "modex/backend/internal/auth" - "modex/backend/internal/deploy" - "modex/backend/internal/embedding" + "modex/backend/internal/application" + "modex/backend/internal/redisurl" "modex/backend/internal/search" "modex/backend/internal/store" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/redis/go-redis/v9" ) +// dialRedis builds the Redis client shared by the rate and deploy limiters, +// or returns nil (so both fall back to in-process behavior) when Redis is +// unconfigured, malformed, or unreachable. +func dialRedis() *redis.Client { + rawURL := redisurl.FromEnv() + if rawURL == "" { + return nil + } + options, err := redis.ParseURL(rawURL) + if err != nil { + log.Printf("redis: invalid connection settings (%v); using in-process limiters", err) + return nil + } + client := redis.NewClient(options) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + log.Printf("redis: ping failed (%v); using in-process limiters", err) + _ = client.Close() + return nil + } + return client +} + type Server struct { - store *store.Store - auth *auth.Service - search search.Service + app *application.Service minioClient *minio.Client minioBucket string + limiter rateLimiter + // deploy bounds how many artifacts are ingested at once so a burst of large + // uploads can't exhaust memory/CPU. Redis-backed when Redis is configured (a + // single global bound across replicas), per-process otherwise. Requests + // beyond the bound wait briefly, then get 503. nil means unbounded. + deploy deployLimiter +} + +func New(st store.DataStore) *Server { + return NewWithVectorStore(st, nil) } -func New(st *store.Store) *Server { - provider := embedding.FromEnv() - authSvc := auth.NewService(auth.FromEnv()) +func NewWithVectorStore(st store.DataStore, vectors search.VectorStore) *Server { + return NewWithApplication(application.New(st, vectors, nil)) +} + +func NewWithApplication(app *application.Service) *Server { + redisClient := dialRedis() s := &Server{ - store: st, - auth: authSvc, - search: search.Service{ - Store: st, - Embedder: provider, - KeywordWeight: envFloat("HYBRID_KEYWORD_WEIGHT", 0.6), - SemanticWeight: envFloat("HYBRID_SEMANTIC_WEIGHT", 0.4), - }, + app: app, + limiter: newRateLimiter(redisClient), + deploy: newDeployLimiter(redisClient), } // init MinIO for real site file storage (upload SiteFiles from deploys) if endpoint := os.Getenv("MINIO_ENDPOINT"); endpoint != "" { accessKey := os.Getenv("MINIO_ROOT_USER") secretKey := os.Getenv("MINIO_ROOT_PASSWORD") secure := strings.HasPrefix(strings.ToLower(endpoint), "https://") - client, err := minio.New(endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(accessKey, secretKey, ""), - Secure: secure, + // minio-go expects a bare host:port; strip any scheme/trailing slash so + // MINIO_ENDPOINT=http://minio:9000 doesn't fail init ("Endpoint url + // cannot have fully qualified paths") and fall back to database assets. + host := endpoint + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + host = strings.TrimRight(host, "/") + client, err := minio.New(host, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: secure, + Region: os.Getenv("MINIO_REGION"), + BucketLookup: minioBucketLookup(), }) if err == nil { + if envBool("MINIO_TRACE", false) { + client.TraceOn(os.Stderr) + } s.minioClient = client s.minioBucket = os.Getenv("MINIO_BUCKET") if s.minioBucket == "" { s.minioBucket = "modex" } - ctx := context.Background() - exists, _ := client.BucketExists(ctx, s.minioBucket) - if !exists { - client.MakeBucket(ctx, s.minioBucket, minio.MakeBucketOptions{}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + exists, bucketErr := client.BucketExists(ctx, s.minioBucket) + if bucketErr == nil && !exists { + bucketErr = client.MakeBucket(ctx, s.minioBucket, minio.MakeBucketOptions{}) + } + if bucketErr != nil { + log.Printf("minio bucket init failed: %v", bucketErr) + s.minioClient = nil } } else { log.Printf("minio client init failed: %v", err) @@ -75,14 +121,33 @@ func New(st *store.Store) *Server { return s } +func minioBucketLookup() minio.BucketLookupType { + switch strings.ToLower(strings.TrimSpace(os.Getenv("MINIO_BUCKET_LOOKUP"))) { + case "", "path": + return minio.BucketLookupPath + case "auto": + return minio.BucketLookupAuto + case "dns", "virtual-host", "virtualhost": + return minio.BucketLookupDNS + default: + log.Printf("minio: unknown MINIO_BUCKET_LOOKUP=%q; using path-style bucket lookup", os.Getenv("MINIO_BUCKET_LOOKUP")) + return minio.BucketLookupPath + } +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.handleHealth) mux.HandleFunc("/api/auth/me", s.handleMe) - mux.HandleFunc("/api/auth/mock-login", s.handleMockLogin) + mux.HandleFunc("/api/me/mcp-token", s.handleMeMCPToken) + mux.HandleFunc("/api/mcp/token-info", s.handleMCPTokenInfo) mux.HandleFunc("/api/auth/login", s.handleLogin) mux.HandleFunc("/api/auth/callback", s.handleCallback) mux.HandleFunc("/api/auth/logout", s.handleLogout) + mux.HandleFunc("/.well-known/oauth-authorization-server", s.handleOAuthMetadata) + mux.HandleFunc("/oauth/authorize", s.handleOAuthAuthorize) + mux.HandleFunc("/oauth/token", s.handleOAuthToken) + mux.HandleFunc("/oauth/revoke", s.handleOAuthRevoke) mux.HandleFunc("/api/config", s.handleConfig) mux.HandleFunc("/api/categories/tree", s.handleCategories) mux.HandleFunc("/api/modules", s.handleModules) @@ -98,14 +163,33 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/deploy", s.handleDeploy) mux.HandleFunc("/api/analytics/page-view", s.handlePageView) mux.HandleFunc("/api/analytics/read-progress", s.handleReadProgress) + mux.HandleFunc("/api/analytics/feedback", s.handleDocFeedback) + mux.HandleFunc("/api/analytics/doc", s.handleDocAnalytics) mux.HandleFunc("/api/admin/releases", s.handleReleases) mux.HandleFunc("/api/admin/releases/", s.handleReleaseRoutes) - mux.HandleFunc("/api/admin/analytics/pages", s.handlePageAnalytics) + mux.HandleFunc("/api/admin/analytics/feedback", s.handleDocFeedbackLogs) mux.HandleFunc("/api/admin/analytics/search", s.handleSearchLogs) mux.HandleFunc("/api/admin/analytics/mcp", s.handleMCPLogs) + mux.HandleFunc("/api/admin/analytics/pages", http.NotFound) mux.HandleFunc("/api/mcp/log", s.handleMCPLog) + mux.HandleFunc("/api/me/favorites", s.handleMeFavorites) + mux.HandleFunc("/api/me/recent", s.handleMeRecent) + mux.HandleFunc("/api/mcp/dist", s.handleMcpDist) + mux.HandleFunc("/api/mcp/dist/", s.handleMcpDist) + mux.HandleFunc("/.well-known/agent-skills/index.json", s.handleSkillDiscovery) + mux.HandleFunc("/.well-known/skills/index.json", s.handleSkillDiscovery) mux.HandleFunc("/api/admin/settings/models", s.handleAdminModels) + mux.HandleFunc("/api/admin/settings/test-connection", s.handleAdminModelConnectionTest) + mux.HandleFunc("/api/admin/settings/recall-test", s.handleAdminRecallTest) mux.HandleFunc("/api/admin/settings", s.handleAdminSettings) + mux.HandleFunc("/api/admin/plugins", s.handleAdminPlugins) + mux.HandleFunc("/api/admin/plugins/import", s.handleAdminPluginImport) + mux.HandleFunc("/api/admin/plugins/import/", s.handleAdminPluginImport) + mux.HandleFunc("/api/admin/snippets", s.handleAdminSnippets) + mux.HandleFunc("/api/admin/connected-apps", s.handleAdminConnectedApps) + mux.HandleFunc("/api/admin/connected-apps/", s.handleAdminConnectedAppByID) + mux.HandleFunc("/api/docs/snippets", s.handleDocsSnippets) + mux.HandleFunc("/api/docs/plugins", s.handleDocsPlugins) mux.HandleFunc("/api/admin/categories", s.handleAdminCategories) mux.HandleFunc("/api/admin/categories/", s.handleAdminCategoryByID) mux.HandleFunc("/api/admin/modules", s.handleAdminModules) @@ -113,16 +197,52 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/admin/entries/", s.handleAdminEntryByID) mux.HandleFunc("/api/admin/users", s.handleAdminUsers) mux.HandleFunc("/api/admin/users/", s.handleAdminUserByID) - mux.HandleFunc("/api/admin/groups", s.handleAdminGroups) mux.HandleFunc("/api/admin/teams", s.handleAdminTeams) mux.HandleFunc("/api/admin/teams/", s.handleAdminTeamRoutes) mux.HandleFunc("/api/admin/", s.handleAdminAccepted) mux.HandleFunc("/api/webhooks/gitlab", s.handleGitLabWebhook) - return s.cors(recoverer(mux)) + return s.cors(accessLogger(recoverer(s.requestGuards(mux)))) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "modex-api"}) + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + embeddingCount, embeddingErr := s.app.Search().EmbeddingCount(ctx) + sessionErr := s.app.Auth().Healthy(ctx) + searchStatus := "ok" + if embeddingErr != nil { + searchStatus = "degraded" + } + status := "ok" + statusCode := http.StatusOK + if sessionErr != nil { + status = "unavailable" + statusCode = http.StatusServiceUnavailable + } + writeJSON(w, statusCode, map[string]any{ + "status": status, + "service": "modex-api", + "dependencies": map[string]any{ + "repository": map[string]any{"configured": true}, + "sessions": map[string]any{"status": ternary(sessionErr == nil, "ok", "unavailable"), "error": errorString(sessionErr)}, + "object_storage": map[string]any{ + "mode": ternary(s.minioClient != nil, "minio", "postgres"), + "bucket": s.minioBucket, + }, + "search": map[string]any{ + "status": searchStatus, + "provider": s.app.Search().Embedder.Name(), + "external_vector": s.app.Search().Vectors != nil, + "embedding_count": embeddingCount, + "error": errorString(embeddingErr), + }, + }, + "counts": map[string]int{ + "modules": len(s.app.Store().Modules("", "")), + "pages": len(s.app.Store().Pages()), + "releases": len(s.app.Store().Releases()), + }, + }) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { @@ -130,41 +250,19 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, struct { store.User IsSuperAdmin bool `json:"is_super_admin"` - }{User: user, IsSuperAdmin: s.auth.IsSuperAdmin(user)}) - return - } - writeError(w, http.StatusUnauthorized, "unauthorized", "not logged in") -} - -func (s *Server) handleMockLogin(w http.ResponseWriter, r *http.Request) { - if s.auth.Config().Mode == "oidc" { - writeError(w, http.StatusForbidden, "mock_login_disabled", "mock login is disabled when AUTH_MODE=oidc") + IsTeamAdmin bool `json:"is_team_admin"` + }{User: user, IsSuperAdmin: s.app.Auth().IsSuperAdmin(user), IsTeamAdmin: s.isTeamAdmin(user)}) return } - // Allow developers to choose which seeded identity to log in as. - var req struct { - Username string `json:"username"` - } - _ = decodeBody(r, &req) - user := s.store.CurrentUser() - if req.Username != "" { - for _, u := range s.store.Users("") { - if strings.EqualFold(u.Username, req.Username) { - user = u - break - } - } - } - user = s.store.UpsertUser(user) - if err := s.auth.CreateSession(w, user); err != nil { - writeError(w, http.StatusInternalServerError, "session_failed", err.Error()) + if r.URL.Query().Get("optional") == "1" { + writeJSON(w, http.StatusOK, nil) return } - writeJSON(w, http.StatusOK, map[string]any{"user": user}) + writeError(w, http.StatusUnauthorized, "unauthorized", "not logged in") } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - loginURL, err := s.auth.BeginLogin(w) + loginURL, err := s.app.Auth().BeginLogin(w) if err != nil { writeError(w, http.StatusServiceUnavailable, "oidc_not_configured", err.Error()) return @@ -173,8 +271,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { - user, err := s.auth.CompleteLogin(r.Context(), r, w) - frontend := s.auth.Config().FrontendBaseURL + user, err := s.app.Auth().CompleteLogin(r.Context(), r, w) + frontend := s.app.Auth().Config().FrontendBaseURL if err != nil { // Surface the failure to the user in the portal rather than a bare JSON // 400, and log the detail server-side for diagnostics. @@ -182,38 +280,65 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, frontend+"/?login_error="+url.QueryEscape(err.Error()), http.StatusFound) return } - // Sync the SSO identity (and its groups) into the user directory. - s.store.UpsertUser(user) + // Sync the SSO identity into the user directory. + s.app.Store().UpsertUser(user) http.Redirect(w, r, frontend, http.StatusFound) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - s.auth.Logout(w, r) + s.app.Auth().Logout(w, r) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { - cfg := s.auth.Config() + cfg := s.app.Auth().Config() loginURL := "" - // Advertise the login URL when a real login is available: always in mock - // mode, and in OIDC mode only once the provider is fully configured. - if cfg.Mode != "oidc" || cfg.LoginReady() { + // Advertise the login URL only once the OIDC provider is fully configured. + if cfg.LoginReady() { loginURL = cfg.AppBaseURL + "/api/auth/login" } writeJSON(w, http.StatusOK, map[string]any{ - "auth_mode": cfg.Mode, "oidc_login_enabled": cfg.LoginReady(), "login_url": loginURL, + "app_base_url": cfg.AppBaseURL, "frontend_base_url": cfg.FrontendBaseURL, + "auto_login": cfg.AutoLogin, + // Effective doc-engine plugin state (enabled + non-secret config) so the + // renderer can conditionally apply plugins without admin rights. + "plugins": s.app.Store().PluginEffective(), }) } func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.store.CategoryTree()) + tree := s.app.Store().CategoryTree() + // Admin views pass ?scope=managed to get only the subtrees a team admin may + // manage. Public/browse calls (no scope) always get the full tree. + if r.URL.Query().Get("scope") == "managed" { + if user, ok := s.currentUser(r); ok { + if set, all := s.accessibleCategoryIDs(user); !all { + tree = filterCategoryTree(tree, set) + } + } + } + writeJSON(w, http.StatusOK, tree) +} + +// filterCategoryTree keeps only nodes whose id is in the set, preserving any +// ancestors needed to reach them. +func filterCategoryTree(nodes []store.Category, set map[string]bool) []store.Category { + out := make([]store.Category, 0, len(nodes)) + for _, n := range nodes { + children := filterCategoryTree(n.Children, set) + if set[n.ID] || len(children) > 0 { + n.Children = children + out = append(out, n) + } + } + return out } func (s *Server) handleModules(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.store.Modules(r.URL.Query().Get("category_id"), r.URL.Query().Get("keyword"))) + writeJSON(w, http.StatusOK, s.app.Store().Modules(r.URL.Query().Get("category_id"), r.URL.Query().Get("keyword"))) } func (s *Server) handleModuleRoutes(w http.ResponseWriter, r *http.Request) { @@ -224,16 +349,20 @@ func (s *Server) handleModuleRoutes(w http.ResponseWriter, r *http.Request) { } moduleKey := parts[0] if len(parts) == 1 || (len(parts) == 2 && parts[1] == "info") { - m, err := s.store.Module(moduleKey) + m, err := s.app.Store().Module(moduleKey) writeResult(w, m, err) return } if len(parts) == 2 && parts[1] == "versions" { - writeJSON(w, http.StatusOK, s.store.Versions(moduleKey)) + writeJSON(w, http.StatusOK, s.app.Store().Versions(moduleKey)) return } if len(parts) == 4 && parts[1] == "versions" && parts[3] == "entries" { - writeJSON(w, http.StatusOK, s.store.Entries(moduleKey, parts[2])) + entries := s.app.Store().Entries(moduleKey, parts[2]) + if entries == nil { + entries = []store.Entry{} + } + writeJSON(w, http.StatusOK, entries) return } writeError(w, http.StatusNotFound, "not_found", "module route not found") @@ -245,7 +374,7 @@ func (s *Server) handleDocRoutes(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found", "docs route not found") return } - module, err := s.store.Module(parts[0]) + module, err := s.app.Store().Module(parts[0]) if err != nil { writeResult(w, module, err) return @@ -255,16 +384,20 @@ func (s *Server) handleDocRoutes(w http.ResponseWriter, r *http.Request) { return } if len(parts) == 2 { - writeJSON(w, http.StatusOK, map[string]any{"module": module, "version": parts[1], "entries": s.store.Entries(module.ModuleKey, parts[1])}) + entries := s.app.Store().Entries(module.ModuleKey, parts[1]) + if entries == nil { + entries = []store.Entry{} + } + writeJSON(w, http.StatusOK, map[string]any{"module": module, "version": parts[1], "entries": entries}) return } if len(parts) >= 3 { if len(parts) >= 4 && parts[3] == "site" { - s.handleDocSiteFile(w, module.ModuleKey, parts[1], parts[2], strings.Join(parts[4:], "/")) + s.handleDocSiteFile(w, r, module.ModuleKey, parts[1], parts[2], strings.Join(parts[4:], "/")) return } if len(parts) == 4 && parts[3] == "nav" { - nav := s.store.Nav(module.ModuleKey, parts[1]) + nav := s.app.Store().Nav(module.ModuleKey, parts[1]) if len(nav) == 0 { writeJSON(w, http.StatusOK, []map[string]string{{"title": "概览", "path": "#overview"}, {"title": "正文", "path": "#content"}, {"title": "元数据", "path": "#metadata"}}) return @@ -272,63 +405,289 @@ func (s *Server) handleDocRoutes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, nav) return } - page, err := s.store.PageByRoute(module.ModuleKey, parts[1], parts[2]) + page, err := s.app.Store().PageByRoute(module.ModuleKey, parts[1], parts[2]) if err != nil { writeResult(w, page, err) return } - page.ContentHTML = s.store.PageHTML(module.ModuleKey, parts[1], parts[2]) + page.ContentHTML, err = s.docPageHTML(r.Context(), module.ModuleKey, parts[1], parts[2]) + if err != nil { + writeError(w, http.StatusBadGateway, "site_read_failed", err.Error()) + return + } writeJSON(w, http.StatusOK, page) return } } -func (s *Server) handleDocSiteFile(w http.ResponseWriter, moduleKey, docsVersion, entryKey, name string) { - if name == "" { - name = "index.html" - } +func (s *Server) handleDocSiteFile(w http.ResponseWriter, r *http.Request, moduleKey, docsVersion, entryKey, name string) { if s.minioClient != nil { - zipName := fmt.Sprintf("site/%s/%s", entryKey, name) - key := fmt.Sprintf("modules/%s/%s/%s", moduleKey, docsVersion, zipName) - obj, err := s.minioClient.GetObject(context.Background(), s.minioBucket, key, minio.GetObjectOptions{}) - if err == nil { - stat, statErr := obj.Stat() - if statErr == nil && stat.Size > 0 { - ct := stat.ContentType - if ct == "" { - ct = contentTypeForName(name, nil) - } - w.Header().Set("Content-Type", ct) + for _, candidate := range siteFileCandidates(name) { + zipName := fmt.Sprintf("site/%s/%s", entryKey, candidate) + key := fmt.Sprintf("modules/%s/%s/%s", moduleKey, docsVersion, zipName) + obj, err := s.minioClient.GetObject(r.Context(), s.minioBucket, key, minio.GetObjectOptions{}) + if err != nil { + continue + } + defer obj.Close() + head := make([]byte, 512) + n, readErr := obj.Read(head) + if readErr == nil || readErr == io.EOF { + head = head[:n] + contentType := contentTypeForName(candidate, head) w.Header().Set("Cache-Control", "private, max-age=60") + if shouldRewriteServedSiteFile(candidate, contentType) { + var body []byte + body = append(body, head...) + if readErr != io.EOF { + rest, err := io.ReadAll(obj) + if err != nil { + log.Printf("read site asset %s failed: %v", name, err) + writeError(w, http.StatusBadGateway, "site_read_failed", err.Error()) + return + } + body = append(body, rest...) + } + body = rewriteServedSiteRootRefs(body, moduleKey, docsVersion, entryKey) + w.Header().Set("Content-Type", contentTypeForName(candidate, body)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + return + } + w.Header().Set("Content-Type", contentType) w.WriteHeader(http.StatusOK) - io.Copy(w, obj) + if len(head) > 0 { + _, _ = w.Write(head) + } + if readErr != io.EOF { + if _, err := io.Copy(w, obj); err != nil { + log.Printf("stream site asset %s failed: %v", name, err) + } + } return } + log.Printf("minio site asset read failed bucket=%s key=%s error=%v", s.minioBucket, key, readErr) } } - // fallback to in-memory - f, err := s.store.SiteFile(moduleKey, docsVersion, entryKey, name) - if err != nil { - writeResult(w, nil, err) + // Without MinIO, static assets are read from PostgreSQL. + var f store.SiteFile + var found bool + for _, candidate := range siteFileCandidates(name) { + next, err := s.app.Store().SiteFile(moduleKey, docsVersion, entryKey, candidate) + if err == nil { + f = next + found = true + break + } + } + if !found { + writeResult(w, nil, store.ErrNotFound) return } w.Header().Set("Content-Type", f.ContentType) w.Header().Set("Cache-Control", "private, max-age=60") + if shouldRewriteServedSiteFile(name, f.ContentType) { + f.Content = rewriteServedSiteRootRefs(f.Content, moduleKey, docsVersion, entryKey) + } w.WriteHeader(http.StatusOK) _, _ = w.Write(f.Content) } +func siteFileCandidates(name string) []string { + clean := path.Clean(strings.TrimPrefix(name, "/")) + if clean == "." || clean == "" { + clean = "index.html" + } + candidates := []string{clean} + add := func(value string) { + value = path.Clean(strings.TrimPrefix(value, "/")) + if value == "." || value == "" { + value = "index.html" + } + for _, existing := range candidates { + if existing == value { + return + } + } + candidates = append(candidates, value) + } + ext := path.Ext(clean) + if ext == "" { + add(clean + ".html") + add(path.Join(clean, "index.html")) + add("index.html") + } + if strings.EqualFold(ext, ".md") { + add(strings.TrimSuffix(clean, ext) + ".html") + } + return candidates +} + +func shouldRewriteServedSiteFile(name, contentType string) bool { + name = strings.ToLower(name) + contentType = strings.ToLower(contentType) + if strings.HasSuffix(name, ".html") || strings.HasSuffix(name, ".htm") || + strings.HasSuffix(name, ".css") || strings.HasSuffix(name, ".js") || + strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".webmanifest") { + return true + } + return strings.HasPrefix(contentType, "text/") || + strings.Contains(contentType, "javascript") || + strings.Contains(contentType, "json") +} + +var ( + servedSiteAttrRootRef = regexp.MustCompile(`(?i)(\b(?:href|src|poster|action)\s*=\s*["'])(/[^"'<>]*)(["'])`) + servedSiteCSSRootRef = regexp.MustCompile(`(?i)(url\(\s*["']?)(/[^)'"\s]+)(["']?\s*\))`) + servedSiteSrcset = regexp.MustCompile(`(?i)(\bsrcset\s*=\s*["'])([^"'<>]*)(["'])`) + servedSiteStringRef = regexp.MustCompile("([\"'`])(/internal-tools/[^\"'`<>\\\\\\s]*)([\"'`])") +) + +func rewriteServedSiteRootRefs(content []byte, moduleKey, docsVersion, entryKey string) []byte { + if len(content) == 0 { + return content + } + siteBase := "/api/docs/" + moduleKey + "/" + docsVersion + "/" + entryKey + "/site/" + text := string(content) + text = rewriteServedSiteMatches(text, servedSiteAttrRootRef, siteBase) + text = rewriteServedSiteMatches(text, servedSiteCSSRootRef, siteBase) + text = rewriteServedSiteStringRefs(text, siteBase) + text = servedSiteSrcset.ReplaceAllStringFunc(text, func(match string) string { + parts := servedSiteSrcset.FindStringSubmatch(match) + if len(parts) != 4 { + return match + } + candidates := strings.Split(parts[2], ",") + for i, candidate := range candidates { + fields := strings.Fields(strings.TrimSpace(candidate)) + if len(fields) == 0 { + continue + } + if rewritten, ok := rewriteServedSiteRootURL(fields[0], siteBase); ok { + fields[0] = rewritten + candidates[i] = strings.Join(fields, " ") + } + } + return parts[1] + strings.Join(candidates, ", ") + parts[3] + }) + return []byte(text) +} + +func rewriteServedSiteStringRefs(input, siteBase string) string { + return servedSiteStringRef.ReplaceAllStringFunc(input, func(match string) string { + parts := servedSiteStringRef.FindStringSubmatch(match) + if len(parts) != 4 || parts[1] != parts[3] { + return match + } + rewritten, ok := rewriteServedSiteRootURL(parts[2], siteBase) + if !ok { + return match + } + return parts[1] + rewritten + parts[3] + }) +} + +func rewriteServedSiteMatches(input string, pattern *regexp.Regexp, siteBase string) string { + return pattern.ReplaceAllStringFunc(input, func(match string) string { + parts := pattern.FindStringSubmatch(match) + if len(parts) != 4 { + return match + } + rewritten, ok := rewriteServedSiteRootURL(parts[2], siteBase) + if !ok { + return match + } + return parts[1] + rewritten + parts[3] + }) +} + +func rewriteServedSiteRootURL(raw, siteBase string) (string, bool) { + if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || + strings.HasPrefix(raw, siteBase) || strings.HasPrefix(raw, "/api/docs/") || + strings.HasPrefix(raw, "/_next/") || strings.HasPrefix(raw, "/brand/") { + return raw, false + } + pathPart, trailer := splitServedSiteURLPath(raw) + rel := strings.TrimPrefix(pathPart, "/") + if rel == "" { + return siteBase, true + } + parts := strings.Split(rel, "/") + if len(parts) >= 2 && (shouldStripServedSiteLegacyBase(parts[0]) || shouldStripServedSiteLegacyPrefix(parts[1])) { + rel = strings.Join(parts[1:], "/") + } + if rel == "" { + return siteBase, true + } + return siteBase + rel + trailer, true +} + +func splitServedSiteURLPath(raw string) (string, string) { + if i := strings.IndexAny(raw, "?#"); i >= 0 { + return raw[:i], raw[i:] + } + return raw, "" +} + +func shouldStripServedSiteLegacyPrefix(next string) bool { + if next == "assets" || next == "images" || next == "posts" { + return true + } + return strings.Contains(next, ".") +} + +func shouldStripServedSiteLegacyBase(first string) bool { + switch strings.Trim(first, "/") { + case "internal-tools": + return true + default: + return false + } +} + func (s *Server) handleDocPage(w http.ResponseWriter, r *http.Request) { docID := strings.TrimPrefix(r.URL.Path, "/api/docs/page/") - page, err := s.store.Page(docID) + page, err := s.app.Store().Page(docID) if err != nil { writeResult(w, page, err) return } - page.ContentHTML = s.store.PageHTML(page.ModuleKey, page.DocsVersion, page.EntryKey) + page.ContentHTML, err = s.docPageHTML(r.Context(), page.ModuleKey, page.DocsVersion, page.EntryKey) + if err != nil { + writeError(w, http.StatusBadGateway, "site_read_failed", err.Error()) + return + } writeJSON(w, http.StatusOK, page) } +func (s *Server) docPageHTML(ctx context.Context, moduleKey, docsVersion, entryKey string) (string, error) { + if s.minioClient != nil { + key := fmt.Sprintf("modules/%s/%s/site/%s/index.html", moduleKey, docsVersion, entryKey) + obj, err := s.minioClient.GetObject(ctx, s.minioBucket, key, minio.GetObjectOptions{}) + if err == nil { + defer obj.Close() + b, readErr := io.ReadAll(obj) + if readErr == nil { + return string(b), nil + } + err = readErr + } + if fallback := s.app.Store().PageHTML(moduleKey, docsVersion, entryKey); fallback != "" { + return fallback, nil + } + // Markdown/static entries legitimately have no site/index.html object: they + // render from content_md/content_text on the frontend, and site-builder + // entries render from the iframe (site route), not this HTML. So a missing + // object is not fatal — degrade to empty HTML instead of failing the whole + // page render. Only log so genuine MinIO outages remain visible. + if err != nil { + log.Printf("minio page html read failed bucket=%s key=%s error=%v", s.minioBucket, key, err) + } + return "", nil + } + return s.app.Store().PageHTML(moduleKey, docsVersion, entryKey), nil +} + func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") @@ -339,21 +698,28 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - resp, err := s.search.Search(r.Context(), req) + if len(req.Filters.DocsVersions) == 0 { + req.DefaultVersionsOnly = true + } + resp, err := s.app.Search().Search(r.Context(), req) if err != nil { writeError(w, http.StatusInternalServerError, "search_failed", err.Error()) return } - filters, _ := json.Marshal(req.Filters) - user, _ := s.currentUser(r) - s.store.AddSearchLog(store.SearchLog{ID: fmt.Sprintf("sl-%d", time.Now().UnixNano()), UserID: user.ID, Query: req.Query, Mode: string(resp.Mode), FiltersJSON: string(filters), ResultCount: resp.Total, SearchedAt: time.Now().UTC()}) + // Only persist explicit, user-committed searches (Enter / search button / + // result click). Live as-you-type queries set Log=false to avoid flooding + // the log with one row per keystroke. + if req.Log && strings.TrimSpace(req.Query) != "" { + filters, _ := json.Marshal(req.Filters) + user, _ := s.currentUser(r) + s.app.Store().AddSearchLog(store.SearchLog{ID: fmt.Sprintf("sl-%d", time.Now().UnixNano()), UserID: user.ID, IPAddress: clientIP(r), Query: req.Query, Mode: string(resp.Mode), FiltersJSON: string(filters), ResultCount: resp.Total, ClickedDocID: req.ClickedDocID, SearchedAt: time.Now().UTC()}) + } writeJSON(w, http.StatusOK, resp) } // handleAsk answers a natural-language question using retrieval over the docs -// (RAG). When ASK_HTTP_URL is configured it forwards the question plus retrieved -// context to an external LLM; otherwise it returns an extractive answer built -// from the top matches so the "Ask AI" flow works without an LLM key. +// (RAG). The answer model is configured by an administrator; when it is absent +// or unavailable, the endpoint returns an extractive answer from the top hits. func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") @@ -363,6 +729,7 @@ func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { Query string `json:"query"` ModuleKey string `json:"module_key"` CategoryIDs []string `json:"category_ids"` + Stream bool `json:"stream"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) @@ -381,39 +748,132 @@ func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) { if len(req.CategoryIDs) > 0 { filters.CategoryIDs = req.CategoryIDs } - resp, err := s.search.Search(r.Context(), search.Request{Query: req.Query, Mode: search.ModeHybrid, Filters: filters, Page: 1, PageSize: 5}) + if req.Stream { + s.handleAskStream(w, r, req.Query, filters) + return + } + resp, err := s.app.Search().Search(r.Context(), search.Request{Query: req.Query, Mode: search.ModeHybrid, Filters: filters, Page: 1, PageSize: 8, DefaultVersionsOnly: len(filters.DocsVersions) == 0}) if err != nil { writeError(w, http.StatusInternalServerError, "ask_failed", err.Error()) return } - answer, provider := s.synthesizeAnswer(r.Context(), req.Query, resp.Results) + answer, provider, warning := s.synthesizeAnswer(r.Context(), req.Query, resp.Results) user, _ := s.currentUser(r) - s.store.AddSearchLog(store.SearchLog{ID: fmt.Sprintf("ask-%d", time.Now().UnixNano()), UserID: user.ID, Query: req.Query, Mode: "ask", ResultCount: len(resp.Results), SearchedAt: time.Now().UTC()}) - writeJSON(w, http.StatusOK, map[string]any{ + filtersJSON, _ := json.Marshal(filters) + s.app.Store().AddSearchLog(store.SearchLog{ID: fmt.Sprintf("ask-%d", time.Now().UnixNano()), UserID: user.ID, IPAddress: clientIP(r), Query: req.Query, Mode: "ask", FiltersJSON: string(filtersJSON), ResultCount: len(resp.Results), SearchedAt: time.Now().UTC()}) + payload := map[string]any{ "query": req.Query, "answer": answer, "provider": provider, "sources": resp.Results, + } + if warning != "" { + payload["warning"] = warning + } + writeJSON(w, http.StatusOK, payload) +} + +func (s *Server) handleAskStream(w http.ResponseWriter, r *http.Request, query string, filters search.Filters) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "stream_unavailable", "streaming is not supported") + return + } + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + writeEvent := func(event map[string]any) bool { + if err := json.NewEncoder(w).Encode(event); err != nil { + return false + } + flusher.Flush() + return true + } + resp, err := s.app.Search().Search(r.Context(), search.Request{Query: query, Mode: search.ModeHybrid, Filters: filters, Page: 1, PageSize: 8, DefaultVersionsOnly: len(filters.DocsVersions) == 0}) + if err != nil { + writeEvent(map[string]any{"type": "error", "error": err.Error()}) + return + } + if !writeEvent(map[string]any{"type": "sources", "query": query, "sources": resp.Results}) { + return + } + provider, warning := s.synthesizeAnswerStream(r.Context(), query, resp.Results, func(delta string) bool { + return writeEvent(map[string]any{"type": "delta", "delta": delta}) }) + if provider == "" { + return + } + user, _ := s.currentUser(r) + filtersJSON, _ := json.Marshal(filters) + s.app.Store().AddSearchLog(store.SearchLog{ID: fmt.Sprintf("ask-%d", time.Now().UnixNano()), UserID: user.ID, IPAddress: clientIP(r), Query: query, Mode: "ask", FiltersJSON: string(filtersJSON), ResultCount: len(resp.Results), SearchedAt: time.Now().UTC()}) + if !writeEvent(map[string]any{"type": "meta", "provider": provider, "warning": warning}) { + return + } + writeEvent(map[string]any{"type": "done"}) } -func (s *Server) synthesizeAnswer(ctx context.Context, query string, results []search.Result) (string, string) { +func streamTextChunks(text string, size int) []string { + if size <= 0 { + size = 48 + } + runes := []rune(text) + if len(runes) == 0 { + return nil + } + chunks := make([]string, 0, (len(runes)/size)+1) + for start := 0; start < len(runes); start += size { + end := start + size + if end > len(runes) { + end = len(runes) + } + chunks = append(chunks, string(runes[start:end])) + } + return chunks +} + +func (s *Server) synthesizeAnswerStream(ctx context.Context, query string, results []search.Result, onDelta func(string) bool) (string, string) { + ai := s.app.Store().Settings().AI + if strings.TrimSpace(ai.AskBaseURL) != "" && strings.TrimSpace(ai.AskModel) != "" { + if err := s.askOpenAICompatibleStream(ctx, ai, query, results, onDelta); err == nil { + return "llm", "" + } else { + log.Printf("ask llm stream failed: %v", err) + answer := s.extractiveAnswer(results) + for _, chunk := range streamTextChunks(answer, 48) { + if !onDelta(chunk) { + return "", "" + } + } + return "extractive", "大模型流式调用失败,已退回本地文档摘要:" + err.Error() + } + } + answer := s.extractiveAnswer(results) + for _, chunk := range streamTextChunks(answer, 48) { + if !onDelta(chunk) { + return "", "" + } + } + return "extractive", "问答大模型未配置完整,请在模型设置中配置 API Base URL 和模型名称。" +} + +func (s *Server) synthesizeAnswer(ctx context.Context, query string, results []search.Result) (string, string, string) { // 1) Admin-configured OpenAI-compatible chat model (preferred). - if ai := s.store.Settings().AI; strings.TrimSpace(ai.AskBaseURL) != "" && strings.TrimSpace(ai.AskModel) != "" { + if ai := s.app.Store().Settings().AI; strings.TrimSpace(ai.AskBaseURL) != "" && strings.TrimSpace(ai.AskModel) != "" { if answer, err := s.askOpenAICompatible(ctx, ai, query, results); err == nil && strings.TrimSpace(answer) != "" { - return answer, "llm" + return answer, "llm", "" } else if err != nil { log.Printf("ask llm failed: %v", err) + return s.extractiveAnswer(results), "extractive", "大模型调用失败,已退回本地文档摘要:" + err.Error() } + } else { + return s.extractiveAnswer(results), "extractive", "问答大模型未配置完整,请在模型设置中配置 API Base URL 和模型名称。" } - // 2) Legacy custom {query,context}->{answer} proxy via env. - if url := os.Getenv("ASK_HTTP_URL"); url != "" { - if answer, err := s.askExternalLLM(ctx, url, query, results); err == nil && strings.TrimSpace(answer) != "" { - return answer, "http" - } - } + return s.extractiveAnswer(results), "extractive", "大模型返回空答案,已退回本地文档摘要。" +} + +func (s *Server) extractiveAnswer(results []search.Result) string { if len(results) == 0 { - return "未在文档库中找到与该问题相关的内容。可以换个关键词,或在左侧按平台浏览。", "extractive" + return "未在文档库中找到与该问题相关的内容。可以换个关键词,或在左侧按平台浏览。" } var b strings.Builder b.WriteString("根据文档库中最相关的内容,整理如下:\n\n") @@ -427,98 +887,62 @@ func (s *Server) synthesizeAnswer(ctx context.Context, query string, results []s } } b.WriteString("\n以上内容来自下方引用文档,点击可查看完整页面。") - return b.String(), "extractive" + return b.String() } -func (s *Server) askExternalLLM(ctx context.Context, url, query string, results []search.Result) (string, error) { +// askOpenAICompatible calls any OpenAI-compatible /chat/completions endpoint +// with a retrieval-augmented prompt built from the top search results. +func (s *Server) askOpenAICompatible(ctx context.Context, ai store.AISettings, query string, results []search.Result) (string, error) { var ctxBuilder strings.Builder for i, r := range results { - ctxBuilder.WriteString(fmt.Sprintf("[%d] %s (%s)\n%s\n\n", i+1, r.Title, r.Breadcrumb, r.Snippet)) - } - payload, _ := json.Marshal(map[string]any{"query": query, "context": ctxBuilder.String()}) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload))) - if err != nil { - return "", err - } - httpReq.Header.Set("Content-Type", "application/json") - if key := os.Getenv("ASK_HTTP_API_KEY"); key != "" { - httpReq.Header.Set("Authorization", "Bearer "+key) - } - client := &http.Client{Timeout: 30 * time.Second} - httpResp, err := client.Do(httpReq) - if err != nil { - return "", err - } - defer httpResp.Body.Close() - if httpResp.StatusCode >= 300 { - return "", fmt.Errorf("ask endpoint returned %d", httpResp.StatusCode) - } - var out struct { - Answer string `json:"answer"` + if i >= 6 { + break + } + ctxBuilder.WriteString(s.askContextForResult(i+1, r)) } - if err := json.NewDecoder(httpResp.Body).Decode(&out); err != nil { - return "", err + system := strings.TrimSpace(ai.AskSystemPrompt) + if system == "" { + system = store.DefaultAskSystemPrompt } - return out.Answer, nil + userMsg := fmt.Sprintf("文档片段:\n%s\n问题:%s", ctxBuilder.String(), query) + // Dispatch to the configured API format (OpenAI / Anthropic / Gemini / …). + return chatComplete(ctx, ai, system, userMsg) } -// askOpenAICompatible calls any OpenAI-compatible /chat/completions endpoint -// with a retrieval-augmented prompt built from the top search results. -func (s *Server) askOpenAICompatible(ctx context.Context, ai store.AISettings, query string, results []search.Result) (string, error) { +func (s *Server) askOpenAICompatibleStream(ctx context.Context, ai store.AISettings, query string, results []search.Result, onDelta func(string) bool) error { var ctxBuilder strings.Builder for i, r := range results { if i >= 6 { break } - ctxBuilder.WriteString(fmt.Sprintf("[%d] %s (%s)\n%s\n\n", i+1, r.Title, r.Breadcrumb, firstNonEmptyStr(r.Snippet, r.Title))) + ctxBuilder.WriteString(s.askContextForResult(i+1, r)) } system := strings.TrimSpace(ai.AskSystemPrompt) if system == "" { - system = "你是企业研发文档助手。只依据提供的【文档片段】回答用户问题,使用简洁中文;若片段中没有答案,明确说明未在文档中找到,不要编造。回答末尾不要重复罗列来源。" + system = store.DefaultAskSystemPrompt } userMsg := fmt.Sprintf("文档片段:\n%s\n问题:%s", ctxBuilder.String(), query) - payload, _ := json.Marshal(map[string]any{ - "model": ai.AskModel, - "messages": []map[string]string{ - {"role": "system", "content": system}, - {"role": "user", "content": userMsg}, - }, - "temperature": 0.2, - "stream": false, - }) - endpoint := strings.TrimRight(ai.AskBaseURL, "/") + "/chat/completions" - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(payload))) - if err != nil { - return "", err - } - httpReq.Header.Set("Content-Type", "application/json") - if ai.AskAPIKey != "" { - httpReq.Header.Set("Authorization", "Bearer "+ai.AskAPIKey) - } - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(httpReq) - if err != nil { - return "", err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 300 { - return "", fmt.Errorf("chat endpoint %d: %s", resp.StatusCode, string(body)) - } - var out struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` + return chatCompleteStream(ctx, ai, system, userMsg, onDelta) +} + +func (s *Server) askContextForResult(i int, r search.Result) string { + content := firstNonEmptyStr(r.Snippet, r.Title) + if p, err := s.app.Store().Page(r.DocID); err == nil { + content = firstNonEmptyStr(p.ContentMD, p.ContentText, p.Description, r.Snippet) } - if err := json.Unmarshal(body, &out); err != nil { - return "", err + content = truncateRunes(strings.TrimSpace(content), 4200) + return fmt.Sprintf("[%d] %s\n路径:%s\n分类:%s\n正文:\n%s\n\n", i, r.Title, r.Path, r.Breadcrumb, content) +} + +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" } - if len(out.Choices) == 0 { - return "", fmt.Errorf("chat endpoint returned no choices") + rs := []rune(s) + if len(rs) <= max { + return s } - return out.Choices[0].Message.Content, nil + return string(rs[:max]) + "\n……" } func firstNonEmptyStr(vals ...string) string { @@ -537,23 +961,21 @@ func (s *Server) handleAdminSettings(w http.ResponseWriter, r *http.Request) { } switch r.Method { case http.MethodGet: - writeJSON(w, http.StatusOK, maskedSettings(s.store.Settings())) + writeJSON(w, http.StatusOK, maskedSettings(s.app.Store().Settings())) case http.MethodPut, http.MethodPost: var body store.AISettings if err := decodeBody(r, &body); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - saved := s.store.SaveAISettings(body) - writeJSON(w, http.StatusOK, maskedSettings(saved)) + saved := s.app.Store().SaveAISettings(body) + s.writeMutation(w, maskedSettings(saved), http.StatusOK, nil) default: writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or PUT") } } -// handleAdminModels proxies GET {base}/models so the admin UI can populate a -// model picker. Uses the request's api_key, falling back to the stored key. -func (s *Server) handleAdminModels(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleAdminRecallTest(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireSuperAdmin(w, r); !ok { return } @@ -561,1121 +983,553 @@ func (s *Server) handleAdminModels(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } - var body struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - } - if err := decodeBody(r, &body); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + ai := s.app.Store().Settings().AI + query := strings.TrimSpace(ai.RecallTestQuery) + if query == "" { + writeError(w, http.StatusBadRequest, "bad_request", "recall_test_query is required") return } - base := strings.TrimSpace(body.BaseURL) - if base == "" { - base = s.store.Settings().AI.AskBaseURL + topK := ai.RecallTestTopK + if topK <= 0 { + topK = 10 } - if base == "" { - writeError(w, http.StatusBadRequest, "bad_request", "base_url required") - return + if topK > 100 { + topK = 100 } - key := strings.TrimSpace(body.APIKey) - if key == "" { - key = s.store.Settings().AI.AskAPIKey + expected := parseDocIDList(ai.RecallTestDocIDs) + if len(expected) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "recall_test_doc_ids is required") + return } - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, strings.TrimRight(base, "/")+"/models", nil) + resp, err := s.app.Search().Search(r.Context(), search.Request{ + Query: query, + Mode: search.ModeHybrid, + Page: 1, + PageSize: topK, + DefaultVersionsOnly: true, + }) if err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + writeError(w, http.StatusInternalServerError, "search_failed", err.Error()) return } - if key != "" { - req.Header.Set("Authorization", "Bearer "+key) + actual := make([]string, 0, len(resp.Results)) + for _, r := range resp.Results { + actual = append(actual, r.DocID) } - resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) - if err != nil { - writeError(w, http.StatusBadGateway, "models_failed", err.Error()) + writeJSON(w, http.StatusOK, map[string]any{ + "query": query, + "top_k": topK, + "expected_doc_ids": expected, + "actual_doc_ids": actual, + "recall_at_k": recallAtK(expected, actual), + "mrr": reciprocalRank(expected, actual), + "ndcg": ndcg(expected, actual), + "results": resp.Results, + "note": "当前版本评估 hybrid 召回;重排序模型接入后可在此返回 rerank 前后对比。", + }) +} + +// handleAdminPlugins exposes the built-in doc-engine plugin registry. GET +// returns the catalog merged with saved overrides; PUT persists enable/config +// overrides. Super-admin only; effective state is served to viewers via /api/config. +func (s *Server) handleAdminPlugins(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { return } - defer resp.Body.Close() - raw, _ := io.ReadAll(resp.Body) - if resp.StatusCode >= 300 { - writeError(w, http.StatusBadGateway, "models_failed", fmt.Sprintf("%d: %s", resp.StatusCode, string(raw))) - return + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{"plugins": s.app.Store().PluginStates()}) + case http.MethodPut, http.MethodPost: + var body struct { + Plugins map[string]store.PluginSetting `json:"plugins"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + s.writeMutation(w, map[string]any{"plugins": s.app.Store().SavePluginSettings(body.Plugins)}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or PUT") } - var parsed struct { - Data []struct { - ID string `json:"id"` - } `json:"data"` +} + +// handleAdminPluginImport imports (POST) or removes (DELETE) a sandbox-rendered +// JSX plugin. Super-admin only. Imports stay disabled until toggled on. +func (s *Server) handleAdminPluginImport(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { + return } - _ = json.Unmarshal(raw, &parsed) - ids := make([]string, 0, len(parsed.Data)) - for _, m := range parsed.Data { - if m.ID != "" { - ids = append(ids, m.ID) + key := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/api/admin/plugins/import"), "/") + switch r.Method { + case http.MethodPost, http.MethodPut: + var body store.UploadedPlugin + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + saved, err := s.app.Store().SaveUploadedPlugin(body) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_plugin", err.Error()) + return + } + s.writeMutation(w, map[string]any{"plugin": saved, "plugins": s.app.Store().PluginStates()}, http.StatusOK, nil) + case http.MethodDelete: + if key == "" { + writeError(w, http.StatusBadRequest, "bad_request", "plugin key required") + return + } + if !s.app.Store().DeleteUploadedPlugin(key) { + writeError(w, http.StatusNotFound, "not_found", "plugin not found") + return } + s.writeMutation(w, map[string]any{"status": "deleted", "key": key, "plugins": s.app.Store().PluginStates()}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST or DELETE") } - writeJSON(w, http.StatusOK, map[string]any{"models": ids}) } -// maskedSettings hides the stored API key but reports whether one is set. -func maskedSettings(set store.Settings) map[string]any { - ai := set.AI - keySet := strings.TrimSpace(ai.AskAPIKey) != "" - ai.AskAPIKey = "" - return map[string]any{ - "ai": ai, - "ask_api_key_set": keySet, - } -} - -func (s *Server) handleFacets(w http.ResponseWriter, r *http.Request) { - resp, _ := s.search.Search(r.Context(), search.Request{Mode: search.ModeKeyword, PageSize: 1}) - writeJSON(w, http.StatusOK, resp.Facets) -} - -func (s *Server) handleEmbedText(w http.ResponseWriter, r *http.Request) { - var req struct { - Text string `json:"text"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - vec, err := s.search.Embedder.EmbedText(r.Context(), req.Text) - if err != nil { - writeError(w, http.StatusBadGateway, "embedding_failed", err.Error()) +// handleDocsPlugins exposes enabled uploaded plugins (with their JSX source) to +// the renderer. Read-only and un-gated, like /api/docs/snippets. +func (s *Server) handleDocsPlugins(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } - writeJSON(w, http.StatusOK, map[string]any{"provider": s.search.Embedder.Name(), "dimension": len(vec), "embedding": vec}) + writeJSON(w, http.StatusOK, map[string]any{"plugins": s.app.Store().EnabledUploadedPlugins()}) } -func (s *Server) handleEmbeddingReindex(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") - return - } - if user, ok := s.requireUser(w, r); !ok { - return - } else if !isAdmin(user) && !s.auth.IsSuperAdmin(user) { - writeError(w, http.StatusForbidden, "forbidden", "admin required") +// handleAdminSnippets manages the reusable snippet library and variables. +// Super-admin only. GET returns the current set; PUT replaces it. +func (s *Server) handleAdminSnippets(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { return } - count, err := s.search.Reindex(r.Context()) - if err != nil { - writeError(w, http.StatusBadGateway, "embedding_reindex_failed", err.Error()) - return + switch r.Method { + case http.MethodGet: + snips, vars := s.app.Store().SnippetData() + writeJSON(w, http.StatusOK, map[string]any{"snippets": snips, "variables": vars}) + case http.MethodPut, http.MethodPost: + var body struct { + Snippets []store.Snippet `json:"snippets"` + Variables map[string]string `json:"variables"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + snips, vars := s.app.Store().SaveSnippetData(body.Snippets, body.Variables) + s.writeMutation(w, map[string]any{"snippets": snips, "variables": vars}, http.StatusOK, nil) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or PUT") } - writeJSON(w, http.StatusOK, map[string]any{ - "status": "reindexed", - "provider": s.search.Embedder.Name(), - "embedded_pages": count, - "cached_documents": s.store.EmbeddingCount(), - }) } -func (s *Server) handleSearchReindex(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") - return - } - if user, ok := s.requireUser(w, r); !ok { - return - } else if !isAdmin(user) && !s.auth.IsSuperAdmin(user) { - writeError(w, http.StatusForbidden, "forbidden", "admin required") - return - } - // The keyword index is computed from the in-memory page set, so reindexing - // primarily (re)builds the embedding cache used by semantic/hybrid search. - count, err := s.search.Reindex(r.Context()) - if err != nil { - writeError(w, http.StatusBadGateway, "search_reindex_failed", err.Error()) +// handleDocsSnippets exposes the snippet library + variables to the renderer. +// Read-only and un-gated (no secrets), mirroring /api/config. +func (s *Server) handleDocsSnippets(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") return } - writeJSON(w, http.StatusOK, map[string]any{ - "status": "reindexed", - "indexed_documents": len(s.store.Pages()), - "embedded_documents": count, - }) + snips, vars := s.app.Store().SnippetData() + writeJSON(w, http.StatusOK, map[string]any{"snippets": snips, "variables": vars}) } -func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") - return - } - artifact, err := deploy.ParseZip(r.Body, envInt64("DOCS_DEPLOY_MAX_BYTES", 100*1024*1024)) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid_artifact", err.Error()) - return - } - - // Deploy auth (GitLab CI / docsctl integration) - // - Global token via DOCS_DEPLOY_TOKEN env (for simple setups) - // - Per-module DeployToken (recommended for GitLab对接) - // Token can be sent as X-Modex-Deploy-Token header or Authorization: Bearer - moduleKey := artifact.Metadata.ModuleKey - globalToken := os.Getenv("DOCS_DEPLOY_TOKEN") - provided := r.Header.Get("X-Modex-Deploy-Token") - if provided == "" { - provided = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - } - - allowed := false - if globalToken != "" && provided == globalToken { - allowed = true - } - if m, merr := s.store.Module(moduleKey); merr == nil && m.DeployToken != "" { - if provided == m.DeployToken { - allowed = true - } - } - // If no tokens are configured at all (dev), allow. Otherwise require match. - hasAnyToken := globalToken != "" - if m, merr := s.store.Module(moduleKey); merr == nil && m.DeployToken != "" { - hasAnyToken = true - } - if hasAnyToken && !allowed { - writeError(w, http.StatusForbidden, "invalid_deploy_token", "deploy token required or invalid for this module") - return - } - - if s.minioClient != nil { - s.uploadSiteFilesToMinIO(artifact, moduleKey, artifact.Metadata.DocsVersion) - } - - result, err := s.store.IngestArtifact(toStoreArtifact(artifact)) - if err != nil { - writeError(w, http.StatusBadRequest, "deploy_failed", err.Error()) +// handleAdminModels proxies GET {base}/models so the admin UI can populate a +// model picker. Uses the request's api_key, falling back to the stored key. +func (s *Server) handleAdminModels(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { return } - writeJSON(w, http.StatusAccepted, map[string]any{"status": "published", "result": result}) -} - -func (s *Server) handleReleases(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.store.Releases()) -} - -func (s *Server) handlePageAnalytics(w http.ResponseWriter, r *http.Request) { - stats := s.store.PageAnalytics() - var totalPV, totalReads7d int - for _, st := range stats { - totalPV += st.PV - totalReads7d += st.Reads7d - } - writeJSON(w, http.StatusOK, map[string]any{ - "popular_pages": stats, - "total_pv": totalPV, - "reads_7d": totalReads7d, - "events": []string{"docs_page_view", "docs_search_result_click"}, - }) -} - -func (s *Server) handlePageView(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } - var req struct { - DocID string `json:"doc_id"` - SessionID string `json:"session_id"` - Duration int `json:"duration_seconds"` - ScrollDepth float64 `json:"scroll_depth"` + var body struct { + Protocol string `json:"protocol"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if err := decodeBody(r, &body); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - if strings.TrimSpace(req.DocID) == "" { - writeError(w, http.StatusBadRequest, "bad_request", "doc_id is required") - return + cur := s.app.Store().Settings().AI + base := strings.TrimSpace(body.BaseURL) + if base == "" { + base = cur.AskBaseURL } - user, _ := s.currentUser(r) - pv := s.store.RecordPageView(store.PageView{ - DocID: req.DocID, UserID: user.ID, SessionID: req.SessionID, - DurationSeconds: req.Duration, ScrollDepth: req.ScrollDepth, - }) - writeJSON(w, http.StatusAccepted, map[string]any{"status": "recorded", "view_id": pv.ID}) -} - -func (s *Server) handleReadProgress(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + if base == "" { + writeError(w, http.StatusBadRequest, "bad_request", "base_url required") return } - var req struct { - DocID string `json:"doc_id"` - SessionID string `json:"session_id"` - Duration int `json:"duration_seconds"` - ScrollDepth float64 `json:"scroll_depth"` + key := strings.TrimSpace(body.APIKey) + if key == "" { + key = cur.AskAPIKey } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return + protocol := strings.TrimSpace(body.Protocol) + if protocol == "" { + protocol = cur.AskProtocol } - if strings.TrimSpace(req.DocID) == "" { - writeError(w, http.StatusBadRequest, "bad_request", "doc_id is required") + ids, err := listModels(r.Context(), protocol, base, key) + if err != nil { + writeError(w, http.StatusBadGateway, "models_failed", err.Error()) return } - s.store.RecordReadProgress(req.DocID, req.SessionID, req.Duration, req.ScrollDepth) - writeJSON(w, http.StatusAccepted, map[string]any{"status": "recorded"}) -} - -func (s *Server) handleSearchLogs(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.store.SearchLogs()) -} - -func (s *Server) handleMCPLogs(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, s.store.MCPLogs()) + writeJSON(w, http.StatusOK, map[string]any{"models": ids}) } -func (s *Server) handleMCPLog(w http.ResponseWriter, r *http.Request) { - var req struct { - ToolName string `json:"tool_name"` - Query string `json:"query"` - InputJSON string `json:"input_json"` - ResultCount int `json:"result_count"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) +func (s *Server) handleAdminModelConnectionTest(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireSuperAdmin(w, r); !ok { return } - user, _ := s.currentUser(r) - s.store.AddMCPLog(store.MCPLog{ID: fmt.Sprintf("ml-%d", time.Now().UnixNano()), ToolName: req.ToolName, UserID: user.ID, Query: req.Query, InputJSON: req.InputJSON, ResultCount: req.ResultCount, CreatedAt: time.Now().UTC()}) - writeJSON(w, http.StatusAccepted, map[string]any{"status": "logged"}) -} - -// currentUser returns the authenticated user from the session cookie. Login is -// a real, cookie-backed action in both mock and OIDC modes, so there is no -// silent impersonation; anonymous callers simply get ok == false. -func (s *Server) currentUser(r *http.Request) (store.User, bool) { - return s.auth.CurrentUser(r) -} - -func isAdmin(u store.User) bool { - for _, r := range u.Roles { - if r == "admin" { - return true - } - } - return false -} - -// canManageCategory reports whether the user may manage the given platform. -// A managed category id covers its descendants (e.g. "engineering" covers -// "engineering.cbb"). -func canManageCategory(u store.User, categoryID string) bool { - for _, m := range u.ManagedCategories { - if m == categoryID || strings.HasPrefix(categoryID, m+".") { - return true - } - } - return false -} - -func canManageCategories(u store.User, categoryIDs []string) bool { - for _, id := range categoryIDs { - if canManageCategory(u, id) { - return true - } - } - return false -} - -// isTeamLeader checks if the user is the designated leader of the team. -func (s *Server) isTeamLeader(u store.User, teamKey string) bool { - if teamKey == "" { - return false - } - t, err := s.store.Team(teamKey) - if err != nil { - return false - } - return t.Leader != "" && (strings.EqualFold(t.Leader, u.Username) || strings.EqualFold(t.Leader, u.ID)) -} - -// teamMembers returns usernames/ids in the team (for ownership checks). -func (s *Server) teamMembers(teamKey string) []string { - return s.store.TeamMembers(teamKey) -} - -// canManageViaResponsibleTeam allows members (incl. leader) of a category's responsible team -// to manage that category's resources (generic domain ownership). -func (s *Server) canManageViaResponsibleTeam(u store.User, categoryIDs []string) bool { - for _, cid := range categoryIDs { - // Check direct; for hierarchy the responsible on parent covers subs conceptually, - // but we also check the specific id's assignment. - resp := s.categoryResponsible(cid) - if resp == "" { - continue - } - for _, m := range s.teamMembers(resp) { - if strings.EqualFold(m, u.Username) || strings.EqualFold(m, u.ID) { - return true - } - } - } - return false -} - -func (s *Server) categoryResponsible(id string) string { - // Walk the tree (small data) to find assignment for id or nearest ancestor with one. - tree := s.store.CategoryTree() - var find func([]store.Category) string - find = func(cats []store.Category) string { - for _, c := range cats { - if c.ID == id { - if c.ResponsibleTeam != "" { - return c.ResponsibleTeam - } - // inherit from parent? caller walks up if needed; here return what we have at leaf. - return "" - } - if hit := find(c.Children); hit != "" { - return hit - } - } - return "" - } - // Also try ancestor match for sub-ids (e.g. "standards.foo" covered by "standards" team) - for _, c := range tree { - if c.ID == id || strings.HasPrefix(id, c.ID+".") { - if c.ResponsibleTeam != "" { - return c.ResponsibleTeam - } - } - for _, ch := range c.Children { - if ch.ID == id || strings.HasPrefix(id, ch.ID+".") { - if ch.ResponsibleTeam != "" { - return ch.ResponsibleTeam - } - } - } - } - return find(tree) -} - -// requireUser writes 401 and returns false when no valid session is present. -func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (store.User, bool) { - user, ok := s.currentUser(r) - if !ok { - writeError(w, http.StatusUnauthorized, "unauthorized", "login required") - return store.User{}, false - } - return user, true -} - -// requireSuperAdmin gates super-admin-only actions (e.g. user/permission mgmt). -func (s *Server) requireSuperAdmin(w http.ResponseWriter, r *http.Request) (store.User, bool) { - user, ok := s.requireUser(w, r) - if !ok { - return store.User{}, false - } - if !s.auth.IsSuperAdmin(user) { - writeError(w, http.StatusForbidden, "forbidden", "super admin required") - return store.User{}, false - } - return user, true -} - -// requirePlatform gates platform-scoped writes: super admins pass; otherwise the -// user must have management rights on at least one of the target categories. -// Team responsible for the domain (via Category.ResponsibleTeam) also grants access -// to its members/leaders (generic for OSS doc maintenance teams owning 领域). -func (s *Server) requirePlatform(w http.ResponseWriter, r *http.Request, categoryIDs []string) (store.User, bool) { - user, ok := s.requireUser(w, r) - if !ok { - return store.User{}, false - } - if s.auth.IsSuperAdmin(user) { - return user, true - } - if isAdmin(user) && canManageCategories(user, categoryIDs) { - return user, true - } - if s.canManageViaResponsibleTeam(user, categoryIDs) { - return user, true - } - writeError(w, http.StatusForbidden, "forbidden", "no management permission for this platform") - return store.User{}, false -} - -// moduleCategories resolves the category IDs attached to a module key. -func (s *Server) moduleCategories(moduleKey string) []string { - if m, err := s.store.Module(moduleKey); err == nil { - return m.CategoryIDs - } - return nil -} - -func (s *Server) handleAdminCategories(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - writeJSON(w, http.StatusOK, s.store.CategoryTree()) + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } - var c store.Category - if err := decodeBody(r, &c); err != nil { + var body struct { + Kind string `json:"kind"` + Protocol string `json:"protocol"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + APIKey string `json:"api_key"` + } + if err := decodeBody(r, &body); err != nil { writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - // Top-level platforms are super-admin only; sub-platforms can be created by - // a manager of the parent platform. - if c.ParentID == "" { - if _, ok := s.requireSuperAdmin(w, r); !ok { - return - } - } else if _, ok := s.requirePlatform(w, r, []string{c.ParentID}); !ok { + kind := strings.TrimSpace(body.Kind) + base := strings.TrimSpace(body.BaseURL) + model := strings.TrimSpace(body.Model) + if base == "" || model == "" { + writeError(w, http.StatusBadRequest, "bad_request", "base_url and model are required") return } - created, err := s.store.CreateCategory(c) - writeMutation(w, created, http.StatusCreated, err) -} - -func (s *Server) handleAdminCategoryByID(w http.ResponseWriter, r *http.Request) { - id := strings.TrimPrefix(r.URL.Path, "/api/admin/categories/") - // Drag-and-drop move: POST /api/admin/categories/{id}/move {parent_id, index}. - if strings.HasSuffix(id, "/move") { - id = strings.TrimSuffix(id, "/move") - if _, ok := s.requireSuperAdmin(w, r); !ok { - return + ai := s.app.Store().Settings().AI + key := strings.TrimSpace(body.APIKey) + switch kind { + case "chat": + if key == "" { + key = ai.AskAPIKey + } + protocol := strings.TrimSpace(body.Protocol) + if protocol == "" { + protocol = ai.AskProtocol } - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + result, err := testChatEndpoint(r.Context(), protocol, base, model, key) + if err != nil { + writeError(w, http.StatusBadGateway, "chat_test_failed", err.Error()) return } - var body struct { - ParentID string `json:"parent_id"` - Index int `json:"index"` + writeJSON(w, http.StatusOK, result) + case "embedding": + if key == "" { + key = ai.EmbeddingAPIKey } - if err := decodeBody(r, &body); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + result, err := testEmbeddingEndpoint(r.Context(), base, model, key) + if err != nil { + writeError(w, http.StatusBadGateway, "embedding_test_failed", err.Error()) return } - moved, err := s.store.MoveCategory(id, body.ParentID, body.Index) - writeMutation(w, moved, http.StatusOK, err) - return - } - if _, ok := s.requirePlatform(w, r, []string{id}); !ok { - return - } - switch r.Method { - case http.MethodPut: - var c store.Category - if err := decodeBody(r, &c); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return + writeJSON(w, http.StatusOK, result) + case "rerank": + if key == "" { + key = ai.RerankAPIKey } - updated, err := s.store.UpdateCategory(id, c) - writeMutation(w, updated, http.StatusOK, err) - case http.MethodDelete: - if err := s.store.DeleteCategory(id); err != nil { - writeResult(w, nil, err) + result, err := testRerankEndpoint(r.Context(), base, model, key) + if err != nil { + writeError(w, http.StatusBadGateway, "rerank_test_failed", err.Error()) return } - writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "id": id}) + writeJSON(w, http.StatusOK, result) default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use PUT or DELETE") + writeError(w, http.StatusBadRequest, "bad_request", "kind must be chat, embedding or rerank") } } -func (s *Server) handleAdminModules(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeJSON(w, http.StatusOK, s.store.Modules("", "")) - return +func testChatEndpoint(ctx context.Context, protocol, base, model, key string) (map[string]any, error) { + temp := 0.0 + answer, err := chatComplete(ctx, store.AISettings{ + AskProtocol: protocol, + AskBaseURL: base, + AskModel: model, + AskAPIKey: key, + AskMaxTokens: 64, + AskTemperature: &temp, + }, "你是连接测试助手。", "请只回复 OK。") + if err != nil { + return nil, err } - var m store.Module - if err := decodeBody(r, &m); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return + if strings.TrimSpace(answer) == "" { + return nil, fmt.Errorf("chat endpoint returned an empty answer") } - if _, ok := s.requirePlatform(w, r, m.CategoryIDs); !ok { - return + return map[string]any{ + "kind": "chat", + "status": "ok", + "endpoint": chatEndpoint(base, protocol, model), + "model": model, + "sample": truncateRunes(strings.TrimSpace(answer), 80), + }, nil +} + +func chatEndpoint(base, protocol, model string) string { + endpoint := strings.TrimRight(strings.TrimSpace(base), "/") + switch normalizeProtocol(protocol) { + case protoAnthropic: + return endpoint + "/v1/messages" + case protoGemini: + return endpoint + "/v1beta/models/" + url.PathEscape(model) + ":generateContent" + case protoOpenAIResponses: + return endpoint + "/responses" + default: + return endpoint + "/chat/completions" } - created, err := s.store.CreateModule(m) - writeMutation(w, created, http.StatusCreated, err) } -func (s *Server) handleAdminModuleRoutes(w http.ResponseWriter, r *http.Request) { - parts := splitPath(strings.TrimPrefix(r.URL.Path, "/api/admin/modules/")) - if len(parts) == 0 { - writeError(w, http.StatusNotFound, "not_found", "admin module route not found") - return - } - moduleKey := parts[0] - // Migration (reassign platform/owner) has its own dual-platform permission - // check, so handle it before the generic platform gate. - if len(parts) == 2 && parts[1] == "migrate" && r.Method == http.MethodPost { - s.handleMigrateModule(w, r, moduleKey) - return +func testEmbeddingEndpoint(ctx context.Context, base, model, key string) (map[string]any, error) { + endpoint := modelEndpoint(base, "/embeddings") + raw, code, err := httpJSON(ctx, http.MethodPost, endpoint, + map[string]string{"Content-Type": "application/json", "Authorization": bearer(key)}, + map[string]any{"model": model, "input": []string{"Modex embedding connection test"}}) + if err != nil { + return nil, err } - if _, ok := s.requirePlatform(w, r, s.moduleCategories(moduleKey)); !ok { - return + if code >= 300 { + return nil, fmt.Errorf("%s returned HTTP %d: %s", endpoint, code, strings.TrimSpace(string(raw))) } - // Reveal / rotate the CI deploy token (kept out of normal serialization). - if len(parts) == 2 && parts[1] == "deploy-token" { - m, err := s.store.Module(moduleKey) - if err != nil { - writeError(w, http.StatusNotFound, "not_found", "module not found") - return - } - if r.Method == http.MethodPost { // rotate - token := "mdx_" + strconv.FormatInt(time.Now().UnixNano(), 36) - if _, err := s.store.UpdateModule(moduleKey, store.Module{DeployToken: token}); err != nil { - writeResult(w, nil, err) - return - } - m.DeployToken = token - } else if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "deploy_token": m.DeployToken, - "deploy_url": firstNonEmptyStr(os.Getenv("APP_BASE_URL"), "") + "/api/deploy", - "module_key": moduleKey, - }) - return + var out struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` } - switch { - case len(parts) == 1 && r.Method == http.MethodPut: - var m store.Module - if err := decodeBody(r, &m); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - updated, err := s.store.UpdateModule(moduleKey, m) - writeMutation(w, updated, http.StatusOK, err) - case len(parts) == 2 && parts[1] == "versions" && r.Method == http.MethodPost: - var v store.Version - if err := decodeBody(r, &v); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - created, err := s.store.CreateVersion(moduleKey, v) - writeMutation(w, created, http.StatusCreated, err) - case len(parts) == 3 && parts[1] == "versions" && r.Method == http.MethodPut: - var v store.Version - if err := decodeBody(r, &v); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - updated, err := s.store.UpdateVersion(moduleKey, parts[2], v) - writeMutation(w, updated, http.StatusOK, err) - case len(parts) == 4 && parts[1] == "versions" && parts[3] == "entries" && r.Method == http.MethodPost: - var e store.Entry - if err := decodeBody(r, &e); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - created, err := s.store.CreateEntry(moduleKey, parts[2], e) - writeMutation(w, created, http.StatusCreated, err) - default: - writeError(w, http.StatusNotFound, "not_found", "admin module route not found") + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decode embedding response from %s: %w", endpoint, err) } -} - -// handleMigrateModule reassigns a module to different platform(s) (and -// optionally a new owner). The caller must be able to manage both the source -// and destination platforms (super admins bypass the check). -func (s *Server) handleMigrateModule(w http.ResponseWriter, r *http.Request, moduleKey string) { - user, ok := s.requireUser(w, r) - if !ok { - return + if len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 { + return nil, fmt.Errorf("%s returned no embedding vector", endpoint) } - var req struct { - CategoryIDs []string `json:"category_ids"` - OwnerGroup string `json:"owner_group"` + return map[string]any{ + "kind": "embedding", + "status": "ok", + "endpoint": endpoint, + "model": model, + "dimension": len(out.Data[0].Embedding), + }, nil +} + +func testRerankEndpoint(ctx context.Context, base, model, key string) (map[string]any, error) { + endpoint := modelEndpoint(base, "/rerank") + raw, code, err := httpJSON(ctx, http.MethodPost, endpoint, + map[string]string{"Content-Type": "application/json", "Authorization": bearer(key)}, + map[string]any{ + "model": model, + "query": "Modex rerank connection test", + "documents": []string{"Modex rerank connection test document", "unrelated document"}, + "top_n": 2, + }) + if err != nil { + return nil, err } - if err := decodeBody(r, &req); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return + if code >= 300 { + return nil, fmt.Errorf("%s returned HTTP %d: %s", endpoint, code, strings.TrimSpace(string(raw))) } - if len(req.CategoryIDs) == 0 { - writeError(w, http.StatusBadRequest, "bad_request", "category_ids is required") - return + var out struct { + Results []struct { + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` + } `json:"results"` } - if !s.auth.IsSuperAdmin(user) { - source := s.moduleCategories(moduleKey) - if !isAdmin(user) || !canManageCategories(user, source) || !canManageCategories(user, req.CategoryIDs) { - writeError(w, http.StatusForbidden, "forbidden", "need management permission on both source and target platforms") - return - } + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decode rerank response from %s: %w", endpoint, err) } - names := make([]string, 0, len(req.CategoryIDs)) - for _, id := range req.CategoryIDs { - names = append(names, s.store.CategoryName(id)) + if len(out.Results) == 0 { + return nil, fmt.Errorf("%s returned no rerank results", endpoint) } - updated, err := s.store.UpdateModule(moduleKey, store.Module{ - CategoryIDs: req.CategoryIDs, - CategoryPath: strings.Join(names, " / "), - OwnerGroup: req.OwnerGroup, - }) - writeMutation(w, updated, http.StatusOK, err) + return map[string]any{ + "kind": "rerank", + "status": "ok", + "endpoint": endpoint, + "model": model, + "top_index": out.Results[0].Index, + "score": out.Results[0].RelevanceScore, + }, nil } -func (s *Server) handleAdminEntryByID(w http.ResponseWriter, r *http.Request) { - entryID := strings.TrimPrefix(r.URL.Path, "/api/admin/entries/") - if moduleKey, ok := s.store.EntryModuleKey(entryID); ok { - if _, ok := s.requirePlatform(w, r, s.moduleCategories(moduleKey)); !ok { - return - } - } else if _, ok := s.requireUser(w, r); !ok { - return - } - switch r.Method { - case http.MethodPut: - var e store.Entry - if err := decodeBody(r, &e); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - updated, err := s.store.UpdateEntry(entryID, e) - writeMutation(w, updated, http.StatusOK, err) - case http.MethodDelete: - if err := s.store.DeleteEntry(entryID); err != nil { - writeResult(w, nil, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "id": entryID}) - default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use PUT or DELETE") +func modelEndpoint(base, suffix string) string { + endpoint := strings.TrimRight(strings.TrimSpace(base), "/") + if !strings.HasSuffix(endpoint, suffix) { + endpoint += suffix } + return endpoint } -func (s *Server) handleReleaseRoutes(w http.ResponseWriter, r *http.Request) { - parts := splitPath(strings.TrimPrefix(r.URL.Path, "/api/admin/releases/")) - if len(parts) == 0 { - writeError(w, http.StatusNotFound, "not_found", "release route not found") - return - } - releaseID := parts[0] - if len(parts) == 2 && parts[1] == "rollback" && r.Method == http.MethodPost { - rel, err := s.store.RollbackRelease(releaseID) - writeResult(w, rel, err) - return +// maskedSettings hides the stored API key but reports whether one is set. +func maskedSettings(set store.Settings) map[string]any { + ai := set.AI + keySet := strings.TrimSpace(ai.AskAPIKey) != "" + embeddingKeySet := strings.TrimSpace(ai.EmbeddingAPIKey) != "" + rerankKeySet := strings.TrimSpace(ai.RerankAPIKey) != "" + ai.AskAPIKey = "" + ai.EmbeddingAPIKey = "" + ai.RerankAPIKey = "" + return map[string]any{ + "ai": ai, + "ask_api_key_set": keySet, + "embedding_api_key_set": embeddingKeySet, + "rerank_api_key_set": rerankKeySet, + "ask_system_prompt_default": store.DefaultAskSystemPrompt, } - rel, err := s.store.Release(releaseID) - writeResult(w, rel, err) } -func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireSuperAdmin(w, r); !ok { - return - } - switch r.Method { - case http.MethodGet: - users := s.store.Users(r.URL.Query().Get("keyword")) - // Enrich with the effective super admin status (persisted flag OR env SUPER_ADMIN_USERS). - for i := range users { - users[i].SuperAdmin = s.auth.IsSuperAdmin(users[i]) - } - writeJSON(w, http.StatusOK, users) - case http.MethodPost: - var u store.User - if err := decodeBody(r, &u); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - created, err := s.store.CreateUser(u) - if err == nil { - created.SuperAdmin = s.auth.IsSuperAdmin(created) +func parseDocIDList(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == '\n' || r == '\r' || r == '\t' || r == ',' || r == ',' || r == ';' || r == ';' + }) + seen := map[string]bool{} + out := []string{} + for _, f := range fields { + v := strings.TrimSpace(f) + if v == "" || seen[v] { + continue } - writeMutation(w, created, http.StatusCreated, err) - default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") + seen[v] = true + out = append(out, v) } + return out } -func (s *Server) handleAdminUserByID(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireSuperAdmin(w, r); !ok { - return - } - id := strings.TrimPrefix(r.URL.Path, "/api/admin/users/") - switch r.Method { - case http.MethodGet: - u, err := s.store.UserByID(id) - if err == nil { - u.SuperAdmin = s.auth.IsSuperAdmin(u) - } - writeResult(w, u, err) - case http.MethodPut: - var u store.User - if err := decodeBody(r, &u); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - updated, err := s.store.UpdateUser(id, u) - if err == nil { - updated.SuperAdmin = s.auth.IsSuperAdmin(updated) - } - writeMutation(w, updated, http.StatusOK, err) - case http.MethodDelete: - if err := s.store.DeleteUser(id); err != nil { - writeResult(w, nil, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "id": id}) - default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET, PUT or DELETE") +func expectedSet(expected []string) map[string]bool { + set := make(map[string]bool, len(expected)) + for _, id := range expected { + set[id] = true } + return set } -func (s *Server) handleAdminGroups(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireSuperAdmin(w, r); !ok { - return +func recallAtK(expected, actual []string) float64 { + if len(expected) == 0 { + return 0 } - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, s.store.Groups()) - case http.MethodPost: - var g store.Group - if err := decodeBody(r, &g); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return + set := expectedSet(expected) + hits := 0 + for _, id := range actual { + if set[id] { + hits++ } - created, err := s.store.CreateGroup(g) - writeMutation(w, created, http.StatusCreated, err) - default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") } + return float64(hits) / float64(len(expected)) } -func (s *Server) handleAdminTeams(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireSuperAdmin(w, r); !ok { - return - } - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, s.store.Teams()) - case http.MethodPost: - var t store.Team - if err := decodeBody(r, &t); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return +func reciprocalRank(expected, actual []string) float64 { + set := expectedSet(expected) + for i, id := range actual { + if set[id] { + return 1 / float64(i+1) } - created, err := s.store.CreateTeam(t) - writeMutation(w, created, http.StatusCreated, err) - default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET or POST") } + return 0 } -func (s *Server) handleAdminTeamRoutes(w http.ResponseWriter, r *http.Request) { - parts := splitPath(strings.TrimPrefix(r.URL.Path, "/api/admin/teams/")) - if len(parts) == 0 { - writeError(w, http.StatusNotFound, "not_found", "team route not found") - return +func ndcg(expected, actual []string) float64 { + if len(expected) == 0 || len(actual) == 0 { + return 0 } - key := parts[0] - switch { - case len(parts) == 1: - // View: super or any member of the team. Mutations: leader or super. - user, ok := s.requireUser(w, r) - if !ok { - return - } - tm, err := s.store.Team(key) - if err != nil { - writeResult(w, tm, err) - return - } - isMember := false - for _, m := range tm.Members { - if strings.EqualFold(m, user.Username) || strings.EqualFold(m, user.ID) { - isMember = true - break - } - } - if !s.auth.IsSuperAdmin(user) && !isMember { - writeError(w, http.StatusForbidden, "forbidden", "team membership or super required") - return + set := expectedSet(expected) + dcg := 0.0 + for i, id := range actual { + if set[id] { + dcg += 1 / math.Log2(float64(i+2)) } - switch r.Method { - case http.MethodGet: - writeJSON(w, http.StatusOK, tm) - case http.MethodPut: - if !s.auth.IsSuperAdmin(user) && !s.isTeamLeader(user, key) { - writeError(w, http.StatusForbidden, "forbidden", "only leader or super can update team") - return - } - var t store.Team - if err := decodeBody(r, &t); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - updated, err := s.store.UpdateTeam(key, t) - writeMutation(w, updated, http.StatusOK, err) - case http.MethodDelete: - if _, ok := s.requireSuperAdmin(w, r); !ok { - return - } - if err := s.store.DeleteTeam(key); err != nil { - writeResult(w, nil, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "key": key}) - default: - writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET/PUT/DELETE") - } - case len(parts) == 2 && parts[1] == "members" && r.Method == http.MethodPost: - // Leader or super can pull (add) members. - user, ok := s.requireUser(w, r) - if !ok { - return - } - if !s.auth.IsSuperAdmin(user) && !s.isTeamLeader(user, key) { - writeError(w, http.StatusForbidden, "forbidden", "only team leader or super can add members") - return - } - var req struct { - Username string `json:"username"` - } - if err := decodeBody(r, &req); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) - return - } - if strings.TrimSpace(req.Username) == "" { - writeError(w, http.StatusBadRequest, "invalid_input", "username required") - return - } - updated, err := s.store.AddTeamMember(key, req.Username) - writeMutation(w, updated, http.StatusOK, err) - case len(parts) == 3 && parts[1] == "members" && r.Method == http.MethodDelete: - // Leader or super can remove member. - user, ok := s.requireUser(w, r) - if !ok { - return - } - if !s.auth.IsSuperAdmin(user) && !s.isTeamLeader(user, key) { - writeError(w, http.StatusForbidden, "forbidden", "only team leader or super can remove members") - return - } - member := parts[2] - updated, err := s.store.RemoveTeamMember(key, member) - writeMutation(w, updated, http.StatusOK, err) - default: - writeError(w, http.StatusNotFound, "not_found", "team route not found") } + idealHits := len(expected) + if idealHits > len(actual) { + idealHits = len(actual) + } + idcg := 0.0 + for i := 0; i < idealHits; i++ { + idcg += 1 / math.Log2(float64(i+2)) + } + if idcg == 0 { + return 0 + } + return dcg / idcg } -func (s *Server) handleAdminAccepted(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "note": "MVP admin mutation endpoint placeholder"}) -} - -func decodeBody(r *http.Request, v any) error { - return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v) +func (s *Server) handleFacets(w http.ResponseWriter, r *http.Request) { + resp, _ := s.app.Search().Search(r.Context(), search.Request{Mode: search.ModeKeyword, PageSize: 1}) + writeJSON(w, http.StatusOK, resp.Facets) } -func writeMutation(w http.ResponseWriter, v any, successStatus int, err error) { - if err != nil { - writeResult(w, nil, err) +func (s *Server) handleEmbedText(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } - writeJSON(w, successStatus, v) -} - -func writeResult(w http.ResponseWriter, v any, err error) { - if err == nil { - writeJSON(w, http.StatusOK, v) + if user, ok := s.requireUser(w, r); !ok { return - } - if errors.Is(err, store.ErrNotFound) { - writeError(w, http.StatusNotFound, "not_found", "resource not found") + } else if !isAdmin(user) && !s.app.Auth().IsSuperAdmin(user) { + writeError(w, http.StatusForbidden, "forbidden", "admin required") return } - if errors.Is(err, store.ErrInvalid) { - writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) - return + var req struct { + Text string `json:"text"` } - if errors.Is(err, store.ErrConflict) { - writeError(w, http.StatusConflict, "conflict", err.Error()) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) -} - -func splitPath(path string) []string { - var out []string - for _, p := range strings.Split(path, "/") { - if p != "" { - out = append(out, p) - } + vec, err := s.app.Search().Embedder.EmbedText(r.Context(), req.Text) + if err != nil { + writeError(w, http.StatusBadGateway, "embedding_failed", err.Error()) + return } - return out + writeJSON(w, http.StatusOK, map[string]any{"provider": s.app.Search().Embedder.Name(), "dimension": len(vec), "embedding": vec}) } -func (s *Server) cors(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - if s.originAllowed(origin) { - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Vary", "Origin") - w.Header().Set("Access-Control-Allow-Credentials", "true") - } else if origin == "" { - w.Header().Set("Access-Control-Allow-Origin", "*") - } - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -func (s *Server) handleGitLabWebhook(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleEmbeddingReindex(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } - secret := r.Header.Get("X-Gitlab-Token") - globalSecret := os.Getenv("GITLAB_WEBHOOK_SECRET") - if globalSecret != "" && secret != globalSecret { - writeError(w, http.StatusForbidden, "forbidden", "invalid webhook secret") + if _, ok := s.requireSuperAdmin(w, r); !ok { return } - var payload struct { - ObjectKind string `json:"object_kind"` - Project struct { - PathWithNamespace string `json:"path_with_namespace"` - } `json:"project"` - Commits []struct { - ID string `json:"id"` - Message string `json:"message"` - Timestamp string `json:"timestamp"` - } `json:"commits"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + count, err := s.app.Search().Reindex(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "embedding_reindex_failed", err.Error()) return } - if payload.ObjectKind == "push" && len(payload.Commits) > 0 { - latest := payload.Commits[0] - log.Printf("GitLab webhook push for %s, latest commit %s: %s", payload.Project.PathWithNamespace, latest.ID, latest.Message) - // optionally update module last push info here if repo matches a module - } - writeJSON(w, http.StatusOK, map[string]any{"status": "received", "kind": payload.ObjectKind}) -} - -func (s *Server) originAllowed(origin string) bool { - if origin == "" { - return false - } - for _, allowed := range s.auth.Config().CORSAllowOrigins { - if allowed == "*" || allowed == origin { - return true - } + storedCount, err := s.app.Search().EmbeddingCount(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "embedding_count_failed", err.Error()) + return } - return false -} - -func recoverer(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if rec := recover(); rec != nil { - writeError(w, http.StatusInternalServerError, "panic", fmt.Sprint(rec)) - } - }() - next.ServeHTTP(w, r) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "reindexed", + "provider": s.app.Search().Embedder.Name(), + "embedded_pages": count, + "cached_documents": storedCount, }) } -func toStoreArtifact(a deploy.Artifact) store.DeployArtifact { - out := store.DeployArtifact{ - ModuleKey: a.Metadata.ModuleKey, - ModuleName: a.Metadata.ModuleName, - DocsVersion: a.Metadata.DocsVersion, - PackageVersion: a.Metadata.PackageVersion, - Description: a.Metadata.Description, - Authors: append([]string(nil), a.Metadata.Authors...), - Edition: a.Metadata.Edition, - Keywords: append([]string(nil), a.Metadata.Keywords...), - RepoURL: a.Metadata.RepoURL, - RepoType: a.Metadata.RepoType, - Branch: a.Metadata.Branch, - CommitSHA: a.Metadata.CommitSHA, - Bytes: a.Bytes, - SiteHTML: map[string]string{}, - SiteFiles: map[string][]byte{}, - } - for _, e := range a.Manifest.Entries { - out.Entries = append(out.Entries, store.DeployEntry{Key: e.Key, Title: e.Title, Type: e.Type, Source: e.Source, Output: e.Output}) - } - for _, d := range a.Documents { - out.Documents = append(out.Documents, store.DeployDocument{ - DocID: d.DocID, ModuleKey: d.ModuleKey, ModuleName: d.ModuleName, DocsVersion: d.DocsVersion, - PackageVersion: d.PackageVersion, EntryKey: d.EntryKey, EntryType: d.EntryType, Title: d.Title, - Description: d.Description, Content: d.Content, Path: d.Path, SourceFile: d.SourceFile, - Keywords: append([]string(nil), d.Keywords...), Status: d.Status, - }) - } - for _, n := range a.Nav { - out.Nav = append(out.Nav, toStoreNav(n)) - } - for name, html := range a.SiteHTML { - out.SiteHTML[name] = html - } - for name, content := range a.SiteFiles { - out.SiteFiles[name] = append([]byte(nil), content...) - } - return out -} - -func toStoreNav(n deploy.NavItem) store.NavItem { - out := store.NavItem{Title: n.Title, Path: n.Path} - for _, child := range n.Children { - out.Children = append(out.Children, toStoreNav(child)) - } - return out -} - -func (s *Server) uploadSiteFilesToMinIO(artifact deploy.Artifact, moduleKey, docsVersion string) { - if s.minioClient == nil { +func (s *Server) handleSearchReindex(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") return } - ctx := context.Background() - for name, content := range artifact.SiteFiles { - key := fmt.Sprintf("modules/%s/%s/%s", moduleKey, docsVersion, name) - ct := contentTypeForName(name, content) - _, err := s.minioClient.PutObject(ctx, s.minioBucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{ - ContentType: ct, - }) - if err != nil { - log.Printf("minio upload failed for %s: %v", key, err) - } - } -} - -func contentTypeForName(name string, content []byte) string { - if ct := mime.TypeByExtension(filepath.Ext(name)); ct != "" { - return ct - } - if len(content) > 0 { - return http.DetectContentType(content) - } - return "application/octet-stream" -} - -func envFloat(key string, fallback float64) float64 { - v, err := strconv.ParseFloat(os.Getenv(key), 64) - if err != nil || v == 0 { - return fallback + if _, ok := s.requireSuperAdmin(w, r); !ok { + return } - return v -} - -func envInt64(key string, fallback int64) int64 { - v, err := strconv.ParseInt(os.Getenv(key), 10, 64) - if err != nil || v <= 0 { - return fallback + // Keyword scoring reads the current PostgreSQL page set; reindexing rebuilds + // the embedding data used by semantic and hybrid search. + count, err := s.app.Search().Reindex(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "search_reindex_failed", err.Error()) + return } - return v + writeJSON(w, http.StatusOK, map[string]any{ + "status": "reindexed", + "indexed_documents": len(s.app.Store().Pages()), + "embedded_documents": count, + }) } diff --git a/backend/internal/api/server_analytics_test.go b/backend/internal/api/server_analytics_test.go new file mode 100644 index 0000000..20a766b --- /dev/null +++ b/backend/internal/api/server_analytics_test.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "modex/backend/internal/store" +) + +func TestDocAnalyticsFallsBackToBuiltinWithoutPostHog(t *testing.T) { + t.Setenv("POSTHOG_PERSONAL_API_KEY", "") + t.Setenv("POSTHOG_PROJECT_ID", "") + + srv := New(store.NewSeededTestStore()) + viewReq := httptest.NewRequest(http.MethodPost, "/api/analytics/page-view", strings.NewReader(`{"doc_id":"DemoModule:latest:guide","session_id":"s1","read_id":"r1"}`)) + viewReq.Header.Set("Content-Type", "application/json") + viewRR := httptest.NewRecorder() + srv.Handler().ServeHTTP(viewRR, viewReq) + if viewRR.Code != http.StatusAccepted { + t.Fatalf("page-view status = %d, want %d: %s", viewRR.Code, http.StatusAccepted, viewRR.Body.String()) + } + progressReq := httptest.NewRequest(http.MethodPost, "/api/analytics/read-progress", strings.NewReader(`{"doc_id":"DemoModule:latest:guide","session_id":"s1","read_id":"r1","duration_seconds":42,"scroll_depth":0.8}`)) + progressReq.Header.Set("Content-Type", "application/json") + progressRR := httptest.NewRecorder() + srv.Handler().ServeHTTP(progressRR, progressReq) + if progressRR.Code != http.StatusAccepted { + t.Fatalf("read-progress status = %d, want %d: %s", progressRR.Code, http.StatusAccepted, progressRR.Body.String()) + } + + req := httptest.NewRequest(http.MethodGet, "/api/analytics/doc?doc_id=DemoModule:latest:guide", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + if !strings.Contains(rr.Body.String(), `"source":"builtin"`) { + t.Fatalf("response does not use builtin analytics: %s", rr.Body.String()) + } +} + +func TestAdminPageAnalyticsRouteIsNotPublic(t *testing.T) { + srv := New(store.NewTestStore()) + req := httptest.NewRequest(http.MethodGet, "/api/admin/analytics/pages", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} diff --git a/backend/internal/api/server_deploy_test.go b/backend/internal/api/server_deploy_test.go new file mode 100644 index 0000000..6e9afbe --- /dev/null +++ b/backend/internal/api/server_deploy_test.go @@ -0,0 +1,301 @@ +package api + +import ( + "archive/zip" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "modex/backend/internal/deploy" + "modex/backend/internal/store" +) + +func TestHealthIncludesOperationalSnapshot(t *testing.T) { + srv := New(store.NewSeededTestStore()) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{`"dependencies"`, `"counts"`, `"modules"`, `"pages"`, `"object_storage"`} { + if !strings.Contains(body, want) { + t.Fatalf("health response missing %s: %s", want, body) + } + } +} + +func TestRewriteServedSiteRootRefsHandlesLegacyVitePressBase(t *testing.T) { + input := []byte(`FAQ`) + + out := string(rewriteServedSiteRootRefs(input, "internal-wiki", "latest", "guide")) + + base := "/api/docs/internal-wiki/latest/guide/site/" + for _, want := range []string{ + `href="` + base + `assets/style.css"`, + `src="` + base + `assets/app.js"`, + base + `assets/a.png 1x`, + base + `assets/b.png 2x`, + `url('` + base + `assets/bg.png')`, + `href="` + base + `posts/cbb-shelf/faq.html"`, + `"` + base + `posts/cbb-shelf/faq.html"`, + `'` + base + `posts/cbb-shelf/system.html'`, + } { + if !strings.Contains(out, want) { + t.Fatalf("rewritten output missing %q: %s", want, out) + } + } + if strings.Contains(out, "/internal-tools/") { + t.Fatalf("legacy base still present: %s", out) + } + if !strings.Contains(out, `"/standards/sidebar-state"`) { + t.Fatalf("unrelated JS string path was rewritten: %s", out) + } +} + +func TestSiteFileCandidatesSupportVitePressRoutes(t *testing.T) { + cases := map[string][]string{ + "": {"index.html"}, + "posts/cbb-shelf/system-overview": {"posts/cbb-shelf/system-overview", "posts/cbb-shelf/system-overview.html", "posts/cbb-shelf/system-overview/index.html", "index.html"}, + "posts/cbb-shelf/system-overview.md": {"posts/cbb-shelf/system-overview.md", "posts/cbb-shelf/system-overview.html"}, + "assets/style.css": {"assets/style.css"}, + "/posts/process-tools/itr/user-guide/": {"posts/process-tools/itr/user-guide", "posts/process-tools/itr/user-guide.html", "posts/process-tools/itr/user-guide/index.html", "index.html"}, + } + for input, want := range cases { + got := siteFileCandidates(input) + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("siteFileCandidates(%q) = %#v, want %#v", input, got, want) + } + } +} + +func TestDeployErrorIncludesStageReport(t *testing.T) { + st := store.NewSeededTestStore() + if _, err := st.UpdateModule("DemoModule", store.Module{DeployToken: "secret"}); err != nil { + t.Fatalf("UpdateModule: %v", err) + } + srv := New(st) + req := httptest.NewRequest(http.MethodPost, "/api/deploy", bytes.NewReader(testDeployZip(t))) + req.Header.Set("Content-Type", "application/zip") + req.Header.Set("X-Modex-Deploy-Token", "wrong") + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rr.Code, rr.Body.String()) + } + var payload struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + Deploy struct { + Stages []struct { + Name string `json:"name"` + Status string `json:"status"` + } `json:"stages"` + } `json:"deploy"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Error.Code != "invalid_deploy_token" { + t.Fatalf("error code = %q", payload.Error.Code) + } + if len(payload.Deploy.Stages) != 2 || payload.Deploy.Stages[0].Name != "parse_artifact" || payload.Deploy.Stages[0].Status != "ok" || payload.Deploy.Stages[1].Name != "authenticate" || payload.Deploy.Stages[1].Status != "failed" { + t.Fatalf("unexpected deploy stages: %+v", payload.Deploy.Stages) + } +} + +func TestDeployTokenSelectsDocumentSource(t *testing.T) { + st := store.NewSeededTestStore() + if _, err := st.UpdateModule("DemoModule", store.Module{DeployToken: "secret"}); err != nil { + t.Fatalf("UpdateModule: %v", err) + } + srv := New(st) + req := httptest.NewRequest(http.MethodPost, "/api/deploy", bytes.NewReader(testDeployZipForModule(t, "WrongModule"))) + req.Header.Set("Content-Type", "application/zip") + req.Header.Set("X-Modex-Deploy-Token", "secret") + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202: %s", rr.Code, rr.Body.String()) + } + if _, err := st.Page("DemoModule:latest:guide"); err != nil { + t.Fatalf("expected page to be indexed under token-owned module: %v", err) + } + if _, err := st.Page("WrongModule:latest:guide"); err == nil { + t.Fatal("page was indexed under artifact module instead of token-owned module") + } +} + +func TestCanonicalizeDeployArtifactAppliesSplitMount(t *testing.T) { + artifact := deployArtifactForSplitTest("WrongModule") + + got := canonicalizeDeployArtifact(artifact, store.Module{ + ModuleKey: "DemoModule", + Name: "Demo Module", + DocType: "markdown", + Mount: "split", + }) + + if len(got.Manifest.Entries) != 2 { + t.Fatalf("entries = %#v, want two top-level groups", got.Manifest.Entries) + } + if got.Manifest.Entries[0].Key != "standard" || got.Manifest.Entries[1].Key != "tools" { + t.Fatalf("entry keys = %#v, want standard/tools", got.Manifest.Entries) + } + if len(got.Documents) != 2 { + t.Fatalf("documents = %#v, want two grouped documents", got.Documents) + } + if got.Documents[0].DocID != "DemoModule:latest:standard" || got.Documents[1].DocID != "DemoModule:latest:tools" { + t.Fatalf("doc ids = %#v", got.Documents) + } + if !strings.Contains(got.Documents[0].ContentMD, "Standard A") || !strings.Contains(got.Documents[1].ContentMD, "Tools B") { + t.Fatalf("grouped markdown content missing source text: %#v", got.Documents) + } +} + +func TestCanonicalizeDeployArtifactRewritesContentResourceBase(t *testing.T) { + artifact := deploy.Artifact{ + Metadata: deploy.Metadata{ModuleKey: "UnknownModule", ModuleName: "UnknownModule", DocsVersion: "latest", PackageVersion: "1.0.0"}, + Manifest: deploy.Manifest{Entries: []deploy.Entry{{Key: "guide", Title: "Guide", Type: "markdown", Source: "docs"}}}, + Documents: []deploy.DocumentRecord{{ + DocID: "UnknownModule:latest:guide", + ModuleKey: "UnknownModule", + DocsVersion: "latest", + EntryKey: "guide", + EntryType: "markdown", + Title: "Guide", + Content: "see /api/docs/UnknownModule/latest/guide/site/images/shot.png", + ContentMD: "![shot](/api/docs/UnknownModule/latest/guide/site/images/shot.png)", + }}, + } + + got := canonicalizeDeployArtifact(artifact, store.Module{ModuleKey: "standards", Name: "Standards"}) + + if strings.Contains(got.Documents[0].ContentMD, "UnknownModule") || strings.Contains(got.Documents[0].Content, "UnknownModule") { + t.Fatalf("placeholder module not rewritten: %#v", got.Documents[0]) + } + if !strings.Contains(got.Documents[0].ContentMD, "/api/docs/standards/latest/guide/site/images/shot.png") { + t.Fatalf("content not rewritten to resolved module: %q", got.Documents[0].ContentMD) + } +} + +func TestCanonicalizeDeployArtifactPreservesSitePageDocIDs(t *testing.T) { + artifact := deploy.Artifact{ + Metadata: deploy.Metadata{ModuleKey: "UnknownModule", ModuleName: "UnknownModule", DocsVersion: "latest"}, + Manifest: deploy.Manifest{Entries: []deploy.Entry{{Key: "guide", Title: "Guide", Type: "vitepress", Source: "."}}}, + Documents: []deploy.DocumentRecord{ + {DocID: "UnknownModule:latest:guide/", ModuleKey: "UnknownModule", DocsVersion: "latest", EntryKey: "guide", EntryType: "vitepress", Title: "Home", Content: "home"}, + {DocID: "UnknownModule:latest:guide/standards/coding/memory-safety", ModuleKey: "UnknownModule", DocsVersion: "latest", EntryKey: "guide", EntryType: "vitepress", Title: "Memory Safety", Content: "memory safety"}, + }, + } + + got := canonicalizeDeployArtifact(artifact, store.Module{ModuleKey: "standards", Name: "Standards"}) + + if len(got.Documents) != 2 { + t.Fatalf("documents = %#v, want two pages", got.Documents) + } + if got.Documents[0].DocID != "standards:latest:guide/" { + t.Fatalf("first doc id = %q", got.Documents[0].DocID) + } + if got.Documents[1].DocID != "standards:latest:guide/standards/coding/memory-safety" { + t.Fatalf("second doc id = %q", got.Documents[1].DocID) + } +} + +func TestModelEndpointAppendsExpectedSuffixOnce(t *testing.T) { + tests := []struct { + name string + base string + suffix string + want string + }{ + {name: "embedding base", base: "https://api.example.com/v1", suffix: "/embeddings", want: "https://api.example.com/v1/embeddings"}, + {name: "embedding endpoint", base: "https://api.example.com/v1/embeddings", suffix: "/embeddings", want: "https://api.example.com/v1/embeddings"}, + {name: "rerank base", base: "https://api.example.com/v1/", suffix: "/rerank", want: "https://api.example.com/v1/rerank"}, + {name: "rerank endpoint", base: "https://api.example.com/v1/rerank", suffix: "/rerank", want: "https://api.example.com/v1/rerank"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := modelEndpoint(tt.base, tt.suffix); got != tt.want { + t.Fatalf("modelEndpoint() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestChatEndpointUsesSelectedProtocol(t *testing.T) { + tests := []struct { + name string + protocol string + base string + model string + want string + }{ + {name: "openai chat", protocol: "openai-chat", base: "https://api.example.com/v1", model: "gpt", want: "https://api.example.com/v1/chat/completions"}, + {name: "responses", protocol: "openai-responses", base: "https://api.example.com/v1/", model: "gpt", want: "https://api.example.com/v1/responses"}, + {name: "anthropic", protocol: "anthropic", base: "https://api.anthropic.com", model: "claude", want: "https://api.anthropic.com/v1/messages"}, + {name: "gemini", protocol: "gemini", base: "https://generativelanguage.googleapis.com", model: "gemini pro", want: "https://generativelanguage.googleapis.com/v1beta/models/gemini%20pro:generateContent"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chatEndpoint(tt.base, tt.protocol, tt.model); got != tt.want { + t.Fatalf("chatEndpoint() = %q, want %q", got, tt.want) + } + }) + } +} + +func testDeployZip(t *testing.T) []byte { + return testDeployZipForModule(t, "DemoModule") +} + +func deployArtifactForSplitTest(moduleKey string) deploy.Artifact { + return deploy.Artifact{ + Metadata: deploy.Metadata{ModuleKey: moduleKey, ModuleName: moduleKey, DocsVersion: "latest", PackageVersion: "1.0.0"}, + Manifest: deploy.Manifest{Entries: []deploy.Entry{ + {Key: "guide-standard-a", Title: "Standard A", Type: "markdown", Source: "docs/standard/a.md"}, + {Key: "guide-tools-b", Title: "Tools B", Type: "markdown", Source: "docs/tools/b.md"}, + }}, + Documents: []deploy.DocumentRecord{ + {DocID: moduleKey + ":latest:guide-standard-a", ModuleKey: moduleKey, ModuleName: moduleKey, DocsVersion: "latest", EntryKey: "guide-standard-a", EntryType: "markdown", Title: "Standard A", SourceFile: "docs/standard/a.md", Content: "Standard text", ContentMD: "# Standard A"}, + {DocID: moduleKey + ":latest:guide-tools-b", ModuleKey: moduleKey, ModuleName: moduleKey, DocsVersion: "latest", EntryKey: "guide-tools-b", EntryType: "markdown", Title: "Tools B", SourceFile: "docs/tools/b.md", Content: "Tools text", ContentMD: "# Tools B"}, + }, + } +} + +func testDeployZipForModule(t *testing.T, moduleKey string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + files := map[string]string{ + "metadata.json": `{"module_key":"` + moduleKey + `","module_name":"` + moduleKey + `","docs_version":"latest","package_version":"1.2.3"}`, + "manifest.json": `{"schema_version":"modex.docs/v1","generated_by":"test","entries":[{"key":"guide","title":"Guide","type":"markdown","source":"README.md"}]}`, + "nav.json": `[{"title":"Guide","path":"/guide"}]`, + "documents.jsonl": `{"doc_id":"` + moduleKey + `:latest:guide","module_key":"` + moduleKey + `","module_name":"` + moduleKey + `","docs_version":"latest","entry_key":"guide","entry_type":"markdown","title":"Guide","content":"Hello","path":"/docs/` + moduleKey + `/latest/guide"}` + "\n", + "llms.txt": "Guide\n", + } + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("create zip entry: %v", err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatalf("write zip entry: %v", err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("close zip: %v", err) + } + return buf.Bytes() +} diff --git a/backend/internal/api/server_security_test.go b/backend/internal/api/server_security_test.go new file mode 100644 index 0000000..7632aaf --- /dev/null +++ b/backend/internal/api/server_security_test.go @@ -0,0 +1,168 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "modex/backend/internal/store" +) + +func TestAdminReleaseRollbackRequiresLogin(t *testing.T) { + srv := New(store.NewSeededTestStore()) + req := httptest.NewRequest(http.MethodPost, "/api/admin/releases/rel-demo-latest-001/rollback", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestOptionalCurrentUserProbeIsAnonymousWithout401(t *testing.T) { + srv := New(store.NewTestStore()) + req := httptest.NewRequest(http.MethodGet, "/api/auth/me?optional=1", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rr.Code, http.StatusOK, rr.Body.String()) + } + if strings.TrimSpace(rr.Body.String()) != "null" { + t.Fatalf("body = %q, want null", rr.Body.String()) + } +} + +func TestEmptyPublicModulesUsesJSONArray(t *testing.T) { + srv := New(store.NewTestStore()) + req := httptest.NewRequest(http.MethodGet, "/api/modules", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rr.Code, http.StatusOK, rr.Body.String()) + } + if strings.TrimSpace(rr.Body.String()) != "[]" { + t.Fatalf("body = %q, want []", rr.Body.String()) + } +} + +func TestEmbedTextRequiresAdminSession(t *testing.T) { + srv := New(store.NewSeededTestStore()) + req := httptest.NewRequest(http.MethodPost, "/api/embeddings/embed-text", strings.NewReader(`{"text":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestAdminPluginsRequiresLogin(t *testing.T) { + srv := New(store.NewSeededTestStore()) + req := httptest.NewRequest(http.MethodGet, "/api/admin/plugins", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestMCPLogRequiresAuthenticatedCaller(t *testing.T) { + srv := New(store.NewSeededTestStore()) + req := httptest.NewRequest(http.MethodPost, "/api/mcp/log", strings.NewReader(`{"tool_name":"search_docs"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestMCPLogAcceptsPersonalMCPToken(t *testing.T) { + st := store.NewSeededTestStore() + current := st.CurrentUser() + if _, err := st.SetUserMCPToken(current.ID, "mcp-test-token"); err != nil { + t.Fatalf("SetUserMCPToken: %v", err) + } + srv := New(st) + req := httptest.NewRequest(http.MethodPost, "/api/mcp/log", strings.NewReader(`{"tool_name":"search_docs","query":"guide","result_count":1}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer mcp-test-token") + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d: %s", rr.Code, http.StatusAccepted, rr.Body.String()) + } + logs := st.MCPLogs() + if len(logs) != 1 || logs[0].UserID != current.ID { + t.Fatalf("logs = %+v, want one log attributed to %s", logs, current.ID) + } +} + +func TestMCPTokenInfoAcceptsPersonalMCPToken(t *testing.T) { + st := store.NewSeededTestStore() + current := st.CurrentUser() + if _, err := st.SetUserMCPToken(current.ID, "mcp-test-token"); err != nil { + t.Fatalf("SetUserMCPToken: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/mcp/token-info", nil) + req.Header.Set("Authorization", "Bearer mcp-test-token") + rr := httptest.NewRecorder() + + New(st).Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) + } + var info struct { + UserID string `json:"user_id"` + Scopes []string `json:"scopes"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if info.UserID != current.ID || len(info.Scopes) != 2 { + t.Fatalf("token info = %+v", info) + } +} + +func TestUnknownAdminRouteReturnsNotFound(t *testing.T) { + srv := New(store.NewSeededTestStore()) + req := httptest.NewRequest(http.MethodPost, "/api/admin/unknown-placeholder", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +func TestConfigExposesPluginDefaults(t *testing.T) { + srv := New(store.NewSeededTestStore()) + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + rr := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), `"kroki"`) || !strings.Contains(rr.Body.String(), `"plugins"`) { + t.Fatalf("config missing plugin defaults: %s", rr.Body.String()) + } +} diff --git a/backend/internal/api/server_util.go b/backend/internal/api/server_util.go new file mode 100644 index 0000000..a417017 --- /dev/null +++ b/backend/internal/api/server_util.go @@ -0,0 +1,353 @@ +package api + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "modex/backend/internal/deploy" + "modex/backend/internal/store" + + "github.com/minio/minio-go/v7" +) + +func (s *Server) handleAdminAccepted(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "not_found", "admin route not found") +} + +func decodeBody(r *http.Request, v any) error { + return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v) +} + +func (s *Server) writeMutation(w http.ResponseWriter, v any, successStatus int, err error) { + if err != nil { + writeResult(w, nil, err) + return + } + writeJSON(w, successStatus, v) +} + +func writeResult(w http.ResponseWriter, v any, err error) { + if err == nil { + writeJSON(w, http.StatusOK, v) + return + } + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "resource not found") + return + } + if errors.Is(err, store.ErrInvalid) { + writeError(w, http.StatusBadRequest, "invalid_input", err.Error()) + return + } + if errors.Is(err, store.ErrConflict) { + writeError(w, http.StatusConflict, "conflict", err.Error()) + return + } + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) +} + +func splitPath(path string) []string { + var out []string + for _, p := range strings.Split(path, "/") { + if p != "" { + out = append(out, p) + } + } + return out +} + +func (s *Server) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if s.originAllowed(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + } else if origin == "" { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) handleGitLabWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST") + return + } + secret := r.Header.Get("X-Gitlab-Token") + globalSecret := os.Getenv("GITLAB_WEBHOOK_SECRET") + if globalSecret != "" && secret != globalSecret { + writeError(w, http.StatusForbidden, "forbidden", "invalid webhook secret") + return + } + var payload struct { + ObjectKind string `json:"object_kind"` + Project struct { + PathWithNamespace string `json:"path_with_namespace"` + } `json:"project"` + Commits []struct { + ID string `json:"id"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` + } `json:"commits"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if payload.ObjectKind == "push" && len(payload.Commits) > 0 { + latest := payload.Commits[0] + log.Printf("GitLab webhook push for %s, latest commit %s: %s", payload.Project.PathWithNamespace, latest.ID, latest.Message) + // optionally update module last push info here if repo matches a module + } + writeJSON(w, http.StatusOK, map[string]any{"status": "received", "kind": payload.ObjectKind}) +} + +func (s *Server) originAllowed(origin string) bool { + if origin == "" { + return false + } + for _, allowed := range s.app.Auth().Config().CORSAllowOrigins { + if allowed == "*" || allowed == origin { + return true + } + } + return false +} + +func recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + writeError(w, http.StatusInternalServerError, "panic", fmt.Sprint(rec)) + } + }() + next.ServeHTTP(w, r) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) Flush() { + if flusher, ok := r.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func accessLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rr := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rr, r) + if r.URL.Path == "/healthz" { + return + } + log.Printf("http method=%s path=%s status=%d duration_ms=%d remote=%s", r.Method, r.URL.Path, rr.status, time.Since(start).Milliseconds(), r.RemoteAddr) + }) +} + +type deployStep struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Note string `json:"note,omitempty"` +} + +type deployReport struct { + Stages []deployStep `json:"stages"` +} + +func newDeployReport() *deployReport { + return &deployReport{Stages: []deployStep{}} +} + +func (r *deployReport) ok(name string) { + r.Stages = append(r.Stages, deployStep{Name: name, Status: "ok"}) +} + +func (r *deployReport) skip(name, note string) { + r.Stages = append(r.Stages, deployStep{Name: name, Status: "skipped", Note: note}) +} + +func (r *deployReport) fail(name string, err error) { + r.Stages = append(r.Stages, deployStep{Name: name, Status: "failed", Error: errorString(err)}) + log.Printf("deploy stage failed stage=%s error=%v", name, err) +} + +func writeDeployError(w http.ResponseWriter, status int, code, message string, report *deployReport) { + writeJSON(w, status, map[string]any{ + "error": map[string]string{"code": code, "message": message}, + "deploy": report, + }) +} + +func toStoreArtifact(a deploy.Artifact) store.DeployArtifact { + out := store.DeployArtifact{ + ModuleKey: a.Metadata.ModuleKey, + ModuleName: a.Metadata.ModuleName, + DocsVersion: a.Metadata.DocsVersion, + PackageVersion: a.Metadata.PackageVersion, + Description: a.Metadata.Description, + Authors: append([]string(nil), a.Metadata.Authors...), + Edition: a.Metadata.Edition, + Keywords: append([]string(nil), a.Metadata.Keywords...), + RepoURL: a.Metadata.RepoURL, + RepoType: a.Metadata.RepoType, + Branch: a.Metadata.Branch, + CommitSHA: a.Metadata.CommitSHA, + Bytes: a.Bytes, + SiteHTML: map[string]string{}, + SiteFiles: map[string][]byte{}, + } + for _, e := range a.Manifest.Entries { + out.Entries = append(out.Entries, store.DeployEntry{Key: e.Key, Title: e.Title, Type: e.Type, Source: e.Source, Output: e.Output}) + } + for _, d := range a.Documents { + out.Documents = append(out.Documents, store.DeployDocument{ + DocID: d.DocID, ModuleKey: d.ModuleKey, ModuleName: d.ModuleName, DocsVersion: d.DocsVersion, + PackageVersion: d.PackageVersion, EntryKey: d.EntryKey, EntryType: d.EntryType, Title: d.Title, + Description: d.Description, Content: d.Content, ContentMD: d.ContentMD, Path: d.Path, SourceFile: d.SourceFile, + Keywords: append([]string(nil), d.Keywords...), Status: d.Status, + }) + } + for _, n := range a.Nav { + out.Nav = append(out.Nav, toStoreNav(n)) + } + for name, html := range a.SiteHTML { + out.SiteHTML[name] = html + } + for name, content := range a.SiteFiles { + // Hand over the bytes directly instead of cloning: the source artifact is + // discarded right after ingestion, and cloning a large (image/GIF-heavy) + // site here doubles peak memory and can OOM the backend on big deploys. + out.SiteFiles[name] = content + } + return out +} + +func toStoreNav(n deploy.NavItem) store.NavItem { + out := store.NavItem{Title: n.Title, Path: n.Path} + for _, child := range n.Children { + out.Children = append(out.Children, toStoreNav(child)) + } + return out +} + +func (s *Server) uploadSiteFilesToMinIO(ctx context.Context, artifact deploy.Artifact, moduleKey, docsVersion string) error { + if s.minioClient == nil { + return nil + } + for name, content := range artifact.SiteFiles { + key := fmt.Sprintf("modules/%s/%s/%s", moduleKey, docsVersion, name) + ct := contentTypeForName(name, content) + _, err := s.minioClient.PutObject(ctx, s.minioBucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{ + ContentType: ct, + }) + if err != nil { + return fmt.Errorf("upload %s: %w", key, err) + } + } + return nil +} + +func (s *Server) cleanupUploadedSiteFiles(moduleKey, docsVersion string, files map[string][]byte) { + if s.minioClient == nil || len(files) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for name := range files { + key := fmt.Sprintf("modules/%s/%s/%s", moduleKey, docsVersion, name) + if err := s.minioClient.RemoveObject(ctx, s.minioBucket, key, minio.RemoveObjectOptions{}); err != nil { + log.Printf("cleanup uploaded site file failed key=%s error=%v", key, err) + } + } +} + +func contentTypeForName(name string, content []byte) string { + if ct := mime.TypeByExtension(filepath.Ext(name)); ct != "" { + return ct + } + if len(content) > 0 { + return http.DetectContentType(content) + } + return "application/octet-stream" +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func ternary[T any](cond bool, yes, no T) T { + if cond { + return yes + } + return no +} + +func envInt64(key string, fallback int64) int64 { + v, err := strconv.ParseInt(os.Getenv(key), 10, 64) + if err != nil || v <= 0 { + return fallback + } + return v +} + +func bearerToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if strings.HasPrefix(auth, prefix) { + return strings.TrimSpace(auth[len(prefix):]) + } + return "" +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go new file mode 100644 index 0000000..792c7e4 --- /dev/null +++ b/backend/internal/application/service.go @@ -0,0 +1,88 @@ +package application + +import ( + "modex/backend/internal/auth" + "modex/backend/internal/config" + "modex/backend/internal/embedding" + "modex/backend/internal/search" + "modex/backend/internal/store" +) + +// Repository is the persistence boundary used by the application layer. +// Implementations can be backed by pgx, an ORM, or a test double without +// leaking database details into HTTP controllers. +type Repository interface { + Close() +} + +// Service owns the business-facing dependencies used by controllers. +type Service struct { + store store.DataStore + auth *auth.Service + search search.Service + repository Repository +} + +func New(st store.DataStore, vectors search.VectorStore, repository Repository) *Service { + return newService(st, vectors, repository, auth.NewService(auth.FromEnv())) +} + +func NewConfigured(st store.DataStore, vectors search.VectorStore, repository Repository) (*Service, error) { + authService, err := auth.NewConfiguredService(auth.FromEnv()) + if err != nil { + return nil, err + } + return newService(st, vectors, repository, authService), nil +} + +func newService(st store.DataStore, vectors search.VectorStore, repository Repository, authService *auth.Service) *Service { + contentStore := search.ContentStore(st) + scoring := config.LoadSearchScoring() + provider := embedding.SettingsProvider{Load: func() embedding.Settings { + ai := contentStore.Settings().AI + return embedding.Settings{ + BaseURL: ai.EmbeddingBaseURL, + Model: ai.EmbeddingModel, + APIKey: ai.EmbeddingAPIKey, + Dim: ai.EmbeddingDim, + } + }} + return &Service{ + store: st, + auth: authService, + repository: repository, + search: search.Service{ + Store: contentStore, + Embedder: provider, + Vectors: vectors, + KeywordWeight: positiveFloat(scoring.KeywordWeight, 0.6), + SemanticWeight: positiveFloat(scoring.SemanticWeight, 0.4), + }, + } +} + +func (s *Service) Store() store.DataStore { + return s.store +} + +func (s *Service) Auth() *auth.Service { + return s.auth +} + +func (s *Service) Search() *search.Service { + return &s.search +} + +func (s *Service) Close() { + _ = s.auth.Close() + if s.repository != nil { + s.repository.Close() + } +} + +func positiveFloat(v, fallback float64) float64 { + if v <= 0 { + return fallback + } + return v +} diff --git a/backend/internal/auth/config.go b/backend/internal/auth/config.go index ece7888..d5eeef5 100644 --- a/backend/internal/auth/config.go +++ b/backend/internal/auth/config.go @@ -4,12 +4,14 @@ import ( "net/url" "os" "strings" + "time" "modex/backend/internal/config" + "modex/backend/internal/dburl" + "modex/backend/internal/redisurl" ) type Config struct { - Mode string AppBaseURL string FrontendBaseURL string IssuerURL string @@ -26,8 +28,12 @@ type Config struct { CookieDomain string CookieSameSite string CookieSecure bool + AutoLogin bool CORSAllowOrigins []string SuperAdmins []string + RedisURL string + DatabaseURL string + SessionTTL time.Duration // UserMapping controls which OIDC claims are used for core user identity fields. // Values are resolved from an optional config file + environment variable overrides. @@ -42,14 +48,9 @@ func FromEnv() Config { issuer = keycloakBase + "/realms/" + realm } appBase := strings.TrimRight(env("APP_BASE_URL", "http://localhost:8671"), "/") - mode := env("AUTH_MODE", "mock") - if mode == "keycloak" { - mode = "oidc" - } cfg := Config{ - Mode: mode, AppBaseURL: appBase, - FrontendBaseURL: strings.TrimRight(env("FRONTEND_BASE_URL", "http://localhost:3000"), "/"), + FrontendBaseURL: strings.TrimRight(env("FRONTEND_BASE_URL", "http://localhost:3456"), "/"), IssuerURL: issuer, AuthURL: os.Getenv("OIDC_AUTH_URL"), TokenURL: os.Getenv("OIDC_TOKEN_URL"), @@ -64,12 +65,15 @@ func FromEnv() Config { CookieDomain: os.Getenv("COOKIE_DOMAIN"), CookieSameSite: env("COOKIE_SAME_SITE", "lax"), CookieSecure: env("COOKIE_SECURE", "false") == "true", - CORSAllowOrigins: splitList(env("CORS_ALLOW_ORIGINS", "http://localhost:3000")), - SuperAdmins: splitList(os.Getenv("SUPER_ADMIN_USERS")), + AutoLogin: env("AUTO_LOGIN", "false") == "true", + CORSAllowOrigins: splitList(env("CORS_ALLOW_ORIGINS", "http://localhost:3456")), + SuperAdmins: splitList(os.Getenv("SUPER_ADMIN_USERS")), + RedisURL: redisurl.FromEnv(), + DatabaseURL: dburl.FromEnv(), + SessionTTL: envDuration("SESSION_TTL", 8*time.Hour), - // UserMapping comes from a combination of (optional) config file + env overrides. - // See internal/config for precedence rules and why some settings live in files - // while connection secrets stay in the environment. + // UserMapping comes from the optional application config file. Connection + // secrets stay in environment variables; identity semantics stay in config.yaml. UserMapping: resolveUserMapping(), } if cfg.AuthURL == "" && issuer != "" { @@ -91,10 +95,10 @@ func FromEnv() Config { } func (c Config) LoginReady() bool { - return c.Mode == "oidc" && c.AuthURL != "" && c.TokenURL != "" && c.UserInfoURL != "" && c.ClientID != "" + return c.IssuerURL != "" && c.AuthURL != "" && c.TokenURL != "" && c.ClientID != "" } -func (c Config) LoginURL(state string) string { +func (c Config) LoginURL(state string, options ...string) string { u, _ := url.Parse(c.AuthURL) q := u.Query() q.Set("client_id", c.ClientID) @@ -102,10 +106,22 @@ func (c Config) LoginURL(state string) string { q.Set("response_type", "code") q.Set("scope", strings.Join(c.Scopes, " ")) q.Set("state", state) + for i := 0; i+1 < len(options); i += 2 { + q.Set(options[i], options[i+1]) + } u.RawQuery = q.Encode() return u.String() } +func envDuration(key string, fallback time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 { + return parsed + } + } + return fallback +} + func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v @@ -123,10 +139,8 @@ func splitList(v string) []string { return out } -// resolveUserMapping loads the user attribute mapping with the following precedence: -// 1. Values from config file (if CONFIG_FILE or conventional locations exist) -// 2. OIDC_CLAIM_* environment variables (explicit overrides) -// 3. Reasonable defaults (company prefers email as unique id) +// resolveUserMapping loads user attribute mapping from config.yaml, then fills +// defaults for fields omitted by the file. func resolveUserMapping() config.UserMapping { m := config.LoadUserMapping() diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 8b2a7a1..b6527af 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -3,6 +3,8 @@ package auth import ( "context" "crypto/rand" + "crypto/sha256" + "crypto/subtle" "encoding/base64" "encoding/json" "errors" @@ -14,21 +16,45 @@ import ( "time" "modex/backend/internal/store" + + "github.com/coreos/go-oidc/v3/oidc" ) type Service struct { - cfg Config - client *http.Client - mu sync.RWMutex - sessions map[string]store.User + cfg Config + client *http.Client + sessions sessionStore + providerMu sync.Mutex + verifier *oidc.IDTokenVerifier } func NewService(cfg Config) *Service { - return &Service{ - cfg: cfg, - client: &http.Client{Timeout: 20 * time.Second}, - sessions: map[string]store.User{}, + return newService(cfg, newMemorySessionStore()) +} + +func NewConfiguredService(cfg Config) (*Service, error) { + if cfg.RedisURL != "" { + sessions, err := newRedisSessionStore(cfg.RedisURL) + if err != nil { + return nil, fmt.Errorf("connect Redis session store: %w", err) + } + return newService(cfg, sessions), nil } + if cfg.DatabaseURL == "" { + return nil, errors.New("PostgreSQL connection is required when Redis is not configured") + } + sessions, err := newPostgresSessionStore(cfg.DatabaseURL) + if err != nil { + return nil, fmt.Errorf("connect PostgreSQL session store: %w", err) + } + return newService(cfg, sessions), nil +} + +func newService(cfg Config, sessions sessionStore) *Service { + if cfg.SessionTTL <= 0 { + cfg.SessionTTL = 8 * time.Hour + } + return &Service{cfg: cfg, client: &http.Client{Timeout: 20 * time.Second}, sessions: sessions} } func (s *Service) Config() Config { @@ -78,6 +104,18 @@ func (s *Service) BeginLogin(w http.ResponseWriter) (string, error) { if err != nil { return "", err } + nonce, err := randomToken(32) + if err != nil { + return "", err + } + verifier, err := randomToken(48) + if err != nil { + return "", err + } + transaction := loginTransaction{Nonce: nonce, CodeVerifier: verifier} + if err := s.sessions.Set(context.Background(), oauthStateKey(state), transaction, 5*time.Minute); err != nil { + return "", fmt.Errorf("store OAuth2 transaction: %w", err) + } http.SetCookie(w, &http.Cookie{ Name: s.cfg.StateCookie, Value: state, @@ -88,7 +126,13 @@ func (s *Service) BeginLogin(w http.ResponseWriter) (string, error) { SameSite: sameSiteMode(s.cfg.CookieSameSite), Secure: s.cfg.CookieSecure, }) - return s.cfg.LoginURL(state), nil + challenge := sha256.Sum256([]byte(verifier)) + return s.cfg.LoginURL( + state, + "nonce", nonce, + "code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:]), + "code_challenge_method", "S256", + ), nil } func (s *Service) CompleteLogin(ctx context.Context, r *http.Request, w http.ResponseWriter) (store.User, error) { @@ -105,14 +149,23 @@ func (s *Service) CompleteLogin(ctx context.Context, r *http.Request, w http.Res return store.User{}, errors.New("missing OAuth2 code or state") } stateCookie, err := r.Cookie(s.cfg.StateCookie) - if err != nil || stateCookie.Value != state { + if err != nil || subtle.ConstantTimeCompare([]byte(stateCookie.Value), []byte(state)) != 1 { return store.User{}, errors.New("invalid OAuth2 state") } - token, err := s.exchangeCode(ctx, code) + var transaction loginTransaction + if err := s.sessions.Get(ctx, oauthStateKey(state), &transaction); err != nil { + return store.User{}, errors.New("expired or invalid OAuth2 transaction") + } + _ = s.sessions.Delete(ctx, oauthStateKey(state)) + token, err := s.exchangeCode(ctx, code, transaction.CodeVerifier) + if err != nil { + return store.User{}, err + } + claims, err := s.verifyIDToken(ctx, token.IDToken, token.AccessToken, transaction.Nonce) if err != nil { return store.User{}, err } - user, err := s.fetchUserInfo(ctx, token.AccessToken, token.IDToken) + user, err := s.fetchUserInfo(ctx, token.AccessToken, claims) if err != nil { return store.User{}, err } @@ -123,24 +176,23 @@ func (s *Service) CompleteLogin(ctx context.Context, r *http.Request, w http.Res return user, nil } -// CreateSession issues a session cookie for the given user. It is shared by the -// OIDC callback and the local mock-login endpoint so both paths produce a real, -// cookie-backed session. +// CreateSession issues a session cookie for the given user after the OIDC +// callback establishes a real, cookie-backed session. func (s *Service) CreateSession(w http.ResponseWriter, user store.User) error { user = s.applySuperAdmin(user) sessionID, err := randomToken(32) if err != nil { return err } - s.mu.Lock() - s.sessions[sessionID] = user - s.mu.Unlock() + if err := s.sessions.Set(context.Background(), userSessionKey(sessionID), user, s.cfg.SessionTTL); err != nil { + return fmt.Errorf("store user session: %w", err) + } http.SetCookie(w, &http.Cookie{ Name: s.cfg.SessionCookie, Value: sessionID, Domain: s.cfg.CookieDomain, Path: "/", - MaxAge: int((8 * time.Hour).Seconds()), + MaxAge: int(s.cfg.SessionTTL.Seconds()), HttpOnly: true, SameSite: sameSiteMode(s.cfg.CookieSameSite), Secure: s.cfg.CookieSecure, @@ -153,27 +205,27 @@ func (s *Service) CurrentUser(r *http.Request) (store.User, bool) { if err != nil || cookie.Value == "" { return store.User{}, false } - s.mu.RLock() - defer s.mu.RUnlock() - user, ok := s.sessions[cookie.Value] - return user, ok + var user store.User + if err := s.sessions.Get(r.Context(), userSessionKey(cookie.Value), &user); err != nil { + return store.User{}, false + } + return user, true } func (s *Service) Logout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(s.cfg.SessionCookie); err == nil { - s.mu.Lock() - delete(s.sessions, cookie.Value) - s.mu.Unlock() + _ = s.sessions.Delete(r.Context(), userSessionKey(cookie.Value)) } http.SetCookie(w, &http.Cookie{Name: s.cfg.SessionCookie, Value: "", Domain: s.cfg.CookieDomain, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: sameSiteMode(s.cfg.CookieSameSite), Secure: s.cfg.CookieSecure}) } -func (s *Service) exchangeCode(ctx context.Context, code string) (tokenResponse, error) { +func (s *Service) exchangeCode(ctx context.Context, code, codeVerifier string) (tokenResponse, error) { form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("client_id", s.cfg.ClientID) form.Set("code", code) form.Set("redirect_uri", s.cfg.RedirectURL) + form.Set("code_verifier", codeVerifier) if s.cfg.ClientSecret != "" { form.Set("client_secret", s.cfg.ClientSecret) } @@ -200,16 +252,7 @@ func (s *Service) exchangeCode(ctx context.Context, code string) (tokenResponse, return token, nil } -func (s *Service) fetchUserInfo(ctx context.Context, accessToken, idToken string) (store.User, error) { - // Collect claims from both ID token (often has custom mappers like wxPhotoURL) and userinfo endpoint. - claims := map[string]any{} - - if idToken != "" { - if idClaims, err := parseJWTClaims(idToken); err == nil { - mergeInto(claims, idClaims) - } - } - +func (s *Service) fetchUserInfo(ctx context.Context, accessToken string, claims map[string]any) (store.User, error) { if s.cfg.UserInfoURL != "" && accessToken != "" { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.cfg.UserInfoURL, nil) if err != nil { @@ -239,7 +282,7 @@ func (s *Service) fetchUserInfo(ctx context.Context, accessToken, idToken string // Stable internal ID: prefer the configured unique claim value; fall back to sub. userID := uniqueID if userID == "" { - userID = getString(claims, "sub") + return store.User{}, errors.New("OIDC claims do not contain a stable user identifier") } // Username for login/display fallback. @@ -261,7 +304,6 @@ func (s *Service) fetchUserInfo(ctx context.Context, accessToken, idToken string avatar := pickClaim(claims, m.AvatarClaim, "picture", "avatar", "photo", "wxPhotoURL") - groups := extractStringSlice(claims, "groups") roles := extractStringSlice(claims, "roles") return store.User{ @@ -271,11 +313,62 @@ func (s *Service) fetchUserInfo(ctx context.Context, accessToken, idToken string Email: email, Department: secondary, Avatar: avatar, - Groups: groups, Roles: roles, }, nil } +type loginTransaction struct { + Nonce string `json:"nonce"` + CodeVerifier string `json:"code_verifier"` +} + +func userSessionKey(id string) string { return "modex:session:" + id } +func oauthStateKey(state string) string { return "modex:oauth-state:" + state } + +func (s *Service) verifyIDToken(ctx context.Context, rawIDToken, accessToken, nonce string) (map[string]any, error) { + if rawIDToken == "" { + return nil, errors.New("OIDC token endpoint returned empty id_token") + } + verifier, err := s.idTokenVerifier(ctx) + if err != nil { + return nil, err + } + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("verify OIDC ID token: %w", err) + } + if nonce == "" || subtle.ConstantTimeCompare([]byte(idToken.Nonce), []byte(nonce)) != 1 { + return nil, errors.New("invalid OIDC nonce") + } + if idToken.AccessTokenHash != "" { + if err := idToken.VerifyAccessToken(accessToken); err != nil { + return nil, fmt.Errorf("verify OIDC access token hash: %w", err) + } + } + claims := map[string]any{} + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("decode verified OIDC claims: %w", err) + } + return claims, nil +} + +func (s *Service) idTokenVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) { + s.providerMu.Lock() + defer s.providerMu.Unlock() + if s.verifier != nil { + return s.verifier, nil + } + provider, err := oidc.NewProvider(ctx, s.cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("discover OIDC provider: %w", err) + } + s.verifier = provider.Verifier(&oidc.Config{ClientID: s.cfg.ClientID}) + return s.verifier, nil +} + +func (s *Service) Healthy(ctx context.Context) error { return s.sessions.Ping(ctx) } +func (s *Service) Close() error { return s.sessions.Close() } + type tokenResponse struct { AccessToken string `json:"access_token"` IDToken string `json:"id_token"` @@ -292,46 +385,6 @@ func randomToken(n int) (string, error) { return base64.RawURLEncoding.EncodeToString(b), nil } -func first(values ...string) string { - for _, v := range values { - if v != "" { - return v - } - } - return "" -} - -// parseJWTClaims extracts the payload claims from a JWT without signature validation. -// This is safe here because the token was obtained directly from the token endpoint -// after a successful authorization code exchange. -func parseJWTClaims(token string) (map[string]any, error) { - if token == "" { - return nil, nil - } - parts := strings.Split(token, ".") - if len(parts) != 3 { - return nil, errors.New("invalid jwt format") - } - payload := parts[1] - // Pad base64 if needed - if pad := len(payload) % 4; pad != 0 { - payload += strings.Repeat("=", 4-pad) - } - b, err := base64.URLEncoding.DecodeString(payload) - if err != nil { - // try raw encoding (no padding) - b, err = base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } - } - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - return nil, err - } - return m, nil -} - // getString safely extracts a string value from claims (handles string, number, single-element slice). func getString(m map[string]any, key string) string { if m == nil || key == "" { @@ -381,7 +434,7 @@ func mergeInto(dst, src map[string]any) { } } -// extractStringSlice returns a []string for common group/role claims that may be string or []string. +// extractStringSlice returns a []string for claims (e.g. roles) that may be string or []string. func extractStringSlice(m map[string]any, key string) []string { if m == nil { return nil diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go new file mode 100644 index 0000000..7d33929 --- /dev/null +++ b/backend/internal/auth/service_test.go @@ -0,0 +1,83 @@ +package auth + +import ( + "context" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "modex/backend/internal/store" +) + +func TestBeginLoginUsesNonceAndPKCE(t *testing.T) { + service := NewService(Config{ + IssuerURL: "https://issuer.example.com", + AuthURL: "https://issuer.example.com/authorize", + TokenURL: "https://issuer.example.com/token", + ClientID: "modex", + RedirectURL: "https://modex.example.com/callback", + Scopes: []string{"openid"}, + StateCookie: "oauth_state", + SessionCookie: "session", + SessionTTL: time.Hour, + }) + recorder := httptest.NewRecorder() + loginURL, err := service.BeginLogin(recorder) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(loginURL) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + for _, key := range []string{"state", "nonce", "code_challenge"} { + if query.Get(key) == "" { + t.Fatalf("missing %s in login URL", key) + } + } + if query.Get("code_challenge_method") != "S256" { + t.Fatalf("code challenge method = %q", query.Get("code_challenge_method")) + } + if !strings.Contains(recorder.Header().Get("Set-Cookie"), "HttpOnly") { + t.Fatal("state cookie must be HttpOnly") + } +} + +func TestMemoryBackedSessionLifecycle(t *testing.T) { + service := NewService(Config{SessionCookie: "session", SessionTTL: time.Hour}) + recorder := httptest.NewRecorder() + user := store.User{ID: "user-1", Username: "alice"} + if err := service.CreateSession(recorder, user); err != nil { + t.Fatal(err) + } + response := recorder.Result() + cookies := response.Cookies() + if len(cookies) != 1 { + t.Fatalf("cookies = %d", len(cookies)) + } + request := httptest.NewRequest("GET", "/", nil) + request.AddCookie(cookies[0]) + got, ok := service.CurrentUser(request) + if !ok || got.ID != user.ID { + t.Fatalf("CurrentUser = %#v, %v", got, ok) + } + logout := httptest.NewRecorder() + service.Logout(logout, request) + if _, ok := service.CurrentUser(request); ok { + t.Fatal("session should be deleted on logout") + } +} + +func TestMemorySessionExpires(t *testing.T) { + sessions := newMemorySessionStore() + if err := sessions.Set(context.Background(), "key", map[string]string{"id": "1"}, -time.Second); err != nil { + t.Fatal(err) + } + var value map[string]string + if err := sessions.Get(context.Background(), "key", &value); err != errSessionNotFound { + t.Fatalf("Get error = %v", err) + } +} diff --git a/backend/internal/auth/session_store.go b/backend/internal/auth/session_store.go new file mode 100644 index 0000000..b09ec6f --- /dev/null +++ b/backend/internal/auth/session_store.go @@ -0,0 +1,177 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +var errSessionNotFound = errors.New("session not found") + +type sessionStore interface { + Set(ctx context.Context, key string, value any, ttl time.Duration) error + Get(ctx context.Context, key string, value any) error + Delete(ctx context.Context, key string) error + Ping(ctx context.Context) error + Close() error +} + +type memorySession struct { + value []byte + expiresAt time.Time +} + +type memorySessionStore struct { + mu sync.RWMutex + items map[string]memorySession +} + +func newMemorySessionStore() *memorySessionStore { + return &memorySessionStore{items: map[string]memorySession{}} +} + +func (s *memorySessionStore) Set(_ context.Context, key string, value any, ttl time.Duration) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + s.mu.Lock() + s.items[key] = memorySession{value: raw, expiresAt: time.Now().Add(ttl)} + s.mu.Unlock() + return nil +} + +func (s *memorySessionStore) Get(_ context.Context, key string, value any) error { + s.mu.RLock() + item, ok := s.items[key] + s.mu.RUnlock() + if !ok || time.Now().After(item.expiresAt) { + if ok { + s.mu.Lock() + delete(s.items, key) + s.mu.Unlock() + } + return errSessionNotFound + } + return json.Unmarshal(item.value, value) +} + +func (s *memorySessionStore) Delete(_ context.Context, key string) error { + s.mu.Lock() + delete(s.items, key) + s.mu.Unlock() + return nil +} + +func (s *memorySessionStore) Ping(context.Context) error { return nil } +func (s *memorySessionStore) Close() error { return nil } + +type postgresSessionStore struct { + pool *pgxpool.Pool +} + +func newPostgresSessionStore(databaseURL string) (sessionStore, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, err + } + if err = pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + if _, err = pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS auth_session ( + key TEXT PRIMARY KEY, + value_json JSONB NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); err != nil { + pool.Close() + return nil, err + } + return &postgresSessionStore{pool: pool}, nil +} + +func (s *postgresSessionStore) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, `INSERT INTO auth_session(key,value_json,expires_at,updated_at) VALUES($1,$2::jsonb,$3,now()) ON CONFLICT(key) DO UPDATE SET value_json=EXCLUDED.value_json,expires_at=EXCLUDED.expires_at,updated_at=now()`, key, string(raw), time.Now().UTC().Add(ttl)) + return err +} + +func (s *postgresSessionStore) Get(ctx context.Context, key string, value any) error { + var raw []byte + if _, err := s.pool.Exec(ctx, `DELETE FROM auth_session WHERE key=$1 AND expires_at<=now()`, key); err != nil { + return err + } + err := s.pool.QueryRow(ctx, `SELECT value_json FROM auth_session WHERE key=$1 AND expires_at>now()`, key).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return errSessionNotFound + } + if err != nil { + return err + } + return json.Unmarshal(raw, value) +} + +func (s *postgresSessionStore) Delete(ctx context.Context, key string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM auth_session WHERE key=$1`, key) + return err +} + +func (s *postgresSessionStore) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } +func (s *postgresSessionStore) Close() error { s.pool.Close(); return nil } + +type redisSessionStore struct { + client *redis.Client +} + +func newRedisSessionStore(rawURL string) (sessionStore, error) { + options, err := redis.ParseURL(rawURL) + if err != nil { + return nil, err + } + client := redis.NewClient(options) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + _ = client.Close() + return nil, err + } + return &redisSessionStore{client: client}, nil +} + +func (s *redisSessionStore) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + return s.client.Set(ctx, key, raw, ttl).Err() +} + +func (s *redisSessionStore) Get(ctx context.Context, key string, value any) error { + raw, err := s.client.Get(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return errSessionNotFound + } + if err != nil { + return err + } + return json.Unmarshal(raw, value) +} + +func (s *redisSessionStore) Delete(ctx context.Context, key string) error { + return s.client.Del(ctx, key).Err() +} + +func (s *redisSessionStore) Ping(ctx context.Context) error { return s.client.Ping(ctx).Err() } +func (s *redisSessionStore) Close() error { return s.client.Close() } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e2ad3b6..850aa1c 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "errors" + "fmt" "log" "os" "path/filepath" @@ -13,7 +14,8 @@ import ( // Use this for settings that describe application behavior/semantics rather than // per-deployment infrastructure wiring (the latter belongs in environment variables). type FileConfig struct { - Auth AuthSection `yaml:"auth"` + Auth AuthSection `yaml:"auth"` + Search SearchSection `yaml:"search"` } type AuthSection struct { @@ -23,6 +25,15 @@ type AuthSection struct { UserMapping UserMapping `yaml:"user_mapping"` } +type SearchSection struct { + Scoring SearchScoring `yaml:"scoring"` +} + +type SearchScoring struct { + KeywordWeight float64 `yaml:"keyword_weight"` + SemanticWeight float64 `yaml:"semantic_weight"` +} + // UserMapping defines claim names coming from the identity provider (Keycloak etc.). // These are used during OIDC login to populate the local User record. type UserMapping struct { @@ -43,18 +54,17 @@ type UserMapping struct { SecondaryInfoClaim string `yaml:"secondary_info_claim"` } -// Load reads the application config. -// Precedence (lowest to highest): -// 1. Sensible defaults inside the code that calls this. -// 2. Values from the YAML config file (if found). -// 3. Explicit environment variable overrides (highest — allows per-deployment tweaks -// without modifying the committed config file). +// Load reads the application config. Sensible defaults live in the code that +// calls this package; application-level behavior overrides live in YAML. // // The config file location is determined by: // - CONFIG_FILE environment variable (highest priority for location) // - Then a short list of conventional locations (./config.yaml, ./configs/config.yaml, /etc/modex/config.yaml) func Load() (FileConfig, error) { - path := findConfigPath() + path, err := findConfigPath() + if err != nil { + return FileConfig{}, err + } if path == "" { return FileConfig{}, nil // no config file configured or present — this is fine } @@ -73,40 +83,37 @@ func Load() (FileConfig, error) { return fc, nil } -// LoadUserMapping returns the effective UserMapping after applying file + env precedence. +// LoadUserMapping returns the UserMapping from the application config file. func LoadUserMapping() UserMapping { fc, err := Load() if err != nil { - log.Printf("warning: failed to load config file for user mapping, falling back to env only: %v", err) + log.Printf("warning: failed to load config file for user mapping: %v", err) } + return fc.Auth.UserMapping +} - m := fc.Auth.UserMapping - - // Environment variables take highest priority (explicit overrides). - // This preserves the previous pure-env behavior and allows emergency / per-env changes. - if v := os.Getenv("OIDC_CLAIM_UNIQUE_ID"); v != "" { - m.UniqueIDClaim = v - } - if v := os.Getenv("OIDC_CLAIM_AVATAR"); v != "" { - m.AvatarClaim = v - } - if v := os.Getenv("OIDC_CLAIM_DISPLAY_NAME"); v != "" { - m.DisplayNameClaim = v - } - if v := os.Getenv("OIDC_CLAIM_SECONDARY_INFO"); v != "" { - m.SecondaryInfoClaim = v +// LoadSearchScoring returns search ranking weights from the application config file. +func LoadSearchScoring() SearchScoring { + fc, err := Load() + if err != nil { + log.Printf("warning: failed to load config file for search scoring: %v", err) } - - return m + return fc.Search.Scoring } // findConfigPath returns the first existing config file path according to the lookup rules. // It does not return an error if nothing is found (the caller decides what to do). -func findConfigPath() string { +func findConfigPath() (string, error) { candidates := []string{} if explicit := os.Getenv("CONFIG_FILE"); explicit != "" { - candidates = append(candidates, explicit) + if _, err := os.Stat(explicit); err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("%w: %s", ErrConfigNotFound, explicit) + } + return "", err + } + return explicit, nil } // Conventional locations. @@ -134,10 +141,10 @@ func findConfigPath() string { continue } if _, err := os.Stat(p); err == nil { - return p + return p, nil } } - return "" + return "", nil } // MustLoad is like Load but logs and returns an empty config on error (never panics the server). diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..fb129e1 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestLoadRejectsMissingExplicitConfig(t *testing.T) { + t.Setenv("CONFIG_FILE", filepath.Join(t.TempDir(), "missing.yaml")) + _, err := Load() + if !errors.Is(err, ErrConfigNotFound) { + t.Fatalf("Load error = %v, want ErrConfigNotFound", err) + } +} + +func TestLoadExplicitConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("auth:\n user_mapping:\n unique_id_claim: sub\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CONFIG_FILE", path) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Auth.UserMapping.UniqueIDClaim != "sub" { + t.Fatalf("unique id claim = %q", cfg.Auth.UserMapping.UniqueIDClaim) + } +} + +func TestLoadUserMappingIgnoresOIDCClaimEnv(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("auth:\n user_mapping:\n unique_id_claim: sub\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CONFIG_FILE", path) + t.Setenv("OIDC_CLAIM_UNIQUE_ID", "email") + + mapping := LoadUserMapping() + if mapping.UniqueIDClaim != "sub" { + t.Fatalf("unique id claim = %q, want config file value", mapping.UniqueIDClaim) + } +} + +func TestLoadSearchScoring(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("search:\n scoring:\n keyword_weight: 0.7\n semantic_weight: 0.3\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CONFIG_FILE", path) + + scoring := LoadSearchScoring() + if scoring.KeywordWeight != 0.7 || scoring.SemanticWeight != 0.3 { + t.Fatalf("search scoring = %+v", scoring) + } +} diff --git a/backend/internal/dburl/dburl.go b/backend/internal/dburl/dburl.go new file mode 100644 index 0000000..e39500b --- /dev/null +++ b/backend/internal/dburl/dburl.go @@ -0,0 +1,40 @@ +package dburl + +import ( + "net" + "net/url" + "os" + "strings" +) + +// FromEnv returns DATABASE_URL when explicitly set; otherwise it builds a +// PostgreSQL URL from POSTGRES_* connection settings. +func FromEnv() string { + if databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")); databaseURL != "" { + return databaseURL + } + host := env("POSTGRES_HOST", "postgres") + port := env("POSTGRES_PORT", "5432") + database := env("POSTGRES_DB", "modex") + user := env("POSTGRES_USER", "modex") + password := env("POSTGRES_PASSWORD", "modex") + sslMode := env("POSTGRES_SSLMODE", "disable") + + u := url.URL{ + Scheme: "postgres", + User: url.UserPassword(user, password), + Host: net.JoinHostPort(host, port), + Path: "/" + database, + } + q := u.Query() + q.Set("sslmode", sslMode) + u.RawQuery = q.Encode() + return u.String() +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/backend/internal/dburl/dburl_test.go b/backend/internal/dburl/dburl_test.go new file mode 100644 index 0000000..594aa58 --- /dev/null +++ b/backend/internal/dburl/dburl_test.go @@ -0,0 +1,43 @@ +package dburl + +import ( + "strings" + "testing" +) + +func TestFromEnvPrefersDatabaseURL(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://custom:secret@db.example.com:6543/custom?sslmode=require") + t.Setenv("POSTGRES_HOST", "ignored") + + if got := FromEnv(); got != "postgres://custom:secret@db.example.com:6543/custom?sslmode=require" { + t.Fatalf("FromEnv() = %q", got) + } +} + +func TestFromEnvBuildsFromPostgresParts(t *testing.T) { + t.Setenv("POSTGRES_HOST", "10.0.0.5") + t.Setenv("POSTGRES_PORT", "15432") + t.Setenv("POSTGRES_DB", "modex_prod") + t.Setenv("POSTGRES_USER", "modex_user") + t.Setenv("POSTGRES_PASSWORD", "p@ss/word") + t.Setenv("POSTGRES_SSLMODE", "require") + + got := FromEnv() + want := "postgres://modex_user:p%40ss%2Fword@10.0.0.5:15432/modex_prod?sslmode=require" + if got != want { + t.Fatalf("FromEnv() = %q, want %q", got, want) + } +} + +func TestFromEnvDefaultsToComposePostgres(t *testing.T) { + got := FromEnv() + if !strings.HasPrefix(got, "postgres://modex:") { + t.Fatalf("FromEnv() = %q, want default modex credentials", got) + } + if !strings.Contains(got, "@postgres:5432/modex?") { + t.Fatalf("FromEnv() = %q, want default compose host and database", got) + } + if !strings.Contains(got, "sslmode=disable") { + t.Fatalf("FromEnv() = %q, want sslmode=disable", got) + } +} diff --git a/backend/internal/deploy/artifact.go b/backend/internal/deploy/artifact.go index a30006a..6f84aec 100644 --- a/backend/internal/deploy/artifact.go +++ b/backend/internal/deploy/artifact.go @@ -61,6 +61,7 @@ type DocumentRecord struct { Title string `json:"title"` Description string `json:"description"` Content string `json:"content"` + ContentMD string `json:"content_md,omitempty"` Path string `json:"path"` SourceFile string `json:"source_file"` Keywords []string `json:"keywords"` @@ -135,9 +136,6 @@ func ParseZip(r io.Reader, maxBytes int64) (Artifact, error) { } a.Documents = docs } - if strings.TrimSpace(a.Metadata.ModuleKey) == "" { - return Artifact{}, fmt.Errorf("metadata.module_key is required") - } if strings.TrimSpace(a.Metadata.DocsVersion) == "" { a.Metadata.DocsVersion = "latest" } diff --git a/backend/internal/embedding/provider.go b/backend/internal/embedding/provider.go index 428a3ca..750057a 100644 --- a/backend/internal/embedding/provider.go +++ b/backend/internal/embedding/provider.go @@ -8,27 +8,39 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" - "os" + "strings" "time" ) +// Dim is the system-wide embedding vector dimension. It must match the +// docs_embedding.embedding column (vector(1024)) in schema.sql and the +// admin settings UI. Changing it requires a column migration and a full +// reindex, since pgvector columns are fixed-width and vectors of different +// dimensions cannot coexist or be compared. +const Dim = 1024 + type Provider interface { Name() string EmbedText(ctx context.Context, text string) ([]float32, error) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) } -type MockProvider struct { +// FallbackProvider produces deterministic hash-based vectors when no real +// embedding API is configured. The vectors carry no semantic meaning, so search +// treats this provider as keyword-only; it exists so the system still runs +// (ingest, indexing, keyword search) before an embedding provider is set up. +type FallbackProvider struct { Dim int } -func (p MockProvider) Name() string { return "mock" } +func (p FallbackProvider) Name() string { return "fallback" } -func (p MockProvider) EmbedText(ctx context.Context, text string) ([]float32, error) { +func (p FallbackProvider) EmbedText(ctx context.Context, text string) ([]float32, error) { dim := p.Dim if dim <= 0 { - dim = 384 + dim = Dim } vec := make([]float32, dim) seed := sha256.Sum256([]byte(text)) @@ -40,7 +52,7 @@ func (p MockProvider) EmbedText(ctx context.Context, text string) ([]float32, er return vec, nil } -func (p MockProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { +func (p FallbackProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { out := make([][]float32, 0, len(texts)) for _, text := range texts { vec, err := p.EmbedText(ctx, text) @@ -52,37 +64,56 @@ func (p MockProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float return out, nil } -type HTTPProvider struct { - URL string - APIKey string +// Settings is the admin-managed embedding configuration needed by Provider. +type Settings struct { + BaseURL string + Model string + APIKey string + Dim int +} + +// SettingsProvider resolves configuration for every request, so model changes +// made in the admin page take effect without restarting the backend. +type SettingsProvider struct { + Load func() Settings Client *http.Client } -func (p HTTPProvider) Name() string { return "http" } +func (p SettingsProvider) Name() string { + if cfg := p.settings(); cfg.BaseURL != "" && cfg.Model != "" { + return "admin" + } + return "fallback" +} -func (p HTTPProvider) EmbedText(ctx context.Context, text string) ([]float32, error) { - batch, err := p.EmbedBatch(ctx, []string{text}) +func (p SettingsProvider) EmbedText(ctx context.Context, text string) ([]float32, error) { + vectors, err := p.EmbedBatch(ctx, []string{text}) if err != nil { return nil, err } - if len(batch) == 0 { + if len(vectors) == 0 { return nil, errors.New("embedding provider returned no vectors") } - return batch[0], nil + return vectors[0], nil } -func (p HTTPProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { - if p.URL == "" { - return nil, errors.New("EMBEDDING_HTTP_URL is empty") +func (p SettingsProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { + cfg := p.settings() + if cfg.BaseURL == "" || cfg.Model == "" { + return FallbackProvider{Dim: cfg.Dim}.EmbedBatch(ctx, texts) + } + endpoint := strings.TrimRight(cfg.BaseURL, "/") + if !strings.HasSuffix(endpoint, "/embeddings") { + endpoint += "/embeddings" } - body, _ := json.Marshal(map[string]any{"texts": texts}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.URL, bytes.NewReader(body)) + body, _ := json.Marshal(map[string]any{"model": cfg.Model, "input": texts}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") - if p.APIKey != "" { - req.Header.Set("Authorization", "Bearer "+p.APIKey) + if cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) } client := p.Client if client == nil { @@ -94,29 +125,40 @@ func (p HTTPProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float } defer resp.Body.Close() if resp.StatusCode >= 300 { - return nil, fmt.Errorf("embedding http status %d", resp.StatusCode) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return nil, fmt.Errorf("embedding http status %d", resp.StatusCode) + } + return nil, fmt.Errorf("embedding http status %d: %s", resp.StatusCode, msg) } var decoded struct { - Embeddings [][]float32 `json:"embeddings"` + Data []struct { + Embedding []float32 `json:"embedding"` + Index int `json:"index"` + } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { return nil, err } - return decoded.Embeddings, nil -} - -func FromEnv() Provider { - dim := envInt("EMBEDDING_DIM", 384) - if os.Getenv("EMBEDDING_PROVIDER") == "http" { - return HTTPProvider{URL: os.Getenv("EMBEDDING_HTTP_URL"), APIKey: os.Getenv("EMBEDDING_HTTP_API_KEY")} + out := make([][]float32, len(decoded.Data)) + for i, item := range decoded.Data { + idx := item.Index + if idx < 0 || idx >= len(out) { + idx = i + } + out[idx] = item.Embedding } - return MockProvider{Dim: dim} + return out, nil } -func envInt(key string, fallback int) int { - var v int - if _, err := fmt.Sscanf(os.Getenv(key), "%d", &v); err == nil && v > 0 { - return v +func (p SettingsProvider) settings() Settings { + if p.Load == nil { + return Settings{Dim: Dim} + } + cfg := p.Load() + if cfg.Dim <= 0 { + cfg.Dim = Dim } - return fallback + return cfg } diff --git a/backend/internal/embedding/provider_test.go b/backend/internal/embedding/provider_test.go new file mode 100644 index 0000000..4a5a73e --- /dev/null +++ b/backend/internal/embedding/provider_test.go @@ -0,0 +1,49 @@ +package embedding + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSettingsProviderUsesCurrentAdminSettings(t *testing.T) { + var requestedModel string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/embeddings" { + t.Fatalf("path = %q, want /v1/embeddings", r.URL.Path) + } + var body struct { + Model string `json:"model"` + Input []string `json:"input"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + requestedModel = body.Model + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []any{map[string]any{"index": 0, "embedding": []float32{0.1, 0.2}}}, + }) + })) + defer server.Close() + + cfg := Settings{Dim: 3} + provider := SettingsProvider{Load: func() Settings { return cfg }} + if provider.Name() != "fallback" { + t.Fatalf("provider name = %q, want fallback", provider.Name()) + } + fallbackVector, err := provider.EmbedText(context.Background(), "hello") + if err != nil || len(fallbackVector) != 3 { + t.Fatalf("fallback vector len = %d, err = %v", len(fallbackVector), err) + } + + cfg = Settings{BaseURL: server.URL + "/v1", Model: "embed-v2", APIKey: "secret"} + vector, err := provider.EmbedText(context.Background(), "hello") + if err != nil { + t.Fatal(err) + } + if provider.Name() != "admin" || requestedModel != "embed-v2" || len(vector) != 2 { + t.Fatalf("name=%q model=%q vector=%v", provider.Name(), requestedModel, vector) + } +} diff --git a/backend/internal/redisurl/redisurl.go b/backend/internal/redisurl/redisurl.go new file mode 100644 index 0000000..1174c4d --- /dev/null +++ b/backend/internal/redisurl/redisurl.go @@ -0,0 +1,47 @@ +package redisurl + +import ( + "net" + "net/url" + "os" + "strings" +) + +// FromEnv returns REDIS_URL when explicitly set. When REDIS_URL is empty it +// builds a Redis URL from REDIS_HOST/REDIS_PORT/REDIS_DB/REDIS_USER/REDIS_PASSWORD. +// If REDIS_HOST is not set, Redis remains disabled and callers can fall back. +func FromEnv() string { + if redisURL := strings.TrimSpace(os.Getenv("REDIS_URL")); redisURL != "" { + return redisURL + } + host := strings.TrimSpace(os.Getenv("REDIS_HOST")) + if host == "" { + return "" + } + port := env("REDIS_PORT", "6379") + db := strings.Trim(strings.TrimSpace(env("REDIS_DB", "0")), "/") + user := strings.TrimSpace(os.Getenv("REDIS_USER")) + password := os.Getenv("REDIS_PASSWORD") + + u := url.URL{ + Scheme: "redis", + Host: net.JoinHostPort(host, port), + Path: "/" + db, + } + switch { + case user != "" && password != "": + u.User = url.UserPassword(user, password) + case user != "": + u.User = url.User(user) + case password != "": + u.User = url.UserPassword("", password) + } + return u.String() +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/backend/internal/redisurl/redisurl_test.go b/backend/internal/redisurl/redisurl_test.go new file mode 100644 index 0000000..1bdb712 --- /dev/null +++ b/backend/internal/redisurl/redisurl_test.go @@ -0,0 +1,43 @@ +package redisurl + +import "testing" + +func TestFromEnvPrefersRedisURL(t *testing.T) { + t.Setenv("REDIS_URL", "redis://custom:6380/2") + t.Setenv("REDIS_HOST", "ignored") + + if got := FromEnv(); got != "redis://custom:6380/2" { + t.Fatalf("FromEnv() = %q", got) + } +} + +func TestFromEnvBuildsFromRedisParts(t *testing.T) { + t.Setenv("REDIS_HOST", "redis.internal") + t.Setenv("REDIS_PORT", "6380") + t.Setenv("REDIS_DB", "3") + t.Setenv("REDIS_USER", "modex") + t.Setenv("REDIS_PASSWORD", "p@ss/word") + + got := FromEnv() + want := "redis://modex:p%40ss%2Fword@redis.internal:6380/3" + if got != want { + t.Fatalf("FromEnv() = %q, want %q", got, want) + } +} + +func TestFromEnvBuildsPasswordOnlyURL(t *testing.T) { + t.Setenv("REDIS_HOST", "redis") + t.Setenv("REDIS_PASSWORD", "secret") + + got := FromEnv() + want := "redis://:secret@redis:6379/0" + if got != want { + t.Fatalf("FromEnv() = %q, want %q", got, want) + } +} + +func TestFromEnvDisabledWithoutHost(t *testing.T) { + if got := FromEnv(); got != "" { + t.Fatalf("FromEnv() = %q, want empty", got) + } +} diff --git a/backend/internal/repository/postgres.go b/backend/internal/repository/postgres.go new file mode 100644 index 0000000..0b84e0c --- /dev/null +++ b/backend/internal/repository/postgres.go @@ -0,0 +1,16 @@ +package repository + +import ( + "context" + + "modex/backend/internal/store" +) + +// PostgresRepository is the formal-table business repository used in +// production. It is exposed from this package so application assembly does not +// depend on store implementation details directly. +type PostgresRepository = store.PostgresRepository + +func OpenPostgres(ctx context.Context, databaseURL string) (*PostgresRepository, error) { + return store.OpenPostgresRepository(ctx, databaseURL) +} diff --git a/backend/internal/search/search.go b/backend/internal/search/search.go index a2877a9..fe6567f 100644 --- a/backend/internal/search/search.go +++ b/backend/internal/search/search.go @@ -1,15 +1,25 @@ package search import ( + "bytes" "context" + "encoding/json" + "fmt" + "log" "math" + "net/http" + "regexp" "sort" "strings" + "time" "modex/backend/internal/embedding" "modex/backend/internal/store" ) +const defaultEmbeddingInputRunes = 800 +const maxEmbeddingChunksPerDoc = 512 + type Mode string const ( @@ -34,6 +44,14 @@ type Request struct { Filters Filters `json:"filters"` Page int `json:"page"` PageSize int `json:"page_size"` + // DefaultVersionsOnly limits results to each module's configured default + // docs version when the caller has not explicitly requested versions. + DefaultVersionsOnly bool `json:"default_versions_only"` + // Log marks an explicit, user-committed search (Enter / search button / + // result click) that should be persisted to the search log. Live + // as-you-type queries leave this false so the log isn't flooded. + Log bool `json:"log"` + ClickedDocID string `json:"clicked_doc_id"` } type Result struct { @@ -67,9 +85,22 @@ type Response struct { Facets map[string]map[string]int `json:"facets"` } +// ContentStore is the search-facing data boundary. Production uses the +// PostgreSQL repository; tests may use MemoryStore as an explicit fake. +type ContentStore interface { + Pages() []store.Page + Modules(categoryID, keyword string) []store.Module + Settings() store.Settings + Embedding(docID string) ([]float32, bool) + SetEmbedding(docID string, vector []float32) + ClearEmbeddings() + EmbeddingCount() int +} + type Service struct { - Store *store.Store + Store ContentStore Embedder embedding.Provider + Vectors VectorStore KeywordWeight float64 SemanticWeight float64 } @@ -95,25 +126,71 @@ func (s Service) Search(ctx context.Context, req Request) (Response, error) { pages := s.Store.Pages() // Resolve module -> category breadcrumb once so each result carries a path. breadcrumbs := map[string]string{} + defaultVersions := map[string]string{} for _, m := range s.Store.Modules("", "") { breadcrumbs[m.ModuleKey] = m.CategoryPath + defaultVersions[m.ModuleKey] = defaultVersionForModule(m) + } + candidates := make([]store.Page, 0, len(pages)) + for _, p := range pages { + if !matchFilters(p, req.Filters) { + continue + } + if req.DefaultVersionsOnly && len(req.Filters.DocsVersions) == 0 { + if def := defaultVersions[p.ModuleKey]; def != "" && p.DocsVersion != def { + continue + } + } + candidates = append(candidates, p) } terms := matchTerms(req.Query) // Semantic and hybrid modes need a query embedding; keyword mode skips it to // avoid an unnecessary embedding-provider call. var queryVec []float32 if req.Mode != ModeKeyword && strings.TrimSpace(req.Query) != "" { - queryVec, _ = s.Embedder.EmbedText(ctx, req.Query) + var err error + queryVec, err = s.Embedder.EmbedText(ctx, s.truncateEmbeddingInput(req.Query)) + if err != nil { + return Response{}, fmt.Errorf("embed query: %w", err) + } } - var scored []Result - for _, p := range pages { - if !matchFilters(p, req.Filters) { - continue + // The fallback provider returns hash-based vectors (no real semantics). In + // hybrid mode that noise floats weak docs up and buries strong keyword hits + // (e.g. an "EventBus" page for the query "EventBus怎么用"), so when embeddings + // come from the fallback we score hybrid by keyword only. Explicit semantic + // mode still uses the vectors — the caller asked for vector ranking. + fallbackEmbed := s.Embedder.Name() == "fallback" + vectors := map[string][]float32{} + semanticScores := map[string]float64{} + if len(queryVec) > 0 { + docIDs := make([]string, 0, len(candidates)) + for _, p := range candidates { + docIDs = append(docIDs, p.DocID) + } + if s.Vectors != nil { + var err error + semanticScores, err = s.Vectors.Similarities(ctx, queryVec, docIDs, len(docIDs)) + if err != nil { + return Response{}, err + } + } else { + vectors, _ = s.embeddingBatch(docIDs) } + } + var scored []Result + for _, p := range candidates { kScore := keywordScore(req.Query, p) var sScore float64 if len(queryVec) > 0 { - sScore = cosine(queryVec, s.pageVector(ctx, p)) + if s.Vectors != nil { + sScore = semanticScores[p.DocID] + } else { + var err error + sScore, err = s.pageSemanticScore(ctx, queryVec, p, vectors) + if err != nil { + return Response{}, err + } + } } var final float64 switch req.Mode { @@ -122,7 +199,11 @@ func (s Service) Search(ctx context.Context, req Request) (Response, error) { case ModeSemantic: final = sScore default: - final = kScore*kw + sScore*sw + if fallbackEmbed { + final = kScore + } else { + final = kScore*kw + sScore*sw + } req.Mode = ModeHybrid } if strings.TrimSpace(req.Query) != "" && final <= 0 { @@ -135,7 +216,7 @@ func (s Service) Search(ctx context.Context, req Request) (Response, error) { breadcrumb = p.ModuleName } scored = append(scored, Result{ - DocID: p.DocID, Title: p.Title, Snippet: snippet(req.Query, p.ContentText), Path: p.Path, + DocID: p.DocID, Title: plainText(p.Title), Snippet: snippet(req.Query, plainText(p.ContentText)), Path: p.Path, Score: final, SearchMode: req.Mode, ModuleKey: p.ModuleKey, ModuleName: p.ModuleName, DocsVersion: p.DocsVersion, PackageVersion: p.PackageVersion, EntryType: p.EntryType, OwnerGroup: p.OwnerGroup, Status: p.Status, UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), @@ -143,6 +224,11 @@ func (s Service) Search(ctx context.Context, req Request) (Response, error) { }) } sort.Slice(scored, func(i, j int) bool { return scored[i].Score > scored[j].Score }) + if reranked, err := s.rerank(ctx, req.Query, scored); err == nil { + scored = reranked + } else { + log.Printf("search rerank skipped: %v", err) + } total := len(scored) start := (req.Page - 1) * req.PageSize if start > len(scored) { @@ -155,6 +241,90 @@ func (s Service) Search(ctx context.Context, req Request) (Response, error) { return Response{Query: req.Query, Mode: req.Mode, Page: req.Page, PageSize: req.PageSize, Total: total, Results: scored[start:end], Facets: facets(pages)}, nil } +func (s Service) rerank(ctx context.Context, query string, results []Result) ([]Result, error) { + ai := s.Store.Settings().AI + if strings.TrimSpace(query) == "" || ai.RerankBaseURL == "" || ai.RerankModel == "" || len(results) < 2 { + return results, nil + } + topK := ai.RerankTopK + if topK <= 0 || topK > len(results) { + topK = len(results) + } + documents := make([]string, topK) + for i := 0; i < topK; i++ { + documents[i] = results[i].Title + "\n" + results[i].Snippet + } + payload, _ := json.Marshal(map[string]any{ + "model": ai.RerankModel, "query": query, "documents": documents, "top_n": topK, + }) + endpoint := strings.TrimRight(ai.RerankBaseURL, "/") + if !strings.HasSuffix(endpoint, "/rerank") { + endpoint += "/rerank" + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if ai.RerankAPIKey != "" { + req.Header.Set("Authorization", "Bearer "+ai.RerankAPIKey) + } + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("rerank http status %d", resp.StatusCode) + } + var decoded struct { + Results []struct { + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return nil, err + } + if len(decoded.Results) == 0 { + return nil, fmt.Errorf("rerank provider returned no results") + } + reranked := make([]Result, 0, len(results)) + seen := make(map[int]bool, topK) + for _, item := range decoded.Results { + if item.Index < 0 || item.Index >= topK || seen[item.Index] { + continue + } + result := results[item.Index] + result.Score = item.RelevanceScore + reranked = append(reranked, result) + seen[item.Index] = true + } + for i := 0; i < topK; i++ { + if !seen[i] { + reranked = append(reranked, results[i]) + } + } + return append(reranked, results[topK:]...), nil +} + +func defaultVersionForModule(m store.Module) string { + for _, v := range m.AvailableVers { + if v.IsDefault && v.Status != "archived" { + return v.DocsVersion + } + } + if strings.TrimSpace(m.DefaultVersion) != "" { + return m.DefaultVersion + } + for _, v := range m.AvailableVers { + if v.Status != "archived" { + return v.DocsVersion + } + } + return "" +} + func matchFilters(p store.Page, f Filters) bool { return inAny(p.CategoryIDs, f.CategoryIDs) && inValue(p.ModuleKey, f.Modules) && @@ -165,8 +335,33 @@ func matchFilters(p store.Page, f Filters) bool { inValue(p.Status, f.Status) } +// queryTokenRe splits a query into searchable tokens: runs of ASCII +// letters/digits and runs of CJK (Han) characters. Plain whitespace splitting +// fails on mixed input like "EventBus怎么用" (no spaces), collapsing it to one +// token that matches nothing; this separates it into "eventbus" + "怎么用". +var queryTokenRe = regexp.MustCompile(`[a-z0-9]+|\p{Han}+`) + +func queryTokens(query string) []string { + var out []string + for _, tok := range queryTokenRe.FindAllString(strings.ToLower(query), -1) { + r := []rune(tok) + // ASCII alnum run: keep whole (e.g. "eventbus"). + if r[0] < 128 || len(r) == 1 { + out = append(out, tok) + continue + } + // CJK run: emit 2-char shingles so a query like "如何下载插件" matches docs + // containing "下载"/"插件" without a full word segmenter. Single chars would + // over-match; whole runs (the previous behaviour) under-match. + for i := 0; i+1 < len(r); i++ { + out = append(out, string(r[i:i+2])) + } + } + return out +} + func keywordScore(query string, p store.Page) float64 { - q := strings.Fields(strings.ToLower(query)) + q := queryTokens(query) if len(q) == 0 { return 1 } @@ -189,48 +384,194 @@ func keywordScore(query string, p store.Page) float64 { return score / float64(len(q)*4) } -// Reindex (re)computes and caches an embedding for every page using the +// Reindex (re)computes and caches embeddings for every page chunk using the // configured provider. It powers POST /api/embeddings/reindex and pre-warms the // cache so semantic search does not pay an embedding call on the hot path. func (s Service) Reindex(ctx context.Context) (int, error) { - s.Store.ClearEmbeddings() - pages := s.Store.Pages() + if err := s.clearEmbeddings(ctx); err != nil { + return 0, err + } + return s.reindexPages(ctx, s.Store.Pages()) +} + +func (s Service) ReindexModuleVersion(ctx context.Context, moduleKey, docsVersion string) (int, error) { + prefix := moduleKey + ":" + docsVersion + ":" + if err := s.DeleteModuleVersionEmbeddings(ctx, moduleKey, docsVersion); err != nil { + return 0, err + } + pages := make([]store.Page, 0) + for _, p := range s.Store.Pages() { + if strings.HasPrefix(p.DocID, prefix) { + pages = append(pages, p) + } + } + return s.reindexPages(ctx, pages) +} + +func (s Service) reindexPages(ctx context.Context, pages []store.Page) (int, error) { count := 0 for _, p := range pages { - text := embedText(p) - if text == "" { + for _, chunk := range s.embeddingChunks(p) { + vec, err := s.Embedder.EmbedText(ctx, chunk.Content) + if err != nil { + return count, fmt.Errorf("embed page %s chunk %s: %w", p.DocID, chunk.ID, err) + } + if err := s.upsertEmbedding(ctx, p.DocID, chunk.ID, chunk.Content, vec); err != nil { + return count, err + } + count++ + } + } + return count, nil +} + +// pageSemanticScore returns the best precomputed chunk score for the page. +// Missing embeddings are skipped; indexing is done by Reindex/ReindexModuleVersion +// during document publish or explicit admin maintenance, not on the search path. +func (s Service) pageSemanticScore(ctx context.Context, queryVec []float32, p store.Page, vectors map[string][]float32) (float64, error) { + best := 0.0 + for _, chunk := range s.embeddingChunks(p) { + vec, ok := vectors[chunk.ID] + if !ok { continue } - vec, err := s.Embedder.EmbedText(ctx, text) - if err != nil { - return count, err + if score := cosine(queryVec, vec); score > best { + best = score } - s.Store.SetEmbedding(p.DocID, vec) - count++ } - return count, nil + return best, nil +} + +func (s Service) embeddingBatch(docIDs []string) (map[string][]float32, error) { + out := make(map[string][]float32, len(docIDs)) + for _, p := range s.Store.Pages() { + for _, chunk := range s.embeddingChunks(p) { + if vector, ok := s.Store.Embedding(chunk.ID); ok { + out[chunk.ID] = vector + } + } + } + return out, nil +} + +func (s Service) upsertEmbedding(ctx context.Context, docID, chunkID, content string, vector []float32) error { + if s.Vectors != nil { + return s.Vectors.UpsertChunk(ctx, docID, chunkID, content, vector) + } + s.Store.SetEmbedding(chunkID, vector) + return nil } -// pageVector returns the page's embedding from cache, computing and caching it -// on demand the first time it is needed. -func (s Service) pageVector(ctx context.Context, p store.Page) []float32 { - if vec, ok := s.Store.Embedding(p.DocID); ok { - return vec +func (s Service) clearEmbeddings(ctx context.Context) error { + if s.Vectors != nil { + return s.Vectors.Clear(ctx) } - text := embedText(p) + s.Store.ClearEmbeddings() + return nil +} + +func (s Service) EmbeddingCount(ctx context.Context) (int, error) { + if s.Vectors != nil { + return s.Vectors.Count(ctx) + } + return s.Store.EmbeddingCount(), nil +} + +func (s Service) DeleteModuleVersionEmbeddings(ctx context.Context, moduleKey, docsVersion string) error { + prefix := moduleKey + ":" + docsVersion + ":" + if s.Vectors != nil { + return s.Vectors.DeletePrefix(ctx, prefix) + } + return nil +} + +type embeddingChunk struct { + ID string + Content string +} + +func (s Service) embeddingChunks(p store.Page) []embeddingChunk { + text := plainText(strings.TrimSpace(p.ContentText)) + prefix := strings.TrimSpace(p.Title + "\n" + p.Description) if text == "" { - return nil + text = prefix } - vec, err := s.Embedder.EmbedText(ctx, text) - if err != nil { + if strings.TrimSpace(text) == "" { return nil } - s.Store.SetEmbedding(p.DocID, vec) - return vec + limit := s.embeddingInputLimit() + overlap := s.Store.Settings().AI.ChunkOverlap + if overlap < 0 { + overlap = 0 + } + if overlap >= limit { + overlap = limit / 5 + } + prefixRunes := []rune(prefix) + if len(prefixRunes) > limit/4 { + prefix = string(prefixRunes[:limit/4]) + prefixRunes = []rune(prefix) + } + bodyLimit := limit + if prefix != "" { + bodyLimit = limit - len(prefixRunes) - 1 + if bodyLimit <= 0 { + bodyLimit = limit + prefix = "" + } + } + bodyRunes := []rune(text) + step := bodyLimit - overlap + if step <= 0 { + step = bodyLimit + } + chunks := make([]embeddingChunk, 0, (len(bodyRunes)/step)+1) + for start, index := 0, 0; start < len(bodyRunes) && index < maxEmbeddingChunksPerDoc; index++ { + end := start + bodyLimit + if end > len(bodyRunes) { + end = len(bodyRunes) + } + content := strings.TrimSpace(string(bodyRunes[start:end])) + if prefix != "" && content != prefix { + content = strings.TrimSpace(prefix + "\n" + content) + } + content = s.truncateEmbeddingInput(content) + if content != "" { + chunks = append(chunks, embeddingChunk{ + ID: fmt.Sprintf("%s#chunk-%04d", p.DocID, index), + Content: content, + }) + } + if end == len(bodyRunes) { + break + } + start += step + } + return chunks } -func embedText(p store.Page) string { - return strings.TrimSpace(p.Title + "\n" + p.Description + "\n" + p.ContentText) +func (s Service) embeddingInputLimit() int { + limit := s.Store.Settings().AI.ChunkSize + if limit <= 0 { + return defaultEmbeddingInputRunes + } + return limit +} + +func (s Service) truncateEmbeddingInput(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + limit := s.embeddingInputLimit() + if limit <= 0 { + limit = defaultEmbeddingInputRunes + } + runes := []rune(text) + if len(runes) <= limit { + return text + } + return string(runes[:limit]) } // cosine returns a 0..1 similarity (cosine distance shifted into [0,1]). @@ -250,6 +591,44 @@ func cosine(a, b []float32) float64 { return (dot/math.Sqrt(an*bn) + 1) / 2 } +// Markdown-stripping patterns for plainText. Applied in order so links/images +// resolve to their text before stray emphasis markers are removed. +var ( + mdCodeFence = regexp.MustCompile("(?s)```.*?```") + mdImage = regexp.MustCompile(`!\[([^\]]*)\]\([^)]*\)`) + mdLink = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`) + mdInlineCode = regexp.MustCompile("`([^`]*)`") + mdTableSep = regexp.MustCompile(`(?m)^\s*\|?[\s:|-]{3,}\|?\s*$`) + mdHeadingHash = regexp.MustCompile(`(?m)(^|\s)#{1,6}\s+`) + mdBlockquote = regexp.MustCompile(`(?m)^\s{0,3}>\s?`) + mdListMarker = regexp.MustCompile(`(?m)^\s{0,3}([-*+]|\d+\.)\s+`) + mdHTMLTag = regexp.MustCompile(`<[^>]+>`) + mdEmphasis = regexp.MustCompile(`[*_~]{1,3}`) + mdWhitespace = regexp.MustCompile(`\s+`) +) + +// plainText strips common Markdown syntax so search titles and snippets read as +// clean prose instead of raw markup (** , #, [text](url), ``` ... ```, tables). +// It is display-only; keyword scoring and embeddings still use the raw content. +func plainText(s string) string { + if s == "" { + return "" + } + s = mdCodeFence.ReplaceAllString(s, " ") + s = mdImage.ReplaceAllString(s, "$1") + s = mdLink.ReplaceAllString(s, "$1") + s = mdInlineCode.ReplaceAllString(s, "$1") + s = mdTableSep.ReplaceAllString(s, " ") + s = mdHeadingHash.ReplaceAllString(s, "$1") + s = mdBlockquote.ReplaceAllString(s, "") + s = mdListMarker.ReplaceAllString(s, "") + s = mdHTMLTag.ReplaceAllString(s, "") + s = mdEmphasis.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, "|", " ") + s = mdWhitespace.ReplaceAllString(s, " ") + return strings.TrimSpace(s) +} + // snippet returns a rune-safe excerpt centered on the first matched term, with // surrounding context before/after and ellipses, so callers can highlight the // matched keywords. It never splits a multibyte (e.g. CJK) character. @@ -298,8 +677,7 @@ func snippet(query, content string) string { func matchTerms(query string) []string { seen := map[string]bool{} var out []string - for _, f := range strings.Fields(strings.ToLower(query)) { - f = strings.Trim(f, ",.;:!?,。、;:!?\"'") + for _, f := range queryTokens(query) { if f == "" || seen[f] { continue } diff --git a/backend/internal/search/search_test.go b/backend/internal/search/search_test.go index 2073129..0077799 100644 --- a/backend/internal/search/search_test.go +++ b/backend/internal/search/search_test.go @@ -2,16 +2,234 @@ package search import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "modex/backend/internal/embedding" "modex/backend/internal/store" ) +type tokenEmbedder struct { + token string +} + +func (e tokenEmbedder) Name() string { return "token-test" } + +func (e tokenEmbedder) EmbedText(_ context.Context, text string) ([]float32, error) { + if strings.Contains(text, e.token) { + return []float32{1, 0}, nil + } + return []float32{0, 1}, nil +} + +func (e tokenEmbedder) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { + out := make([][]float32, len(texts)) + for i, text := range texts { + vec, err := e.EmbedText(ctx, text) + if err != nil { + return nil, err + } + out[i] = vec + } + return out, nil +} + +func TestRerankUsesAdminSettings(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/rerank" { + t.Fatalf("path = %q, want /v1/rerank", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer secret" { + t.Fatalf("authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{ + map[string]any{"index": 1, "relevance_score": 0.9}, + map[string]any{"index": 0, "relevance_score": 0.2}, + }}) + })) + defer server.Close() + + st := store.NewTestStore() + st.SaveAISettings(store.AISettings{ + RerankBaseURL: server.URL + "/v1", + RerankModel: "rerank-v1", + RerankAPIKey: "secret", + RerankTopK: 2, + }) + results, err := (Service{Store: st}).rerank(context.Background(), "query", []Result{ + {DocID: "first", Title: "First"}, + {DocID: "second", Title: "Second"}, + }) + if err != nil { + t.Fatal(err) + } + if results[0].DocID != "second" || results[0].Score != 0.9 { + t.Fatalf("results = %#v", results) + } +} + +func TestSearchFallsBackWhenRerankProviderFails(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + st := store.NewSeededTestStore() + st.SaveAISettings(store.AISettings{ + RerankBaseURL: server.URL + "/v1", + RerankModel: "rerank-v1", + RerankTopK: 2, + }) + s := Service{Store: st, Embedder: embedding.FallbackProvider{Dim: 256}, KeywordWeight: 0.6, SemanticWeight: 0.4} + + resp, err := s.Search(context.Background(), Request{Query: "构建缓存", Mode: ModeKeyword, PageSize: 5}) + if err != nil { + t.Fatalf("Search should fall back when rerank fails: %v", err) + } + if len(resp.Results) == 0 { + t.Fatal("expected search results without rerank") + } +} + +func TestEmbeddingInputUsesChunkSizeBudget(t *testing.T) { + long := strings.Repeat("测", 1000) + defaultSvc := Service{Store: store.NewTestStore()} + if got := []rune(defaultSvc.truncateEmbeddingInput(long)); len(got) != 800 { + t.Fatalf("default truncated length = %d, want 800", len(got)) + } + + st := store.NewTestStore() + st.SaveAISettings(store.AISettings{ChunkSize: 320}) + customSvc := Service{Store: st} + chunks := customSvc.embeddingChunks(store.Page{DocID: "doc", Title: "标题", ContentText: long}) + if len(chunks) < 2 { + t.Fatalf("chunk count = %d, want multiple chunks", len(chunks)) + } + if got := []rune(chunks[0].Content); len(got) != 320 { + t.Fatalf("custom truncated length = %d, want 320", len(got)) + } +} + +func TestEmbeddingChunksCoverWholeDocumentWithOverlap(t *testing.T) { + st := store.NewTestStore() + st.SaveAISettings(store.AISettings{ChunkSize: 10, ChunkOverlap: 2}) + svc := Service{Store: st} + chunks := svc.embeddingChunks(store.Page{DocID: "doc", ContentText: "0123456789abcdefghij"}) + if len(chunks) != 3 { + t.Fatalf("chunk count = %d, want 3", len(chunks)) + } + if chunks[0].Content != "0123456789" || chunks[1].Content != "89abcdefgh" || chunks[2].Content != "ghij" { + t.Fatalf("chunks = %#v", chunks) + } +} + +func TestSemanticSearchUsesPrebuiltLaterChunks(t *testing.T) { + st := store.NewTestStore() + st.SaveAISettings(store.AISettings{ChunkSize: 20}) + _, err := st.IngestArtifact(store.DeployArtifact{ + ModuleKey: "LongDocs", + ModuleName: "LongDocs", + DocsVersion: "latest", + Entries: []store.DeployEntry{{Key: "guide", Title: "Guide", Type: "markdown"}}, + Documents: []store.DeployDocument{{ + DocID: "LongDocs:latest:guide", + EntryKey: "guide", + EntryType: "markdown", + Title: "Long Guide", + Content: strings.Repeat("a", 60) + " needle", + Status: "active", + }}, + }) + if err != nil { + t.Fatal(err) + } + s := Service{Store: st, Embedder: tokenEmbedder{token: "needle"}} + if count, err := s.ReindexModuleVersion(context.Background(), "LongDocs", "latest"); err != nil { + t.Fatalf("ReindexModuleVersion: %v", err) + } else if count < 2 { + t.Fatalf("reindexed chunks = %d, want multiple chunks", count) + } + before := st.EmbeddingCount() + resp, err := s.Search(context.Background(), Request{Query: "needle", Mode: ModeSemantic, PageSize: 5}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(resp.Results) != 1 || resp.Results[0].DocID != "LongDocs:latest:guide" { + t.Fatalf("results = %#v", resp.Results) + } + if st.EmbeddingCount() != before { + t.Fatalf("search changed embedding count from %d to %d", before, st.EmbeddingCount()) + } +} + +type fakeVectorStore struct { + chunks map[string]fakeChunkVector + simCalls int +} + +type fakeChunkVector struct { + docID string + vector []float32 +} + +func (f *fakeVectorStore) Existing(_ context.Context, docIDs []string) (map[string]bool, error) { + out := map[string]bool{} + wanted := map[string]bool{} + for _, id := range docIDs { + wanted[id] = true + } + for _, chunk := range f.chunks { + if wanted[chunk.docID] { + out[chunk.docID] = true + } + } + return out, nil +} + +func (f *fakeVectorStore) Similarities(_ context.Context, query []float32, docIDs []string, _ int) (map[string]float64, error) { + f.simCalls++ + out := map[string]float64{} + wanted := map[string]bool{} + for _, id := range docIDs { + wanted[id] = true + } + for _, chunk := range f.chunks { + if !wanted[chunk.docID] { + continue + } + if score := cosine(query, chunk.vector); score > out[chunk.docID] { + out[chunk.docID] = score + } + } + return out, nil +} + +func (f *fakeVectorStore) UpsertChunk(_ context.Context, docID, chunkID, _ string, vector []float32) error { + f.chunks[chunkID] = fakeChunkVector{docID: docID, vector: append([]float32(nil), vector...)} + return nil +} + +func (f *fakeVectorStore) Clear(context.Context) error { + f.chunks = map[string]fakeChunkVector{} + return nil +} + +func (f *fakeVectorStore) DeletePrefix(_ context.Context, prefix string) error { + for id, chunk := range f.chunks { + if strings.HasPrefix(chunk.docID, prefix) { + delete(f.chunks, id) + } + } + return nil +} + +func (f *fakeVectorStore) Count(context.Context) (int, error) { return len(f.chunks), nil } + func newService() Service { return Service{ - Store: store.NewSeeded(), - Embedder: embedding.MockProvider{Dim: 256}, + Store: store.NewSeededTestStore(), + Embedder: embedding.FallbackProvider{Dim: 256}, KeywordWeight: 0.6, SemanticWeight: 0.4, } @@ -54,6 +272,24 @@ func TestSemanticSearchReturnsRankedResults(t *testing.T) { } } +func TestSemanticSearchUsesExternalVectorStoreWithoutMemoryCache(t *testing.T) { + s := newService() + vectors := &fakeVectorStore{chunks: map[string]fakeChunkVector{}} + s.Vectors = vectors + if _, err := s.Reindex(context.Background()); err != nil { + t.Fatalf("Reindex: %v", err) + } + if s.Store.EmbeddingCount() != 0 { + t.Fatalf("in-memory embeddings = %d, want 0", s.Store.EmbeddingCount()) + } + if _, err := s.Search(context.Background(), Request{Query: "构建缓存", Mode: ModeSemantic}); err != nil { + t.Fatalf("Search: %v", err) + } + if vectors.simCalls != 1 { + t.Fatalf("similarity queries = %d, want one pgvector query", vectors.simCalls) + } +} + func TestKeywordModeSkipsEmbeddingButStillMatches(t *testing.T) { s := newService() resp, err := s.Search(context.Background(), Request{Query: "构建缓存", Mode: ModeKeyword, PageSize: 5}) @@ -68,3 +304,61 @@ func TestKeywordModeSkipsEmbeddingButStillMatches(t *testing.T) { t.Fatalf("keyword search should not populate embedding cache, got %d", s.Store.EmbeddingCount()) } } + +func TestDefaultVersionsOnlyFiltersDuplicateOldVersions(t *testing.T) { + st := store.NewTestStore() + ingest := func(version, body string) { + t.Helper() + _, err := st.IngestArtifact(store.DeployArtifact{ + ModuleKey: "Threadpool", + ModuleName: "Threadpool", + DocsVersion: version, + Entries: []store.DeployEntry{{Key: "guide", Title: "Guide", Type: "markdown"}}, + Documents: []store.DeployDocument{{ + DocID: "Threadpool:" + version + ":guide", + EntryKey: "guide", + EntryType: "markdown", + Title: "线程池配置", + Description: "线程池配置", + Content: body, + Status: "active", + }}, + }) + if err != nil { + t.Fatalf("IngestArtifact(%s): %v", version, err) + } + } + ingest("v1.0.0", "线程池配置 max_workers legacy") + ingest("v2.0.0", "线程池配置 max_workers current") + + s := Service{Store: st, Embedder: embedding.FallbackProvider{Dim: 256}} + resp, err := s.Search(context.Background(), Request{Query: "max_workers", Mode: ModeKeyword, PageSize: 10, DefaultVersionsOnly: true}) + if err != nil { + t.Fatalf("Search default versions: %v", err) + } + if len(resp.Results) != 1 || resp.Results[0].DocsVersion != "v2.0.0" { + t.Fatalf("default-version results = %#v, want only v2.0.0", resp.Results) + } + + resp, err = s.Search(context.Background(), Request{Query: "max_workers", Mode: ModeKeyword, PageSize: 10, Filters: Filters{DocsVersions: []string{"v1.0.0"}}, DefaultVersionsOnly: true}) + if err != nil { + t.Fatalf("Search explicit version: %v", err) + } + if len(resp.Results) != 1 || resp.Results[0].DocsVersion != "v1.0.0" { + t.Fatalf("explicit-version results = %#v, want v1.0.0", resp.Results) + } +} + +func TestPlainText(t *testing.T) { + cases := []struct{ in, want string }{ + {"**二、使用指南**", "二、使用指南"}, + {"# **系统概述**\n## **产品定位**\n**[CBB系统](https://cbb.fsdev.cn/#/x)** 是一个集质量管控", "系统概述 产品定位 CBB系统 是一个集质量管控"}, + {"```html\nCBB V25\n```\n- [0.概述](https://x)", "0.概述"}, + {"use C# here", "use C# here"}, // hash not a heading marker + } + for _, c := range cases { + if got := plainText(c.in); got != c.want { + t.Errorf("plainText(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/backend/internal/search/vector_store.go b/backend/internal/search/vector_store.go new file mode 100644 index 0000000..760dee1 --- /dev/null +++ b/backend/internal/search/vector_store.go @@ -0,0 +1,14 @@ +package search + +import "context" + +// VectorStore persists document embeddings outside the process. Implementations +// should return independent slices that callers may safely discard or modify. +type VectorStore interface { + Existing(ctx context.Context, docIDs []string) (map[string]bool, error) + Similarities(ctx context.Context, query []float32, docIDs []string, limit int) (map[string]float64, error) + UpsertChunk(ctx context.Context, docID, chunkID, content string, vector []float32) error + Clear(ctx context.Context) error + DeletePrefix(ctx context.Context, docIDPrefix string) error + Count(ctx context.Context) (int, error) +} diff --git a/backend/internal/store/analytics.go b/backend/internal/store/analytics.go new file mode 100644 index 0000000..2b45e1f --- /dev/null +++ b/backend/internal/store/analytics.go @@ -0,0 +1,412 @@ +package store + +import ( + "sort" + "strings" + "time" +) + +func (s *MemoryStore) AddSearchLog(log SearchLog) { + s.mu.Lock() + defer s.mu.Unlock() + s.searchLogs = append(s.searchLogs, log) +} + +func (s *MemoryStore) SearchLogs() []SearchLog { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]SearchLog(nil), s.searchLogs...) +} + +func (s *MemoryStore) AddMCPLog(log MCPLog) { + s.mu.Lock() + defer s.mu.Unlock() + s.mcpLogs = append(s.mcpLogs, log) +} + +func (s *MemoryStore) MCPLogs() []MCPLog { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]MCPLog(nil), s.mcpLogs...) +} + +func (s *MemoryStore) AddDocFeedback(f DocFeedback) DocFeedback { + s.mu.Lock() + defer s.mu.Unlock() + if f.CreatedAt.IsZero() { + f.CreatedAt = time.Now().UTC() + } + if f.ID == "" { + f.ID = s.nextIDLocked("df") + } + for _, p := range s.pages { + if p.DocID == f.DocID { + f.PageID = p.ID + f.ModuleKey = p.ModuleKey + f.Title = p.Title + break + } + } + s.feedbacks = append(s.feedbacks, f) + return f +} + +func (s *MemoryStore) DocFeedbacks() []DocFeedback { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]DocFeedback(nil), s.feedbacks...) +} + +// RecordPageView appends a page view and returns the stored record. +func (s *MemoryStore) RecordPageView(pv PageView) PageView { + s.mu.Lock() + defer s.mu.Unlock() + if pv.ViewedAt.IsZero() { + pv.ViewedAt = time.Now().UTC() + } + if pv.ID == "" { + pv.ID = s.nextIDLocked("pv") + } + for _, p := range s.pages { + if p.DocID == pv.DocID { + pv.PageID = p.ID + pv.ModuleKey = p.ModuleKey + pv.ModuleName = p.ModuleName + pv.DocsVersion = p.DocsVersion + pv.EntryKey = p.EntryKey + pv.Title = p.Title + pv.Path = p.Path + break + } + } + s.pageViews = append(s.pageViews, pv) + return pv +} + +// RecordReadProgress updates the latest matching page view with duration and +// scroll depth for the given session and doc, or records a new view if none. +func (s *MemoryStore) RecordReadProgress(docID, sessionID, readID string, durationSeconds int, scrollDepth float64) PageView { + s.mu.Lock() + defer s.mu.Unlock() + for i := len(s.pageViews) - 1; i >= 0; i-- { + pv := &s.pageViews[i] + if pv.DocID == docID && ((readID != "" && pv.ReadID == readID) || (readID == "" && pv.SessionID == sessionID)) { + if durationSeconds > pv.DurationSeconds { + pv.DurationSeconds = durationSeconds + } + if scrollDepth > pv.ScrollDepth { + pv.ScrollDepth = scrollDepth + } + return *pv + } + } + pv := PageView{ + ID: s.nextIDLocked("pv"), DocID: docID, SessionID: sessionID, ReadID: readID, + DurationSeconds: durationSeconds, ScrollDepth: scrollDepth, ViewedAt: time.Now().UTC(), + } + for _, p := range s.pages { + if p.DocID == pv.DocID { + pv.PageID = p.ID + pv.ModuleKey = p.ModuleKey + pv.ModuleName = p.ModuleName + pv.DocsVersion = p.DocsVersion + pv.EntryKey = p.EntryKey + pv.Title = p.Title + pv.Path = p.Path + break + } + } + s.pageViews = append(s.pageViews, pv) + return pv +} + +// PageAnalytics aggregates recorded views into per-page reading statistics. +// Pages with no recorded views fall back to seeded read counts so the admin +// dashboard is populated on a fresh start. +func (s *MemoryStore) PageAnalytics() []PageStat { + s.mu.RLock() + defer s.mu.RUnlock() + now := time.Now().UTC() + week := now.AddDate(0, 0, -7) + month := now.AddDate(0, 0, -30) + type agg struct { + pv, reads7, reads30, durSum, durCount int + users map[string]struct{} + last time.Time + } + byDoc := map[string]*agg{} + for _, pv := range s.pageViews { + a := byDoc[pv.DocID] + if a == nil { + a = &agg{users: map[string]struct{}{}} + byDoc[pv.DocID] = a + } + a.pv++ + uid := pv.UserID + if uid == "" { + uid = pv.SessionID + } + if uid != "" { + a.users[uid] = struct{}{} + } + if pv.ViewedAt.After(week) { + a.reads7++ + } + if pv.ViewedAt.After(month) { + a.reads30++ + } + if pv.DurationSeconds > 0 { + a.durSum += pv.DurationSeconds + a.durCount++ + } + if pv.ViewedAt.After(a.last) { + a.last = pv.ViewedAt + } + } + var out []PageStat + for _, p := range s.pages { + stat := PageStat{DocID: p.DocID, Title: p.Title, ModuleKey: p.ModuleKey, ModuleName: p.ModuleName, DocsVersion: p.DocsVersion, Path: p.Path, LastViewedAt: p.UpdatedAt} + if a := byDoc[p.DocID]; a != nil { + stat.PV = a.pv + stat.UV = len(a.users) + stat.Reads7d = a.reads7 + stat.Reads30d = a.reads30 + stat.LastViewedAt = a.last + if a.durCount > 0 { + stat.AvgDurationSec = a.durSum / a.durCount + } + } else { + stat.Reads7d = s.seedReadsLocked(p.ModuleKey, true) + stat.Reads30d = s.seedReadsLocked(p.ModuleKey, false) + } + out = append(out, stat) + } + sort.Slice(out, func(i, j int) bool { + if out[i].PV != out[j].PV { + return out[i].PV > out[j].PV + } + return out[i].Reads30d > out[j].Reads30d + }) + return out +} + +func (s *MemoryStore) seedReadsLocked(moduleKey string, week bool) int { + for _, m := range s.modules { + if strings.EqualFold(m.ModuleKey, moduleKey) { + if week { + return m.Reads7d + } + return m.Reads30d + } + } + return 0 +} + +// PageReadStats aggregates recorded views for one document into a daily read +// trend (last `days` days, inclusive of today) plus a per-reader breakdown. +// Readers are keyed by user id, falling back to session id for anonymous views. +func (s *MemoryStore) PageReadStats(docID string, days int) PageReadStats { + if days <= 0 { + days = 30 + } + s.mu.RLock() + defer s.mu.RUnlock() + + now := time.Now().UTC() + today := now.Truncate(24 * time.Hour) + // Pre-seed every day in the window so the line chart has no gaps. + idxByDate := map[string]int{} + daily := make([]DailyReadPoint, days) + for i := 0; i < days; i++ { + d := today.AddDate(0, 0, -(days - 1 - i)) + key := d.Format("2006-01-02") + daily[i] = DailyReadPoint{Date: key, Count: 0} + idxByDate[key] = i + } + windowStart := today.AddDate(0, 0, -(days - 1)) + + type ragg struct { + userID string + count int + duration int + last time.Time + } + readers := map[string]*ragg{} + total := 0 + totalDuration := 0 + timedReads := 0 + for _, pv := range s.pageViews { + if pv.DocID != docID { + continue + } + total++ + if pv.DurationSeconds > 0 { + totalDuration += pv.DurationSeconds + timedReads++ + } + if !pv.ViewedAt.Before(windowStart) { + if i, ok := idxByDate[pv.ViewedAt.UTC().Format("2006-01-02")]; ok { + daily[i].Count++ + } + } + key := pv.UserID + if key == "" { + key = "session:" + pv.SessionID + } + r := readers[key] + if r == nil { + r = &ragg{userID: pv.UserID} + readers[key] = r + } + r.count++ + r.duration += pv.DurationSeconds + if pv.ViewedAt.After(r.last) { + r.last = pv.ViewedAt + } + } + + out := PageReadStats{DocID: docID, Total: total, Daily: daily, Readers: []ReaderStat{}} + if timedReads > 0 { + out.AvgDurationSec = totalDuration / timedReads + } + for _, r := range readers { + name := "匿名" + if r.userID != "" { + if u, err := s.userByIDLocked(r.userID); err == nil { + if u.DisplayName != "" { + name = u.DisplayName + } else if u.Username != "" { + name = u.Username + } + } else { + name = r.userID + } + } + avgDuration := 0 + if r.count > 0 { + avgDuration = r.duration / r.count + } + out.Readers = append(out.Readers, ReaderStat{Reader: name, UserID: r.userID, Count: r.count, AvgDurationSec: avgDuration, LastReadAt: r.last}) + } + sort.Slice(out.Readers, func(i, j int) bool { + if out.Readers[i].Count != out.Readers[j].Count { + return out.Readers[i].Count > out.Readers[j].Count + } + return out.Readers[i].LastReadAt.After(out.Readers[j].LastReadAt) + }) + return out +} + +func (s *MemoryStore) UserFavorites(userID string) []UserFavorite { + s.mu.RLock() + defer s.mu.RUnlock() + out := []UserFavorite{} + for _, f := range s.favorites { + if f.UserID == userID { + out = append(out, f) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out +} + +func (s *MemoryStore) SetUserFavorite(userID, moduleKey string, favorite bool) ([]UserFavorite, error) { + s.mu.Lock() + defer s.mu.Unlock() + userID = strings.TrimSpace(userID) + moduleKey = strings.TrimSpace(moduleKey) + if userID == "" || moduleKey == "" { + return nil, ErrInvalid + } + exists := false + for _, m := range s.modules { + if m.ModuleKey == moduleKey { + exists = true + break + } + } + if !exists { + return nil, ErrNotFound + } + for i := range s.favorites { + if s.favorites[i].UserID == userID && s.favorites[i].ModuleKey == moduleKey { + if favorite { + return s.userFavoritesLocked(userID), nil + } + s.favorites = append(s.favorites[:i], s.favorites[i+1:]...) + return s.userFavoritesLocked(userID), nil + } + } + if favorite { + s.favorites = append(s.favorites, UserFavorite{ID: s.nextIDLocked("fav"), UserID: userID, ModuleKey: moduleKey, CreatedAt: time.Now().UTC()}) + } + return s.userFavoritesLocked(userID), nil +} + +func (s *MemoryStore) userFavoritesLocked(userID string) []UserFavorite { + out := []UserFavorite{} + for _, f := range s.favorites { + if f.UserID == userID { + out = append(out, f) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out +} + +func (s *MemoryStore) UserRecentDocs(userID string, limit int) []UserRecentDoc { + s.mu.RLock() + defer s.mu.RUnlock() + if limit <= 0 { + limit = 30 + } + out := []UserRecentDoc{} + for _, r := range s.recentDocs { + if r.UserID == userID { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ViewedAt.After(out[j].ViewedAt) }) + if len(out) > limit { + out = out[:limit] + } + return out +} + +func (s *MemoryStore) RecordUserRecentDoc(userID string, recent UserRecentDoc) (UserRecentDoc, error) { + s.mu.Lock() + defer s.mu.Unlock() + userID = strings.TrimSpace(userID) + recent.DocID = strings.TrimSpace(recent.DocID) + if userID == "" || recent.DocID == "" { + return UserRecentDoc{}, ErrInvalid + } + for _, p := range s.pages { + if p.DocID == recent.DocID { + recent.Title = firstNonEmpty(recent.Title, p.Title) + recent.ModuleKey = firstNonEmpty(recent.ModuleKey, p.ModuleKey) + recent.ModuleName = firstNonEmpty(recent.ModuleName, p.ModuleName) + recent.DocsVersion = firstNonEmpty(recent.DocsVersion, p.DocsVersion) + recent.EntryKey = firstNonEmpty(recent.EntryKey, p.EntryKey) + recent.Href = firstNonEmpty(recent.Href, "/docs/"+p.ModuleKey+"/"+p.DocsVersion+"/"+p.EntryKey) + break + } + } + if recent.ID == "" { + recent.ID = s.nextIDLocked("recent") + } + recent.UserID = userID + if recent.ViewedAt.IsZero() { + recent.ViewedAt = time.Now().UTC() + } + filtered := s.recentDocs[:0] + for _, item := range s.recentDocs { + if !(item.UserID == userID && item.DocID == recent.DocID) { + filtered = append(filtered, item) + } + } + s.recentDocs = append(filtered, recent) + return recent, nil +} + +// CreateCategory adds a new category. Key is required and must be unique. diff --git a/backend/internal/store/catalog_mutations.go b/backend/internal/store/catalog_mutations.go new file mode 100644 index 0000000..b91ef39 --- /dev/null +++ b/backend/internal/store/catalog_mutations.go @@ -0,0 +1,776 @@ +package store + +import ( + "mime" + "net/http" + "net/url" + "path" + "sort" + "strconv" + "strings" + "time" +) + +func (s *MemoryStore) CreateCategory(c Category) (Category, error) { + s.mu.Lock() + defer s.mu.Unlock() + // Key is system-generated from the name (dotted under the parent's key) so + // users never have to invent one. A user-supplied key is still honored. + if strings.TrimSpace(c.Key) == "" { + if strings.TrimSpace(c.Name) == "" { + return Category{}, ErrInvalid + } + c.Key = s.generateCategoryKeyLocked(c.Name, c.ParentID) + } + if c.ID == "" { + c.ID = c.Key + } + for _, existing := range s.categories { + if existing.ID == c.ID { + return Category{}, ErrConflict + } + } + if c.Status == "" { + c.Status = "active" + } + c.Children = nil + s.categories = append(s.categories, c) + return c, nil +} + +// slugifyKey lowercases and keeps [a-z0-9-]; non-ASCII (e.g. Chinese) collapses +// to empty, in which case callers fall back to a short unique token. +func slugifyKey(name string) string { + var b strings.Builder + prevDash := false + for _, r := range strings.ToLower(strings.TrimSpace(name)) { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + prevDash = false + case r == ' ' || r == '-' || r == '_' || r == '.' || r == '/': + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + } + } + return strings.Trim(b.String(), "-") +} + +func (s *MemoryStore) generateCategoryKeyLocked(name, parentID string) string { + base := slugifyKey(name) + if base == "" { + base = "d" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) + } + prefix := "" + for _, c := range s.categories { + if c.ID == parentID { + prefix = c.Key + "." + break + } + } + taken := func(k string) bool { + for _, c := range s.categories { + if c.Key == k || c.ID == k { + return true + } + } + return false + } + key := prefix + base + for i := 2; taken(key); i++ { + key = prefix + base + "-" + strconv.Itoa(i) + } + return key +} + +func (s *MemoryStore) generateTeamKeyLocked(name string) string { + base := slugifyKey(name) + if base == "" { + base = "team-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) + } + taken := func(k string) bool { + for _, t := range s.teams { + if strings.EqualFold(t.Key, k) { + return true + } + } + return false + } + key := base + for i := 2; taken(key); i++ { + key = base + "-" + strconv.Itoa(i) + } + return key +} + +// MoveCategory reparents a category and positions it at `index` among its new +// siblings, renumbering sibling SortOrder so the tree order is stable. Rejects +// moves that would create a cycle (into the node's own subtree). +func (s *MemoryStore) MoveCategory(id, parentID string, index int) (Category, error) { + s.mu.Lock() + defer s.mu.Unlock() + + idx := -1 + for i := range s.categories { + if s.categories[i].ID == id { + idx = i + break + } + } + if idx == -1 { + return Category{}, ErrNotFound + } + if parentID == id { + return Category{}, ErrInvalid + } + // Walk parent chain to reject cycles. + for p := parentID; p != ""; { + if p == id { + return Category{}, ErrInvalid + } + next := "" + for i := range s.categories { + if s.categories[i].ID == p { + next = s.categories[i].ParentID + break + } + } + p = next + } + if parentID != "" { + found := false + for i := range s.categories { + if s.categories[i].ID == parentID { + found = true + break + } + } + if !found { + return Category{}, ErrInvalid + } + } + + s.categories[idx].ParentID = parentID + + // Collect new siblings (same parent) in current order, excluding the moved + // node, then insert it at the requested index and renumber. + var sibs []int + for i := range s.categories { + if s.categories[i].ParentID == parentID && s.categories[i].ID != id { + sibs = append(sibs, i) + } + } + sort.SliceStable(sibs, func(a, b int) bool { return s.categories[sibs[a]].SortOrder < s.categories[sibs[b]].SortOrder }) + order := make([]int, 0, len(sibs)+1) + if index < 0 { + index = 0 + } + if index > len(sibs) { + index = len(sibs) + } + order = append(order, sibs[:index]...) + order = append(order, idx) + order = append(order, sibs[index:]...) + for pos, ci := range order { + s.categories[ci].SortOrder = (pos + 1) * 10 + } + out := s.categories[idx] + out.Children = nil + return out, nil +} + +func (s *MemoryStore) UpdateCategory(id string, c Category) (Category, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.categories { + if s.categories[i].ID == id { + if c.Name != "" { + s.categories[i].Name = c.Name + } + if c.Description != "" { + s.categories[i].Description = c.Description + } + if c.Icon != "" { + s.categories[i].Icon = c.Icon + } + if c.SortOrder != 0 { + s.categories[i].SortOrder = c.SortOrder + } + if c.Status != "" { + s.categories[i].Status = c.Status + } + if c.ParentID != "" { + s.categories[i].ParentID = c.ParentID + } + // Always accept ResponsibleTeam from patch (send "" explicitly to clear assignment to a team). + s.categories[i].ResponsibleTeam = c.ResponsibleTeam + out := s.categories[i] + out.Children = nil + return out, nil + } + } + return Category{}, ErrNotFound +} + +func (s *MemoryStore) DeleteCategory(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, c := range s.categories { + if c.ParentID == id { + return ErrConflict + } + } + for i := range s.categories { + if s.categories[i].ID == id { + s.categories = append(s.categories[:i], s.categories[i+1:]...) + return nil + } + } + return ErrNotFound +} + +func (s *MemoryStore) moduleKeyTakenLocked(key string) bool { + for _, m := range s.modules { + if strings.EqualFold(m.ModuleKey, key) { + return true + } + } + return false +} + +func (s *MemoryStore) CreateModule(m Module) (Module, error) { + s.mu.Lock() + defer s.mu.Unlock() + // Auto-generate module_key from the name (slug, unique) so admins never type one. + if strings.TrimSpace(m.ModuleKey) == "" { + if strings.TrimSpace(m.Name) == "" { + return Module{}, ErrInvalid + } + base := slugifyKey(m.Name) + if base == "" { + base = "doc-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) + } + key := base + for i := 2; s.moduleKeyTakenLocked(key); i++ { + key = base + "-" + strconv.Itoa(i) + } + m.ModuleKey = key + } + for _, existing := range s.modules { + if strings.EqualFold(existing.ModuleKey, m.ModuleKey) { + return Module{}, ErrConflict + } + } + if m.ID == "" { + m.ID = s.nextIDLocked("m") + } + // Every doc source gets a deploy token for CI push auth. + if strings.TrimSpace(m.DeployToken) == "" { + m.DeployToken = "mdx_" + strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.FormatInt(int64(len(s.modules)+1), 36) + } + if m.Name == "" { + m.Name = m.ModuleKey + } + if m.Status == "" { + m.Status = "active" + } + if m.DefaultVersion == "" { + m.DefaultVersion = "latest" + } + if m.CategoryPath == "" { + m.CategoryPath = s.categoryPathLocked(m.CategoryIDs) + } + m.UpdatedAt = time.Now().UTC() + m.AvailableVers = nil + m.DeployTokenSet = m.DeployToken != "" + s.modules = append(s.modules, m) + return m, nil +} + +func (s *MemoryStore) UpdateModule(moduleKey string, patch Module) (Module, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.modules { + if strings.EqualFold(s.modules[i].ModuleKey, moduleKey) { + m := &s.modules[i] + if patch.Name != "" { + m.Name = patch.Name + } + if patch.Description != "" { + m.Description = patch.Description + } + if patch.OwnerGroup != "" { + m.OwnerGroup = patch.OwnerGroup + } + if patch.RepoType != "" { + m.RepoType = patch.RepoType + } + if patch.RepoURL != "" { + m.RepoURL = patch.RepoURL + } + if patch.DefaultVersion != "" { + m.DefaultVersion = patch.DefaultVersion + } + if patch.Visibility != "" { + m.Visibility = patch.Visibility + } + if patch.Status != "" { + m.Status = patch.Status + } + if patch.PackageVersion != "" { + m.PackageVersion = patch.PackageVersion + } + if patch.Channel != "" { + m.Channel = patch.Channel + } + if patch.Edition != "" { + m.Edition = patch.Edition + } + if patch.Keywords != nil { + m.Keywords = patch.Keywords + } + if patch.Maintainers != nil { + m.Maintainers = patch.Maintainers + } + if patch.CategoryIDs != nil { + m.CategoryIDs = patch.CategoryIDs + if patch.CategoryPath == "" { + m.CategoryPath = s.categoryPathLocked(m.CategoryIDs) + } + s.syncPageCategoriesForModuleLocked(m.ModuleKey, m.CategoryIDs) + } + if patch.CategoryPath != "" { + m.CategoryPath = patch.CategoryPath + } + if patch.SourceType != "" { + m.SourceType = patch.SourceType + } + if patch.DocType != "" { + m.DocType = patch.DocType + } + if patch.Mount != "" { + m.Mount = patch.Mount + } + if patch.GitLabBranch != "" { + m.GitLabBranch = patch.GitLabBranch + } + if patch.GitLabPath != "" { + m.GitLabPath = patch.GitLabPath + } + if patch.DeployToken != "" { + m.DeployToken = patch.DeployToken + } + m.UpdatedAt = time.Now().UTC() + out := *m + out.AvailableVers = s.versionsForLocked(m.ModuleKey) + out.DeployTokenSet = out.DeployToken != "" + return out, nil + } + } + return Module{}, ErrNotFound +} + +func (s *MemoryStore) syncPageCategoriesForModuleLocked(moduleKey string, categoryIDs []string) { + for i := range s.pages { + if strings.EqualFold(s.pages[i].ModuleKey, moduleKey) { + s.pages[i].CategoryIDs = cloneStrings(categoryIDs) + } + } +} + +func (s *MemoryStore) CreateVersion(moduleKey string, v Version) (Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.moduleIndexLocked(moduleKey); err != nil { + return Version{}, err + } + if strings.TrimSpace(v.DocsVersion) == "" { + return Version{}, ErrInvalid + } + for _, existing := range s.versions { + if strings.EqualFold(existing.ModuleKey, moduleKey) && existing.DocsVersion == v.DocsVersion { + return Version{}, ErrConflict + } + } + v.ModuleKey = moduleKey + if v.ID == "" { + v.ID = s.nextIDLocked("v") + } + if v.DisplayName == "" { + v.DisplayName = v.DocsVersion + } + if v.Status == "" { + v.Status = "active" + } + v.CreatedAt = time.Now().UTC() + if v.IsDefault { + for i := range s.versions { + if strings.EqualFold(s.versions[i].ModuleKey, moduleKey) { + s.versions[i].IsDefault = false + } + } + if idx, err := s.moduleIndexLocked(moduleKey); err == nil { + s.modules[idx].DefaultVersion = v.DocsVersion + } + } + s.versions = append(s.versions, v) + return v, nil +} + +func (s *MemoryStore) UpdateVersion(moduleKey, docsVersion string, patch Version) (Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.versions { + if strings.EqualFold(s.versions[i].ModuleKey, moduleKey) && s.versions[i].DocsVersion == docsVersion { + v := &s.versions[i] + if patch.DisplayName != "" { + v.DisplayName = patch.DisplayName + } + if patch.VersionType != "" { + v.VersionType = patch.VersionType + } + if patch.Status != "" { + v.Status = patch.Status + } + if patch.SourceBranch != "" { + v.SourceBranch = patch.SourceBranch + } + if patch.PackageVersion != "" { + v.PackageVersion = patch.PackageVersion + } + if patch.Channel != "" { + v.Channel = patch.Channel + } + if patch.Edition != "" { + v.Edition = patch.Edition + } + if patch.SupportStatus != "" { + v.SupportStatus = patch.SupportStatus + } + if patch.IsDefault { + for j := range s.versions { + if strings.EqualFold(s.versions[j].ModuleKey, moduleKey) { + s.versions[j].IsDefault = false + } + } + v.IsDefault = true + if idx, err := s.moduleIndexLocked(moduleKey); err == nil { + s.modules[idx].DefaultVersion = v.DocsVersion + } + } + return *v, nil + } + } + return Version{}, ErrNotFound +} + +func (s *MemoryStore) CreateEntry(moduleKey, docsVersion string, e Entry) (Entry, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.moduleIndexLocked(moduleKey); err != nil { + return Entry{}, err + } + if strings.TrimSpace(e.EntryKey) == "" { + return Entry{}, ErrInvalid + } + for _, existing := range s.entries { + if strings.EqualFold(existing.ModuleKey, moduleKey) && existing.DocsVersion == docsVersion && existing.EntryKey == e.EntryKey { + return Entry{}, ErrConflict + } + } + e.ModuleKey = moduleKey + e.DocsVersion = docsVersion + if e.ID == "" { + e.ID = s.nextIDLocked("e") + } + if e.EntryType == "" { + e.EntryType = "markdown" + } + if e.Builder == "" { + e.Builder = e.EntryType + } + if e.IndexStatus == "" { + e.IndexStatus = "pending" + } + if e.Status == "" { + e.Status = "active" + } + e.CreatedAt = time.Now().UTC() + s.entries = append(s.entries, e) + return e, nil +} + +func (s *MemoryStore) UpdateEntry(entryID string, patch Entry) (Entry, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.entries { + if s.entries[i].ID == entryID { + e := &s.entries[i] + if patch.Title != "" { + e.Title = patch.Title + } + if patch.EntryType != "" { + e.EntryType = patch.EntryType + } + if patch.Builder != "" { + e.Builder = patch.Builder + } + if patch.Source != "" { + e.Source = patch.Source + } + if patch.StorageURI != "" { + e.StorageURI = patch.StorageURI + } + if patch.NavURI != "" { + e.NavURI = patch.NavURI + } + if patch.IndexStatus != "" { + e.IndexStatus = patch.IndexStatus + } + if patch.SortOrder != 0 { + e.SortOrder = patch.SortOrder + } + if patch.Status != "" { + e.Status = patch.Status + } + e.IsPrimary = patch.IsPrimary + return *e, nil + } + } + return Entry{}, ErrNotFound +} + +func (s *MemoryStore) DeleteEntry(entryID string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.entries { + if s.entries[i].ID == entryID { + s.entries = append(s.entries[:i], s.entries[i+1:]...) + return nil + } + } + return ErrNotFound +} + +func (s *MemoryStore) Release(releaseID string) (Release, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, r := range s.releases { + if r.ReleaseID == releaseID || r.ID == releaseID { + return r, nil + } + } + return Release{}, ErrNotFound +} + +// RollbackRelease marks the target release as rolled back. A real +// implementation would also re-point storage and search to the prior artifact. +func (s *MemoryStore) RollbackRelease(releaseID string) (Release, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.releases { + if s.releases[i].ReleaseID == releaseID || s.releases[i].ID == releaseID { + s.releases[i].Status = "rolled_back" + return s.releases[i], nil + } + } + return Release{}, ErrNotFound +} + +func (s *MemoryStore) moduleIndexLocked(moduleKey string) (int, error) { + for i := range s.modules { + if strings.EqualFold(s.modules[i].ModuleKey, moduleKey) { + return i, nil + } + } + return -1, ErrNotFound +} + +func (s *MemoryStore) nextIDLocked(prefix string) string { + s.seq++ + return prefix + "-" + strconv.FormatInt(s.seq, 10) + "-" + strconv.FormatInt(time.Now().UnixNano(), 36) +} + +func (s *MemoryStore) versionsForLocked(moduleKey string) []Version { + var out []Version + for _, v := range s.versions { + if strings.EqualFold(v.ModuleKey, moduleKey) { + out = append(out, v) + } + } + return out +} + +func contains(xs []string, target string) bool { + for _, x := range xs { + if x == target { + return true + } + } + return false +} + +func routeKey(moduleKey, docsVersion, entryKey string) string { + if entryKey == "" { + return strings.ToLower(moduleKey) + ":" + docsVersion + } + return strings.ToLower(moduleKey) + ":" + docsVersion + ":" + entryKey +} + +func siteFileKey(moduleKey, docsVersion, entryKey, name string) string { + return routeKey(moduleKey, docsVersion, entryKey) + ":" + path.Clean(strings.TrimPrefix(name, "/")) +} + +// isSiteBuilderType reports whether an entry ships a pre-built static site +// (VitePress/VuePress/Fumadocs) rather than Markdown rendered by Modex. +func isSiteBuilderType(entryType string) bool { + switch strings.ToLower(entryType) { + case "vitepress", "vuepress", "fumadocs": + return true + default: + return false + } +} + +// docPagePath builds the in-app URL for an indexed page. For site-builder +// entries it preserves the per-page route as a ?p= deep link so a search hit +// opens the matched page inside the embedded site, instead of collapsing every +// page to the entry root. Markdown/static entries keep the plain entry URL. +func docPagePath(moduleKey, docsVersion, entryKey, entryType, route string) string { + base := "/docs/" + moduleKey + "/" + docsVersion + "/" + entryKey + route = strings.TrimSpace(route) + if isSiteBuilderType(entryType) && route != "" && route != "/" && !strings.HasPrefix(route, "/docs/") { + return base + "?p=" + url.QueryEscape(route) + } + return base +} + +func cloneStrings(xs []string) []string { + if xs == nil { + return nil + } + return append([]string(nil), xs...) +} + +func cloneNav(xs []NavItem) []NavItem { + if xs == nil { + return nil + } + out := make([]NavItem, len(xs)) + for i, x := range xs { + out[i] = x + out[i].Children = cloneNav(x.Children) + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func firstString(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[0] +} + +func coalesceStrings(primary, fallback []string) []string { + if len(primary) > 0 { + return primary + } + return fallback +} + +func removeEntries(entries []Entry, moduleKey, docsVersion string) []Entry { + out := entries[:0] + for _, e := range entries { + if strings.EqualFold(e.ModuleKey, moduleKey) && e.DocsVersion == docsVersion { + continue + } + out = append(out, e) + } + return out +} + +func removePages(pages []Page, moduleKey, docsVersion string) []Page { + out := pages[:0] + for _, p := range pages { + if strings.EqualFold(p.ModuleKey, moduleKey) && p.DocsVersion == docsVersion { + continue + } + out = append(out, p) + } + return out +} + +func entryKeyFromDocID(docID string) string { + parts := strings.Split(docID, ":") + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} + +func entryTypeForEntry(entries []DeployEntry, entryKey string) string { + for _, e := range entries { + if e.Key == entryKey { + return e.Type + } + } + return "markdown" +} + +func titleForEntry(entries []DeployEntry, entryKey string) string { + for _, e := range entries { + if e.Key == entryKey { + return e.Title + } + } + return entryKey +} + +func htmlForEntry(files map[string]string, entryKey string) string { + for _, name := range []string{"site/" + entryKey + "/index.html", "site/" + entryKey + ".html"} { + if html := files[name]; html != "" { + return html + } + } + prefix := "site/" + entryKey + "/" + for name, html := range files { + if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".html") { + return html + } + } + return "" +} + +func splitSiteFile(name string) (string, string, bool) { + name = path.Clean(strings.TrimPrefix(name, "/")) + parts := strings.Split(name, "/") + if len(parts) < 3 || parts[0] != "site" || parts[1] == "" { + return "", "", false + } + rel := path.Join(parts[2:]...) + if rel == "." || rel == "" { + rel = "index.html" + } + return parts[1], rel, true +} + +func contentTypeForName(name string, content []byte) string { + if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { + return ct + } + if len(content) > 0 { + return http.DetectContentType(content) + } + return "application/octet-stream" +} diff --git a/backend/internal/store/datastore.go b/backend/internal/store/datastore.go new file mode 100644 index 0000000..69d5e55 --- /dev/null +++ b/backend/internal/store/datastore.go @@ -0,0 +1,104 @@ +package store + +import "time" + +// DataStore is the business data boundary used by the application. Production +// injects PostgresRepository; MemoryStore exists only as a deterministic test +// fake. Methods intentionally preserve the existing service API while storage +// is migrated away from process-local state. +type DataStore interface { + Settings() Settings + SaveAISettings(AISettings) Settings + CurrentUser() User + Users(string) []User + UserByID(string) (User, error) + UserByMCPToken(string) (User, error) + SetUserMCPToken(string, string) (User, error) + CreateUser(User) (User, error) + UpdateUser(string, User) (User, error) + DeleteUser(string) error + UpsertUser(User) User + Teams() []Team + Team(string) (Team, error) + CreateTeam(Team) (Team, error) + UpdateTeam(string, Team) (Team, error) + DeleteTeam(string) error + AddTeamMember(string, string) (Team, error) + RemoveTeamMember(string, string) (Team, error) + SetTeamLeader(string, string) (Team, error) + TeamMembers(string) []string + TeamKeysForUser(User) []string + AllCategories() []Category + CategoryName(string) string + CategoryTree() []Category + CreateCategory(Category) (Category, error) + MoveCategory(string, string, int) (Category, error) + UpdateCategory(string, Category) (Category, error) + DeleteCategory(string) error + Modules(string, string) []Module + Module(string) (Module, error) + ModuleByDeployToken(string) (Module, error) + CreateModule(Module) (Module, error) + UpdateModule(string, Module) (Module, error) + Versions(string) []Version + CreateVersion(string, Version) (Version, error) + UpdateVersion(string, string, Version) (Version, error) + Entries(string, string) []Entry + EntryModuleKey(string) (string, bool) + CreateEntry(string, string, Entry) (Entry, error) + UpdateEntry(string, Entry) (Entry, error) + DeleteEntry(string) error + Releases() []Release + Release(string) (Release, error) + RollbackRelease(string) (Release, error) + Page(string) (Page, error) + PageByRoute(string, string, string) (Page, error) + Pages() []Page + Nav(string, string) []NavItem + PageHTML(string, string, string) string + SiteFile(string, string, string, string) (SiteFile, error) + SiteObjects() map[string]SiteFile + ClearSiteAssets() + Embedding(string) ([]float32, bool) + SetEmbedding(string, []float32) + EmbeddingCount() int + ClearEmbeddings() + IngestArtifact(DeployArtifact) (DeployResult, error) + AddSearchLog(SearchLog) + SearchLogs() []SearchLog + AddMCPLog(MCPLog) + MCPLogs() []MCPLog + AddDocFeedback(DocFeedback) DocFeedback + DocFeedbacks() []DocFeedback + RecordPageView(PageView) PageView + RecordReadProgress(string, string, string, int, float64) PageView + PageAnalytics() []PageStat + PageReadStats(string, int) PageReadStats + UserFavorites(string) []UserFavorite + SetUserFavorite(string, string, bool) ([]UserFavorite, error) + UserRecentDocs(string, int) []UserRecentDoc + RecordUserRecentDoc(string, UserRecentDoc) (UserRecentDoc, error) + ConnectedApps() []ConnectedApp + ConnectedAppByClientID(string) (ConnectedApp, error) + CreateConnectedApp(ConnectedApp, string) (ConnectedApp, error) + UpdateConnectedApp(string, ConnectedApp) (ConnectedApp, error) + DeleteConnectedApp(string) error + VerifyConnectedAppSecret(string, string) (ConnectedApp, error) + CreateOAuthCode(string, string, string, []string, string, time.Duration) (OAuthGrant, error) + RedeemOAuthCode(string, string, string, string, string, time.Duration, time.Duration) (OAuthGrant, ConnectedApp, User, error) + RefreshOAuthToken(string, string, string, string, time.Duration, time.Duration) (OAuthGrant, ConnectedApp, User, error) + UserByOAuthAccessToken(string) (User, ConnectedApp, OAuthGrant, error) + RevokeOAuthToken(string, string) bool + PluginStates() []PluginState + SavePluginSettings(map[string]PluginSetting) []PluginState + PluginEffective() map[string]PluginSetting + UploadedPlugins() []UploadedPlugin + EnabledUploadedPlugins() []UploadedPlugin + SaveUploadedPlugin(UploadedPlugin) (UploadedPlugin, error) + DeleteUploadedPlugin(string) bool + SnippetData() ([]Snippet, map[string]string) + SaveSnippetData([]Snippet, map[string]string) ([]Snippet, map[string]string) +} + +var _ DataStore = (*MemoryStore)(nil) +var _ DataStore = (*PostgresRepository)(nil) diff --git a/backend/internal/store/ingest.go b/backend/internal/store/ingest.go new file mode 100644 index 0000000..3b833ac --- /dev/null +++ b/backend/internal/store/ingest.go @@ -0,0 +1,253 @@ +package store + +import ( + "strconv" + "strings" + "time" +) + +func (s *MemoryStore) IngestArtifact(a DeployArtifact) (DeployResult, error) { + if strings.TrimSpace(a.ModuleKey) == "" || strings.TrimSpace(a.DocsVersion) == "" || len(a.Entries) == 0 || len(a.Documents) == 0 { + return DeployResult{}, ErrInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + moduleName := firstNonEmpty(a.ModuleName, a.ModuleKey) + moduleIdx, err := s.moduleIndexLocked(a.ModuleKey) + if err != nil { + s.modules = append(s.modules, Module{ + ID: s.nextIDLocked("m"), + ModuleKey: a.ModuleKey, + Name: moduleName, + Description: a.Description, + OwnerGroup: firstNonEmpty(firstString(a.Authors), "docs"), + RepoType: firstNonEmpty(a.RepoType, "git"), + RepoURL: a.RepoURL, + SourceType: "gitlab", + GitLabBranch: a.Branch, + DefaultVersion: a.DocsVersion, + Visibility: "internal", + Status: "active", + PackageName: a.ModuleKey, + PackageVersion: a.PackageVersion, + Channel: "docs", + Edition: a.Edition, + Keywords: cloneStrings(a.Keywords), + Maintainers: cloneStrings(a.Authors), + LastSyncedCommit: a.CommitSHA, + LastSyncedAt: now, + UpdatedAt: now, + }) + moduleIdx = len(s.modules) - 1 + } else { + m := &s.modules[moduleIdx] + m.Name = moduleName + if a.Description != "" { + m.Description = a.Description + } + m.DefaultVersion = a.DocsVersion + if a.PackageVersion != "" { + m.PackageVersion = a.PackageVersion + } + if a.Edition != "" { + m.Edition = a.Edition + } + if len(a.Keywords) > 0 { + m.Keywords = cloneStrings(a.Keywords) + } + if len(a.Authors) > 0 { + m.Maintainers = cloneStrings(a.Authors) + if m.OwnerGroup == "" { + m.OwnerGroup = a.Authors[0] + } + } + if m.Status == "" { + m.Status = "active" + } + if m.Visibility == "" { + m.Visibility = "internal" + } + // Refresh source metadata from each CI push (repo/branch/commit). + if a.RepoURL != "" { + m.RepoURL = a.RepoURL + } + if a.RepoType != "" { + m.RepoType = a.RepoType + } + if m.SourceType == "" || a.TriggerType == "pipeline" || a.RepoType == "gitlab" { + m.SourceType = deploySourceType(a.RepoType, a.RepoURL) + if m.SourceType == "manual" && a.TriggerType == "pipeline" { + m.SourceType = "gitlab" + } + } + if a.Branch != "" { + m.GitLabBranch = a.Branch + } + if a.CommitSHA != "" { + m.LastSyncedCommit = a.CommitSHA + } + m.LastSyncedAt = now + m.UpdatedAt = now + } + // Preserve existing category assignment and rebuild the display path from + // current categories so the admin UI and module cards show the right labels. + module := &s.modules[moduleIdx] + module.CategoryPath = s.categoryPathLocked(module.CategoryIDs) + versionFound := false + for i := range s.versions { + if strings.EqualFold(s.versions[i].ModuleKey, a.ModuleKey) && s.versions[i].DocsVersion == a.DocsVersion { + v := &s.versions[i] + v.DisplayName = firstNonEmpty(v.DisplayName, a.DocsVersion) + v.IsDefault = true + v.Status = "active" + v.PackageVersion = a.PackageVersion + v.Edition = a.Edition + if v.VersionType == "" { + v.VersionType = "release" + } + if v.SupportStatus == "" { + v.SupportStatus = "supported" + } + versionFound = true + } else if strings.EqualFold(s.versions[i].ModuleKey, a.ModuleKey) { + s.versions[i].IsDefault = false + } + } + if !versionFound { + s.versions = append(s.versions, Version{ + ID: s.nextIDLocked("v"), + ModuleKey: a.ModuleKey, + DocsVersion: a.DocsVersion, + DisplayName: a.DocsVersion, + VersionType: "release", + IsDefault: true, + Status: "active", + PackageVersion: a.PackageVersion, + Channel: firstNonEmpty(module.Channel, "docs"), + Edition: a.Edition, + SupportStatus: "supported", + CreatedAt: now, + }) + } + s.entries = removeEntries(s.entries, a.ModuleKey, a.DocsVersion) + for i, e := range a.Entries { + s.entries = append(s.entries, Entry{ + ID: s.nextIDLocked("e"), + ModuleKey: a.ModuleKey, + DocsVersion: a.DocsVersion, + EntryKey: e.Key, + Title: e.Title, + EntryType: firstNonEmpty(e.Type, "markdown"), + Builder: firstNonEmpty(e.Type, "markdown"), + Source: e.Source, + StorageURI: "memory://" + routeKey(a.ModuleKey, a.DocsVersion, e.Key), + NavURI: "memory://" + routeKey(a.ModuleKey, a.DocsVersion, ""), + IndexStatus: "indexed", + IsPrimary: i == 0, + SortOrder: i + 1, + Status: "active", + CreatedAt: now, + }) + } + s.pages = removePages(s.pages, a.ModuleKey, a.DocsVersion) + // Drop cached embeddings for this module/version so re-published content is + // re-embedded on the next reindex (or lazily during search). + if s.embeddings != nil { + embPrefix := a.ModuleKey + ":" + a.DocsVersion + ":" + for docID := range s.embeddings { + if strings.HasPrefix(docID, embPrefix) { + delete(s.embeddings, docID) + } + } + } + for _, d := range a.Documents { + entryKey := firstNonEmpty(d.EntryKey, entryKeyFromDocID(d.DocID)) + docID := firstNonEmpty(d.DocID, a.ModuleKey+":"+a.DocsVersion+":"+entryKey) + s.pages = append(s.pages, Page{ + ID: s.nextIDLocked("p"), + DocID: docID, + ModuleKey: a.ModuleKey, + ModuleName: moduleName, + DocsVersion: a.DocsVersion, + PackageVersion: firstNonEmpty(d.PackageVersion, a.PackageVersion), + EntryKey: entryKey, + EntryType: firstNonEmpty(d.EntryType, entryTypeForEntry(a.Entries, entryKey)), + Title: firstNonEmpty(d.Title, titleForEntry(a.Entries, entryKey)), + Description: d.Description, + Path: docPagePath(a.ModuleKey, a.DocsVersion, entryKey, firstNonEmpty(d.EntryType, entryTypeForEntry(a.Entries, entryKey)), d.Path), + SourceFile: d.SourceFile, + DocType: firstNonEmpty(d.EntryType, entryTypeForEntry(a.Entries, entryKey)), + Status: firstNonEmpty(d.Status, "active"), + OwnerGroup: module.OwnerGroup, + CategoryIDs: cloneStrings(module.CategoryIDs), + Tags: cloneStrings(coalesceStrings(d.Keywords, a.Keywords)), + ContentText: d.Content, + ContentMD: d.ContentMD, + UpdatedAt: now, + }) + } + if s.navs == nil { + s.navs = map[string][]NavItem{} + } + s.navs[routeKey(a.ModuleKey, a.DocsVersion, "")] = cloneNav(a.Nav) + if s.html == nil { + s.html = map[string]string{} + } + if s.siteFiles == nil { + s.siteFiles = map[string]SiteFile{} + } + for k := range s.html { + prefix := routeKey(a.ModuleKey, a.DocsVersion, "") + ":" + if strings.HasPrefix(k, prefix) { + delete(s.html, k) + } + } + for k := range s.siteFiles { + prefix := routeKey(a.ModuleKey, a.DocsVersion, "") + ":" + if strings.HasPrefix(k, prefix) { + delete(s.siteFiles, k) + } + } + for _, e := range a.Entries { + html := htmlForEntry(a.SiteHTML, e.Key) + if html != "" { + s.html[routeKey(a.ModuleKey, a.DocsVersion, e.Key)] = html + } + } + for name, content := range a.SiteFiles { + entryKey, relName, ok := splitSiteFile(name) + if !ok { + continue + } + s.siteFiles[siteFileKey(a.ModuleKey, a.DocsVersion, entryKey, relName)] = SiteFile{ + Name: relName, Content: append([]byte(nil), content...), ContentType: contentTypeForName(relName, content), + } + } + rel := Release{ + ID: s.nextIDLocked("r"), + ReleaseID: "rel-" + strings.ToLower(a.ModuleKey) + "-" + strings.ToLower(a.DocsVersion) + "-" + strconv.FormatInt(now.UnixNano(), 36), + ModuleKey: a.ModuleKey, + DocsVersion: a.DocsVersion, + Publisher: firstNonEmpty(firstString(a.Authors), "docsctl"), + BuildSystem: "docsctl", + TriggerType: firstNonEmpty(a.TriggerType, "manual"), + SourceIP: a.SourceIP, + ArtifactVersion: now.Format("20060102.150405"), + PackageVersion: a.PackageVersion, + StorageURI: "memory://" + a.ModuleKey + "/" + a.DocsVersion + "/docs-artifact.zip", + Status: "published", + PublishedAt: now, + CreatedAt: now, + } + s.releases = append(s.releases, rel) + return DeployResult{Release: rel, PagesIndexed: len(a.Documents), EntriesIndexed: len(a.Entries), HTMLFiles: len(a.SiteHTML), SiteFiles: len(a.SiteFiles), BytesReceived: a.Bytes}, nil +} + +func deploySourceType(repoType, repoURL string) string { + repoType = strings.ToLower(strings.TrimSpace(repoType)) + if repoType == "gitlab" || strings.Contains(strings.ToLower(repoURL), "gitlab") { + return "gitlab" + } + return firstNonEmpty(repoType, "manual") +} diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go index e7f3f8e..6cd9f5a 100644 --- a/backend/internal/store/memory.go +++ b/backend/internal/store/memory.go @@ -2,11 +2,8 @@ package store import ( "errors" - "mime" - "net/http" "path" "sort" - "strconv" "strings" "sync" "time" @@ -18,11 +15,13 @@ var ( ErrConflict = errors.New("resource already exists") ) -type Store struct { +// MemoryStore is a test fake. Production assembly injects PostgresRepository. +type MemoryStore struct { mu sync.RWMutex user User users []User - groups []Group + apps []ConnectedApp + grants []OAuthGrant teams []Team categories []Category modules []Module @@ -32,7 +31,10 @@ type Store struct { pages []Page searchLogs []SearchLog mcpLogs []MCPLog + feedbacks []DocFeedback pageViews []PageView + favorites []UserFavorite + recentDocs []UserRecentDoc navs map[string][]NavItem html map[string]string siteFiles map[string]SiteFile @@ -42,7 +44,7 @@ type Store struct { } // Settings returns a copy of the persisted platform settings. -func (s *Store) Settings() Settings { +func (s *MemoryStore) Settings() Settings { s.mu.RLock() defer s.mu.RUnlock() return s.settings @@ -50,24 +52,29 @@ func (s *Store) Settings() Settings { // SaveAISettings updates the AI connection. An empty AskAPIKey keeps the // previously stored key (so a masked round-trip from the UI does not wipe it). -func (s *Store) SaveAISettings(ai AISettings) Settings { +func (s *MemoryStore) SaveAISettings(ai AISettings) Settings { s.mu.Lock() defer s.mu.Unlock() if strings.TrimSpace(ai.AskAPIKey) == "" { ai.AskAPIKey = s.settings.AI.AskAPIKey } + if strings.TrimSpace(ai.EmbeddingAPIKey) == "" { + ai.EmbeddingAPIKey = s.settings.AI.EmbeddingAPIKey + } + if strings.TrimSpace(ai.RerankAPIKey) == "" { + ai.RerankAPIKey = s.settings.AI.RerankAPIKey + } ai.UpdatedAt = time.Now().UTC() s.settings.AI = ai return s.settings } -// New returns a completely empty store. This is the default for a fresh start -// ("从0开始"). No demo users, categories, modules or documents are created. -// Use NewSeeded() only for tests or when you explicitly want demo data. -func New() *Store { - return &Store{ +// NewTestStore returns an empty deterministic fake for unit tests. +func NewTestStore() *MemoryStore { + return &MemoryStore{ users: []User{}, - groups: []Group{}, + apps: []ConnectedApp{builtinCodexOAuthApp(time.Now().UTC())}, + grants: []OAuthGrant{}, teams: []Team{}, categories: []Category{}, modules: []Module{}, @@ -77,7 +84,10 @@ func New() *Store { pages: []Page{}, searchLogs: []SearchLog{}, mcpLogs: []MCPLog{}, + feedbacks: []DocFeedback{}, pageViews: []PageView{}, + favorites: []UserFavorite{}, + recentDocs: []UserRecentDoc{}, navs: map[string][]NavItem{}, html: map[string]string{}, siteFiles: map[string]SiteFile{}, @@ -85,32 +95,29 @@ func New() *Store { } } -func NewSeeded() *Store { +func NewSeededTestStore() *MemoryStore { now := time.Now().UTC() - s := &Store{ - user: User{ID: "u-dev", Username: "dev", DisplayName: "研发用户", Email: "dev@example.com", Department: "工程化", Groups: []string{"cad-team", "engineering"}, Roles: []string{"admin"}}, + s := &MemoryStore{ + user: User{ID: "u-dev", Username: "dev", DisplayName: "研发用户", Email: "dev@example.com", Department: "工程化", Roles: []string{"admin"}}, users: []User{ - {ID: "u-dev", Username: "dev", DisplayName: "研发用户", Email: "dev@example.com", Department: "工程化", Groups: []string{"cad-team", "engineering"}, Roles: []string{"admin"}, Source: "seed", Status: "active", CreatedAt: now, UpdatedAt: now}, - {ID: "u-alice", Username: "alice", DisplayName: "Alice", Email: "alice@example.com", Department: "CAD", Groups: []string{"cad-team"}, Roles: []string{"maintainer"}, Source: "seed", Status: "active", CreatedAt: now, UpdatedAt: now}, - {ID: "u-bob", Username: "bob", DisplayName: "Bob", Email: "bob@example.com", Department: "前端", Groups: []string{"frontend-platform"}, Roles: []string{"viewer"}, Source: "seed", Status: "active", CreatedAt: now, UpdatedAt: now}, - }, - groups: []Group{ - {ID: "g-admin", GroupKey: "admin", Name: "平台管理员", Source: "seed", CreatedAt: now, UpdatedAt: now}, - {ID: "g-engineering", GroupKey: "engineering", Name: "工程化", Source: "seed", CreatedAt: now, UpdatedAt: now}, - {ID: "g-cad", GroupKey: "cad-team", Name: "CAD 团队", Source: "seed", CreatedAt: now, UpdatedAt: now}, - {ID: "g-frontend", GroupKey: "frontend-platform", Name: "前端平台", Source: "seed", CreatedAt: now, UpdatedAt: now}, - {ID: "g-standards", GroupKey: "standards", Name: "研发规范", Source: "seed", CreatedAt: now, UpdatedAt: now}, + {ID: "u-dev", Username: "dev", DisplayName: "研发用户", Email: "dev@example.com", Department: "工程化", Roles: []string{"admin"}, Source: "seed", Status: "active", CreatedAt: now, UpdatedAt: now}, + {ID: "u-alice", Username: "alice", DisplayName: "Alice", Email: "alice@example.com", Department: "CAD", Roles: []string{"maintainer"}, Source: "seed", Status: "active", CreatedAt: now, UpdatedAt: now}, + {ID: "u-bob", Username: "bob", DisplayName: "Bob", Email: "bob@example.com", Department: "前端", Roles: []string{"viewer"}, Source: "seed", Status: "active", CreatedAt: now, UpdatedAt: now}, }, + apps: []ConnectedApp{builtinCodexOAuthApp(now)}, + grants: []OAuthGrant{}, teams: []Team{ - {ID: "t-cad", Key: "cad-team", Name: "CAD 团队", Description: "CAD 内核与插件文档维护团队", Leader: "alice", Members: []string{"alice", "bob"}, CreatedAt: now, UpdatedAt: now}, - {ID: "t-eng", Key: "engineering", Name: "工程化团队", Description: "工程化平台与 CBB 规范维护", Leader: "dev", Members: []string{"dev", "alice"}, CreatedAt: now, UpdatedAt: now}, - {ID: "t-fe", Key: "frontend-platform", Name: "前端平台团队", Description: "前端文档框架与组件规范", Leader: "bob", Members: []string{"bob", "dev"}, CreatedAt: now, UpdatedAt: now}, - {ID: "t-std", Key: "standards", Name: "研发规范团队", Description: "通用研发规范、流程与工具规范维护团队 (参考 GitBook/Mintlify 层级领域)", Leader: "alice", Members: []string{"alice", "dev"}, CreatedAt: now, UpdatedAt: now}, + {ID: "t-cad", Key: "cad-team", Name: "CAD 团队", Description: "CAD 内核与插件文档维护团队", Leaders: []string{"alice"}, Members: []string{"alice", "bob"}, CreatedAt: now, UpdatedAt: now}, + {ID: "t-eng", Key: "engineering", Name: "工程化团队", Description: "工程化平台与 CBB 规范维护", Leaders: []string{"dev"}, Members: []string{"dev", "alice"}, CreatedAt: now, UpdatedAt: now}, + {ID: "t-fe", Key: "frontend-platform", Name: "前端平台团队", Description: "前端文档框架与组件规范", Leaders: []string{"bob"}, Members: []string{"bob", "dev"}, CreatedAt: now, UpdatedAt: now}, + {ID: "t-std", Key: "standards", Name: "研发规范团队", Description: "通用研发规范、流程与工具规范维护团队 (参考 GitBook/Mintlify 层级领域)", Leaders: []string{"alice"}, Members: []string{"alice", "dev"}, CreatedAt: now, UpdatedAt: now}, }, navs: map[string][]NavItem{}, html: map[string]string{}, siteFiles: map[string]SiteFile{}, embeddings: map[string][]float32{}, + favorites: []UserFavorite{}, + recentDocs: []UserRecentDoc{}, categories: []Category{ {ID: "engineering", Key: "engineering", Name: "工程化", Description: "研发效能、构建、CI/CD 与质量平台", Icon: "wrench", SortOrder: 10, Status: "active", ResponsibleTeam: "engineering"}, {ID: "engineering.cbb", ParentID: "engineering", Key: "engineering.cbb", Name: "CBB", Description: "CBB 构建与模块治理", Icon: "package", SortOrder: 11, Status: "active", ResponsibleTeam: "engineering"}, @@ -146,17 +153,17 @@ func NewSeeded() *Store { {ID: "e-fumadocs-guide", ModuleKey: "FumadocsKit", DocsVersion: "latest", EntryKey: "guide", Title: "Fumadocs 文档站接入", EntryType: "fumadocs", Builder: "fumadocs", Source: "content/docs", StorageURI: "minio://modex/FumadocsKit/latest/site/guide/index.html", NavURI: "minio://modex/FumadocsKit/latest/nav.json", IndexStatus: "indexed", IsPrimary: true, SortOrder: 1, Status: "active", CreatedAt: now}, } s.pages = []Page{ - {ID: "p-demo-guide", DocID: "DemoModule:latest:guide", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "guide", EntryType: "markdown", Title: "模块落地指导", Description: "面向业务开发人员的模块接入、部署、接口和异常处理说明。", Path: "/docs/DemoModule/latest/guide", SourceFile: "docs/integration-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad"}, ContentText: "模块落地指导说明如何接入 DemoModule,包括接口设计、部署运行、异常处理、风险影响面和发布检查。", UpdatedAt: now}, - {ID: "p-demo-maintenance", DocID: "DemoModule:latest:maintenance", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "maintenance", EntryType: "markdown", Title: "模块维护说明", Description: "面向维护开发人员的架构、设计、流程和维护说明。", Path: "/docs/DemoModule/latest/maintenance", SourceFile: "docs/maintenance-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad", "architecture"}, ContentText: "模块维护说明包含总体架构、设计原则、模块结构、核心流程、时序逻辑、前后端设计和质量可维护性要求。", UpdatedAt: now}, - {ID: "p-cbb-build", DocID: "CBB:latest:build-cache", ModuleKey: "CBB", ModuleName: "CBB 文档", DocsVersion: "latest", PackageVersion: "2.8.0", EntryKey: "build-cache", EntryType: "markdown", Title: "构建缓存清理", Description: "CBB 构建缓存清理和常见构建问题排查。", Path: "/docs/CBB/latest/build-cache", SourceFile: "docs/build-cache.md", DocType: "markdown", Status: "active", OwnerGroup: "engineering", CategoryIDs: []string{"engineering", "engineering.cbb"}, Tags: []string{"cbb", "ci", "build"}, ContentText: "构建缓存清理用于解决依赖缓存、编译缓存和 CI 工作区残留导致的构建异常。可以重新拉取依赖并清理本地缓存。", UpdatedAt: now}, + {ID: "p-demo-guide", DocID: "DemoModule:latest:guide", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "guide", EntryType: "markdown", Title: "模块落地指导", Description: "面向业务开发人员的模块接入、部署、接口和异常处理说明。", Path: "/docs/DemoModule/latest/guide", SourceFile: "docs/integration-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad"}, ContentText: "模块落地指导说明如何接入 DemoModule,包括接口设计、部署运行、异常处理、风险影响面和发布检查。", ContentMD: seedDemoGuideMD, UpdatedAt: now}, + {ID: "p-demo-maintenance", DocID: "DemoModule:latest:maintenance", ModuleKey: "DemoModule", ModuleName: "DemoModule", DocsVersion: "latest", PackageVersion: "1.2.3", EntryKey: "maintenance", EntryType: "markdown", Title: "模块维护说明", Description: "面向维护开发人员的架构、设计、流程和维护说明。", Path: "/docs/DemoModule/latest/maintenance", SourceFile: "docs/maintenance-guide.md", DocType: "markdown", Status: "active", OwnerGroup: "cad-team", CategoryIDs: []string{"cad", "cad.demo"}, Tags: []string{"demo", "cad", "architecture"}, ContentText: "模块维护说明包含总体架构、设计原则、模块结构、核心流程、时序逻辑、前后端设计和质量可维护性要求。", ContentMD: seedDemoMaintenanceMD, UpdatedAt: now}, + {ID: "p-cbb-build", DocID: "CBB:latest:build-cache", ModuleKey: "CBB", ModuleName: "CBB 文档", DocsVersion: "latest", PackageVersion: "2.8.0", EntryKey: "build-cache", EntryType: "markdown", Title: "构建缓存清理", Description: "CBB 构建缓存清理和常见构建问题排查。", Path: "/docs/CBB/latest/build-cache", SourceFile: "docs/build-cache.md", DocType: "markdown", Status: "active", OwnerGroup: "engineering", CategoryIDs: []string{"engineering", "engineering.cbb"}, Tags: []string{"cbb", "ci", "build"}, ContentText: "构建缓存清理用于解决依赖缓存、编译缓存和 CI 工作区残留导致的构建异常。可以重新拉取依赖并清理本地缓存。", ContentMD: seedCBBBuildCacheMD, UpdatedAt: now}, {ID: "p-vuepress-guide", DocID: "VuePressGuide:latest:guide", ModuleKey: "VuePressGuide", ModuleName: "VuePressGuide", DocsVersion: "latest", PackageVersion: "0.4.0", EntryKey: "guide", EntryType: "vuepress", Title: "VuePress 文档站接入", Description: "VuePress 文档通过 docsctl 执行构建命令并复制 dist 输出目录。", Path: "/docs/VuePressGuide/latest/guide", SourceFile: "docs/README.md", DocType: "vuepress", Status: "active", OwnerGroup: "frontend-platform", CategoryIDs: []string{"frontend", "frontend.docs"}, Tags: []string{"vuepress", "frontend", "markdown"}, ContentText: "VuePress 文档站接入说明如何声明 docs.yaml、执行 npm run docs:build、复制 docs/.vuepress/dist 并生成标准文档包。", UpdatedAt: now.Add(-2 * time.Hour)}, {ID: "p-fumadocs-guide", DocID: "FumadocsKit:latest:guide", ModuleKey: "FumadocsKit", ModuleName: "FumadocsKit", DocsVersion: "latest", PackageVersion: "0.2.0", EntryKey: "guide", EntryType: "fumadocs", Title: "Fumadocs 文档站接入", Description: "Fumadocs 文档通过 Next.js 与 MDX 构建,适合现代前端文档站。", Path: "/docs/FumadocsKit/latest/guide", SourceFile: "content/docs/index.mdx", DocType: "fumadocs", Status: "active", OwnerGroup: "frontend-platform", CategoryIDs: []string{"frontend", "frontend.docs"}, Tags: []string{"fumadocs", "nextjs", "mdx", "frontend"}, ContentText: "Fumadocs 文档站接入说明如何维护 MDX 内容、运行 Next.js 构建、输出静态站点并交给 Modex 进行搜索和 MCP 读取。", UpdatedAt: now.Add(-90 * time.Minute)}, } - s.releases = []Release{{ID: "r-demo-1", ReleaseID: "rel-demo-latest-001", ModuleKey: "DemoModule", DocsVersion: "latest", CommitSHA: "d34db33f", Branch: "main", Publisher: "alice", PipelineURL: "https://gitlab.example.com/cad/demo-module/-/pipelines/1", BuildSystem: "gitlab", BuildID: "1", ArtifactVersion: "20260609.1", PackageVersion: "1.2.3", StorageURI: "minio://modex/DemoModule/latest/docs-artifact.zip", Status: "published", PublishedAt: now, CreatedAt: now}} + s.releases = []Release{{ID: "r-demo-1", ReleaseID: "rel-demo-latest-001", ModuleKey: "DemoModule", DocsVersion: "latest", CommitSHA: "d34db33f", Branch: "main", Publisher: "alice", PipelineURL: "https://gitlab.example.com/cad/demo-module/-/pipelines/1", BuildSystem: "gitlab", BuildID: "1", TriggerType: "pipeline", SourceIP: "192.0.2.10", ArtifactVersion: "20260609.1", PackageVersion: "1.2.3", StorageURI: "minio://modex/DemoModule/latest/docs-artifact.zip", Status: "published", PublishedAt: now, CreatedAt: now}} return s } -func (s *Store) CurrentUser() User { +func (s *MemoryStore) CurrentUser() User { s.mu.RLock() defer s.mu.RUnlock() return s.user @@ -164,7 +171,7 @@ func (s *Store) CurrentUser() User { // Users returns all users, optionally filtered by a case-insensitive keyword // matching username, display name, email, or department. -func (s *Store) Users(keyword string) []User { +func (s *MemoryStore) Users(keyword string) []User { s.mu.RLock() defer s.mu.RUnlock() q := strings.ToLower(strings.TrimSpace(keyword)) @@ -179,9 +186,15 @@ func (s *Store) Users(keyword string) []User { return out } -func (s *Store) UserByID(id string) (User, error) { +func (s *MemoryStore) UserByID(id string) (User, error) { s.mu.RLock() defer s.mu.RUnlock() + return s.userByIDLocked(id) +} + +// userByIDLocked looks up a user without acquiring the lock; callers must hold +// s.mu (read or write). +func (s *MemoryStore) userByIDLocked(id string) (User, error) { for _, u := range s.users { if u.ID == id { return u, nil @@ -190,7 +203,31 @@ func (s *Store) UserByID(id string) (User, error) { return User{}, ErrNotFound } -func (s *Store) CreateUser(u User) (User, error) { +func (s *MemoryStore) UserByMCPToken(token string) (User, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, u := range s.users { + if u.MCPToken == token { + return u, nil + } + } + return User{}, ErrNotFound +} + +func (s *MemoryStore) SetUserMCPToken(id string, token string) (User, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.users { + if s.users[i].ID == id { + s.users[i].MCPToken = token + s.users[i].UpdatedAt = time.Now().UTC() + return s.users[i], nil + } + } + return User{}, ErrNotFound +} + +func (s *MemoryStore) CreateUser(u User) (User, error) { s.mu.Lock() defer s.mu.Unlock() if strings.TrimSpace(u.Username) == "" { @@ -218,11 +255,10 @@ func (s *Store) CreateUser(u User) (User, error) { u.UpdatedAt = now // SuperAdmin can be set at creation time (usually only by another super admin via the admin UI). s.users = append(s.users, u) - s.ensureGroupsLocked(u.Groups) return u, nil } -func (s *Store) UpdateUser(id string, patch User) (User, error) { +func (s *MemoryStore) UpdateUser(id string, patch User) (User, error) { s.mu.Lock() defer s.mu.Unlock() for i := range s.users { @@ -239,10 +275,6 @@ func (s *Store) UpdateUser(id string, patch User) (User, error) { if patch.Department != "" { u.Department = patch.Department } - if patch.Groups != nil { - u.Groups = patch.Groups - s.ensureGroupsLocked(patch.Groups) - } if patch.Roles != nil { u.Roles = patch.Roles } @@ -260,7 +292,7 @@ func (s *Store) UpdateUser(id string, patch User) (User, error) { return User{}, ErrNotFound } -func (s *Store) DeleteUser(id string) error { +func (s *MemoryStore) DeleteUser(id string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.users { @@ -273,13 +305,12 @@ func (s *Store) DeleteUser(id string) error { } // UpsertUser syncs an identity from the SSO provider into the user directory on -// login, recording groups and refreshing the last-login timestamp. Manual role -// assignments are preserved unless the provider supplies roles. -func (s *Store) UpsertUser(u User) User { +// login, refreshing the last-login timestamp. Manual role assignments are +// preserved unless the provider supplies roles. +func (s *MemoryStore) UpsertUser(u User) User { s.mu.Lock() defer s.mu.Unlock() now := time.Now().UTC() - s.ensureGroupsLocked(u.Groups) for i := range s.users { existing := &s.users[i] if existing.ID == u.ID || (u.Username != "" && strings.EqualFold(existing.Username, u.Username)) { @@ -295,9 +326,6 @@ func (s *Store) UpsertUser(u User) User { if u.Avatar != "" { existing.Avatar = u.Avatar } - if len(u.Groups) > 0 { - existing.Groups = u.Groups - } if len(u.Roles) > 0 { existing.Roles = u.Roles } @@ -322,79 +350,8 @@ func (s *Store) UpsertUser(u User) User { return u } -func (s *Store) Groups() []Group { - s.mu.RLock() - defer s.mu.RUnlock() - out := append([]Group{}, s.groups...) // non-nil - sort.Slice(out, func(i, j int) bool { return out[i].GroupKey < out[j].GroupKey }) - return out -} - -func (s *Store) CreateGroup(g Group) (Group, error) { - s.mu.Lock() - defer s.mu.Unlock() - if strings.TrimSpace(g.GroupKey) == "" { - return Group{}, ErrInvalid - } - for _, existing := range s.groups { - if strings.EqualFold(existing.GroupKey, g.GroupKey) { - return Group{}, ErrConflict - } - } - if g.ID == "" { - g.ID = s.nextIDLocked("g") - } - if g.Name == "" { - g.Name = g.GroupKey - } - if g.Source == "" { - g.Source = "manual" - } - now := time.Now().UTC() - g.CreatedAt = now - g.UpdatedAt = now - s.groups = append(s.groups, g) - return g, nil -} - -// ensureGroupsLocked auto-registers any group keys referenced by a user so the -// group directory stays consistent. Caller must hold the write lock. -func (s *Store) ensureGroupsLocked(keys []string) { - for _, key := range keys { - key = strings.TrimSpace(key) - if key == "" { - continue - } - found := false - for _, g := range s.groups { - if strings.EqualFold(g.GroupKey, key) { - found = true - break - } - } - if !found { - now := time.Now().UTC() - s.groups = append(s.groups, Group{ID: s.nextIDLocked("g"), GroupKey: key, Name: key, Source: "auto", CreatedAt: now, UpdatedAt: now}) - } - } -} - -// ensureTeamGroupLocked ensures a team's key exists as a group (for owner_group compat). -func (s *Store) ensureTeamGroupLocked(key string) { - if strings.TrimSpace(key) == "" { - return - } - for _, g := range s.groups { - if strings.EqualFold(g.GroupKey, key) { - return - } - } - now := time.Now().UTC() - s.groups = append(s.groups, Group{ID: s.nextIDLocked("g"), GroupKey: key, Name: key, Source: "team", CreatedAt: now, UpdatedAt: now}) -} - // Teams returns all teams sorted by key. -func (s *Store) Teams() []Team { +func (s *MemoryStore) Teams() []Team { s.mu.RLock() defer s.mu.RUnlock() out := append([]Team{}, s.teams...) // always non-nil, even if s.teams was nil @@ -403,7 +360,7 @@ func (s *Store) Teams() []Team { } // Team returns a team by key (preferred) or ID. -func (s *Store) Team(key string) (Team, error) { +func (s *MemoryStore) Team(key string) (Team, error) { s.mu.RLock() defer s.mu.RUnlock() k := strings.ToLower(strings.TrimSpace(key)) @@ -417,7 +374,7 @@ func (s *Store) Team(key string) (Team, error) { // CreateTeam creates a maintenance team. Key is required and unique. Leader is // auto-added to members if provided and not present. -func (s *Store) CreateTeam(t Team) (Team, error) { +func (s *MemoryStore) CreateTeam(t Team) (Team, error) { s.mu.Lock() defer s.mu.Unlock() if strings.TrimSpace(t.Key) == "" { @@ -437,19 +394,20 @@ func (s *Store) CreateTeam(t Team) (Team, error) { if t.Name == "" { t.Name = t.Key } - if t.Leader != "" && !contains(t.Members, t.Leader) { - t.Members = append([]string{t.Leader}, t.Members...) + for _, l := range t.Leaders { + if l != "" && !contains(t.Members, l) { + t.Members = append(t.Members, l) + } } now := time.Now().UTC() t.CreatedAt = now t.UpdatedAt = now s.teams = append(s.teams, t) - s.ensureTeamGroupLocked(t.Key) return t, nil } // UpdateTeam patches name/desc/leader/members. -func (s *Store) UpdateTeam(key string, patch Team) (Team, error) { +func (s *MemoryStore) UpdateTeam(key string, patch Team) (Team, error) { s.mu.Lock() defer s.mu.Unlock() for i := range s.teams { @@ -463,27 +421,26 @@ func (s *Store) UpdateTeam(key string, patch Team) (Team, error) { if patch.Description != "" { tm.Description = patch.Description } - if patch.Leader != "" { - tm.Leader = patch.Leader - if !contains(tm.Members, patch.Leader) { - tm.Members = append(tm.Members, patch.Leader) - } + if patch.Leaders != nil { + tm.Leaders = cloneStrings(patch.Leaders) } if patch.Members != nil { tm.Members = cloneStrings(patch.Members) - if tm.Leader != "" && !contains(tm.Members, tm.Leader) { - tm.Members = append([]string{tm.Leader}, tm.Members...) + } + // Every leader must also be a member. + for _, l := range tm.Leaders { + if l != "" && !contains(tm.Members, l) { + tm.Members = append(tm.Members, l) } } tm.UpdatedAt = time.Now().UTC() - s.ensureTeamGroupLocked(tm.Key) return *tm, nil } return Team{}, ErrNotFound } // DeleteTeam removes a team (no cascade; modules/categories keep the key as string ref). -func (s *Store) DeleteTeam(key string) error { +func (s *MemoryStore) DeleteTeam(key string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.teams { @@ -496,7 +453,7 @@ func (s *Store) DeleteTeam(key string) error { } // AddTeamMember adds a username (or id) to the team's members. Idempotent. -func (s *Store) AddTeamMember(key, member string) (Team, error) { +func (s *MemoryStore) AddTeamMember(key, member string) (Team, error) { s.mu.Lock() defer s.mu.Unlock() member = strings.TrimSpace(member) @@ -518,7 +475,7 @@ func (s *Store) AddTeamMember(key, member string) (Team, error) { } // RemoveTeamMember removes a member. Leader is not auto-removed (call SetTeamLeader first if needed). -func (s *Store) RemoveTeamMember(key, member string) (Team, error) { +func (s *MemoryStore) RemoveTeamMember(key, member string) (Team, error) { s.mu.Lock() defer s.mu.Unlock() member = strings.TrimSpace(member) @@ -541,7 +498,7 @@ func (s *Store) RemoveTeamMember(key, member string) (Team, error) { } // SetTeamLeader sets leader and ensures they are in members. -func (s *Store) SetTeamLeader(key, leader string) (Team, error) { +func (s *MemoryStore) SetTeamLeader(key, leader string) (Team, error) { s.mu.Lock() defer s.mu.Unlock() leader = strings.TrimSpace(leader) @@ -553,7 +510,9 @@ func (s *Store) SetTeamLeader(key, leader string) (Team, error) { continue } tm := &s.teams[i] - tm.Leader = leader + if !contains(tm.Leaders, leader) { + tm.Leaders = append(tm.Leaders, leader) + } if !contains(tm.Members, leader) { tm.Members = append(tm.Members, leader) } @@ -564,7 +523,7 @@ func (s *Store) SetTeamLeader(key, leader string) (Team, error) { } // TeamMembers returns the member list for a team (for permission/display). -func (s *Store) TeamMembers(key string) []string { +func (s *MemoryStore) TeamMembers(key string) []string { s.mu.RLock() defer s.mu.RUnlock() for _, t := range s.teams { @@ -575,8 +534,36 @@ func (s *Store) TeamMembers(key string) []string { return nil } +// TeamKeysForUser returns the keys of every team the user belongs to, counting +// both the designated leader and listed members. +func (s *MemoryStore) TeamKeysForUser(u User) []string { + s.mu.RLock() + defer s.mu.RUnlock() + var keys []string + for _, t := range s.teams { + // Leaders are always also members, so checking Members covers both. + for _, m := range t.Members { + if strings.EqualFold(m, u.Username) || strings.EqualFold(m, u.ID) { + keys = append(keys, t.Key) + break + } + } + } + return keys +} + +// AllCategories returns a flat copy of every category (with ParentID links). +func (s *MemoryStore) AllCategories() []Category { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Category, len(s.categories)) + copy(out, s.categories) + s.attachResponsibleTeamInfoLocked(out) + return out +} + // CategoryName returns the display name for a category id (or the id if unknown). -func (s *Store) CategoryName(id string) string { +func (s *MemoryStore) CategoryName(id string) string { s.mu.RLock() defer s.mu.RUnlock() for _, c := range s.categories { @@ -587,8 +574,26 @@ func (s *Store) CategoryName(id string) string { return id } +func (s *MemoryStore) categoryPathLocked(ids []string) string { + if len(ids) == 0 { + return "" + } + var parts []string + for _, id := range ids { + name := id + for _, c := range s.categories { + if c.ID == id { + name = c.Name + break + } + } + parts = append(parts, name) + } + return strings.Join(parts, " / ") +} + // EntryModuleKey returns the module key that owns an entry, for permission checks. -func (s *Store) EntryModuleKey(entryID string) (string, bool) { +func (s *MemoryStore) EntryModuleKey(entryID string) (string, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, e := range s.entries { @@ -599,13 +604,14 @@ func (s *Store) EntryModuleKey(entryID string) (string, bool) { return "", false } -func (s *Store) CategoryTree() []Category { +func (s *MemoryStore) CategoryTree() []Category { s.mu.RLock() defer s.mu.RUnlock() byParent := map[string][]Category{} for _, c := range s.categories { cp := c cp.Children = nil + cp.ResponsibleTeamInfo = s.responsibleTeamInfoLocked(cp.ResponsibleTeam) byParent[c.ParentID] = append(byParent[c.ParentID], cp) } var attach func(parent string) []Category @@ -624,10 +630,34 @@ func (s *Store) CategoryTree() []Category { return res } -func (s *Store) Modules(categoryID, keyword string) []Module { +func (s *MemoryStore) attachResponsibleTeamInfoLocked(categories []Category) { + for i := range categories { + categories[i].ResponsibleTeamInfo = s.responsibleTeamInfoLocked(categories[i].ResponsibleTeam) + } +} + +func (s *MemoryStore) responsibleTeamInfoLocked(teamKey string) *TeamSummary { + if strings.TrimSpace(teamKey) == "" { + return nil + } + for _, t := range s.teams { + if strings.EqualFold(t.Key, teamKey) { + return &TeamSummary{ + Key: t.Key, + Name: t.Name, + Description: t.Description, + Leaders: cloneStrings(t.Leaders), + Members: cloneStrings(t.Members), + } + } + } + return nil +} + +func (s *MemoryStore) Modules(categoryID, keyword string) []Module { s.mu.RLock() defer s.mu.RUnlock() - var out []Module + out := make([]Module, 0) q := strings.ToLower(keyword) for _, m := range s.modules { if categoryID != "" && !contains(m.CategoryIDs, categoryID) { @@ -638,33 +668,53 @@ func (s *Store) Modules(categoryID, keyword string) []Module { } cp := m cp.AvailableVers = s.versionsForLocked(m.ModuleKey) + cp.DeployTokenSet = cp.DeployToken != "" out = append(out, cp) } return out } -func (s *Store) Module(moduleKey string) (Module, error) { +func (s *MemoryStore) Module(moduleKey string) (Module, error) { s.mu.RLock() defer s.mu.RUnlock() for _, m := range s.modules { if strings.EqualFold(m.ModuleKey, moduleKey) { m.AvailableVers = s.versionsForLocked(m.ModuleKey) + m.DeployTokenSet = m.DeployToken != "" return m, nil } } return Module{}, ErrNotFound } -func (s *Store) Versions(moduleKey string) []Version { +func (s *MemoryStore) ModuleByDeployToken(token string) (Module, error) { + token = strings.TrimSpace(token) + if token == "" { + return Module{}, ErrNotFound + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, m := range s.modules { + if m.DeployToken == token { + cp := m + cp.DeployTokenSet = cp.DeployToken != "" + cp.AvailableVers = s.versionsForLocked(m.ModuleKey) + return cp, nil + } + } + return Module{}, ErrNotFound +} + +func (s *MemoryStore) Versions(moduleKey string) []Version { s.mu.RLock() defer s.mu.RUnlock() return s.versionsForLocked(moduleKey) } -func (s *Store) Entries(moduleKey, docsVersion string) []Entry { +func (s *MemoryStore) Entries(moduleKey, docsVersion string) []Entry { s.mu.RLock() defer s.mu.RUnlock() - var out []Entry + out := make([]Entry, 0) for _, e := range s.entries { if strings.EqualFold(e.ModuleKey, moduleKey) && e.DocsVersion == docsVersion { out = append(out, e) @@ -674,7 +724,7 @@ func (s *Store) Entries(moduleKey, docsVersion string) []Entry { return out } -func (s *Store) Releases() []Release { +func (s *MemoryStore) Releases() []Release { s.mu.RLock() defer s.mu.RUnlock() out := append([]Release(nil), s.releases...) @@ -682,7 +732,7 @@ func (s *Store) Releases() []Release { return out } -func (s *Store) Page(docID string) (Page, error) { +func (s *MemoryStore) Page(docID string) (Page, error) { s.mu.RLock() defer s.mu.RUnlock() for _, p := range s.pages { @@ -693,7 +743,7 @@ func (s *Store) Page(docID string) (Page, error) { return Page{}, ErrNotFound } -func (s *Store) PageByRoute(moduleKey, docsVersion, entryKey string) (Page, error) { +func (s *MemoryStore) PageByRoute(moduleKey, docsVersion, entryKey string) (Page, error) { s.mu.RLock() defer s.mu.RUnlock() for _, p := range s.pages { @@ -704,19 +754,19 @@ func (s *Store) PageByRoute(moduleKey, docsVersion, entryKey string) (Page, erro return Page{}, ErrNotFound } -func (s *Store) Nav(moduleKey, docsVersion string) []NavItem { +func (s *MemoryStore) Nav(moduleKey, docsVersion string) []NavItem { s.mu.RLock() defer s.mu.RUnlock() return cloneNav(s.navs[routeKey(moduleKey, docsVersion, "")]) } -func (s *Store) PageHTML(moduleKey, docsVersion, entryKey string) string { +func (s *MemoryStore) PageHTML(moduleKey, docsVersion, entryKey string) string { s.mu.RLock() defer s.mu.RUnlock() return s.html[routeKey(moduleKey, docsVersion, entryKey)] } -func (s *Store) SiteFile(moduleKey, docsVersion, entryKey, name string) (SiteFile, error) { +func (s *MemoryStore) SiteFile(moduleKey, docsVersion, entryKey, name string) (SiteFile, error) { s.mu.RLock() defer s.mu.RUnlock() if name == "" { @@ -730,14 +780,14 @@ func (s *Store) SiteFile(moduleKey, docsVersion, entryKey, name string) (SiteFil return f, nil } -func (s *Store) Pages() []Page { +func (s *MemoryStore) Pages() []Page { s.mu.RLock() defer s.mu.RUnlock() return append([]Page(nil), s.pages...) } // Embedding returns the cached embedding vector for a document, if present. -func (s *Store) Embedding(docID string) ([]float32, bool) { +func (s *MemoryStore) Embedding(docID string) ([]float32, bool) { s.mu.RLock() defer s.mu.RUnlock() v, ok := s.embeddings[docID] @@ -748,7 +798,7 @@ func (s *Store) Embedding(docID string) ([]float32, bool) { } // SetEmbedding stores (or replaces) the embedding vector for a document. -func (s *Store) SetEmbedding(docID string, vec []float32) { +func (s *MemoryStore) SetEmbedding(docID string, vec []float32) { s.mu.Lock() defer s.mu.Unlock() if s.embeddings == nil { @@ -758,1116 +808,57 @@ func (s *Store) SetEmbedding(docID string, vec []float32) { } // EmbeddingCount reports how many documents currently have cached embeddings. -func (s *Store) EmbeddingCount() int { +func (s *MemoryStore) EmbeddingCount() int { s.mu.RLock() defer s.mu.RUnlock() return len(s.embeddings) } // ClearEmbeddings drops the entire embedding cache (used before a full reindex). -func (s *Store) ClearEmbeddings() { +func (s *MemoryStore) ClearEmbeddings() { s.mu.Lock() defer s.mu.Unlock() s.embeddings = map[string][]float32{} } -func (s *Store) IngestArtifact(a DeployArtifact) (DeployResult, error) { - if strings.TrimSpace(a.ModuleKey) == "" || strings.TrimSpace(a.DocsVersion) == "" || len(a.Entries) == 0 || len(a.Documents) == 0 { - return DeployResult{}, ErrInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now().UTC() - moduleName := firstNonEmpty(a.ModuleName, a.ModuleKey) - moduleIdx, err := s.moduleIndexLocked(a.ModuleKey) - if err != nil { - s.modules = append(s.modules, Module{ - ID: s.nextIDLocked("m"), - ModuleKey: a.ModuleKey, - Name: moduleName, - Description: a.Description, - OwnerGroup: firstNonEmpty(firstString(a.Authors), "docs"), - RepoType: firstNonEmpty(a.RepoType, "git"), - RepoURL: a.RepoURL, - SourceType: "gitlab", - GitLabBranch: a.Branch, - DefaultVersion: a.DocsVersion, - Visibility: "internal", - Status: "active", - PackageName: a.ModuleKey, - PackageVersion: a.PackageVersion, - Channel: "docs", - Edition: a.Edition, - Keywords: cloneStrings(a.Keywords), - Maintainers: cloneStrings(a.Authors), - LastSyncedCommit: a.CommitSHA, - LastSyncedAt: now, - UpdatedAt: now, - }) - moduleIdx = len(s.modules) - 1 - } else { - m := &s.modules[moduleIdx] - m.Name = moduleName - if a.Description != "" { - m.Description = a.Description - } - m.DefaultVersion = a.DocsVersion - if a.PackageVersion != "" { - m.PackageVersion = a.PackageVersion - } - if a.Edition != "" { - m.Edition = a.Edition - } - if len(a.Keywords) > 0 { - m.Keywords = cloneStrings(a.Keywords) - } - if len(a.Authors) > 0 { - m.Maintainers = cloneStrings(a.Authors) - if m.OwnerGroup == "" { - m.OwnerGroup = a.Authors[0] - } - } - if m.Status == "" { - m.Status = "active" - } - if m.Visibility == "" { - m.Visibility = "internal" - } - // Refresh source metadata from each CI push (repo/branch/commit). - if a.RepoURL != "" { - m.RepoURL = a.RepoURL - } - if a.RepoType != "" { - m.RepoType = a.RepoType - } - if a.Branch != "" { - m.GitLabBranch = a.Branch - } - if a.CommitSHA != "" { - m.LastSyncedCommit = a.CommitSHA - } - m.LastSyncedAt = now - m.UpdatedAt = now - } - module := s.modules[moduleIdx] - versionFound := false - for i := range s.versions { - if strings.EqualFold(s.versions[i].ModuleKey, a.ModuleKey) && s.versions[i].DocsVersion == a.DocsVersion { - v := &s.versions[i] - v.DisplayName = firstNonEmpty(v.DisplayName, a.DocsVersion) - v.IsDefault = true - v.Status = "active" - v.PackageVersion = a.PackageVersion - v.Edition = a.Edition - if v.VersionType == "" { - v.VersionType = "release" - } - if v.SupportStatus == "" { - v.SupportStatus = "supported" - } - versionFound = true - } else if strings.EqualFold(s.versions[i].ModuleKey, a.ModuleKey) { - s.versions[i].IsDefault = false - } - } - if !versionFound { - s.versions = append(s.versions, Version{ - ID: s.nextIDLocked("v"), - ModuleKey: a.ModuleKey, - DocsVersion: a.DocsVersion, - DisplayName: a.DocsVersion, - VersionType: "release", - IsDefault: true, - Status: "active", - PackageVersion: a.PackageVersion, - Channel: firstNonEmpty(module.Channel, "docs"), - Edition: a.Edition, - SupportStatus: "supported", - CreatedAt: now, - }) - } - s.entries = removeEntries(s.entries, a.ModuleKey, a.DocsVersion) - for i, e := range a.Entries { - s.entries = append(s.entries, Entry{ - ID: s.nextIDLocked("e"), - ModuleKey: a.ModuleKey, - DocsVersion: a.DocsVersion, - EntryKey: e.Key, - Title: e.Title, - EntryType: firstNonEmpty(e.Type, "markdown"), - Builder: firstNonEmpty(e.Type, "markdown"), - Source: e.Source, - StorageURI: "memory://" + routeKey(a.ModuleKey, a.DocsVersion, e.Key), - NavURI: "memory://" + routeKey(a.ModuleKey, a.DocsVersion, ""), - IndexStatus: "indexed", - IsPrimary: i == 0, - SortOrder: i + 1, - Status: "active", - CreatedAt: now, - }) - } - s.pages = removePages(s.pages, a.ModuleKey, a.DocsVersion) - // Drop cached embeddings for this module/version so re-published content is - // re-embedded on the next reindex (or lazily during search). - if s.embeddings != nil { - embPrefix := a.ModuleKey + ":" + a.DocsVersion + ":" - for docID := range s.embeddings { - if strings.HasPrefix(docID, embPrefix) { - delete(s.embeddings, docID) - } - } - } - for _, d := range a.Documents { - entryKey := firstNonEmpty(d.EntryKey, entryKeyFromDocID(d.DocID)) - docID := firstNonEmpty(d.DocID, a.ModuleKey+":"+a.DocsVersion+":"+entryKey) - s.pages = append(s.pages, Page{ - ID: s.nextIDLocked("p"), - DocID: docID, - ModuleKey: a.ModuleKey, - ModuleName: moduleName, - DocsVersion: a.DocsVersion, - PackageVersion: firstNonEmpty(d.PackageVersion, a.PackageVersion), - EntryKey: entryKey, - EntryType: firstNonEmpty(d.EntryType, entryTypeForEntry(a.Entries, entryKey)), - Title: firstNonEmpty(d.Title, titleForEntry(a.Entries, entryKey)), - Description: d.Description, - Path: "/docs/" + a.ModuleKey + "/" + a.DocsVersion + "/" + entryKey, - SourceFile: d.SourceFile, - DocType: firstNonEmpty(d.EntryType, entryTypeForEntry(a.Entries, entryKey)), - Status: firstNonEmpty(d.Status, "active"), - OwnerGroup: module.OwnerGroup, - CategoryIDs: cloneStrings(module.CategoryIDs), - Tags: cloneStrings(coalesceStrings(d.Keywords, a.Keywords)), - ContentText: d.Content, - UpdatedAt: now, - }) - } - if s.navs == nil { - s.navs = map[string][]NavItem{} - } - s.navs[routeKey(a.ModuleKey, a.DocsVersion, "")] = cloneNav(a.Nav) - if s.html == nil { - s.html = map[string]string{} - } - if s.siteFiles == nil { - s.siteFiles = map[string]SiteFile{} - } - for k := range s.html { - prefix := routeKey(a.ModuleKey, a.DocsVersion, "") + ":" - if strings.HasPrefix(k, prefix) { - delete(s.html, k) - } - } - for k := range s.siteFiles { - prefix := routeKey(a.ModuleKey, a.DocsVersion, "") + ":" - if strings.HasPrefix(k, prefix) { - delete(s.siteFiles, k) - } - } - for _, e := range a.Entries { - html := htmlForEntry(a.SiteHTML, e.Key) - if html != "" { - s.html[routeKey(a.ModuleKey, a.DocsVersion, e.Key)] = html - } - } - for name, content := range a.SiteFiles { - entryKey, relName, ok := splitSiteFile(name) - if !ok { - continue - } - s.siteFiles[siteFileKey(a.ModuleKey, a.DocsVersion, entryKey, relName)] = SiteFile{ - Name: relName, Content: append([]byte(nil), content...), ContentType: contentTypeForName(relName, content), - } - } - rel := Release{ - ID: s.nextIDLocked("r"), - ReleaseID: "rel-" + strings.ToLower(a.ModuleKey) + "-" + strings.ToLower(a.DocsVersion) + "-" + strconv.FormatInt(now.UnixNano(), 36), - ModuleKey: a.ModuleKey, - DocsVersion: a.DocsVersion, - Publisher: firstNonEmpty(firstString(a.Authors), "docsctl"), - BuildSystem: "docsctl", - ArtifactVersion: now.Format("20060102.150405"), - PackageVersion: a.PackageVersion, - StorageURI: "memory://" + a.ModuleKey + "/" + a.DocsVersion + "/docs-artifact.zip", - Status: "published", - PublishedAt: now, - CreatedAt: now, - } - s.releases = append(s.releases, rel) - return DeployResult{Release: rel, PagesIndexed: len(a.Documents), EntriesIndexed: len(a.Entries), HTMLFiles: len(a.SiteHTML), SiteFiles: len(a.SiteFiles), BytesReceived: a.Bytes}, nil -} - -func (s *Store) AddSearchLog(log SearchLog) { - s.mu.Lock() - defer s.mu.Unlock() - s.searchLogs = append(s.searchLogs, log) -} - -func (s *Store) SearchLogs() []SearchLog { - s.mu.RLock() - defer s.mu.RUnlock() - return append([]SearchLog(nil), s.searchLogs...) -} - -func (s *Store) AddMCPLog(log MCPLog) { - s.mu.Lock() - defer s.mu.Unlock() - s.mcpLogs = append(s.mcpLogs, log) -} - -func (s *Store) MCPLogs() []MCPLog { - s.mu.RLock() - defer s.mu.RUnlock() - return append([]MCPLog(nil), s.mcpLogs...) -} - -// RecordPageView appends a page view and returns the stored record. -func (s *Store) RecordPageView(pv PageView) PageView { - s.mu.Lock() - defer s.mu.Unlock() - if pv.ViewedAt.IsZero() { - pv.ViewedAt = time.Now().UTC() - } - if pv.ID == "" { - pv.ID = s.nextIDLocked("pv") - } - for _, p := range s.pages { - if p.DocID == pv.DocID { - pv.PageID = p.ID - pv.ModuleKey = p.ModuleKey - pv.DocsVersion = p.DocsVersion - break - } - } - s.pageViews = append(s.pageViews, pv) - return pv -} - -// RecordReadProgress updates the latest matching page view with duration and -// scroll depth for the given session and doc, or records a new view if none. -func (s *Store) RecordReadProgress(docID, sessionID string, durationSeconds int, scrollDepth float64) { - s.mu.Lock() - defer s.mu.Unlock() - for i := len(s.pageViews) - 1; i >= 0; i-- { - pv := &s.pageViews[i] - if pv.DocID == docID && pv.SessionID == sessionID { - if durationSeconds > pv.DurationSeconds { - pv.DurationSeconds = durationSeconds - } - if scrollDepth > pv.ScrollDepth { - pv.ScrollDepth = scrollDepth - } - return - } - } - s.pageViews = append(s.pageViews, PageView{ - ID: s.nextIDLocked("pv"), DocID: docID, SessionID: sessionID, - DurationSeconds: durationSeconds, ScrollDepth: scrollDepth, ViewedAt: time.Now().UTC(), - }) -} - -// PageAnalytics aggregates recorded views into per-page reading statistics. -// Pages with no recorded views fall back to seeded read counts so the admin -// dashboard is populated on a fresh start. -func (s *Store) PageAnalytics() []PageStat { - s.mu.RLock() - defer s.mu.RUnlock() - now := time.Now().UTC() - week := now.AddDate(0, 0, -7) - month := now.AddDate(0, 0, -30) - type agg struct { - pv, reads7, reads30, durSum, durCount int - users map[string]struct{} - last time.Time - } - byDoc := map[string]*agg{} - for _, pv := range s.pageViews { - a := byDoc[pv.DocID] - if a == nil { - a = &agg{users: map[string]struct{}{}} - byDoc[pv.DocID] = a - } - a.pv++ - uid := pv.UserID - if uid == "" { - uid = pv.SessionID - } - if uid != "" { - a.users[uid] = struct{}{} - } - if pv.ViewedAt.After(week) { - a.reads7++ - } - if pv.ViewedAt.After(month) { - a.reads30++ - } - if pv.DurationSeconds > 0 { - a.durSum += pv.DurationSeconds - a.durCount++ - } - if pv.ViewedAt.After(a.last) { - a.last = pv.ViewedAt - } - } - var out []PageStat - for _, p := range s.pages { - stat := PageStat{DocID: p.DocID, Title: p.Title, ModuleKey: p.ModuleKey, ModuleName: p.ModuleName, DocsVersion: p.DocsVersion, Path: p.Path, LastViewedAt: p.UpdatedAt} - if a := byDoc[p.DocID]; a != nil { - stat.PV = a.pv - stat.UV = len(a.users) - stat.Reads7d = a.reads7 - stat.Reads30d = a.reads30 - stat.LastViewedAt = a.last - if a.durCount > 0 { - stat.AvgDurationSec = a.durSum / a.durCount - } - } else { - stat.Reads7d = s.seedReadsLocked(p.ModuleKey, true) - stat.Reads30d = s.seedReadsLocked(p.ModuleKey, false) - } - out = append(out, stat) - } - sort.Slice(out, func(i, j int) bool { - if out[i].PV != out[j].PV { - return out[i].PV > out[j].PV - } - return out[i].Reads30d > out[j].Reads30d - }) - return out -} - -func (s *Store) seedReadsLocked(moduleKey string, week bool) int { - for _, m := range s.modules { - if strings.EqualFold(m.ModuleKey, moduleKey) { - if week { - return m.Reads7d - } - return m.Reads30d - } - } - return 0 -} - -// CreateCategory adds a new category. Key is required and must be unique. -func (s *Store) CreateCategory(c Category) (Category, error) { - s.mu.Lock() - defer s.mu.Unlock() - // Key is system-generated from the name (dotted under the parent's key) so - // users never have to invent one. A user-supplied key is still honored. - if strings.TrimSpace(c.Key) == "" { - if strings.TrimSpace(c.Name) == "" { - return Category{}, ErrInvalid - } - c.Key = s.generateCategoryKeyLocked(c.Name, c.ParentID) - } - if c.ID == "" { - c.ID = c.Key - } - for _, existing := range s.categories { - if existing.ID == c.ID { - return Category{}, ErrConflict - } - } - if c.Status == "" { - c.Status = "active" - } - c.Children = nil - s.categories = append(s.categories, c) - return c, nil -} - -// slugifyKey lowercases and keeps [a-z0-9-]; non-ASCII (e.g. Chinese) collapses -// to empty, in which case callers fall back to a short unique token. -func slugifyKey(name string) string { - var b strings.Builder - prevDash := false - for _, r := range strings.ToLower(strings.TrimSpace(name)) { - switch { - case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): - b.WriteRune(r) - prevDash = false - case r == ' ' || r == '-' || r == '_' || r == '.' || r == '/': - if !prevDash && b.Len() > 0 { - b.WriteByte('-') - prevDash = true - } - } - } - return strings.Trim(b.String(), "-") -} - -func (s *Store) generateCategoryKeyLocked(name, parentID string) string { - base := slugifyKey(name) - if base == "" { - base = "d" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) - } - prefix := "" - for _, c := range s.categories { - if c.ID == parentID { - prefix = c.Key + "." - break - } - } - taken := func(k string) bool { - for _, c := range s.categories { - if c.Key == k || c.ID == k { - return true - } - } - return false - } - key := prefix + base - for i := 2; taken(key); i++ { - key = prefix + base + "-" + strconv.Itoa(i) - } - return key -} - -func (s *Store) generateTeamKeyLocked(name string) string { - base := slugifyKey(name) - if base == "" { - base = "team-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) - } - taken := func(k string) bool { - for _, t := range s.teams { - if strings.EqualFold(t.Key, k) { - return true - } - } - return false - } - key := base - for i := 2; taken(key); i++ { - key = base + "-" + strconv.Itoa(i) - } - return key -} - -// MoveCategory reparents a category and positions it at `index` among its new -// siblings, renumbering sibling SortOrder so the tree order is stable. Rejects -// moves that would create a cycle (into the node's own subtree). -func (s *Store) MoveCategory(id, parentID string, index int) (Category, error) { +// ClearSiteAssets drops cached rendered HTML and static site files from memory. +// Deployed environments can serve these assets from MinIO instead, which keeps +// the backend RSS from growing with image-heavy or large documentation sites. +func (s *MemoryStore) ClearSiteAssets() { s.mu.Lock() defer s.mu.Unlock() - - idx := -1 - for i := range s.categories { - if s.categories[i].ID == id { - idx = i - break - } - } - if idx == -1 { - return Category{}, ErrNotFound - } - if parentID == id { - return Category{}, ErrInvalid - } - // Walk parent chain to reject cycles. - for p := parentID; p != ""; { - if p == id { - return Category{}, ErrInvalid - } - next := "" - for i := range s.categories { - if s.categories[i].ID == p { - next = s.categories[i].ParentID - break - } - } - p = next - } - if parentID != "" { - found := false - for i := range s.categories { - if s.categories[i].ID == parentID { - found = true - break - } - } - if !found { - return Category{}, ErrInvalid - } - } - - s.categories[idx].ParentID = parentID - - // Collect new siblings (same parent) in current order, excluding the moved - // node, then insert it at the requested index and renumber. - var sibs []int - for i := range s.categories { - if s.categories[i].ParentID == parentID && s.categories[i].ID != id { - sibs = append(sibs, i) - } - } - sort.SliceStable(sibs, func(a, b int) bool { return s.categories[sibs[a]].SortOrder < s.categories[sibs[b]].SortOrder }) - order := make([]int, 0, len(sibs)+1) - if index < 0 { - index = 0 - } - if index > len(sibs) { - index = len(sibs) - } - order = append(order, sibs[:index]...) - order = append(order, idx) - order = append(order, sibs[index:]...) - for pos, ci := range order { - s.categories[ci].SortOrder = (pos + 1) * 10 - } - out := s.categories[idx] - out.Children = nil - return out, nil -} - -func (s *Store) UpdateCategory(id string, c Category) (Category, error) { - s.mu.Lock() - defer s.mu.Unlock() - for i := range s.categories { - if s.categories[i].ID == id { - if c.Name != "" { - s.categories[i].Name = c.Name - } - if c.Description != "" { - s.categories[i].Description = c.Description - } - if c.Icon != "" { - s.categories[i].Icon = c.Icon - } - if c.SortOrder != 0 { - s.categories[i].SortOrder = c.SortOrder - } - if c.Status != "" { - s.categories[i].Status = c.Status - } - if c.ParentID != "" { - s.categories[i].ParentID = c.ParentID - } - // Always accept ResponsibleTeam from patch (send "" explicitly to clear assignment to a team). - s.categories[i].ResponsibleTeam = c.ResponsibleTeam - out := s.categories[i] - out.Children = nil - return out, nil - } - } - return Category{}, ErrNotFound -} - -func (s *Store) DeleteCategory(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - for _, c := range s.categories { - if c.ParentID == id { - return ErrConflict - } - } - for i := range s.categories { - if s.categories[i].ID == id { - s.categories = append(s.categories[:i], s.categories[i+1:]...) - return nil - } - } - return ErrNotFound -} - -func (s *Store) moduleKeyTakenLocked(key string) bool { - for _, m := range s.modules { - if strings.EqualFold(m.ModuleKey, key) { - return true - } - } - return false -} - -func (s *Store) CreateModule(m Module) (Module, error) { - s.mu.Lock() - defer s.mu.Unlock() - // Auto-generate module_key from the name (slug, unique) so admins never type one. - if strings.TrimSpace(m.ModuleKey) == "" { - if strings.TrimSpace(m.Name) == "" { - return Module{}, ErrInvalid - } - base := slugifyKey(m.Name) - if base == "" { - base = "doc-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) - } - key := base - for i := 2; s.moduleKeyTakenLocked(key); i++ { - key = base + "-" + strconv.Itoa(i) - } - m.ModuleKey = key - } - for _, existing := range s.modules { - if strings.EqualFold(existing.ModuleKey, m.ModuleKey) { - return Module{}, ErrConflict - } - } - if m.ID == "" { - m.ID = s.nextIDLocked("m") - } - // Every doc source gets a deploy token for CI push auth. - if strings.TrimSpace(m.DeployToken) == "" { - m.DeployToken = "mdx_" + strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.FormatInt(int64(len(s.modules)+1), 36) - } - if m.Name == "" { - m.Name = m.ModuleKey - } - if m.Status == "" { - m.Status = "active" - } - if m.DefaultVersion == "" { - m.DefaultVersion = "latest" - } - m.UpdatedAt = time.Now().UTC() - m.AvailableVers = nil - s.modules = append(s.modules, m) - return m, nil -} - -func (s *Store) UpdateModule(moduleKey string, patch Module) (Module, error) { - s.mu.Lock() - defer s.mu.Unlock() - for i := range s.modules { - if strings.EqualFold(s.modules[i].ModuleKey, moduleKey) { - m := &s.modules[i] - if patch.Name != "" { - m.Name = patch.Name - } - if patch.Description != "" { - m.Description = patch.Description - } - if patch.OwnerGroup != "" { - m.OwnerGroup = patch.OwnerGroup - } - if patch.RepoType != "" { - m.RepoType = patch.RepoType - } - if patch.RepoURL != "" { - m.RepoURL = patch.RepoURL - } - if patch.DefaultVersion != "" { - m.DefaultVersion = patch.DefaultVersion - } - if patch.Visibility != "" { - m.Visibility = patch.Visibility - } - if patch.Status != "" { - m.Status = patch.Status - } - if patch.PackageVersion != "" { - m.PackageVersion = patch.PackageVersion - } - if patch.Channel != "" { - m.Channel = patch.Channel - } - if patch.Edition != "" { - m.Edition = patch.Edition - } - if patch.Keywords != nil { - m.Keywords = patch.Keywords - } - if patch.Maintainers != nil { - m.Maintainers = patch.Maintainers - } - if patch.CategoryIDs != nil { - m.CategoryIDs = patch.CategoryIDs - } - if patch.CategoryPath != "" { - m.CategoryPath = patch.CategoryPath - } - if patch.SourceType != "" { - m.SourceType = patch.SourceType - } - if patch.DocType != "" { - m.DocType = patch.DocType - } - if patch.Mount != "" { - m.Mount = patch.Mount - } - if patch.GitLabBranch != "" { - m.GitLabBranch = patch.GitLabBranch - } - if patch.GitLabPath != "" { - m.GitLabPath = patch.GitLabPath - } - if patch.DeployToken != "" { - m.DeployToken = patch.DeployToken - } - m.UpdatedAt = time.Now().UTC() - out := *m - out.AvailableVers = s.versionsForLocked(m.ModuleKey) - return out, nil - } - } - return Module{}, ErrNotFound -} - -func (s *Store) CreateVersion(moduleKey string, v Version) (Version, error) { - s.mu.Lock() - defer s.mu.Unlock() - if _, err := s.moduleIndexLocked(moduleKey); err != nil { - return Version{}, err - } - if strings.TrimSpace(v.DocsVersion) == "" { - return Version{}, ErrInvalid - } - for _, existing := range s.versions { - if strings.EqualFold(existing.ModuleKey, moduleKey) && existing.DocsVersion == v.DocsVersion { - return Version{}, ErrConflict - } - } - v.ModuleKey = moduleKey - if v.ID == "" { - v.ID = s.nextIDLocked("v") - } - if v.DisplayName == "" { - v.DisplayName = v.DocsVersion - } - if v.Status == "" { - v.Status = "active" - } - v.CreatedAt = time.Now().UTC() - if v.IsDefault { - for i := range s.versions { - if strings.EqualFold(s.versions[i].ModuleKey, moduleKey) { - s.versions[i].IsDefault = false - } - } - if idx, err := s.moduleIndexLocked(moduleKey); err == nil { - s.modules[idx].DefaultVersion = v.DocsVersion - } - } - s.versions = append(s.versions, v) - return v, nil -} - -func (s *Store) UpdateVersion(moduleKey, docsVersion string, patch Version) (Version, error) { - s.mu.Lock() - defer s.mu.Unlock() - for i := range s.versions { - if strings.EqualFold(s.versions[i].ModuleKey, moduleKey) && s.versions[i].DocsVersion == docsVersion { - v := &s.versions[i] - if patch.DisplayName != "" { - v.DisplayName = patch.DisplayName - } - if patch.VersionType != "" { - v.VersionType = patch.VersionType - } - if patch.Status != "" { - v.Status = patch.Status - } - if patch.SourceBranch != "" { - v.SourceBranch = patch.SourceBranch - } - if patch.PackageVersion != "" { - v.PackageVersion = patch.PackageVersion - } - if patch.Channel != "" { - v.Channel = patch.Channel - } - if patch.Edition != "" { - v.Edition = patch.Edition - } - if patch.SupportStatus != "" { - v.SupportStatus = patch.SupportStatus - } - if patch.IsDefault { - for j := range s.versions { - if strings.EqualFold(s.versions[j].ModuleKey, moduleKey) { - s.versions[j].IsDefault = false - } - } - v.IsDefault = true - if idx, err := s.moduleIndexLocked(moduleKey); err == nil { - s.modules[idx].DefaultVersion = v.DocsVersion - } - } - return *v, nil - } - } - return Version{}, ErrNotFound -} - -func (s *Store) CreateEntry(moduleKey, docsVersion string, e Entry) (Entry, error) { - s.mu.Lock() - defer s.mu.Unlock() - if _, err := s.moduleIndexLocked(moduleKey); err != nil { - return Entry{}, err - } - if strings.TrimSpace(e.EntryKey) == "" { - return Entry{}, ErrInvalid - } - for _, existing := range s.entries { - if strings.EqualFold(existing.ModuleKey, moduleKey) && existing.DocsVersion == docsVersion && existing.EntryKey == e.EntryKey { - return Entry{}, ErrConflict - } - } - e.ModuleKey = moduleKey - e.DocsVersion = docsVersion - if e.ID == "" { - e.ID = s.nextIDLocked("e") - } - if e.EntryType == "" { - e.EntryType = "markdown" - } - if e.Builder == "" { - e.Builder = e.EntryType - } - if e.IndexStatus == "" { - e.IndexStatus = "pending" - } - if e.Status == "" { - e.Status = "active" - } - e.CreatedAt = time.Now().UTC() - s.entries = append(s.entries, e) - return e, nil -} - -func (s *Store) UpdateEntry(entryID string, patch Entry) (Entry, error) { - s.mu.Lock() - defer s.mu.Unlock() - for i := range s.entries { - if s.entries[i].ID == entryID { - e := &s.entries[i] - if patch.Title != "" { - e.Title = patch.Title - } - if patch.EntryType != "" { - e.EntryType = patch.EntryType - } - if patch.Builder != "" { - e.Builder = patch.Builder - } - if patch.Source != "" { - e.Source = patch.Source - } - if patch.StorageURI != "" { - e.StorageURI = patch.StorageURI - } - if patch.NavURI != "" { - e.NavURI = patch.NavURI - } - if patch.IndexStatus != "" { - e.IndexStatus = patch.IndexStatus - } - if patch.SortOrder != 0 { - e.SortOrder = patch.SortOrder - } - if patch.Status != "" { - e.Status = patch.Status - } - e.IsPrimary = patch.IsPrimary - return *e, nil - } - } - return Entry{}, ErrNotFound + s.html = map[string]string{} + s.siteFiles = map[string]SiteFile{} } -func (s *Store) DeleteEntry(entryID string) error { - s.mu.Lock() - defer s.mu.Unlock() - for i := range s.entries { - if s.entries[i].ID == entryID { - s.entries = append(s.entries[:i], s.entries[i+1:]...) - return nil - } - } - return ErrNotFound -} - -func (s *Store) Release(releaseID string) (Release, error) { +// SiteObjects returns the currently cached site assets keyed by their MinIO +// object path. It is used once at startup to migrate legacy snapshot data. +func (s *MemoryStore) SiteObjects() map[string]SiteFile { s.mu.RLock() defer s.mu.RUnlock() - for _, r := range s.releases { - if r.ReleaseID == releaseID || r.ID == releaseID { - return r, nil - } - } - return Release{}, ErrNotFound -} - -// RollbackRelease marks the target release as rolled back. A real -// implementation would also re-point storage and search to the prior artifact. -func (s *Store) RollbackRelease(releaseID string) (Release, error) { - s.mu.Lock() - defer s.mu.Unlock() - for i := range s.releases { - if s.releases[i].ReleaseID == releaseID || s.releases[i].ID == releaseID { - s.releases[i].Status = "rolled_back" - return s.releases[i], nil - } - } - return Release{}, ErrNotFound -} - -func (s *Store) moduleIndexLocked(moduleKey string) (int, error) { - for i := range s.modules { - if strings.EqualFold(s.modules[i].ModuleKey, moduleKey) { - return i, nil - } - } - return -1, ErrNotFound -} - -func (s *Store) nextIDLocked(prefix string) string { - s.seq++ - return prefix + "-" + strconv.FormatInt(s.seq, 10) + "-" + strconv.FormatInt(time.Now().UnixNano(), 36) -} - -func (s *Store) versionsForLocked(moduleKey string) []Version { - var out []Version - for _, v := range s.versions { - if strings.EqualFold(v.ModuleKey, moduleKey) { - out = append(out, v) - } - } - return out -} - -func contains(xs []string, target string) bool { - for _, x := range xs { - if x == target { - return true - } - } - return false -} - -func routeKey(moduleKey, docsVersion, entryKey string) string { - if entryKey == "" { - return strings.ToLower(moduleKey) + ":" + docsVersion - } - return strings.ToLower(moduleKey) + ":" + docsVersion + ":" + entryKey -} - -func siteFileKey(moduleKey, docsVersion, entryKey, name string) string { - return routeKey(moduleKey, docsVersion, entryKey) + ":" + path.Clean(strings.TrimPrefix(name, "/")) -} - -func cloneStrings(xs []string) []string { - if xs == nil { - return nil - } - return append([]string(nil), xs...) -} - -func cloneNav(xs []NavItem) []NavItem { - if xs == nil { - return nil - } - out := make([]NavItem, len(xs)) - for i, x := range xs { - out[i] = x - out[i].Children = cloneNav(x.Children) - } - return out -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - -func firstString(xs []string) string { - if len(xs) == 0 { - return "" - } - return xs[0] -} - -func coalesceStrings(primary, fallback []string) []string { - if len(primary) > 0 { - return primary - } - return fallback -} - -func removeEntries(entries []Entry, moduleKey, docsVersion string) []Entry { - out := entries[:0] - for _, e := range entries { - if strings.EqualFold(e.ModuleKey, moduleKey) && e.DocsVersion == docsVersion { + out := make(map[string]SiteFile, len(s.html)+len(s.siteFiles)) + moduleNames := make(map[string]string, len(s.modules)) + for _, module := range s.modules { + moduleNames[strings.ToLower(module.ModuleKey)] = module.ModuleKey + } + for key, html := range s.html { + parts := strings.SplitN(key, ":", 3) + if len(parts) != 3 { continue } - out = append(out, e) + moduleKey := firstNonEmpty(moduleNames[parts[0]], parts[0]) + name := path.Join("modules", moduleKey, parts[1], "site", parts[2], "index.html") + out[name] = SiteFile{Name: name, Content: []byte(html), ContentType: "text/html; charset=utf-8"} } - return out -} - -func removePages(pages []Page, moduleKey, docsVersion string) []Page { - out := pages[:0] - for _, p := range pages { - if strings.EqualFold(p.ModuleKey, moduleKey) && p.DocsVersion == docsVersion { + for key, file := range s.siteFiles { + parts := strings.SplitN(key, ":", 4) + if len(parts) != 4 { continue } - out = append(out, p) + moduleKey := firstNonEmpty(moduleNames[parts[0]], parts[0]) + name := path.Join("modules", moduleKey, parts[1], "site", parts[2], parts[3]) + file.Name = name + out[name] = file } return out } - -func entryKeyFromDocID(docID string) string { - parts := strings.Split(docID, ":") - if len(parts) == 0 { - return "" - } - return parts[len(parts)-1] -} - -func entryTypeForEntry(entries []DeployEntry, entryKey string) string { - for _, e := range entries { - if e.Key == entryKey { - return e.Type - } - } - return "markdown" -} - -func titleForEntry(entries []DeployEntry, entryKey string) string { - for _, e := range entries { - if e.Key == entryKey { - return e.Title - } - } - return entryKey -} - -func htmlForEntry(files map[string]string, entryKey string) string { - for _, name := range []string{"site/" + entryKey + "/index.html", "site/" + entryKey + ".html"} { - if html := files[name]; html != "" { - return html - } - } - prefix := "site/" + entryKey + "/" - for name, html := range files { - if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".html") { - return html - } - } - return "" -} - -func splitSiteFile(name string) (string, string, bool) { - name = path.Clean(strings.TrimPrefix(name, "/")) - parts := strings.Split(name, "/") - if len(parts) < 3 || parts[0] != "site" || parts[1] == "" { - return "", "", false - } - rel := path.Join(parts[2:]...) - if rel == "." || rel == "" { - rel = "index.html" - } - return parts[1], rel, true -} - -func contentTypeForName(name string, content []byte) string { - if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { - return ct - } - if len(content) > 0 { - return http.DetectContentType(content) - } - return "application/octet-stream" -} diff --git a/backend/internal/store/memory_test.go b/backend/internal/store/memory_test.go index 3ec3231..87b7469 100644 --- a/backend/internal/store/memory_test.go +++ b/backend/internal/store/memory_test.go @@ -1,66 +1,37 @@ package store import ( - "path/filepath" "testing" ) -func TestSnapshotRoundTrip(t *testing.T) { - s := NewSeeded() - if _, err := s.CreateUser(User{Username: "carol", Roles: []string{"viewer"}}); err != nil { - t.Fatalf("CreateUser: %v", err) - } - s.RecordPageView(PageView{DocID: "DemoModule:latest:guide", SessionID: "x"}) - s.SetEmbedding("DemoModule:latest:guide", []float32{0.1, 0.2, 0.3}) - - path := filepath.Join(t.TempDir(), "snap.json") - if err := s.Save(path); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load(path) +func TestSiteObjectsUseCanonicalModuleKey(t *testing.T) { + s := NewTestStore() + _, err := s.IngestArtifact(DeployArtifact{ + ModuleKey: "RuntimeDocs", + DocsVersion: "latest", + Entries: []DeployEntry{{Key: "guide", Title: "Guide"}}, + Documents: []DeployDocument{{DocID: "RuntimeDocs:latest:guide", EntryKey: "guide", Title: "Guide", Content: "body"}}, + SiteHTML: map[string]string{"site/guide/index.html": "

Guide

"}, + SiteFiles: map[string][]byte{"site/guide/assets/app.css": []byte("body{}")}, + }) if err != nil { - t.Fatalf("Load: %v", err) - } - if got, want := len(loaded.Users("")), len(s.Users("")); got != want { - t.Fatalf("users after reload = %d, want %d", got, want) - } - if _, err := loaded.UserByID(""); err == nil { - t.Fatal("expected lookup of empty id to fail") - } - if loaded.EmbeddingCount() != 1 { - t.Fatalf("embeddings after reload = %d, want 1", loaded.EmbeddingCount()) + t.Fatalf("IngestArtifact: %v", err) } - stats := loaded.PageAnalytics() - var pv int - for _, st := range stats { - if st.DocID == "DemoModule:latest:guide" { - pv = st.PV + objects := s.SiteObjects() + for _, key := range []string{ + "modules/RuntimeDocs/latest/site/guide/index.html", + "modules/RuntimeDocs/latest/site/guide/assets/app.css", + } { + if _, ok := objects[key]; !ok { + t.Fatalf("missing MinIO object %q in %#v", key, objects) } } - if pv != 1 { - t.Fatalf("page view after reload = %d, want 1", pv) - } - // Verify new IDs continue past the persisted sequence (no collisions). - created, err := loaded.CreateModule(Module{ModuleKey: "PersistedModule"}) - if err != nil { - t.Fatalf("CreateModule after reload: %v", err) - } - if created.ID == "" { - t.Fatal("expected generated module ID after reload") - } -} - -func TestLoadMissingReturnsNotFound(t *testing.T) { - if _, err := Load(filepath.Join(t.TempDir(), "nope.json")); err != ErrNotFound { - t.Fatalf("err = %v, want ErrNotFound", err) - } } -func TestUserCRUDAndGroupAutoRegister(t *testing.T) { - s := NewSeeded() +func TestUserCRUD(t *testing.T) { + s := NewSeededTestStore() - created, err := s.CreateUser(User{Username: "carol", Department: "测试", Groups: []string{"qa-team"}, Roles: []string{"viewer"}}) + created, err := s.CreateUser(User{Username: "carol", Department: "测试", Roles: []string{"viewer"}}) if err != nil { t.Fatalf("CreateUser: %v", err) } @@ -70,17 +41,6 @@ func TestUserCRUDAndGroupAutoRegister(t *testing.T) { if _, err := s.CreateUser(User{Username: "CAROL"}); err != ErrConflict { t.Fatalf("duplicate username err = %v, want ErrConflict", err) } - // qa-team should have been auto-registered as a group. - var hasQA bool - for _, g := range s.Groups() { - if g.GroupKey == "qa-team" { - hasQA = true - } - } - if !hasQA { - t.Fatal("expected qa-team group to be auto-registered") - } - updated, err := s.UpdateUser(created.ID, User{Roles: []string{"maintainer"}, Status: "disabled"}) if err != nil { t.Fatalf("UpdateUser: %v", err) @@ -98,11 +58,11 @@ func TestUserCRUDAndGroupAutoRegister(t *testing.T) { } func TestUpsertUserSyncsOnLogin(t *testing.T) { - s := NewSeeded() + s := NewSeededTestStore() before := len(s.Users("")) // Existing seeded user alice: upsert should update, not duplicate. - u := s.UpsertUser(User{ID: "u-alice", Username: "alice", Department: "新部门", Groups: []string{"cad-team", "release"}}) + u := s.UpsertUser(User{ID: "u-alice", Username: "alice", Department: "新部门"}) if u.Source != "oidc" || u.Department != "新部门" { t.Fatalf("upsert existing wrong: %+v", u) } @@ -114,20 +74,20 @@ func TestUpsertUserSyncsOnLogin(t *testing.T) { } // New identity from provider: should be created. - s.UpsertUser(User{Username: "dave", Email: "dave@example.com", Groups: []string{"ops"}}) + s.UpsertUser(User{Username: "dave", Email: "dave@example.com"}) if got := len(s.Users("")); got != before+1 { t.Fatalf("expected new user added, count %d -> %d", before, got) } } func TestPageAnalyticsAggregatesViews(t *testing.T) { - s := NewSeeded() + s := NewSeededTestStore() doc := "DemoModule:latest:guide" s.RecordPageView(PageView{DocID: doc, SessionID: "a", DurationSeconds: 10}) s.RecordPageView(PageView{DocID: doc, SessionID: "a"}) s.RecordPageView(PageView{DocID: doc, SessionID: "b"}) - s.RecordReadProgress(doc, "b", 30, 0.9) + s.RecordReadProgress(doc, "b", "", 30, 0.9) var found bool for _, st := range s.PageAnalytics() { @@ -154,7 +114,7 @@ func TestPageAnalyticsAggregatesViews(t *testing.T) { } func TestPageAnalyticsFallsBackToSeedReads(t *testing.T) { - s := NewSeeded() + s := NewSeededTestStore() for _, st := range s.PageAnalytics() { if st.DocID == "CBB:latest:build-cache" { if st.Reads30d == 0 { @@ -167,7 +127,7 @@ func TestPageAnalyticsFallsBackToSeedReads(t *testing.T) { } func TestModuleVersionEntryCRUD(t *testing.T) { - s := NewSeeded() + s := NewSeededTestStore() if _, err := s.CreateModule(Module{ModuleKey: "NCKernel", CategoryIDs: []string{"nc"}}); err != nil { t.Fatalf("CreateModule: %v", err) @@ -207,14 +167,14 @@ func TestModuleVersionEntryCRUD(t *testing.T) { } func TestVersionRequiresExistingModule(t *testing.T) { - s := NewSeeded() + s := NewSeededTestStore() if _, err := s.CreateVersion("Ghost", Version{DocsVersion: "latest"}); err != ErrNotFound { t.Fatalf("err = %v, want ErrNotFound", err) } } func TestRollbackRelease(t *testing.T) { - s := NewSeeded() + s := NewSeededTestStore() rel, err := s.RollbackRelease("rel-demo-latest-001") if err != nil { t.Fatalf("RollbackRelease: %v", err) @@ -228,7 +188,7 @@ func TestRollbackRelease(t *testing.T) { } func TestIngestArtifactPublishesPagesNavAndHTML(t *testing.T) { - s := NewSeeded() + s := NewSeededTestStore() result, err := s.IngestArtifact(DeployArtifact{ ModuleKey: "RuntimeDocs", ModuleName: "Runtime Docs", @@ -279,3 +239,36 @@ func TestIngestArtifactPublishesPagesNavAndHTML(t *testing.T) { t.Fatalf("content type = %q", file.ContentType) } } + +func TestUpdateModulePropagatesCategoriesToIndexedPages(t *testing.T) { + s := NewSeededTestStore() + if _, err := s.CreateModule(Module{ModuleKey: "RuntimeDocs", Name: "Runtime Docs", CategoryIDs: []string{"engineering"}}); err != nil { + t.Fatalf("CreateModule: %v", err) + } + if _, err := s.IngestArtifact(DeployArtifact{ + ModuleKey: "RuntimeDocs", + ModuleName: "Runtime Docs", + DocsVersion: "latest", + Entries: []DeployEntry{{Key: "guide", Title: "Guide", Type: "markdown"}}, + Documents: []DeployDocument{{DocID: "RuntimeDocs:latest:guide", EntryKey: "guide", Title: "Guide", Content: "runtime documentation content"}}, + }); err != nil { + t.Fatalf("IngestArtifact: %v", err) + } + page, err := s.Page("RuntimeDocs:latest:guide") + if err != nil { + t.Fatalf("Page before update: %v", err) + } + if len(page.CategoryIDs) != 1 || page.CategoryIDs[0] != "engineering" { + t.Fatalf("initial page categories = %#v", page.CategoryIDs) + } + if _, err := s.UpdateModule("RuntimeDocs", Module{CategoryIDs: []string{"frontend", "frontend.docs"}}); err != nil { + t.Fatalf("UpdateModule: %v", err) + } + page, err = s.Page("RuntimeDocs:latest:guide") + if err != nil { + t.Fatalf("Page after update: %v", err) + } + if len(page.CategoryIDs) != 2 || page.CategoryIDs[0] != "frontend" || page.CategoryIDs[1] != "frontend.docs" { + t.Fatalf("updated page categories = %#v", page.CategoryIDs) + } +} diff --git a/backend/internal/store/models.go b/backend/internal/store/models.go index 65bfa5f..1dc3b1d 100644 --- a/backend/internal/store/models.go +++ b/backend/internal/store/models.go @@ -9,7 +9,6 @@ type User struct { Email string `json:"email"` Department string `json:"department"` Avatar string `json:"avatar,omitempty"` - Groups []string `json:"groups"` Roles []string `json:"roles"` // ManagedCategories lists the platform/category IDs this user may manage. // Super admins manage everything regardless of this list. @@ -20,48 +19,86 @@ type User struct { // It is combined with SUPER_ADMIN_USERS env (see auth service) so that // super admin status can be granted either statically (env, for bootstrap) // or dynamically via the admin UI. - SuperAdmin bool `json:"is_super_admin,omitempty"` + SuperAdmin bool `json:"is_super_admin,omitempty"` + // MCPToken is the user's personal bearer token for the MCP server, so MCP + // calls can be attributed to them. Never serialized (revealed via /api/me/mcp-token). + MCPToken string `json:"-"` LastLoginAt time.Time `json:"last_login_at,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"` } -type Group struct { - ID string `json:"id"` - GroupKey string `json:"group_key"` - Name string `json:"name"` - Source string `json:"source"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +type ConnectedApp struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ClientID string `json:"client_id"` + ClientSecretHash string `json:"client_secret_hash,omitempty"` + RedirectURIs []string `json:"redirect_uris"` + Scopes []string `json:"scopes"` + Trusted bool `json:"trusted"` + Enabled bool `json:"enabled"` + CreatedBy string `json:"created_by,omitempty"` + LastUsedAt time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type OAuthGrant struct { + ID string `json:"id"` + AppID string `json:"app_id"` + UserID string `json:"user_id"` + CodeHash string `json:"code_hash,omitempty"` + AccessTokenHash string `json:"access_token_hash,omitempty"` + RefreshTokenHash string `json:"refresh_token_hash,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + Scopes []string `json:"scopes,omitempty"` + CodeExpiresAt time.Time `json:"code_expires_at,omitempty"` + AccessExpiresAt time.Time `json:"access_expires_at,omitempty"` + RefreshExpiresAt time.Time `json:"refresh_expires_at,omitempty"` + RevokedAt time.Time `json:"revoked_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Team represents a document maintenance team (文档维护团队). // A team has a leader (负责人) who can add/remove members. Teams can be // assigned as responsible_party for one or more Categories (领域/分类), // owning the doc structure and maintenance under those domains. -// Team.Key can be used as owner_group / group reference for compatibility. +// Team.Key is also used as the module/page owner_group string. type Team struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` - Description string `json:"description"` - Leader string `json:"leader"` - Members []string `json:"members"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + // Leaders are the team's responsible people (at least one). Every leader is + // also kept in Members. + Leaders []string `json:"leaders"` + Members []string `json:"members"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TeamSummary struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Leaders []string `json:"leaders"` + Members []string `json:"members,omitempty"` } type Category struct { - ID string `json:"id"` - ParentID string `json:"parent_id,omitempty"` - Key string `json:"key"` - Name string `json:"name"` - Description string `json:"description"` - Icon string `json:"icon"` - SortOrder int `json:"sort_order"` - Status string `json:"status"` - ResponsibleTeam string `json:"responsible_team,omitempty"` - Children []Category `json:"children,omitempty"` + ID string `json:"id"` + ParentID string `json:"parent_id,omitempty"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + SortOrder int `json:"sort_order"` + Status string `json:"status"` + ResponsibleTeam string `json:"responsible_team,omitempty"` + ResponsibleTeamInfo *TeamSummary `json:"responsible_team_info,omitempty"` + Children []Category `json:"children,omitempty"` } type Module struct { @@ -83,6 +120,7 @@ type Module struct { Maintainers []string `json:"maintainers"` CategoryIDs []string `json:"category_ids"` CategoryPath string `json:"category_path"` + CreatedBy string `json:"created_by,omitempty"` // GitLab / source integration (similar to Mintlify deploy integration) SourceType string `json:"source_type,omitempty"` // "gitlab", "manual" @@ -94,6 +132,9 @@ type Module struct { LastSyncedCommit string `json:"last_synced_commit,omitempty"` LastSyncedAt time.Time `json:"last_synced_at,omitempty"` + // Read-only flag for admins (the actual token is secret and exposed only via /deploy-token). + DeployTokenSet bool `json:"deploy_token_set,omitempty"` + AvailableVers []Version `json:"available_versions,omitempty"` UpdatedAt time.Time `json:"updated_at"` Reads7d int `json:"reads_7d"` @@ -145,6 +186,8 @@ type Release struct { PipelineURL string `json:"pipeline_url"` BuildSystem string `json:"build_system"` BuildID string `json:"build_id"` + TriggerType string `json:"trigger_type"` + SourceIP string `json:"source_ip"` ArtifactVersion string `json:"artifact_version"` PackageVersion string `json:"package_version"` StorageURI string `json:"storage_uri"` @@ -173,23 +216,65 @@ type Page struct { Tags []string `json:"tags"` ContentText string `json:"content_text"` ContentHTML string `json:"content_html,omitempty"` + ContentMD string `json:"content_md,omitempty"` UpdatedAt time.Time `json:"updated_at"` } -// AISettings holds the admin-configured large-model connection used to power -// AI answers (RAG). It targets any OpenAI-compatible /chat/completions endpoint -// (OpenAI, DeepSeek, Qwen/DashScope-compat, local vLLM/Ollama, …). +// DefaultAskSystemPrompt is the built-in RAG system prompt used when an admin +// has not configured a custom one. It is exposed via the settings API so the +// admin UI can pre-fill the editor and offer a "reset to default" action. +const DefaultAskSystemPrompt = "你是企业研发文档助手。只依据提供的【文档片段】回答用户问题,使用简洁中文;若片段中没有答案,明确说明未在文档中找到,不要编造。回答末尾不要重复罗列来源。" + +// AISettings holds the admin-configured model and retrieval settings used to +// power AI answers (RAG), semantic search, reranking, and recall evaluation. type AISettings struct { - AskBaseURL string `json:"ask_base_url"` // e.g. https://api.openai.com/v1 - AskModel string `json:"ask_model"` // e.g. gpt-4o-mini, deepseek-chat - AskAPIKey string `json:"ask_api_key"` // secret; masked when read back - AskSystemPrompt string `json:"ask_system_prompt"` // optional override - UpdatedAt time.Time `json:"updated_at"` + // AskProtocol selects the API format of the chat endpoint: + // "openai-chat" (default), "openai-responses", "anthropic", or "gemini". + AskProtocol string `json:"ask_protocol"` + AskBaseURL string `json:"ask_base_url"` // e.g. https://api.openai.com/v1 + AskModel string `json:"ask_model"` // fetched from the endpoint + AskAPIKey string `json:"ask_api_key"` // secret; masked when read back + AskSystemPrompt string `json:"ask_system_prompt"` // optional override + // AskMaxTokens caps the answer length. 0 means "use the engine default". + // (Required by Anthropic; optional for the others.) + AskMaxTokens int `json:"ask_max_tokens,omitempty"` + // AskTemperature controls sampling. nil means "use the engine default" + // (so an explicit 0 for deterministic output is still distinguishable). + AskTemperature *float64 `json:"ask_temperature,omitempty"` + + EmbeddingBaseURL string `json:"embedding_base_url,omitempty"` + EmbeddingModel string `json:"embedding_model,omitempty"` + EmbeddingAPIKey string `json:"embedding_api_key,omitempty"` // secret; masked when read back + EmbeddingDim int `json:"embedding_dim,omitempty"` // fixed at embedding.Dim (1024); the configured model must output this many dims + + RerankBaseURL string `json:"rerank_base_url,omitempty"` + RerankModel string `json:"rerank_model,omitempty"` + RerankAPIKey string `json:"rerank_api_key,omitempty"` // secret; masked when read back + RerankTopK int `json:"rerank_top_k,omitempty"` + + ChunkStrategy string `json:"chunk_strategy,omitempty"` // fixed, heading, markdown, semantic + ChunkSize int `json:"chunk_size,omitempty"` + ChunkOverlap int `json:"chunk_overlap,omitempty"` + + RecallTestQuery string `json:"recall_test_query,omitempty"` + RecallTestTopK int `json:"recall_test_top_k,omitempty"` + RecallTestDocIDs string `json:"recall_test_doc_ids,omitempty"` // newline/comma separated expected doc ids + + UpdatedAt time.Time `json:"updated_at"` } // Settings is the persisted, admin-editable platform configuration. type Settings struct { AI AISettings `json:"ai"` + // Plugins holds per-plugin enable/config overrides keyed by plugin key. + // Absent keys fall back to the built-in catalog defaults (see plugins.go). + Plugins map[string]PluginSetting `json:"plugins,omitempty"` + // Snippets and Variables power reusable doc content (see snippets.go). + Snippets []Snippet `json:"snippets,omitempty"` + Variables map[string]string `json:"variables,omitempty"` + // UploadedPlugins are admin-imported, sandbox-rendered JSX plugins + // (see uploaded_plugins.go). Enable/disable reuses Plugins overrides. + UploadedPlugins []UploadedPlugin `json:"uploaded_plugins,omitempty"` } type NavItem struct { @@ -217,6 +302,7 @@ type DeployDocument struct { Title string `json:"title"` Description string `json:"description"` Content string `json:"content"` + ContentMD string `json:"content_md,omitempty"` Path string `json:"path"` SourceFile string `json:"source_file"` Keywords []string `json:"keywords"` @@ -236,6 +322,8 @@ type DeployArtifact struct { RepoType string Branch string CommitSHA string + TriggerType string + SourceIP string Entries []DeployEntry Documents []DeployDocument Nav []NavItem @@ -262,6 +350,7 @@ type SiteFile struct { type SearchLog struct { ID string `json:"id"` UserID string `json:"user_id"` + IPAddress string `json:"ip_address,omitempty"` // recorded for anonymous searches Query string `json:"query"` Mode string `json:"mode"` FiltersJSON string `json:"filters_json"` @@ -280,19 +369,57 @@ type MCPLog struct { CreatedAt time.Time `json:"created_at"` } +type DocFeedback struct { + ID string `json:"id"` + DocID string `json:"doc_id"` + PageID string `json:"page_id"` + ModuleKey string `json:"module_key"` + Title string `json:"title"` + Rating string `json:"rating"` + Comment string `json:"comment"` + UserID string `json:"user_id"` + SessionID string `json:"session_id"` + CreatedAt time.Time `json:"created_at"` +} + type PageView struct { ID string `json:"id"` PageID string `json:"page_id"` DocID string `json:"doc_id"` ModuleKey string `json:"module_key"` + ModuleName string `json:"module_name,omitempty"` DocsVersion string `json:"docs_version"` + EntryKey string `json:"entry_key,omitempty"` + Title string `json:"title,omitempty"` + Path string `json:"path,omitempty"` UserID string `json:"user_id"` SessionID string `json:"session_id"` + ReadID string `json:"read_id,omitempty"` DurationSeconds int `json:"duration_seconds"` ScrollDepth float64 `json:"scroll_depth"` ViewedAt time.Time `json:"viewed_at"` } +type UserFavorite struct { + ID string `json:"id"` + UserID string `json:"user_id"` + ModuleKey string `json:"module_key"` + CreatedAt time.Time `json:"created_at"` +} + +type UserRecentDoc struct { + ID string `json:"id"` + UserID string `json:"user_id"` + DocID string `json:"doc_id"` + Title string `json:"title"` + ModuleKey string `json:"module_key"` + ModuleName string `json:"module_name"` + DocsVersion string `json:"docs_version"` + EntryKey string `json:"entry_key"` + Href string `json:"href"` + ViewedAt time.Time `json:"viewed_at"` +} + // PageStat is an aggregated reading-statistics row for one document page. type PageStat struct { DocID string `json:"doc_id"` @@ -308,3 +435,28 @@ type PageStat struct { AvgDurationSec int `json:"avg_duration_seconds"` LastViewedAt time.Time `json:"last_viewed_at"` } + +// DailyReadPoint is one day's read count for a single page (line-chart point). +type DailyReadPoint struct { + Date string `json:"date"` // YYYY-MM-DD (UTC) + Count int `json:"count"` +} + +// ReaderStat is one reader's aggregated read activity for a single page. +type ReaderStat struct { + Reader string `json:"reader"` // display name, username, or "匿名" + UserID string `json:"user_id"` + Count int `json:"count"` + AvgDurationSec int `json:"avg_duration_seconds"` + LastReadAt time.Time `json:"last_read_at"` +} + +// PageReadStats is the per-page reading detail surfaced behind the doc-page +// "eye" popover: a daily read trend plus a per-reader breakdown. +type PageReadStats struct { + DocID string `json:"doc_id"` + Total int `json:"total"` + AvgDurationSec int `json:"avg_duration_seconds"` + Daily []DailyReadPoint `json:"daily"` + Readers []ReaderStat `json:"readers"` +} diff --git a/backend/internal/store/oauth.go b/backend/internal/store/oauth.go new file mode 100644 index 0000000..ec708a3 --- /dev/null +++ b/backend/internal/store/oauth.go @@ -0,0 +1,315 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "time" +) + +const CodexOAuthClientID = "codex-cli" + +func builtinCodexOAuthApp(now time.Time) ConnectedApp { + return ConnectedApp{ + ID: "app-codex-cli", + Name: "Codex CLI MCP OAuth", + Description: "Built-in public OAuth client for Codex MCP login.", + ClientID: CodexOAuthClientID, + ClientSecretHash: "", + RedirectURIs: []string{"http://localhost", "http://127.0.0.1", "http://[::1]"}, + Scopes: []string{"modex:mcp:read", "modex:docs:read"}, + Trusted: true, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } +} + +func (s *MemoryStore) ConnectedApps() []ConnectedApp { + s.mu.RLock() + defer s.mu.RUnlock() + out := append([]ConnectedApp{}, s.apps...) + return out +} + +func (s *MemoryStore) ConnectedAppByClientID(clientID string) (ConnectedApp, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, app := range s.apps { + if app.ClientID == clientID { + return app, nil + } + } + return ConnectedApp{}, ErrNotFound +} + +func (s *MemoryStore) CreateConnectedApp(app ConnectedApp, clientSecret string) (ConnectedApp, error) { + s.mu.Lock() + defer s.mu.Unlock() + if strings.TrimSpace(app.Name) == "" || strings.TrimSpace(app.ClientID) == "" || len(app.RedirectURIs) == 0 { + return ConnectedApp{}, ErrInvalid + } + for _, existing := range s.apps { + if strings.EqualFold(existing.ClientID, app.ClientID) { + return ConnectedApp{}, ErrConflict + } + } + if app.ID == "" { + app.ID = s.nextIDLocked("app") + } + app.Name = strings.TrimSpace(app.Name) + app.ClientID = strings.TrimSpace(app.ClientID) + app.RedirectURIs = cleanNonEmpty(app.RedirectURIs) + app.Scopes = normalizeScopes(app.Scopes) + if len(app.Scopes) == 0 { + app.Scopes = []string{"modex:mcp:read"} + } + app.ClientSecretHash = hashToken(clientSecret) + now := time.Now().UTC() + app.CreatedAt = now + app.UpdatedAt = now + s.apps = append(s.apps, app) + return app, nil +} + +func (s *MemoryStore) UpdateConnectedApp(id string, patch ConnectedApp) (ConnectedApp, error) { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.apps { + if s.apps[i].ID != id { + continue + } + app := &s.apps[i] + if strings.TrimSpace(patch.Name) != "" { + app.Name = strings.TrimSpace(patch.Name) + } + app.Description = patch.Description + if patch.RedirectURIs != nil { + uris := cleanNonEmpty(patch.RedirectURIs) + if len(uris) == 0 { + return ConnectedApp{}, ErrInvalid + } + app.RedirectURIs = uris + } + if patch.Scopes != nil { + app.Scopes = normalizeScopes(patch.Scopes) + } + app.Trusted = patch.Trusted + app.Enabled = patch.Enabled + app.UpdatedAt = time.Now().UTC() + return *app, nil + } + return ConnectedApp{}, ErrNotFound +} + +func (s *MemoryStore) DeleteConnectedApp(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.apps { + if s.apps[i].ID == id { + s.apps = append(s.apps[:i], s.apps[i+1:]...) + now := time.Now().UTC() + for j := range s.grants { + if s.grants[j].AppID == id && s.grants[j].RevokedAt.IsZero() { + s.grants[j].RevokedAt = now + s.grants[j].UpdatedAt = now + } + } + return nil + } + } + return ErrNotFound +} + +func (s *MemoryStore) VerifyConnectedAppSecret(clientID, clientSecret string) (ConnectedApp, error) { + s.mu.RLock() + defer s.mu.RUnlock() + h := hashToken(clientSecret) + for _, app := range s.apps { + if app.ClientID == clientID && app.ClientSecretHash == h && app.Enabled { + return app, nil + } + } + return ConnectedApp{}, ErrNotFound +} + +func (s *MemoryStore) CreateOAuthCode(appID, userID, redirectURI string, scopes []string, code string, ttl time.Duration) (OAuthGrant, error) { + s.mu.Lock() + defer s.mu.Unlock() + if appID == "" || userID == "" || redirectURI == "" || code == "" { + return OAuthGrant{}, ErrInvalid + } + now := time.Now().UTC() + g := OAuthGrant{ + ID: s.nextIDLocked("grant"), + AppID: appID, + UserID: userID, + CodeHash: hashToken(code), + RedirectURI: redirectURI, + Scopes: normalizeScopes(scopes), + CodeExpiresAt: now.Add(ttl), + CreatedAt: now, + UpdatedAt: now, + } + s.grants = append(s.grants, g) + return g, nil +} + +func (s *MemoryStore) RedeemOAuthCode(clientID, code, redirectURI, accessToken, refreshToken string, accessTTL, refreshTTL time.Duration) (OAuthGrant, ConnectedApp, User, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + for i := range s.grants { + g := &s.grants[i] + if g.CodeHash != hashToken(code) || g.RedirectURI != redirectURI || !g.RevokedAt.IsZero() || now.After(g.CodeExpiresAt) { + continue + } + app, ok := s.appByIDLocked(g.AppID) + if !ok || app.ClientID != clientID || !app.Enabled { + return OAuthGrant{}, ConnectedApp{}, User{}, ErrNotFound + } + user, err := s.userByIDLocked(g.UserID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + g.CodeHash = "" + g.AccessTokenHash = hashToken(accessToken) + g.RefreshTokenHash = hashToken(refreshToken) + g.AccessExpiresAt = now.Add(accessTTL) + g.RefreshExpiresAt = now.Add(refreshTTL) + g.UpdatedAt = now + s.touchAppLocked(g.AppID, now) + return *g, app, user, nil + } + return OAuthGrant{}, ConnectedApp{}, User{}, ErrNotFound +} + +func (s *MemoryStore) RefreshOAuthToken(clientID, refreshToken, accessToken, nextRefreshToken string, accessTTL, refreshTTL time.Duration) (OAuthGrant, ConnectedApp, User, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + for i := range s.grants { + g := &s.grants[i] + if g.RefreshTokenHash != hashToken(refreshToken) || !g.RevokedAt.IsZero() || now.After(g.RefreshExpiresAt) { + continue + } + app, ok := s.appByIDLocked(g.AppID) + if !ok || app.ClientID != clientID || !app.Enabled { + return OAuthGrant{}, ConnectedApp{}, User{}, ErrNotFound + } + user, err := s.userByIDLocked(g.UserID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + g.AccessTokenHash = hashToken(accessToken) + g.RefreshTokenHash = hashToken(nextRefreshToken) + g.AccessExpiresAt = now.Add(accessTTL) + g.RefreshExpiresAt = now.Add(refreshTTL) + g.UpdatedAt = now + s.touchAppLocked(g.AppID, now) + return *g, app, user, nil + } + return OAuthGrant{}, ConnectedApp{}, User{}, ErrNotFound +} + +func (s *MemoryStore) UserByOAuthAccessToken(token string) (User, ConnectedApp, OAuthGrant, error) { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + h := hashToken(token) + for i := range s.grants { + g := &s.grants[i] + if g.AccessTokenHash != h || !g.RevokedAt.IsZero() || now.After(g.AccessExpiresAt) { + continue + } + app, ok := s.appByIDLocked(g.AppID) + if !ok || !app.Enabled { + return User{}, ConnectedApp{}, OAuthGrant{}, ErrNotFound + } + user, err := s.userByIDLocked(g.UserID) + if err != nil { + return User{}, ConnectedApp{}, OAuthGrant{}, err + } + g.UpdatedAt = now + s.touchAppLocked(g.AppID, now) + return user, app, *g, nil + } + return User{}, ConnectedApp{}, OAuthGrant{}, ErrNotFound +} + +func (s *MemoryStore) RevokeOAuthToken(clientID, token string) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().UTC() + h := hashToken(token) + revoked := false + for i := range s.grants { + g := &s.grants[i] + app, ok := s.appByIDLocked(g.AppID) + if !ok || app.ClientID != clientID { + continue + } + if g.AccessTokenHash == h || g.RefreshTokenHash == h || g.CodeHash == h { + g.RevokedAt = now + g.UpdatedAt = now + revoked = true + } + } + return revoked +} + +func (s *MemoryStore) appByIDLocked(id string) (ConnectedApp, bool) { + for _, app := range s.apps { + if app.ID == id { + return app, true + } + } + return ConnectedApp{}, false +} + +func (s *MemoryStore) touchAppLocked(id string, at time.Time) { + for i := range s.apps { + if s.apps[i].ID == id { + s.apps[i].LastUsedAt = at + s.apps[i].UpdatedAt = at + return + } + } +} + +func hashToken(token string) string { + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func cleanNonEmpty(items []string) []string { + var out []string + for _, item := range items { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + return out +} + +func normalizeScopes(scopes []string) []string { + seen := map[string]struct{}{} + var out []string + for _, scope := range scopes { + for _, part := range strings.FieldsFunc(scope, func(r rune) bool { return r == ' ' || r == ',' }) { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + out = append(out, part) + } + } + return out +} diff --git a/backend/internal/store/persist.go b/backend/internal/store/persist.go deleted file mode 100644 index bd07974..0000000 --- a/backend/internal/store/persist.go +++ /dev/null @@ -1,175 +0,0 @@ -package store - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" -) - -// snapshot is the serializable projection of the full in-memory store. It is -// used to persist state to disk so the registry survives process restarts until -// the relational (PostgreSQL) backend lands. -type snapshot struct { - Version int `json:"version"` - User User `json:"user"` - Users []User `json:"users"` - Groups []Group `json:"groups"` - Teams []Team `json:"teams"` - Categories []Category `json:"categories"` - Modules []Module `json:"modules"` - Versions []Version `json:"versions"` - Entries []Entry `json:"entries"` - Releases []Release `json:"releases"` - Pages []Page `json:"pages"` - SearchLogs []SearchLog `json:"search_logs"` - MCPLogs []MCPLog `json:"mcp_logs"` - PageViews []PageView `json:"page_views"` - Navs map[string][]NavItem `json:"navs"` - HTML map[string]string `json:"html"` - SiteFiles map[string]SiteFile `json:"site_files"` - Embeddings map[string][]float32 `json:"embeddings"` - Settings Settings `json:"settings"` - Seq int64 `json:"seq"` -} - -func (s *Store) toSnapshot() snapshot { - s.mu.RLock() - defer s.mu.RUnlock() - return snapshot{ - Version: 1, - User: s.user, - Users: s.users, - Groups: s.groups, - Teams: s.teams, - Categories: s.categories, - Modules: s.modules, - Versions: s.versions, - Entries: s.entries, - Releases: s.releases, - Pages: s.pages, - SearchLogs: s.searchLogs, - MCPLogs: s.mcpLogs, - PageViews: s.pageViews, - Navs: s.navs, - HTML: s.html, - SiteFiles: s.siteFiles, - Embeddings: s.embeddings, - Settings: s.settings, - Seq: s.seq, - } -} - -func storeFromSnapshot(snap snapshot) *Store { - s := &Store{ - user: snap.User, - users: snap.Users, - groups: snap.Groups, - teams: snap.Teams, - categories: snap.Categories, - modules: snap.Modules, - versions: snap.Versions, - entries: snap.Entries, - releases: snap.Releases, - pages: snap.Pages, - searchLogs: snap.SearchLogs, - mcpLogs: snap.MCPLogs, - pageViews: snap.PageViews, - navs: snap.Navs, - html: snap.HTML, - siteFiles: snap.SiteFiles, - embeddings: snap.Embeddings, - settings: snap.Settings, - seq: snap.Seq, - } - // Ensure slices are never nil (important for empty start and JSON nulls from old snapshots) - if s.users == nil { - s.users = []User{} - } - if s.groups == nil { - s.groups = []Group{} - } - if s.teams == nil { - s.teams = []Team{} - } - if s.categories == nil { - s.categories = []Category{} - } - if s.modules == nil { - s.modules = []Module{} - } - if s.versions == nil { - s.versions = []Version{} - } - if s.entries == nil { - s.entries = []Entry{} - } - if s.releases == nil { - s.releases = []Release{} - } - if s.pages == nil { - s.pages = []Page{} - } - if s.searchLogs == nil { - s.searchLogs = []SearchLog{} - } - if s.mcpLogs == nil { - s.mcpLogs = []MCPLog{} - } - if s.pageViews == nil { - s.pageViews = []PageView{} - } - if s.navs == nil { - s.navs = map[string][]NavItem{} - } - if s.html == nil { - s.html = map[string]string{} - } - if s.siteFiles == nil { - s.siteFiles = map[string]SiteFile{} - } - if s.embeddings == nil { - s.embeddings = map[string][]float32{} - } - if s.teams == nil { - s.teams = []Team{} - } - return s -} - -// Save atomically writes the store snapshot to path (writing to a temp file and -// renaming to avoid a torn file on crash). -func (s *Store) Save(path string) error { - if path == "" { - return fmt.Errorf("empty snapshot path") - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := json.Marshal(s.toSnapshot()) - if err != nil { - return err - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, path) -} - -// Load reads a store snapshot from path. It returns ErrNotFound when the file -// does not exist so callers can fall back to a freshly seeded store. -func Load(path string) (*Store, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, ErrNotFound - } - return nil, err - } - var snap snapshot - if err := json.Unmarshal(data, &snap); err != nil { - return nil, fmt.Errorf("snapshot %s: %w", path, err) - } - return storeFromSnapshot(snap), nil -} diff --git a/backend/internal/store/plugins.go b/backend/internal/store/plugins.go new file mode 100644 index 0000000..e3d3b5e --- /dev/null +++ b/backend/internal/store/plugins.go @@ -0,0 +1,170 @@ +package store + +import "strings" + +// PluginSetting is the persisted per-plugin override (enable flag + config +// values). It lives inside Settings, so it is snapshotted with everything else. +type PluginSetting struct { + Enabled bool `json:"enabled"` + Config map[string]string `json:"config,omitempty"` +} + +// PluginField describes one configurable value of a plugin for the admin UI. +type PluginField struct { + Key string `json:"key"` + Label string `json:"label"` + Placeholder string `json:"placeholder,omitempty"` + Default string `json:"default,omitempty"` +} + +// PluginDef is a static catalog entry for a built-in plugin. The catalog is the +// single source of truth; admins only toggle/configure these, they cannot add +// arbitrary third-party code. +type PluginDef struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + DefaultEnabled bool `json:"default_enabled"` + Fields []PluginField `json:"fields,omitempty"` +} + +// PluginState is a catalog entry merged with its saved override (admin view). +// Uploaded (imported) plugins carry extra fields so the admin UI can show their +// provenance, kind and source. +type PluginState struct { + PluginDef + Enabled bool `json:"enabled"` + Config map[string]string `json:"config"` + Uploaded bool `json:"uploaded,omitempty"` + Kind string `json:"kind,omitempty"` + Tag string `json:"tag,omitempty"` + Lang string `json:"lang,omitempty"` + Code string `json:"code,omitempty"` +} + +// pluginCatalog is the curated set of built-in doc-engine plugins. +var pluginCatalog = []PluginDef{ + {Key: "kroki", Name: "Kroki 图表", Category: "diagram", DefaultEnabled: true, + Description: "用 Kroki 渲染 PlantUML、Graphviz、C4、DITAA、D2 等图表代码块。", + Fields: []PluginField{{Key: "base_url", Label: "Kroki 服务地址", Placeholder: "https://kroki.io"}}}, + {Key: "mermaid", Name: "Mermaid 图表", Category: "diagram", DefaultEnabled: true, + Description: "渲染 Mermaid 流程图、时序图、甘特图等图表。"}, + {Key: "math", Name: "数学公式", Category: "math", DefaultEnabled: true, + Description: "用 KaTeX 渲染行内公式和块级 LaTeX 公式。"}, + {Key: "github_alerts", Name: "GitHub 提示块", Category: "content", DefaultEnabled: true, + Description: "把 GitHub 风格的 NOTE、WARNING 等提示块转为提示卡片。"}, + {Key: "toc", Name: "自动目录", Category: "content", DefaultEnabled: true, + Description: "把目录占位标记替换为页面内目录导航。"}, + {Key: "footnotes", Name: "脚注", Category: "content", DefaultEnabled: true, + Description: "渲染 Markdown 脚注。"}, + {Key: "snippets", Name: "可复用片段", Category: "content", DefaultEnabled: true, + Description: "支持变量插值与可复用内容片段。"}, + {Key: "openapi", Name: "OpenAPI / API 调试台", Category: "api", DefaultEnabled: true, + Description: "渲染交互式 API 调试台与从 OpenAPI 规范生成的接口参考。", + Fields: []PluginField{{Key: "default_spec_url", Label: "默认 OpenAPI 规范地址", Placeholder: "https://example.com/openapi.json"}}}, +} + +// PluginCatalog returns the static built-in plugin catalog. +func PluginCatalog() []PluginDef { return pluginCatalog } + +// mergePlugins overlays saved overrides onto the catalog defaults, then appends +// uploaded plugins (which default to disabled). +func mergePlugins(overrides map[string]PluginSetting, uploaded []UploadedPlugin) []PluginState { + out := make([]PluginState, 0, len(pluginCatalog)+len(uploaded)) + for _, def := range pluginCatalog { + st := PluginState{PluginDef: def, Enabled: def.DefaultEnabled, Config: map[string]string{}} + for _, f := range def.Fields { + if f.Default != "" { + st.Config[f.Key] = f.Default + } + } + if ov, ok := overrides[def.Key]; ok { + st.Enabled = ov.Enabled + for k, v := range ov.Config { + st.Config[k] = v + } + } + out = append(out, st) + } + for _, up := range uploaded { + st := PluginState{ + PluginDef: PluginDef{Key: up.Key, Name: up.Name, Description: up.Description, Category: up.Category, DefaultEnabled: false}, + Enabled: false, + Config: map[string]string{}, + Uploaded: true, + Kind: up.Kind, + Tag: up.Tag, + Lang: up.Lang, + Code: up.Code, + } + if ov, ok := overrides[up.Key]; ok { + st.Enabled = ov.Enabled + } + out = append(out, st) + } + return out +} + +// PluginStates returns the catalog merged with saved overrides (admin view). +func (s *MemoryStore) PluginStates() []PluginState { + s.mu.RLock() + overrides := s.settings.Plugins + uploaded := s.settings.UploadedPlugins + s.mu.RUnlock() + return mergePlugins(overrides, uploaded) +} + +// SavePluginSettings persists enable/config overrides, ignoring unknown plugin +// keys and config fields not declared in the catalog. +func (s *MemoryStore) SavePluginSettings(overrides map[string]PluginSetting) []PluginState { + allowed := map[string]map[string]bool{} + for _, def := range pluginCatalog { + fs := map[string]bool{} + for _, f := range def.Fields { + fs[f.Key] = true + } + allowed[def.Key] = fs + } + s.mu.RLock() + uploaded := s.settings.UploadedPlugins + for _, up := range uploaded { + if _, ok := allowed[up.Key]; !ok { + allowed[up.Key] = map[string]bool{} // uploaded plugins: enable flag only + } + } + s.mu.RUnlock() + + clean := map[string]PluginSetting{} + for key, ov := range overrides { + fields, known := allowed[key] + if !known { + continue + } + cfg := map[string]string{} + for k, v := range ov.Config { + if fields[k] { + if tv := strings.TrimSpace(v); tv != "" { + cfg[k] = tv + } + } + } + clean[key] = PluginSetting{Enabled: ov.Enabled, Config: cfg} + } + s.mu.Lock() + s.settings.Plugins = clean + uploaded = s.settings.UploadedPlugins + s.mu.Unlock() + return mergePlugins(clean, uploaded) +} + +// PluginEffective returns a slim enabled+config map for the public config API, +// consumed by the doc renderer to drive conditional plugins. +func (s *MemoryStore) PluginEffective() map[string]PluginSetting { + states := s.PluginStates() + out := make(map[string]PluginSetting, len(states)) + for _, st := range states { + out[st.Key] = PluginSetting{Enabled: st.Enabled, Config: st.Config} + } + return out +} diff --git a/backend/internal/store/plugins_test.go b/backend/internal/store/plugins_test.go new file mode 100644 index 0000000..0915fa5 --- /dev/null +++ b/backend/internal/store/plugins_test.go @@ -0,0 +1,68 @@ +package store + +import "testing" + +func findState(states []PluginState, key string) (PluginState, bool) { + for _, s := range states { + if s.Key == key { + return s, true + } + } + return PluginState{}, false +} + +func TestPluginStatesDefaults(t *testing.T) { + states := NewTestStore().PluginStates() + if len(states) != len(pluginCatalog) { + t.Fatalf("states = %d, want %d", len(states), len(pluginCatalog)) + } + kroki, ok := findState(states, "kroki") + if !ok { + t.Fatal("kroki plugin missing from catalog") + } + if !kroki.Enabled { + t.Error("kroki should be enabled by default") + } + if kroki.Category != "diagram" || len(kroki.Fields) == 0 || kroki.Fields[0].Key != "base_url" { + t.Errorf("kroki def unexpected: %+v", kroki.PluginDef) + } +} + +func TestSavePluginSettingsOverrideAndFilter(t *testing.T) { + st := NewTestStore() + st.SavePluginSettings(map[string]PluginSetting{ + "kroki": {Enabled: false, Config: map[string]string{"base_url": " http://kroki.internal:8000 ", "bogus": "x"}}, + "unknown": {Enabled: true}, + }) + states := st.PluginStates() + + kroki, _ := findState(states, "kroki") + if kroki.Enabled { + t.Error("kroki should be disabled after override") + } + if got := kroki.Config["base_url"]; got != "http://kroki.internal:8000" { + t.Errorf("base_url = %q, want trimmed value", got) + } + if _, ok := kroki.Config["bogus"]; ok { + t.Error("unknown config field should be dropped") + } + if _, ok := findState(states, "unknown"); ok { + t.Error("unknown plugin key should not appear") + } + // A plugin we did not touch keeps its default-enabled state. + if math, _ := findState(states, "math"); !math.Enabled { + t.Error("untouched math plugin should stay enabled") + } +} + +func TestPluginEffective(t *testing.T) { + st := NewTestStore() + st.SavePluginSettings(map[string]PluginSetting{"math": {Enabled: false}}) + eff := st.PluginEffective() + if eff["math"].Enabled { + t.Error("math should be disabled in effective config") + } + if !eff["kroki"].Enabled { + t.Error("kroki should remain enabled in effective config") + } +} diff --git a/backend/internal/store/postgres_analytics.go b/backend/internal/store/postgres_analytics.go new file mode 100644 index 0000000..d6c6bdb --- /dev/null +++ b/backend/internal/store/postgres_analytics.go @@ -0,0 +1,282 @@ +package store + +import ( + "context" + "sort" + "strings" + "time" +) + +func (p *PostgresRepository) AddSearchLog(log SearchLog) { + if log.ID == "" { + log.ID = databaseID("sl") + } + if log.SearchedAt.IsZero() { + log.SearchedAt = time.Now().UTC() + } + _, _ = p.pool.Exec(context.Background(), `INSERT INTO docs_search_log(id,user_id,ip_address,query,mode,filters_json,result_count,clicked_doc_id,searched_at) VALUES($1,$2,$3,$4,$5,NULLIF($6,'')::jsonb,$7,$8,$9)`, log.ID, log.UserID, log.IPAddress, log.Query, log.Mode, log.FiltersJSON, log.ResultCount, log.ClickedDocID, log.SearchedAt) +} +func (p *PostgresRepository) SearchLogs() []SearchLog { + var logs []SearchLog + if err := loadQuery(context.Background(), p.pool, `SELECT (to_jsonb(t)-'filters_json')||jsonb_build_object('filters_json',COALESCE(filters_json::text,'')) FROM docs_search_log t ORDER BY searched_at DESC,id`, &logs); err != nil { + return []SearchLog{} + } + return logs +} +func (p *PostgresRepository) AddMCPLog(log MCPLog) { + if log.ID == "" { + log.ID = databaseID("ml") + } + if log.CreatedAt.IsZero() { + log.CreatedAt = time.Now().UTC() + } + _, _ = p.pool.Exec(context.Background(), `INSERT INTO docs_mcp_log(id,tool_name,user_id,query,input_json,result_count,created_at) VALUES($1,$2,$3,$4,NULLIF($5,'')::jsonb,$6,$7)`, log.ID, log.ToolName, log.UserID, log.Query, log.InputJSON, log.ResultCount, log.CreatedAt) +} +func (p *PostgresRepository) MCPLogs() []MCPLog { + var logs []MCPLog + if err := loadQuery(context.Background(), p.pool, `SELECT (to_jsonb(t)-'input_json')||jsonb_build_object('input_json',COALESCE(input_json::text,'')) FROM docs_mcp_log t ORDER BY created_at DESC,id`, &logs); err != nil { + return []MCPLog{} + } + return logs +} + +func (p *PostgresRepository) AddDocFeedback(feedback DocFeedback) DocFeedback { + if feedback.ID == "" { + feedback.ID = databaseID("df") + } + if feedback.CreatedAt.IsZero() { + feedback.CreatedAt = time.Now().UTC() + } + if page, err := p.Page(feedback.DocID); err == nil { + feedback.PageID = page.ID + feedback.ModuleKey = page.ModuleKey + feedback.Title = page.Title + } + saved, err := queryJSONOne[DocFeedback](context.Background(), p.pool, `INSERT INTO docs_feedback(id,doc_id,page_id,module_key,title,rating,comment,user_id,session_id,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING to_jsonb(docs_feedback)`, feedback.ID, feedback.DocID, feedback.PageID, feedback.ModuleKey, feedback.Title, feedback.Rating, feedback.Comment, feedback.UserID, feedback.SessionID, feedback.CreatedAt) + if err != nil { + return feedback + } + return saved +} +func (p *PostgresRepository) DocFeedbacks() []DocFeedback { + var values []DocFeedback + if err := loadQuery(context.Background(), p.pool, `SELECT to_jsonb(t) FROM docs_feedback t ORDER BY created_at DESC,id`, &values); err != nil { + return []DocFeedback{} + } + return values +} + +func (p *PostgresRepository) enrichPageView(view *PageView) { + if page, err := p.Page(view.DocID); err == nil { + view.PageID = page.ID + view.ModuleKey = page.ModuleKey + view.ModuleName = page.ModuleName + view.DocsVersion = page.DocsVersion + view.EntryKey = page.EntryKey + view.Title = page.Title + view.Path = page.Path + } +} +func (p *PostgresRepository) RecordPageView(view PageView) PageView { + if view.ID == "" { + view.ID = databaseID("pv") + } + if view.ViewedAt.IsZero() { + view.ViewedAt = time.Now().UTC() + } + p.enrichPageView(&view) + moduleID, versionID := "", "" + if view.ModuleKey != "" { + if module, err := p.Module(view.ModuleKey); err == nil { + moduleID = module.ID + for _, version := range module.AvailableVers { + if version.DocsVersion == view.DocsVersion { + versionID = version.ID + break + } + } + } + } + saved, err := queryJSONOne[PageView](context.Background(), p.pool, `INSERT INTO docs_page_view(id,page_id,module_id,version_id,doc_id,module_key,module_name,docs_version,entry_key,title,path,user_id,session_id,read_id,duration_seconds,scroll_depth,viewed_at) VALUES($1,NULLIF($2,''),NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8,$9,$10,$11,NULLIF($12,''),$13,$14,$15,$16,$17) RETURNING (to_jsonb(docs_page_view)-'module_id'-'version_id')`, view.ID, view.PageID, moduleID, versionID, view.DocID, view.ModuleKey, view.ModuleName, view.DocsVersion, view.EntryKey, view.Title, view.Path, view.UserID, view.SessionID, view.ReadID, view.DurationSeconds, view.ScrollDepth, view.ViewedAt) + if err != nil { + return view + } + return saved +} +func (p *PostgresRepository) RecordReadProgress(docID, sessionID, readID string, durationSeconds int, scrollDepth float64) PageView { + ctx := context.Background() + condition := `doc_id=$1 AND (($2<>'' AND read_id=$2) OR ($2='' AND session_id=$3))` + view, err := queryJSONOne[PageView](ctx, p.pool, `UPDATE docs_page_view SET duration_seconds=GREATEST(COALESCE(duration_seconds,0),$4),scroll_depth=GREATEST(COALESCE(scroll_depth,0),$5) WHERE id=(SELECT id FROM docs_page_view WHERE `+condition+` ORDER BY viewed_at DESC LIMIT 1) RETURNING (to_jsonb(docs_page_view)-'module_id'-'version_id')`, docID, readID, sessionID, durationSeconds, scrollDepth) + if err == nil { + return view + } + return p.RecordPageView(PageView{DocID: docID, SessionID: sessionID, ReadID: readID, DurationSeconds: durationSeconds, ScrollDepth: scrollDepth}) +} + +func (p *PostgresRepository) pageViews() []PageView { + var values []PageView + if err := loadQuery(context.Background(), p.pool, `SELECT (to_jsonb(t)-'module_id'-'version_id') FROM docs_page_view t ORDER BY viewed_at,id`, &values); err != nil { + return []PageView{} + } + return values +} +func (p *PostgresRepository) PageAnalytics() []PageStat { + var values []PageStat + query := `SELECT jsonb_build_object( + 'doc_id',p.doc_id,'title',p.title,'module_key',m.module_key,'module_name',m.name, + 'docs_version',v.docs_version,'path',p.path,'pv',count(pv.id), + 'uv',count(DISTINCT COALESCE(NULLIF(pv.user_id,''),NULLIF(pv.session_id,''))), + 'reads_7d',CASE WHEN count(pv.id)=0 THEN m.reads_7d ELSE count(pv.id) FILTER(WHERE pv.viewed_at>now()-interval '7 days') END, + 'reads_30d',CASE WHEN count(pv.id)=0 THEN m.reads_30d ELSE count(pv.id) FILTER(WHERE pv.viewed_at>now()-interval '30 days') END, + 'avg_duration_seconds',COALESCE(avg(NULLIF(pv.duration_seconds,0))::int,0), + 'last_viewed_at',COALESCE(max(pv.viewed_at),p.updated_at) + ) FROM docs_page p JOIN docs_module m ON m.id=p.module_id JOIN docs_version v ON v.id=p.version_id LEFT JOIN docs_page_view pv ON pv.doc_id=p.doc_id GROUP BY p.id,m.id,v.id ORDER BY count(pv.id) DESC,m.reads_30d DESC` + if err := loadQuery(context.Background(), p.pool, query, &values); err != nil { + return []PageStat{} + } + return values +} +func (p *PostgresRepository) PageReadStats(docID string, days int) PageReadStats { + if days <= 0 { + days = 30 + } + now := time.Now().UTC() + today := now.Truncate(24 * time.Hour) + daily := make([]DailyReadPoint, days) + index := map[string]int{} + for i := 0; i < days; i++ { + date := today.AddDate(0, 0, -(days - 1 - i)).Format("2006-01-02") + daily[i] = DailyReadPoint{Date: date} + index[date] = i + } + type readerAggregate struct { + userID string + count, duration int + last time.Time + } + readers := map[string]*readerAggregate{} + total, totalDuration, timed := 0, 0, 0 + window := today.AddDate(0, 0, -(days - 1)) + for _, view := range p.pageViews() { + if view.DocID != docID { + continue + } + total++ + if view.DurationSeconds > 0 { + totalDuration += view.DurationSeconds + timed++ + } + if !view.ViewedAt.Before(window) { + if i, ok := index[view.ViewedAt.UTC().Format("2006-01-02")]; ok { + daily[i].Count++ + } + } + key := view.UserID + if key == "" { + key = "session:" + view.SessionID + } + aggregate := readers[key] + if aggregate == nil { + aggregate = &readerAggregate{userID: view.UserID} + readers[key] = aggregate + } + aggregate.count++ + aggregate.duration += view.DurationSeconds + if view.ViewedAt.After(aggregate.last) { + aggregate.last = view.ViewedAt + } + } + result := PageReadStats{DocID: docID, Total: total, Daily: daily, Readers: []ReaderStat{}} + if timed > 0 { + result.AvgDurationSec = totalDuration / timed + } + users := map[string]User{} + for _, user := range p.Users("") { + users[user.ID] = user + } + for _, aggregate := range readers { + name := "匿名" + if aggregate.userID != "" { + if user, ok := users[aggregate.userID]; ok { + name = firstNonEmpty(user.DisplayName, user.Username) + } else { + name = aggregate.userID + } + } + average := 0 + if aggregate.count > 0 { + average = aggregate.duration / aggregate.count + } + result.Readers = append(result.Readers, ReaderStat{Reader: name, UserID: aggregate.userID, Count: aggregate.count, AvgDurationSec: average, LastReadAt: aggregate.last}) + } + sort.Slice(result.Readers, func(i, j int) bool { + if result.Readers[i].Count != result.Readers[j].Count { + return result.Readers[i].Count > result.Readers[j].Count + } + return result.Readers[i].LastReadAt.After(result.Readers[j].LastReadAt) + }) + return result +} + +func (p *PostgresRepository) UserFavorites(userID string) []UserFavorite { + var values []UserFavorite + if err := loadQuery(context.Background(), p.pool, `SELECT to_jsonb(t) FROM user_favorite t WHERE user_id=$1 ORDER BY created_at DESC`, &values, userID); err != nil { + return []UserFavorite{} + } + return values +} +func (p *PostgresRepository) SetUserFavorite(userID, moduleKey string, favorite bool) ([]UserFavorite, error) { + userID = strings.TrimSpace(userID) + moduleKey = strings.TrimSpace(moduleKey) + if userID == "" || moduleKey == "" { + return nil, ErrInvalid + } + if _, err := p.Module(moduleKey); err != nil { + return nil, err + } + ctx := context.Background() + if favorite { + _, err := p.pool.Exec(ctx, `INSERT INTO user_favorite(id,user_id,module_key,created_at) VALUES($1,$2,$3,now()) ON CONFLICT(user_id,module_key) DO NOTHING`, databaseID("fav"), userID, moduleKey) + if err != nil { + return nil, err + } + } else { + if _, err := p.pool.Exec(ctx, `DELETE FROM user_favorite WHERE user_id=$1 AND module_key=$2`, userID, moduleKey); err != nil { + return nil, err + } + } + return p.UserFavorites(userID), nil +} +func (p *PostgresRepository) UserRecentDocs(userID string, limit int) []UserRecentDoc { + if limit <= 0 { + limit = 30 + } + var values []UserRecentDoc + if err := loadQuery(context.Background(), p.pool, `SELECT to_jsonb(t) FROM user_recent_doc t WHERE user_id=$1 ORDER BY viewed_at DESC LIMIT $2`, &values, userID, limit); err != nil { + return []UserRecentDoc{} + } + return values +} +func (p *PostgresRepository) RecordUserRecentDoc(userID string, recent UserRecentDoc) (UserRecentDoc, error) { + userID = strings.TrimSpace(userID) + recent.DocID = strings.TrimSpace(recent.DocID) + if userID == "" || recent.DocID == "" { + return UserRecentDoc{}, ErrInvalid + } + if page, err := p.Page(recent.DocID); err == nil { + recent.Title = firstNonEmpty(recent.Title, page.Title) + recent.ModuleKey = firstNonEmpty(recent.ModuleKey, page.ModuleKey) + recent.ModuleName = firstNonEmpty(recent.ModuleName, page.ModuleName) + recent.DocsVersion = firstNonEmpty(recent.DocsVersion, page.DocsVersion) + recent.EntryKey = firstNonEmpty(recent.EntryKey, page.EntryKey) + recent.Href = firstNonEmpty(recent.Href, "/docs/"+page.ModuleKey+"/"+page.DocsVersion+"/"+page.EntryKey) + } + if recent.ID == "" { + recent.ID = databaseID("recent") + } + recent.UserID = userID + if recent.ViewedAt.IsZero() { + recent.ViewedAt = time.Now().UTC() + } + return queryJSONOne[UserRecentDoc](context.Background(), p.pool, `INSERT INTO user_recent_doc(id,user_id,doc_id,title,module_key,module_name,docs_version,entry_key,href,viewed_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(user_id,doc_id) DO UPDATE SET title=EXCLUDED.title,module_key=EXCLUDED.module_key,module_name=EXCLUDED.module_name,docs_version=EXCLUDED.docs_version,entry_key=EXCLUDED.entry_key,href=EXCLUDED.href,viewed_at=EXCLUDED.viewed_at RETURNING to_jsonb(user_recent_doc)`, recent.ID, recent.UserID, recent.DocID, recent.Title, recent.ModuleKey, recent.ModuleName, recent.DocsVersion, recent.EntryKey, recent.Href, recent.ViewedAt) +} diff --git a/backend/internal/store/postgres_catalog.go b/backend/internal/store/postgres_catalog.go new file mode 100644 index 0000000..be718df --- /dev/null +++ b/backend/internal/store/postgres_catalog.go @@ -0,0 +1,690 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +func (p *PostgresRepository) AllCategories() []Category { + var categories []Category + if err := loadQuery(context.Background(), p.pool, `SELECT (to_jsonb(c)-'created_at'-'updated_at') || jsonb_build_object( + 'responsible_team_info', + CASE WHEN t.id IS NULL THEN NULL ELSE jsonb_build_object( + 'key', t.key, + 'name', t.name, + 'description', t.description, + 'leaders', t.leaders, + 'members', t.members + ) END + ) + FROM docs_category c + LEFT JOIN teams t ON lower(t.key)=lower(c.responsible_team) + ORDER BY c.sort_order,c.id`, &categories); err != nil { + return []Category{} + } + return categories +} + +func (p *PostgresRepository) CategoryName(id string) string { + var name string + if err := p.pool.QueryRow(context.Background(), `SELECT name FROM docs_category WHERE id=$1`, id).Scan(&name); err != nil { + return id + } + return name +} + +func (p *PostgresRepository) categoryPath(ids []string) string { + if len(ids) == 0 { + return "" + } + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, p.CategoryName(id)) + } + return strings.Join(parts, " / ") +} + +func (p *PostgresRepository) CategoryTree() []Category { + byParent := map[string][]Category{} + for _, category := range p.AllCategories() { + category.Children = nil + byParent[category.ParentID] = append(byParent[category.ParentID], category) + } + var attach func(string) []Category + attach = func(parent string) []Category { + nodes := byParent[parent] + sort.SliceStable(nodes, func(i, j int) bool { return nodes[i].SortOrder < nodes[j].SortOrder }) + for i := range nodes { + nodes[i].Children = attach(nodes[i].ID) + } + return nodes + } + result := attach("") + if result == nil { + return []Category{} + } + return result +} + +func (p *PostgresRepository) createCategoryKey(ctx context.Context, name, parentID string) string { + base := slugifyKey(name) + if base == "" { + base = "d" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) + } + prefix := "" + _ = p.pool.QueryRow(ctx, `SELECT key||'.' FROM docs_category WHERE id=$1`, parentID).Scan(&prefix) + for index := 1; ; index++ { + key := prefix + base + if index > 1 { + key += "-" + strconv.Itoa(index) + } + var exists bool + _ = p.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM docs_category WHERE key=$1 OR id=$1)`, key).Scan(&exists) + if !exists { + return key + } + } +} + +func (p *PostgresRepository) CreateCategory(category Category) (Category, error) { + ctx := context.Background() + if strings.TrimSpace(category.Key) == "" { + if strings.TrimSpace(category.Name) == "" { + return Category{}, ErrInvalid + } + category.Key = p.createCategoryKey(ctx, category.Name, category.ParentID) + } + if category.ID == "" { + category.ID = category.Key + } + if category.Status == "" { + category.Status = "active" + } + created, err := queryJSONOne[Category](ctx, p.pool, `INSERT INTO docs_category(id,parent_id,key,name,description,icon,sort_order,status,responsible_team) VALUES($1,NULLIF($2,''),$3,$4,$5,$6,$7,$8,$9) RETURNING to_jsonb(docs_category)-'created_at'-'updated_at'`, category.ID, category.ParentID, category.Key, category.Name, category.Description, category.Icon, category.SortOrder, category.Status, category.ResponsibleTeam) + return created, postgresError(err) +} + +func (p *PostgresRepository) MoveCategory(id, parentID string, index int) (Category, error) { + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return Category{}, err + } + defer tx.Rollback(ctx) + category, err := queryJSONOne[Category](ctx, tx, `SELECT to_jsonb(c)-'created_at'-'updated_at' FROM docs_category c WHERE id=$1 FOR UPDATE`, id) + if err != nil { + return Category{}, err + } + if parentID == id { + return Category{}, ErrInvalid + } + if parentID != "" { + var valid bool + if err = tx.QueryRow(ctx, `WITH RECURSIVE parents AS (SELECT id,parent_id FROM docs_category WHERE id=$1 UNION ALL SELECT c.id,c.parent_id FROM docs_category c JOIN parents p ON c.id=p.parent_id) SELECT EXISTS(SELECT 1 FROM docs_category WHERE id=$1) AND NOT EXISTS(SELECT 1 FROM parents WHERE id=$2)`, parentID, id).Scan(&valid); err != nil || !valid { + return Category{}, ErrInvalid + } + } + rows, err := tx.Query(ctx, `SELECT id FROM docs_category WHERE parent_id IS NOT DISTINCT FROM NULLIF($1,'') AND id<>$2 ORDER BY sort_order,id FOR UPDATE`, parentID, id) + if err != nil { + return Category{}, err + } + var siblings []string + for rows.Next() { + var sibling string + if err = rows.Scan(&sibling); err != nil { + rows.Close() + return Category{}, err + } + siblings = append(siblings, sibling) + } + rows.Close() + if index < 0 { + index = 0 + } + if index > len(siblings) { + index = len(siblings) + } + siblings = append(siblings, "") + copy(siblings[index+1:], siblings[index:]) + siblings[index] = id + if _, err = tx.Exec(ctx, `UPDATE docs_category SET parent_id=NULLIF($2,'') WHERE id=$1`, id, parentID); err != nil { + return Category{}, err + } + for position, sibling := range siblings { + if _, err = tx.Exec(ctx, `UPDATE docs_category SET sort_order=$2 WHERE id=$1`, sibling, (position+1)*10); err != nil { + return Category{}, err + } + } + category.ParentID, category.SortOrder = parentID, (index+1)*10 + return category, tx.Commit(ctx) +} + +func (p *PostgresRepository) UpdateCategory(id string, patch Category) (Category, error) { + current, err := queryJSONOne[Category](context.Background(), p.pool, `SELECT to_jsonb(c)-'created_at'-'updated_at' FROM docs_category c WHERE id=$1`, id) + if err != nil { + return Category{}, err + } + if patch.Name != "" { + current.Name = patch.Name + } + if patch.Description != "" { + current.Description = patch.Description + } + if patch.Icon != "" { + current.Icon = patch.Icon + } + if patch.SortOrder != 0 { + current.SortOrder = patch.SortOrder + } + if patch.Status != "" { + current.Status = patch.Status + } + if patch.ParentID != "" { + current.ParentID = patch.ParentID + } + current.ResponsibleTeam = patch.ResponsibleTeam + return queryJSONOne[Category](context.Background(), p.pool, `UPDATE docs_category SET parent_id=NULLIF($2,''),name=$3,description=$4,icon=$5,sort_order=$6,status=$7,responsible_team=$8 WHERE id=$1 RETURNING to_jsonb(docs_category)-'created_at'-'updated_at'`, id, current.ParentID, current.Name, current.Description, current.Icon, current.SortOrder, current.Status, current.ResponsibleTeam) +} + +func (p *PostgresRepository) DeleteCategory(id string) error { + command, err := p.pool.Exec(context.Background(), `DELETE FROM docs_category WHERE id=$1 AND NOT EXISTS(SELECT 1 FROM docs_category WHERE parent_id=$1)`, id) + if err != nil { + return postgresError(err) + } + if command.RowsAffected() == 0 { + var exists bool + _ = p.pool.QueryRow(context.Background(), `SELECT EXISTS(SELECT 1 FROM docs_category WHERE id=$1)`, id).Scan(&exists) + if exists { + return ErrConflict + } + return ErrNotFound + } + return nil +} + +func (p *PostgresRepository) Module(moduleKey string) (Module, error) { + var raw []byte + var deployToken string + err := p.pool.QueryRow(context.Background(), `SELECT (to_jsonb(m)-'default_version_id'-'created_at'-'deploy_token') || jsonb_build_object('category_ids',COALESCE((SELECT jsonb_agg(mc.category_id ORDER BY mc.is_primary DESC,mc.category_id) FROM docs_module_category mc WHERE mc.module_id=m.id),'[]'::jsonb),'category_path',COALESCE(NULLIF(m.category_path,''),(SELECT string_agg(c.name,' / ' ORDER BY mc.is_primary DESC,mc.category_id) FROM docs_module_category mc JOIN docs_category c ON c.id=mc.category_id WHERE mc.module_id=m.id),''),'deploy_token_set',COALESCE(m.deploy_token,'')<>''),COALESCE(m.deploy_token,'') FROM docs_module m WHERE lower(module_key)=lower($1)`, moduleKey).Scan(&raw, &deployToken) + if errors.Is(err, pgx.ErrNoRows) { + return Module{}, ErrNotFound + } + if err != nil { + return Module{}, err + } + var module Module + if err = json.Unmarshal(raw, &module); err != nil { + return Module{}, err + } + module.DeployToken = deployToken + module.AvailableVers = p.Versions(module.ModuleKey) + return module, nil +} + +func (p *PostgresRepository) ModuleByDeployToken(token string) (Module, error) { + token = strings.TrimSpace(token) + if token == "" { + return Module{}, ErrNotFound + } + var raw []byte + var deployToken string + err := p.pool.QueryRow(context.Background(), `SELECT (to_jsonb(m)-'default_version_id'-'created_at'-'deploy_token') || jsonb_build_object('category_ids',COALESCE((SELECT jsonb_agg(mc.category_id ORDER BY mc.is_primary DESC,mc.category_id) FROM docs_module_category mc WHERE mc.module_id=m.id),'[]'::jsonb),'category_path',COALESCE(NULLIF(m.category_path,''),(SELECT string_agg(c.name,' / ' ORDER BY mc.is_primary DESC,mc.category_id) FROM docs_module_category mc JOIN docs_category c ON c.id=mc.category_id WHERE mc.module_id=m.id),''),'deploy_token_set',COALESCE(m.deploy_token,'')<>''),COALESCE(m.deploy_token,'') FROM docs_module m WHERE deploy_token=$1`, token).Scan(&raw, &deployToken) + if errors.Is(err, pgx.ErrNoRows) { + return Module{}, ErrNotFound + } + if err != nil { + return Module{}, err + } + var module Module + if err = json.Unmarshal(raw, &module); err != nil { + return Module{}, err + } + module.DeployToken = deployToken + module.AvailableVers = p.Versions(module.ModuleKey) + return module, nil +} + +func (p *PostgresRepository) uniqueModuleKey(name string) string { + base := slugifyKey(name) + if base == "" { + base = "doc-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) + } + for index := 1; ; index++ { + key := base + if index > 1 { + key += "-" + strconv.Itoa(index) + } + var exists bool + _ = p.pool.QueryRow(context.Background(), `SELECT EXISTS(SELECT 1 FROM docs_module WHERE lower(module_key)=lower($1))`, key).Scan(&exists) + if !exists { + return key + } + } +} + +func (p *PostgresRepository) CreateModule(module Module) (Module, error) { + if strings.TrimSpace(module.ModuleKey) == "" { + if strings.TrimSpace(module.Name) == "" { + return Module{}, ErrInvalid + } + module.ModuleKey = p.uniqueModuleKey(module.Name) + } + if module.ID == "" { + module.ID = databaseID("m") + } + if module.DeployToken == "" { + module.DeployToken = "mdx_" + strconv.FormatInt(time.Now().UnixNano(), 36) + } + if module.Name == "" { + module.Name = module.ModuleKey + } + if module.Status == "" { + module.Status = "active" + } + if module.DefaultVersion == "" { + module.DefaultVersion = "latest" + } + if module.CategoryPath == "" { + module.CategoryPath = p.categoryPath(module.CategoryIDs) + } + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return Module{}, err + } + defer tx.Rollback(ctx) + created, err := queryJSONOne[Module](ctx, tx, `INSERT INTO docs_module(id,module_key,name,description,owner_group,repo_type,repo_url,default_version,visibility,status,package_name,package_version,channel,edition,keywords,maintainers,category_path,source_type,doc_type,mount,gitlab_branch,gitlab_path,deploy_token,last_synced_commit,last_synced_at,created_by,reads_7d,reads_30d,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15::jsonb,$16::jsonb,$17,$18,$19,$20,$21,$22,$23,$24,NULLIF($25,'')::timestamptz,NULLIF($26,''),$27,$28,now()) RETURNING to_jsonb(docs_module)-'default_version_id'-'created_at'`, module.ID, module.ModuleKey, module.Name, module.Description, module.OwnerGroup, module.RepoType, module.RepoURL, module.DefaultVersion, module.Visibility, module.Status, module.PackageName, module.PackageVersion, module.Channel, module.Edition, mustJSON(module.Keywords), mustJSON(module.Maintainers), module.CategoryPath, module.SourceType, module.DocType, module.Mount, module.GitLabBranch, module.GitLabPath, module.DeployToken, module.LastSyncedCommit, timeText(module.LastSyncedAt), module.CreatedBy, module.Reads7d, module.Reads30d) + if err != nil { + return Module{}, postgresError(err) + } + if err = p.syncModuleCategories(ctx, tx, created.ID, module.CategoryIDs); err != nil { + return Module{}, err + } + created.CategoryIDs = cloneStrings(module.CategoryIDs) + created.DeployToken = module.DeployToken + created.DeployTokenSet = created.DeployToken != "" + return created, tx.Commit(ctx) +} + +func (p *PostgresRepository) syncModuleCategories(ctx context.Context, tx interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +}, moduleID string, ids []string) error { + if _, err := tx.Exec(ctx, `DELETE FROM docs_module_category WHERE module_id=$1`, moduleID); err != nil { + return err + } + for index, id := range ids { + if _, err := tx.Exec(ctx, `INSERT INTO docs_module_category(module_id,category_id,is_primary) VALUES($1,$2,$3)`, moduleID, id, index == 0); err != nil { + return err + } + } + return nil +} + +func (p *PostgresRepository) UpdateModule(moduleKey string, patch Module) (Module, error) { + current, err := p.Module(moduleKey) + if err != nil { + return Module{}, err + } + if patch.Name != "" { + current.Name = patch.Name + } + if patch.Description != "" { + current.Description = patch.Description + } + if patch.OwnerGroup != "" { + current.OwnerGroup = patch.OwnerGroup + } + if patch.RepoType != "" { + current.RepoType = patch.RepoType + } + if patch.RepoURL != "" { + current.RepoURL = patch.RepoURL + } + if patch.DefaultVersion != "" { + current.DefaultVersion = patch.DefaultVersion + } + if patch.Visibility != "" { + current.Visibility = patch.Visibility + } + if patch.Status != "" { + current.Status = patch.Status + } + if patch.PackageVersion != "" { + current.PackageVersion = patch.PackageVersion + } + if patch.Channel != "" { + current.Channel = patch.Channel + } + if patch.Edition != "" { + current.Edition = patch.Edition + } + if patch.Keywords != nil { + current.Keywords = patch.Keywords + } + if patch.Maintainers != nil { + current.Maintainers = patch.Maintainers + } + if patch.CategoryIDs != nil { + current.CategoryIDs = patch.CategoryIDs + if patch.CategoryPath == "" { + current.CategoryPath = p.categoryPath(current.CategoryIDs) + } + } + if patch.CategoryPath != "" { + current.CategoryPath = patch.CategoryPath + } + if patch.SourceType != "" { + current.SourceType = patch.SourceType + } + if patch.DocType != "" { + current.DocType = patch.DocType + } + if patch.Mount != "" { + current.Mount = patch.Mount + } + if patch.GitLabBranch != "" { + current.GitLabBranch = patch.GitLabBranch + } + if patch.GitLabPath != "" { + current.GitLabPath = patch.GitLabPath + } + if patch.DeployToken != "" { + current.DeployToken = patch.DeployToken + } + if patch.CreatedBy != "" { + current.CreatedBy = patch.CreatedBy + } + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return Module{}, err + } + defer tx.Rollback(ctx) + updated, err := queryJSONOne[Module](ctx, tx, `UPDATE docs_module SET name=$2,description=$3,owner_group=$4,repo_type=$5,repo_url=$6,default_version=$7,visibility=$8,status=$9,package_version=$10,channel=$11,edition=$12,keywords=$13::jsonb,maintainers=$14::jsonb,category_path=$15,source_type=$16,doc_type=$17,mount=$18,gitlab_branch=$19,gitlab_path=$20,deploy_token=$21,created_by=NULLIF($22,''),updated_at=now() WHERE id=$1 RETURNING to_jsonb(docs_module)-'default_version_id'-'created_at'`, current.ID, current.Name, current.Description, current.OwnerGroup, current.RepoType, current.RepoURL, current.DefaultVersion, current.Visibility, current.Status, current.PackageVersion, current.Channel, current.Edition, mustJSON(current.Keywords), mustJSON(current.Maintainers), current.CategoryPath, current.SourceType, current.DocType, current.Mount, current.GitLabBranch, current.GitLabPath, current.DeployToken, current.CreatedBy) + if err != nil { + return Module{}, err + } + if patch.CategoryIDs != nil { + if err = p.syncModuleCategories(ctx, tx, current.ID, current.CategoryIDs); err != nil { + return Module{}, err + } + if _, err = tx.Exec(ctx, `UPDATE docs_page SET category_ids=$2::jsonb WHERE module_id=$1`, current.ID, mustJSON(current.CategoryIDs)); err != nil { + return Module{}, err + } + } + updated.CategoryIDs = current.CategoryIDs + updated.AvailableVers = current.AvailableVers + updated.DeployToken = current.DeployToken + updated.DeployTokenSet = updated.DeployToken != "" + return updated, tx.Commit(ctx) +} + +func (p *PostgresRepository) Versions(moduleKey string) []Version { + var values []Version + if err := loadQuery(context.Background(), p.pool, `SELECT (to_jsonb(v)-'module_id'-'updated_at')||jsonb_build_object('module_key',m.module_key) FROM docs_version v JOIN docs_module m ON m.id=v.module_id WHERE lower(m.module_key)=lower($1) ORDER BY v.created_at`, &values, moduleKey); err != nil { + return []Version{} + } + return values +} +func (p *PostgresRepository) Entries(moduleKey, docsVersion string) []Entry { + var values []Entry + if err := loadQuery(context.Background(), p.pool, `SELECT (to_jsonb(e)-'module_id'-'version_id'-'updated_at')||jsonb_build_object('module_key',m.module_key,'docs_version',v.docs_version) FROM docs_entry e JOIN docs_module m ON m.id=e.module_id JOIN docs_version v ON v.id=e.version_id WHERE lower(m.module_key)=lower($1) AND v.docs_version=$2 ORDER BY e.sort_order,e.id`, &values, moduleKey, docsVersion); err != nil { + return []Entry{} + } + return values +} +func (p *PostgresRepository) EntryModuleKey(entryID string) (string, bool) { + var key string + err := p.pool.QueryRow(context.Background(), `SELECT m.module_key FROM docs_entry e JOIN docs_module m ON m.id=e.module_id WHERE e.id=$1`, entryID).Scan(&key) + return key, err == nil +} + +func (p *PostgresRepository) CreateVersion(moduleKey string, v Version) (Version, error) { + if strings.TrimSpace(v.DocsVersion) == "" { + return Version{}, ErrInvalid + } + m, err := p.Module(moduleKey) + if err != nil { + return Version{}, err + } + if v.ID == "" { + v.ID = databaseID("v") + } + if v.DisplayName == "" { + v.DisplayName = v.DocsVersion + } + if v.Status == "" { + v.Status = "active" + } + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return Version{}, err + } + defer tx.Rollback(ctx) + if v.IsDefault { + _, err = tx.Exec(ctx, `UPDATE docs_version SET is_default=false WHERE module_id=$1`, m.ID) + if err == nil { + _, err = tx.Exec(ctx, `UPDATE docs_module SET default_version=$2 WHERE id=$1`, m.ID, v.DocsVersion) + } + if err != nil { + return Version{}, err + } + } + created, err := queryJSONOne[Version](ctx, tx, `WITH changed AS (INSERT INTO docs_version(id,module_id,docs_version,display_name,version_type,is_default,status,source_branch,package_version,channel,edition,support_status,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now()) RETURNING *) SELECT (to_jsonb(changed)-'module_id'-'updated_at')||jsonb_build_object('module_key',$13::text) FROM changed`, v.ID, m.ID, v.DocsVersion, v.DisplayName, v.VersionType, v.IsDefault, v.Status, v.SourceBranch, v.PackageVersion, v.Channel, v.Edition, v.SupportStatus, m.ModuleKey) + if err != nil { + return Version{}, postgresError(err) + } + return created, tx.Commit(ctx) +} + +func (p *PostgresRepository) UpdateVersion(moduleKey, docsVersion string, patch Version) (Version, error) { + m, err := p.Module(moduleKey) + if err != nil { + return Version{}, err + } + var current Version + for _, v := range m.AvailableVers { + if v.DocsVersion == docsVersion { + current = v + break + } + } + if current.ID == "" { + return Version{}, ErrNotFound + } + if patch.DisplayName != "" { + current.DisplayName = patch.DisplayName + } + if patch.VersionType != "" { + current.VersionType = patch.VersionType + } + if patch.Status != "" { + current.Status = patch.Status + } + if patch.SourceBranch != "" { + current.SourceBranch = patch.SourceBranch + } + if patch.PackageVersion != "" { + current.PackageVersion = patch.PackageVersion + } + if patch.Channel != "" { + current.Channel = patch.Channel + } + if patch.Edition != "" { + current.Edition = patch.Edition + } + if patch.SupportStatus != "" { + current.SupportStatus = patch.SupportStatus + } + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return Version{}, err + } + defer tx.Rollback(ctx) + if patch.IsDefault { + if _, err = tx.Exec(ctx, `UPDATE docs_version SET is_default=false WHERE module_id=$1`, m.ID); err != nil { + return Version{}, err + } + if _, err = tx.Exec(ctx, `UPDATE docs_module SET default_version=$2 WHERE id=$1`, m.ID, docsVersion); err != nil { + return Version{}, err + } + current.IsDefault = true + } + updated, err := queryJSONOne[Version](ctx, tx, `WITH changed AS (UPDATE docs_version SET display_name=$2,version_type=$3,is_default=$4,status=$5,source_branch=$6,package_version=$7,channel=$8,edition=$9,support_status=$10 WHERE id=$1 RETURNING *) SELECT (to_jsonb(changed)-'module_id'-'updated_at')||jsonb_build_object('module_key',$11::text) FROM changed`, current.ID, current.DisplayName, current.VersionType, current.IsDefault, current.Status, current.SourceBranch, current.PackageVersion, current.Channel, current.Edition, current.SupportStatus, m.ModuleKey) + if err != nil { + return Version{}, err + } + return updated, tx.Commit(ctx) +} + +func (p *PostgresRepository) CreateEntry(moduleKey, docsVersion string, e Entry) (Entry, error) { + m, err := p.Module(moduleKey) + if err != nil { + return Entry{}, err + } + var versionID string + if err = p.pool.QueryRow(context.Background(), `SELECT id FROM docs_version WHERE module_id=$1 AND docs_version=$2`, m.ID, docsVersion).Scan(&versionID); err != nil { + return Entry{}, ErrNotFound + } + if strings.TrimSpace(e.EntryKey) == "" { + return Entry{}, ErrInvalid + } + if e.ID == "" { + e.ID = databaseID("e") + } + if e.EntryType == "" { + e.EntryType = "markdown" + } + if e.Builder == "" { + e.Builder = e.EntryType + } + if e.IndexStatus == "" { + e.IndexStatus = "pending" + } + if e.Status == "" { + e.Status = "active" + } + created, err := queryJSONOne[Entry](context.Background(), p.pool, `INSERT INTO docs_entry(id,module_id,version_id,entry_key,title,entry_type,builder,source,storage_uri,nav_uri,index_status,is_primary,sort_order,status,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now()) RETURNING (to_jsonb(docs_entry)-'module_id'-'version_id'-'updated_at')||jsonb_build_object('module_key',$15::text,'docs_version',$16::text)`, e.ID, m.ID, versionID, e.EntryKey, e.Title, e.EntryType, e.Builder, e.Source, e.StorageURI, e.NavURI, e.IndexStatus, e.IsPrimary, e.SortOrder, e.Status, m.ModuleKey, docsVersion) + return created, postgresError(err) +} + +func (p *PostgresRepository) UpdateEntry(entryID string, patch Entry) (Entry, error) { + key, ok := p.EntryModuleKey(entryID) + if !ok { + return Entry{}, ErrNotFound + } + var current Entry + for _, version := range p.Versions(key) { + for _, entry := range p.Entries(key, version.DocsVersion) { + if entry.ID == entryID { + current = entry + break + } + } + } + if current.ID == "" { + return Entry{}, ErrNotFound + } + if patch.Title != "" { + current.Title = patch.Title + } + if patch.EntryType != "" { + current.EntryType = patch.EntryType + } + if patch.Builder != "" { + current.Builder = patch.Builder + } + if patch.Source != "" { + current.Source = patch.Source + } + if patch.StorageURI != "" { + current.StorageURI = patch.StorageURI + } + if patch.NavURI != "" { + current.NavURI = patch.NavURI + } + if patch.IndexStatus != "" { + current.IndexStatus = patch.IndexStatus + } + if patch.SortOrder != 0 { + current.SortOrder = patch.SortOrder + } + if patch.Status != "" { + current.Status = patch.Status + } + current.IsPrimary = patch.IsPrimary + return queryJSONOne[Entry](context.Background(), p.pool, `UPDATE docs_entry SET title=$2,entry_type=$3,builder=$4,source=$5,storage_uri=$6,nav_uri=$7,index_status=$8,is_primary=$9,sort_order=$10,status=$11 WHERE id=$1 RETURNING (to_jsonb(docs_entry)-'module_id'-'version_id'-'updated_at')||jsonb_build_object('module_key',$12::text,'docs_version',$13::text)`, entryID, current.Title, current.EntryType, current.Builder, current.Source, current.StorageURI, current.NavURI, current.IndexStatus, current.IsPrimary, current.SortOrder, current.Status, current.ModuleKey, current.DocsVersion) +} +func (p *PostgresRepository) DeleteEntry(entryID string) error { + command, err := p.pool.Exec(context.Background(), `DELETE FROM docs_entry WHERE id=$1`, entryID) + if err != nil { + return err + } + if command.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (p *PostgresRepository) Releases() []Release { + var values []Release + if err := loadQuery(context.Background(), p.pool, `SELECT (to_jsonb(r)-'module_id'-'version_id')||jsonb_build_object('module_key',m.module_key,'docs_version',v.docs_version) FROM docs_release r JOIN docs_module m ON m.id=r.module_id JOIN docs_version v ON v.id=r.version_id ORDER BY r.published_at DESC`, &values); err != nil { + return []Release{} + } + return values +} +func (p *PostgresRepository) Release(releaseID string) (Release, error) { + return queryJSONOne[Release](context.Background(), p.pool, `SELECT (to_jsonb(r)-'module_id'-'version_id')||jsonb_build_object('module_key',m.module_key,'docs_version',v.docs_version) FROM docs_release r JOIN docs_module m ON m.id=r.module_id JOIN docs_version v ON v.id=r.version_id WHERE r.release_id=$1 OR r.id=$1`, releaseID) +} +func (p *PostgresRepository) RollbackRelease(releaseID string) (Release, error) { + return queryJSONOne[Release](context.Background(), p.pool, `WITH updated AS (UPDATE docs_release SET status='rolled_back' WHERE release_id=$1 OR id=$1 RETURNING *) SELECT (to_jsonb(r)-'module_id'-'version_id')||jsonb_build_object('module_key',m.module_key,'docs_version',v.docs_version) FROM updated r JOIN docs_module m ON m.id=r.module_id JOIN docs_version v ON v.id=r.version_id`, releaseID) +} + +func (p *PostgresRepository) Page(docID string) (Page, error) { + return queryJSONOne[Page](context.Background(), p.pool, `SELECT (to_jsonb(p)-'module_id'-'version_id'-'entry_id'-'release_id'-'last_verified_at'-'created_at')||jsonb_build_object('module_key',m.module_key,'module_name',m.name,'docs_version',v.docs_version,'package_version',v.package_version,'entry_key',COALESCE(e.entry_key,''),'entry_type',COALESCE(e.entry_type,'')) FROM docs_page p JOIN docs_module m ON m.id=p.module_id JOIN docs_version v ON v.id=p.version_id LEFT JOIN docs_entry e ON e.id=p.entry_id WHERE p.doc_id=$1`, docID) +} +func (p *PostgresRepository) PageByRoute(moduleKey, docsVersion, entryKey string) (Page, error) { + return queryJSONOne[Page](context.Background(), p.pool, `SELECT (to_jsonb(p)-'module_id'-'version_id'-'entry_id'-'release_id'-'last_verified_at'-'created_at')||jsonb_build_object('module_key',m.module_key,'module_name',m.name,'docs_version',v.docs_version,'package_version',v.package_version,'entry_key',COALESCE(e.entry_key,''),'entry_type',COALESCE(e.entry_type,'')) FROM docs_page p JOIN docs_module m ON m.id=p.module_id JOIN docs_version v ON v.id=p.version_id LEFT JOIN docs_entry e ON e.id=p.entry_id WHERE lower(m.module_key)=lower($1) AND v.docs_version=$2 AND e.entry_key=$3 LIMIT 1`, moduleKey, docsVersion, entryKey) +} +func (p *PostgresRepository) Nav(moduleKey, docsVersion string) []NavItem { + var raw []byte + if err := p.pool.QueryRow(context.Background(), `SELECT items_json FROM docs_nav WHERE lower(module_key)=lower($1) AND docs_version=$2`, moduleKey, docsVersion).Scan(&raw); err != nil { + return []NavItem{} + } + var nav []NavItem + if json.Unmarshal(raw, &nav) != nil { + return []NavItem{} + } + return nav +} +func (p *PostgresRepository) PageHTML(moduleKey, docsVersion, entryKey string) string { + page, err := p.PageByRoute(moduleKey, docsVersion, entryKey) + if err != nil { + return "" + } + return page.ContentHTML +} +func (p *PostgresRepository) SiteFile(moduleKey, docsVersion, entryKey, name string) (SiteFile, error) { + if name == "" { + name = "index.html" + } + var file SiteFile + err := p.pool.QueryRow(context.Background(), `SELECT name,content,content_type FROM docs_site_file WHERE lower(module_key)=lower($1) AND docs_version=$2 AND entry_key=$3 AND name=$4`, moduleKey, docsVersion, entryKey, path.Clean(strings.TrimPrefix(name, "/"))).Scan(&file.Name, &file.Content, &file.ContentType) + if errors.Is(err, pgx.ErrNoRows) { + return SiteFile{}, ErrNotFound + } + return file, err +} +func (p *PostgresRepository) ClearSiteAssets() {} +func (p *PostgresRepository) SiteObjects() map[string]SiteFile { return map[string]SiteFile{} } diff --git a/backend/internal/store/postgres_identity.go b/backend/internal/store/postgres_identity.go new file mode 100644 index 0000000..ba1cfc3 --- /dev/null +++ b/backend/internal/store/postgres_identity.go @@ -0,0 +1,379 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type rowQueryer interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +func queryJSONOne[T any](ctx context.Context, q rowQueryer, query string, args ...any) (T, error) { + var zero T + var raw []byte + if err := q.QueryRow(ctx, query, args...).Scan(&raw); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return zero, ErrNotFound + } + return zero, err + } + var value T + if err := json.Unmarshal(raw, &value); err != nil { + return zero, err + } + return value, nil +} + +func postgresError(err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return ErrConflict + } + return err +} + +func databaseID(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UTC().UnixNano()) +} + +func (p *PostgresRepository) CurrentUser() User { + user, err := queryJSONOne[User](context.Background(), p.pool, `SELECT to_jsonb(u) FROM users u ORDER BY username LIMIT 1`) + if err != nil { + return User{} + } + return user +} + +func (p *PostgresRepository) Users(keyword string) []User { + ctx := context.Background() + q := "%" + strings.TrimSpace(keyword) + "%" + var users []User + if err := loadQuery(ctx, p.pool, `SELECT to_jsonb(u) FROM users u + WHERE $1='' OR username ILIKE $1 OR display_name ILIKE $1 OR email ILIKE $1 OR department ILIKE $1 + ORDER BY username`, &users, q); err != nil { + return []User{} + } + return users +} + +func (p *PostgresRepository) UserByID(id string) (User, error) { + var raw []byte + var mcpToken string + err := p.pool.QueryRow(context.Background(), `SELECT to_jsonb(u),COALESCE(mcp_token,'') FROM users u WHERE id=$1`, id).Scan(&raw, &mcpToken) + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, err + } + var user User + if err = json.Unmarshal(raw, &user); err != nil { + return User{}, err + } + user.MCPToken = mcpToken + return user, nil +} + +func (p *PostgresRepository) UserByMCPToken(token string) (User, error) { + user, err := queryJSONOne[User](context.Background(), p.pool, `SELECT to_jsonb(u) FROM users u WHERE mcp_token=$1`, token) + if err == nil { + user.MCPToken = token + } + return user, err +} + +func (p *PostgresRepository) SetUserMCPToken(id, token string) (User, error) { + user, err := queryJSONOne[User](context.Background(), p.pool, `UPDATE users SET mcp_token=$2,updated_at=now() WHERE id=$1 RETURNING to_jsonb(users)`, id, token) + if err == nil { + user.MCPToken = token + } + return user, err +} + +func (p *PostgresRepository) CreateUser(u User) (User, error) { + u.Username = strings.TrimSpace(u.Username) + if u.Username == "" { + return User{}, ErrInvalid + } + if u.ID == "" { + u.ID = databaseID("u") + } + if u.DisplayName == "" { + u.DisplayName = u.Username + } + if u.Source == "" { + u.Source = "manual" + } + if u.Status == "" { + u.Status = "active" + } + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return User{}, err + } + defer tx.Rollback(ctx) + created, err := queryJSONOne[User](ctx, tx, `INSERT INTO users + (id,username,display_name,email,department,avatar,roles_json,managed_categories_json,source,status,is_super_admin,mcp_token,created_at,updated_at) + VALUES($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,now(),now()) RETURNING to_jsonb(users)`, + u.ID, u.Username, u.DisplayName, u.Email, u.Department, u.Avatar, mustJSONArray(u.Roles), mustJSONArray(u.ManagedCategories), u.Source, u.Status, u.SuperAdmin, u.MCPToken) + if err != nil { + return User{}, postgresError(err) + } + return created, tx.Commit(ctx) +} + +func (p *PostgresRepository) UpdateUser(id string, patch User) (User, error) { + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return User{}, err + } + defer tx.Rollback(ctx) + current, err := queryJSONOne[User](ctx, tx, `SELECT to_jsonb(u) FROM users u WHERE id=$1 FOR UPDATE`, id) + if err != nil { + return User{}, err + } + if patch.DisplayName != "" { + current.DisplayName = patch.DisplayName + } + if patch.Email != "" { + current.Email = patch.Email + } + if patch.Department != "" { + current.Department = patch.Department + } + if patch.Roles != nil { + current.Roles = patch.Roles + } + if patch.ManagedCategories != nil { + current.ManagedCategories = patch.ManagedCategories + } + if patch.Status != "" { + current.Status = patch.Status + } + current.SuperAdmin = patch.SuperAdmin + updated, err := queryJSONOne[User](ctx, tx, `UPDATE users SET display_name=$2,email=$3,department=$4,roles_json=$5::jsonb,managed_categories_json=$6::jsonb,status=$7,is_super_admin=$8,updated_at=now() WHERE id=$1 RETURNING to_jsonb(users)`, id, current.DisplayName, current.Email, current.Department, mustJSONArray(current.Roles), mustJSONArray(current.ManagedCategories), current.Status, current.SuperAdmin) + if err != nil { + return User{}, err + } + return updated, tx.Commit(ctx) +} + +func (p *PostgresRepository) DeleteUser(id string) error { + command, err := p.pool.Exec(context.Background(), `DELETE FROM users WHERE id=$1`, id) + if err != nil { + return err + } + if command.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (p *PostgresRepository) UpsertUser(u User) User { + u.Username = strings.TrimSpace(u.Username) + if u.ID == "" { + u.ID = databaseID("u") + } + if u.Status == "" { + u.Status = "active" + } + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return u + } + defer tx.Rollback(ctx) + result, err := queryJSONOne[User](ctx, tx, `INSERT INTO users + (id,username,display_name,email,department,avatar,roles_json,managed_categories_json,source,status,is_super_admin,last_login_at,created_at,updated_at) + VALUES($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,'oidc','active',$9,now(),now(),now()) + ON CONFLICT(username) DO UPDATE SET display_name=COALESCE(NULLIF(EXCLUDED.display_name,''),users.display_name),email=COALESCE(NULLIF(EXCLUDED.email,''),users.email),department=COALESCE(NULLIF(EXCLUDED.department,''),users.department),avatar=COALESCE(NULLIF(EXCLUDED.avatar,''),users.avatar),roles_json=CASE WHEN jsonb_typeof(EXCLUDED.roles_json)='array' AND jsonb_array_length(EXCLUDED.roles_json)>0 THEN EXCLUDED.roles_json WHEN jsonb_typeof(users.roles_json)='array' THEN users.roles_json ELSE '[]'::jsonb END,managed_categories_json=CASE WHEN jsonb_typeof(users.managed_categories_json)='array' THEN users.managed_categories_json ELSE '[]'::jsonb END,source='oidc',status='active',last_login_at=now(),updated_at=now() + RETURNING to_jsonb(users)`, u.ID, u.Username, u.DisplayName, u.Email, u.Department, u.Avatar, mustJSONArray(u.Roles), mustJSONArray(u.ManagedCategories), u.SuperAdmin) + if err != nil { + return u + } + if tx.Commit(ctx) != nil { + return u + } + return result +} + +func (p *PostgresRepository) Teams() []Team { + var teams []Team + if err := loadQuery(context.Background(), p.pool, `SELECT to_jsonb(t) FROM teams t ORDER BY key`, &teams); err != nil { + return []Team{} + } + return teams +} + +func (p *PostgresRepository) Team(key string) (Team, error) { + return queryJSONOne[Team](context.Background(), p.pool, `SELECT to_jsonb(t) FROM teams t WHERE lower(key)=lower($1) OR id=$1`, key) +} + +func normalizeTeam(t Team) Team { + if t.Name == "" { + t.Name = t.Key + } + for _, leader := range t.Leaders { + if leader != "" && !contains(t.Members, leader) { + t.Members = append(t.Members, leader) + } + } + return t +} + +func (p *PostgresRepository) uniqueTeamKey(ctx context.Context, name string) string { + base := slugifyKey(name) + if base == "" { + base = "team-" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 36) + } + for index := 1; ; index++ { + key := base + if index > 1 { + key += "-" + strconv.Itoa(index) + } + var exists bool + _ = p.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM teams WHERE lower(key)=lower($1))`, key).Scan(&exists) + if !exists { + return key + } + } +} + +func (p *PostgresRepository) CreateTeam(t Team) (Team, error) { + ctx := context.Background() + t.Key = strings.TrimSpace(t.Key) + if t.Key == "" { + if strings.TrimSpace(t.Name) == "" { + return Team{}, ErrInvalid + } + t.Key = p.uniqueTeamKey(ctx, t.Name) + } + if t.ID == "" { + t.ID = databaseID("t") + } + t = normalizeTeam(t) + tx, err := p.pool.Begin(ctx) + if err != nil { + return Team{}, err + } + defer tx.Rollback(ctx) + created, err := queryJSONOne[Team](ctx, tx, `INSERT INTO teams(id,key,name,description,leaders,members,created_at,updated_at) VALUES($1,$2,$3,$4,$5::jsonb,$6::jsonb,now(),now()) RETURNING to_jsonb(teams)`, t.ID, t.Key, t.Name, t.Description, mustJSON(t.Leaders), mustJSON(t.Members)) + if err != nil { + return Team{}, postgresError(err) + } + return created, tx.Commit(ctx) +} + +func (p *PostgresRepository) UpdateTeam(key string, patch Team) (Team, error) { + current, err := p.Team(key) + if err != nil { + return Team{}, err + } + if patch.Name != "" { + current.Name = patch.Name + } + if patch.Description != "" { + current.Description = patch.Description + } + if patch.Leaders != nil { + current.Leaders = cloneStrings(patch.Leaders) + } + if patch.Members != nil { + current.Members = cloneStrings(patch.Members) + } + current = normalizeTeam(current) + return queryJSONOne[Team](context.Background(), p.pool, `UPDATE teams SET name=$2,description=$3,leaders=$4::jsonb,members=$5::jsonb,updated_at=now() WHERE id=$1 RETURNING to_jsonb(teams)`, current.ID, current.Name, current.Description, mustJSON(current.Leaders), mustJSON(current.Members)) +} + +func (p *PostgresRepository) DeleteTeam(key string) error { + command, err := p.pool.Exec(context.Background(), `DELETE FROM teams WHERE lower(key)=lower($1) OR id=$1`, key) + if err != nil { + return err + } + if command.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (p *PostgresRepository) AddTeamMember(key, member string) (Team, error) { + member = strings.TrimSpace(member) + if member == "" { + return Team{}, ErrInvalid + } + t, err := p.Team(key) + if err != nil { + return Team{}, err + } + if !contains(t.Members, member) { + t.Members = append(t.Members, member) + } + return queryJSONOne[Team](context.Background(), p.pool, `UPDATE teams SET members=$2::jsonb,updated_at=now() WHERE id=$1 RETURNING to_jsonb(teams)`, t.ID, mustJSON(t.Members)) +} + +func (p *PostgresRepository) RemoveTeamMember(key, member string) (Team, error) { + t, err := p.Team(key) + if err != nil { + return Team{}, err + } + members := make([]string, 0, len(t.Members)) + for _, value := range t.Members { + if !strings.EqualFold(value, strings.TrimSpace(member)) { + members = append(members, value) + } + } + return queryJSONOne[Team](context.Background(), p.pool, `UPDATE teams SET members=$2::jsonb,updated_at=now() WHERE id=$1 RETURNING to_jsonb(teams)`, t.ID, mustJSON(members)) +} + +func (p *PostgresRepository) SetTeamLeader(key, leader string) (Team, error) { + leader = strings.TrimSpace(leader) + if leader == "" { + return Team{}, ErrInvalid + } + t, err := p.Team(key) + if err != nil { + return Team{}, err + } + if !contains(t.Leaders, leader) { + t.Leaders = append(t.Leaders, leader) + } + if !contains(t.Members, leader) { + t.Members = append(t.Members, leader) + } + return queryJSONOne[Team](context.Background(), p.pool, `UPDATE teams SET leaders=$2::jsonb,members=$3::jsonb,updated_at=now() WHERE id=$1 RETURNING to_jsonb(teams)`, t.ID, mustJSON(t.Leaders), mustJSON(t.Members)) +} + +func (p *PostgresRepository) TeamMembers(key string) []string { + t, err := p.Team(key) + if err != nil { + return nil + } + return cloneStrings(t.Members) +} + +func (p *PostgresRepository) TeamKeysForUser(u User) []string { + var keys []string + rows, err := p.pool.Query(context.Background(), `SELECT key FROM teams WHERE members ? $1 OR members ? $2 ORDER BY key`, u.Username, u.ID) + if err != nil { + return keys + } + defer rows.Close() + for rows.Next() { + var key string + if rows.Scan(&key) == nil { + keys = append(keys, key) + } + } + return keys +} diff --git a/backend/internal/store/postgres_ingest.go b/backend/internal/store/postgres_ingest.go new file mode 100644 index 0000000..39d31ab --- /dev/null +++ b/backend/internal/store/postgres_ingest.go @@ -0,0 +1,180 @@ +package store + +import ( + "context" + "strconv" + "strings" + "time" +) + +func (p *PostgresRepository) IngestArtifact(artifact DeployArtifact) (DeployResult, error) { + if strings.TrimSpace(artifact.ModuleKey) == "" || strings.TrimSpace(artifact.DocsVersion) == "" || len(artifact.Entries) == 0 || len(artifact.Documents) == 0 { + return DeployResult{}, ErrInvalid + } + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return DeployResult{}, err + } + defer tx.Rollback(ctx) + now := time.Now().UTC() + moduleName := firstNonEmpty(artifact.ModuleName, artifact.ModuleKey) + module, err := queryJSONOne[Module](ctx, tx, `SELECT (to_jsonb(m)-'default_version_id'-'created_at')||jsonb_build_object('category_ids',COALESCE((SELECT jsonb_agg(mc.category_id ORDER BY mc.is_primary DESC,mc.category_id) FROM docs_module_category mc WHERE mc.module_id=m.id),'[]'::jsonb)) FROM docs_module m WHERE lower(module_key)=lower($1) FOR UPDATE`, artifact.ModuleKey) + if err == ErrNotFound { + module = Module{ID: databaseID("m"), ModuleKey: artifact.ModuleKey, Name: moduleName, Description: artifact.Description, OwnerGroup: firstNonEmpty(firstString(artifact.Authors), "docs"), RepoType: firstNonEmpty(artifact.RepoType, "git"), RepoURL: artifact.RepoURL, SourceType: "gitlab", GitLabBranch: artifact.Branch, DefaultVersion: artifact.DocsVersion, Visibility: "internal", Status: "active", PackageName: artifact.ModuleKey, PackageVersion: artifact.PackageVersion, Channel: "docs", Edition: artifact.Edition, Keywords: cloneStrings(artifact.Keywords), Maintainers: cloneStrings(artifact.Authors), LastSyncedCommit: artifact.CommitSHA, LastSyncedAt: now} + module, err = queryJSONOne[Module](ctx, tx, `INSERT INTO docs_module(id,module_key,name,description,owner_group,repo_type,repo_url,default_version,visibility,status,package_name,package_version,channel,edition,keywords,maintainers,source_type,gitlab_branch,last_synced_commit,last_synced_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15::jsonb,$16::jsonb,$17,$18,$19,$20,$20) RETURNING to_jsonb(docs_module)-'default_version_id'-'created_at'`, module.ID, module.ModuleKey, module.Name, module.Description, module.OwnerGroup, module.RepoType, module.RepoURL, module.DefaultVersion, module.Visibility, module.Status, module.PackageName, module.PackageVersion, module.Channel, module.Edition, mustJSON(module.Keywords), mustJSON(module.Maintainers), module.SourceType, module.GitLabBranch, module.LastSyncedCommit, now) + } else if err == nil { + module.Name = moduleName + if artifact.Description != "" { + module.Description = artifact.Description + } + module.DefaultVersion = artifact.DocsVersion + if artifact.PackageVersion != "" { + module.PackageVersion = artifact.PackageVersion + } + if artifact.Edition != "" { + module.Edition = artifact.Edition + } + if len(artifact.Keywords) > 0 { + module.Keywords = cloneStrings(artifact.Keywords) + } + if len(artifact.Authors) > 0 { + module.Maintainers = cloneStrings(artifact.Authors) + if module.OwnerGroup == "" { + module.OwnerGroup = artifact.Authors[0] + } + } + module.Status = firstNonEmpty(module.Status, "active") + module.Visibility = firstNonEmpty(module.Visibility, "internal") + if artifact.RepoURL != "" { + module.RepoURL = artifact.RepoURL + } + if artifact.RepoType != "" { + module.RepoType = artifact.RepoType + } + if module.SourceType == "" || artifact.TriggerType == "pipeline" || artifact.RepoType == "gitlab" { + module.SourceType = deploySourceType(artifact.RepoType, artifact.RepoURL) + if module.SourceType == "manual" && artifact.TriggerType == "pipeline" { + module.SourceType = "gitlab" + } + } + if artifact.Branch != "" { + module.GitLabBranch = artifact.Branch + } + if artifact.CommitSHA != "" { + module.LastSyncedCommit = artifact.CommitSHA + } + module.LastSyncedAt = now + module, err = queryJSONOne[Module](ctx, tx, `UPDATE docs_module SET name=$2,description=$3,owner_group=$4,repo_type=$5,repo_url=$6,default_version=$7,visibility=$8,status=$9,package_version=$10,edition=$11,keywords=$12::jsonb,maintainers=$13::jsonb,gitlab_branch=$14,last_synced_commit=$15,last_synced_at=$16,source_type=$17,updated_at=$16 WHERE id=$1 RETURNING to_jsonb(docs_module)-'default_version_id'-'created_at'`, module.ID, module.Name, module.Description, module.OwnerGroup, module.RepoType, module.RepoURL, module.DefaultVersion, module.Visibility, module.Status, module.PackageVersion, module.Edition, mustJSON(module.Keywords), mustJSON(module.Maintainers), module.GitLabBranch, module.LastSyncedCommit, now, module.SourceType) + } + if err != nil { + return DeployResult{}, err + } + var categoryIDs []string + rows, err := tx.Query(ctx, `SELECT category_id FROM docs_module_category WHERE module_id=$1 ORDER BY is_primary DESC,category_id`, module.ID) + if err != nil { + return DeployResult{}, err + } + for rows.Next() { + var id string + if err = rows.Scan(&id); err != nil { + rows.Close() + return DeployResult{}, err + } + categoryIDs = append(categoryIDs, id) + } + rows.Close() + module.CategoryIDs = categoryIDs + var categoryPath string + _ = tx.QueryRow(ctx, `SELECT COALESCE(string_agg(c.name,' / ' ORDER BY mc.is_primary DESC,c.sort_order,c.id),'') FROM docs_module_category mc JOIN docs_category c ON c.id=mc.category_id WHERE mc.module_id=$1`, module.ID).Scan(&categoryPath) + module.CategoryPath = categoryPath + if _, err = tx.Exec(ctx, `UPDATE docs_module SET category_path=$2 WHERE id=$1`, module.ID, categoryPath); err != nil { + return DeployResult{}, err + } + if _, err = tx.Exec(ctx, `UPDATE docs_version SET is_default=false WHERE module_id=$1`, module.ID); err != nil { + return DeployResult{}, err + } + version, err := queryJSONOne[Version](ctx, tx, `WITH changed AS (INSERT INTO docs_version(id,module_id,docs_version,display_name,version_type,is_default,status,package_version,channel,edition,support_status,created_at,updated_at) VALUES($1,$2,$3,$3,'release',true,'active',$4,$5,$6,'supported',$7,$7) ON CONFLICT(module_id,docs_version) DO UPDATE SET display_name=COALESCE(NULLIF(docs_version.display_name,''),EXCLUDED.display_name),version_type=COALESCE(NULLIF(docs_version.version_type,''),'release'),is_default=true,status='active',package_version=EXCLUDED.package_version,edition=EXCLUDED.edition,support_status=COALESCE(NULLIF(docs_version.support_status,''),'supported'),updated_at=EXCLUDED.updated_at RETURNING *) SELECT (to_jsonb(changed)-'module_id'-'updated_at')||jsonb_build_object('module_key',$8::text) FROM changed`, databaseID("v"), module.ID, artifact.DocsVersion, artifact.PackageVersion, firstNonEmpty(module.Channel, "docs"), artifact.Edition, now, module.ModuleKey) + if err != nil { + return DeployResult{}, err + } + if _, err = tx.Exec(ctx, `UPDATE docs_page_view SET page_id=NULL WHERE page_id IN(SELECT id FROM docs_page WHERE module_id=$1 AND version_id=$2)`, module.ID, version.ID); err != nil { + return DeployResult{}, err + } + if _, err = tx.Exec(ctx, `UPDATE docs_embedding SET page_id=NULL,entry_id=NULL WHERE module_id=$1 AND version_id=$2`, module.ID, version.ID); err != nil { + return DeployResult{}, err + } + if _, err = tx.Exec(ctx, `DELETE FROM docs_page WHERE module_id=$1 AND version_id=$2`, module.ID, version.ID); err != nil { + return DeployResult{}, err + } + if _, err = tx.Exec(ctx, `DELETE FROM docs_entry WHERE module_id=$1 AND version_id=$2`, module.ID, version.ID); err != nil { + return DeployResult{}, err + } + entryIDs := map[string]string{} + for index, entry := range artifact.Entries { + id := databaseID("e") + entryIDs[entry.Key] = id + if _, err = tx.Exec(ctx, `INSERT INTO docs_entry(id,module_id,version_id,entry_key,title,entry_type,builder,source,storage_uri,nav_uri,index_status,is_primary,sort_order,status,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,'indexed',$10,$11,'active',$12,$12)`, id, module.ID, version.ID, entry.Key, entry.Title, firstNonEmpty(entry.Type, "markdown"), entry.Source, "minio://modex/modules/"+module.ModuleKey+"/"+artifact.DocsVersion+"/site/"+entry.Key, "minio://modex/modules/"+module.ModuleKey+"/"+artifact.DocsVersion+"/nav.json", index == 0, index+1, now); err != nil { + return DeployResult{}, err + } + } + seenDocIDs := make(map[string]bool, len(artifact.Documents)) + for _, document := range artifact.Documents { + entryKey := firstNonEmpty(document.EntryKey, entryKeyFromDocID(document.DocID)) + docID := firstNonEmpty(document.DocID, artifact.ModuleKey+":"+artifact.DocsVersion+":"+entryKey) + // docs_page.doc_id is globally UNIQUE; a duplicate within one artifact + // would abort the whole deploy. Skip repeats (keeping the first) so a + // malformed artifact degrades to a partial index instead of a 400. + if seenDocIDs[docID] { + continue + } + seenDocIDs[docID] = true + entryType := firstNonEmpty(document.EntryType, entryTypeForEntry(artifact.Entries, entryKey)) + contentHTML := htmlForEntry(artifact.SiteHTML, entryKey) + if _, err = tx.Exec(ctx, ` + INSERT INTO docs_page(id,module_id,version_id,entry_id,doc_id,title,description,path,source_file,doc_type,status,owner_group,tags,category_ids,content_text,content_html,content_md,updated_at,created_at) + VALUES($1,$2,$3,NULLIF($4,''),$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb,$14::jsonb,$15,$16,$17,$18,$18) + ON CONFLICT(doc_id) DO UPDATE SET + module_id=EXCLUDED.module_id, + version_id=EXCLUDED.version_id, + entry_id=EXCLUDED.entry_id, + title=EXCLUDED.title, + description=EXCLUDED.description, + path=EXCLUDED.path, + source_file=EXCLUDED.source_file, + doc_type=EXCLUDED.doc_type, + status=EXCLUDED.status, + owner_group=EXCLUDED.owner_group, + tags=EXCLUDED.tags, + category_ids=EXCLUDED.category_ids, + content_text=EXCLUDED.content_text, + content_html=EXCLUDED.content_html, + content_md=EXCLUDED.content_md, + updated_at=EXCLUDED.updated_at`, databaseID("p"), module.ID, version.ID, entryIDs[entryKey], docID, firstNonEmpty(document.Title, titleForEntry(artifact.Entries, entryKey)), document.Description, docPagePath(artifact.ModuleKey, artifact.DocsVersion, entryKey, entryType, document.Path), document.SourceFile, entryType, firstNonEmpty(document.Status, "active"), module.OwnerGroup, mustJSON(coalesceStrings(document.Keywords, artifact.Keywords)), mustJSON(categoryIDs), document.Content, contentHTML, document.ContentMD, now); err != nil { + return DeployResult{}, err + } + } + if _, err = tx.Exec(ctx, `INSERT INTO docs_nav(module_key,docs_version,items_json,updated_at) VALUES($1,$2,$3::jsonb,$4) ON CONFLICT(module_key,docs_version) DO UPDATE SET items_json=EXCLUDED.items_json,updated_at=EXCLUDED.updated_at`, module.ModuleKey, artifact.DocsVersion, mustJSON(artifact.Nav), now); err != nil { + return DeployResult{}, err + } + if _, err = tx.Exec(ctx, `DELETE FROM docs_site_file WHERE lower(module_key)=lower($1) AND docs_version=$2`, module.ModuleKey, artifact.DocsVersion); err != nil { + return DeployResult{}, err + } + for name, content := range artifact.SiteFiles { + entryKey, relativeName, ok := splitSiteFile(name) + if !ok { + continue + } + if _, err = tx.Exec(ctx, `INSERT INTO docs_site_file(module_key,docs_version,entry_key,name,content,content_type,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7)`, module.ModuleKey, artifact.DocsVersion, entryKey, relativeName, content, contentTypeForName(relativeName, content), now); err != nil { + return DeployResult{}, err + } + } + release := Release{ID: databaseID("r"), ReleaseID: "rel-" + strings.ToLower(artifact.ModuleKey) + "-" + strings.ToLower(artifact.DocsVersion) + "-" + strconv.FormatInt(now.UnixNano(), 36), ModuleKey: module.ModuleKey, DocsVersion: artifact.DocsVersion, CommitSHA: artifact.CommitSHA, Branch: artifact.Branch, Publisher: firstNonEmpty(firstString(artifact.Authors), "docsctl"), BuildSystem: "docsctl", TriggerType: firstNonEmpty(artifact.TriggerType, "manual"), SourceIP: artifact.SourceIP, ArtifactVersion: now.Format("20060102.150405"), PackageVersion: artifact.PackageVersion, StorageURI: "minio://modex/modules/" + module.ModuleKey + "/" + artifact.DocsVersion + "/docs-artifact.zip", Status: "published", PublishedAt: now, CreatedAt: now} + if _, err = tx.Exec(ctx, `INSERT INTO docs_release(id,module_id,version_id,release_id,commit_sha,branch,publisher,build_system,trigger_type,source_ip,artifact_version,package_version,storage_uri,status,published_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15)`, release.ID, module.ID, version.ID, release.ReleaseID, release.CommitSHA, release.Branch, release.Publisher, release.BuildSystem, release.TriggerType, release.SourceIP, release.ArtifactVersion, release.PackageVersion, release.StorageURI, release.Status, now); err != nil { + return DeployResult{}, err + } + if err = tx.Commit(ctx); err != nil { + return DeployResult{}, err + } + return DeployResult{Release: release, PagesIndexed: len(artifact.Documents), EntriesIndexed: len(artifact.Entries), HTMLFiles: len(artifact.SiteHTML), SiteFiles: len(artifact.SiteFiles), BytesReceived: artifact.Bytes}, nil +} diff --git a/backend/internal/store/postgres_oauth.go b/backend/internal/store/postgres_oauth.go new file mode 100644 index 0000000..ecc45a7 --- /dev/null +++ b/backend/internal/store/postgres_oauth.go @@ -0,0 +1,180 @@ +package store + +import ( + "context" + "strings" + "time" +) + +func (p *PostgresRepository) ConnectedApps() []ConnectedApp { + var apps []ConnectedApp + if err := loadQuery(context.Background(), p.pool, `SELECT to_jsonb(a) FROM connected_app a ORDER BY created_at DESC,id`, &apps); err != nil { + return []ConnectedApp{} + } + return apps +} +func (p *PostgresRepository) ConnectedAppByClientID(clientID string) (ConnectedApp, error) { + return queryJSONOne[ConnectedApp](context.Background(), p.pool, `SELECT to_jsonb(a) FROM connected_app a WHERE client_id=$1`, clientID) +} +func (p *PostgresRepository) CreateConnectedApp(app ConnectedApp, clientSecret string) (ConnectedApp, error) { + app.Name = strings.TrimSpace(app.Name) + app.ClientID = strings.TrimSpace(app.ClientID) + app.RedirectURIs = cleanNonEmpty(app.RedirectURIs) + app.Scopes = normalizeScopes(app.Scopes) + if app.Name == "" || app.ClientID == "" || len(app.RedirectURIs) == 0 { + return ConnectedApp{}, ErrInvalid + } + if len(app.Scopes) == 0 { + app.Scopes = []string{"modex:mcp:read"} + } + if app.ID == "" { + app.ID = databaseID("app") + } + app.ClientSecretHash = hashToken(clientSecret) + created, err := queryJSONOne[ConnectedApp](context.Background(), p.pool, `INSERT INTO connected_app(id,name,description,client_id,client_secret_hash,redirect_uris,scopes,trusted,enabled,created_by,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8,$9,NULLIF($10,''),now(),now()) RETURNING to_jsonb(connected_app)`, app.ID, app.Name, app.Description, app.ClientID, app.ClientSecretHash, mustJSON(app.RedirectURIs), mustJSON(app.Scopes), app.Trusted, app.Enabled, app.CreatedBy) + return created, postgresError(err) +} +func (p *PostgresRepository) UpdateConnectedApp(id string, patch ConnectedApp) (ConnectedApp, error) { + app, err := queryJSONOne[ConnectedApp](context.Background(), p.pool, `SELECT to_jsonb(a) FROM connected_app a WHERE id=$1`, id) + if err != nil { + return ConnectedApp{}, err + } + if strings.TrimSpace(patch.Name) != "" { + app.Name = strings.TrimSpace(patch.Name) + } + app.Description = patch.Description + if patch.RedirectURIs != nil { + app.RedirectURIs = cleanNonEmpty(patch.RedirectURIs) + if len(app.RedirectURIs) == 0 { + return ConnectedApp{}, ErrInvalid + } + } + if patch.Scopes != nil { + app.Scopes = normalizeScopes(patch.Scopes) + } + app.Trusted = patch.Trusted + app.Enabled = patch.Enabled + return queryJSONOne[ConnectedApp](context.Background(), p.pool, `UPDATE connected_app SET name=$2,description=$3,redirect_uris=$4::jsonb,scopes=$5::jsonb,trusted=$6,enabled=$7,updated_at=now() WHERE id=$1 RETURNING to_jsonb(connected_app)`, id, app.Name, app.Description, mustJSON(app.RedirectURIs), mustJSON(app.Scopes), app.Trusted, app.Enabled) +} +func (p *PostgresRepository) DeleteConnectedApp(id string) error { + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err = tx.Exec(ctx, `DELETE FROM oauth_grant WHERE app_id=$1`, id); err != nil { + return err + } + command, err := tx.Exec(ctx, `DELETE FROM connected_app WHERE id=$1`, id) + if err != nil { + return err + } + if command.RowsAffected() == 0 { + return ErrNotFound + } + return tx.Commit(ctx) +} +func (p *PostgresRepository) VerifyConnectedAppSecret(clientID, clientSecret string) (ConnectedApp, error) { + return queryJSONOne[ConnectedApp](context.Background(), p.pool, `SELECT to_jsonb(a) FROM connected_app a WHERE client_id=$1 AND client_secret_hash=$2 AND enabled=true`, clientID, hashToken(clientSecret)) +} + +func (p *PostgresRepository) CreateOAuthCode(appID, userID, redirectURI string, scopes []string, code string, ttl time.Duration) (OAuthGrant, error) { + if appID == "" || userID == "" || redirectURI == "" || code == "" { + return OAuthGrant{}, ErrInvalid + } + return queryJSONOne[OAuthGrant](context.Background(), p.pool, `INSERT INTO oauth_grant(id,app_id,user_id,code_hash,redirect_uri,scopes,code_expires_at,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6::jsonb,$7,now(),now()) RETURNING to_jsonb(oauth_grant)`, databaseID("grant"), appID, userID, hashToken(code), redirectURI, mustJSON(normalizeScopes(scopes)), time.Now().UTC().Add(ttl)) +} + +func (p *PostgresRepository) RedeemOAuthCode(clientID, code, redirectURI, accessToken, refreshToken string, accessTTL, refreshTTL time.Duration) (OAuthGrant, ConnectedApp, User, error) { + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + defer tx.Rollback(ctx) + grant, err := queryJSONOne[OAuthGrant](ctx, tx, `SELECT to_jsonb(g) FROM oauth_grant g JOIN connected_app a ON a.id=g.app_id WHERE g.code_hash=$1 AND g.redirect_uri=$2 AND g.revoked_at IS NULL AND g.code_expires_at>now() AND a.client_id=$3 AND a.enabled=true FOR UPDATE OF g`, hashToken(code), redirectURI, clientID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + app, err := queryJSONOne[ConnectedApp](ctx, tx, `SELECT to_jsonb(a) FROM connected_app a WHERE id=$1`, grant.AppID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + user, err := queryJSONOne[User](ctx, tx, `SELECT to_jsonb(u) FROM users u WHERE id=$1`, grant.UserID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + now := time.Now().UTC() + grant, err = queryJSONOne[OAuthGrant](ctx, tx, `UPDATE oauth_grant SET code_hash='',access_token_hash=$2,refresh_token_hash=$3,access_expires_at=$4,refresh_expires_at=$5,updated_at=$6 WHERE id=$1 RETURNING to_jsonb(oauth_grant)`, grant.ID, hashToken(accessToken), hashToken(refreshToken), now.Add(accessTTL), now.Add(refreshTTL), now) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + if _, err = tx.Exec(ctx, `UPDATE connected_app SET last_used_at=$2,updated_at=$2 WHERE id=$1`, app.ID, now); err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + return grant, app, user, tx.Commit(ctx) +} + +func (p *PostgresRepository) RefreshOAuthToken(clientID, refreshToken, accessToken, nextRefreshToken string, accessTTL, refreshTTL time.Duration) (OAuthGrant, ConnectedApp, User, error) { + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + defer tx.Rollback(ctx) + grant, err := queryJSONOne[OAuthGrant](ctx, tx, `SELECT to_jsonb(g) FROM oauth_grant g JOIN connected_app a ON a.id=g.app_id WHERE g.refresh_token_hash=$1 AND g.revoked_at IS NULL AND g.refresh_expires_at>now() AND a.client_id=$2 AND a.enabled=true FOR UPDATE OF g`, hashToken(refreshToken), clientID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + app, err := queryJSONOne[ConnectedApp](ctx, tx, `SELECT to_jsonb(a) FROM connected_app a WHERE id=$1`, grant.AppID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + user, err := queryJSONOne[User](ctx, tx, `SELECT to_jsonb(u) FROM users u WHERE id=$1`, grant.UserID) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + now := time.Now().UTC() + grant, err = queryJSONOne[OAuthGrant](ctx, tx, `UPDATE oauth_grant SET access_token_hash=$2,refresh_token_hash=$3,access_expires_at=$4,refresh_expires_at=$5,updated_at=$6 WHERE id=$1 RETURNING to_jsonb(oauth_grant)`, grant.ID, hashToken(accessToken), hashToken(nextRefreshToken), now.Add(accessTTL), now.Add(refreshTTL), now) + if err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + if _, err = tx.Exec(ctx, `UPDATE connected_app SET last_used_at=$2,updated_at=$2 WHERE id=$1`, app.ID, now); err != nil { + return OAuthGrant{}, ConnectedApp{}, User{}, err + } + return grant, app, user, tx.Commit(ctx) +} + +func (p *PostgresRepository) UserByOAuthAccessToken(token string) (User, ConnectedApp, OAuthGrant, error) { + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return User{}, ConnectedApp{}, OAuthGrant{}, err + } + defer tx.Rollback(ctx) + grant, err := queryJSONOne[OAuthGrant](ctx, tx, `SELECT to_jsonb(g) FROM oauth_grant g JOIN connected_app a ON a.id=g.app_id WHERE g.access_token_hash=$1 AND g.revoked_at IS NULL AND g.access_expires_at>now() AND a.enabled=true FOR UPDATE OF g`, hashToken(token)) + if err != nil { + return User{}, ConnectedApp{}, OAuthGrant{}, err + } + app, err := queryJSONOne[ConnectedApp](ctx, tx, `SELECT to_jsonb(a) FROM connected_app a WHERE id=$1`, grant.AppID) + if err != nil { + return User{}, ConnectedApp{}, OAuthGrant{}, err + } + user, err := queryJSONOne[User](ctx, tx, `SELECT to_jsonb(u) FROM users u WHERE id=$1`, grant.UserID) + if err != nil { + return User{}, ConnectedApp{}, OAuthGrant{}, err + } + now := time.Now().UTC() + if _, err = tx.Exec(ctx, `UPDATE oauth_grant SET updated_at=$2 WHERE id=$1`, grant.ID, now); err != nil { + return User{}, ConnectedApp{}, OAuthGrant{}, err + } + if _, err = tx.Exec(ctx, `UPDATE connected_app SET last_used_at=$2,updated_at=$2 WHERE id=$1`, app.ID, now); err != nil { + return User{}, ConnectedApp{}, OAuthGrant{}, err + } + return user, app, grant, tx.Commit(ctx) +} +func (p *PostgresRepository) RevokeOAuthToken(clientID, token string) bool { + command, err := p.pool.Exec(context.Background(), `UPDATE oauth_grant g SET revoked_at=now(),updated_at=now() FROM connected_app a WHERE a.id=g.app_id AND a.client_id=$1 AND (g.access_token_hash=$2 OR g.refresh_token_hash=$2 OR g.code_hash=$2)`, clientID, hashToken(token)) + return err == nil && command.RowsAffected() > 0 +} diff --git a/backend/internal/store/postgres_repository.go b/backend/internal/store/postgres_repository.go new file mode 100644 index 0000000..0be5707 --- /dev/null +++ b/backend/internal/store/postgres_repository.go @@ -0,0 +1,95 @@ +package store + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed schema.sql +var relationalSchema string + +// PostgresRepository is the production business store. Every method executes +// its read or write against PostgreSQL; it does not cache a process-local copy +// of application state. +type PostgresRepository struct { + pool *pgxpool.Pool +} + +func OpenPostgresRepository(ctx context.Context, databaseURL string) (*PostgresRepository, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, err + } + for { + if err := pool.Ping(ctx); err == nil { + break + } else { + select { + case <-ctx.Done(): + pool.Close() + return nil, fmt.Errorf("connect PostgreSQL: %w", err) + case <-time.After(500 * time.Millisecond): + } + } + } + if _, err := pool.Exec(ctx, relationalSchema); err != nil { + pool.Close() + return nil, fmt.Errorf("apply relational schema: %w", err) + } + return &PostgresRepository{pool: pool}, nil +} + +func (p *PostgresRepository) Close() { + if p != nil && p.pool != nil { + p.pool.Close() + } +} + +type queryer interface { + Query(context.Context, string, ...any) (pgx.Rows, error) +} + +func loadQuery[T any](ctx context.Context, q queryer, query string, out *[]T, args ...any) error { + rows, err := q.Query(ctx, query, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var raw []byte + if err := rows.Scan(&raw); err != nil { + return err + } + var value T + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("decode relational record: %w", err) + } + *out = append(*out, value) + } + return rows.Err() +} + +func mustJSON(value any) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func mustJSONArray[T any](value []T) string { + if value == nil { + value = []T{} + } + return mustJSON(value) +} + +func timeText(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339Nano) +} diff --git a/backend/internal/store/postgres_repository_test.go b/backend/internal/store/postgres_repository_test.go new file mode 100644 index 0000000..be9ec38 --- /dev/null +++ b/backend/internal/store/postgres_repository_test.go @@ -0,0 +1,164 @@ +package store + +import ( + "context" + "fmt" + "os" + "testing" + "time" +) + +func TestPostgresRepositoryRequestLevelCRUD(t *testing.T) { + databaseURL := os.Getenv("TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + repository, err := OpenPostgresRepository(ctx, databaseURL) + if err != nil { + t.Fatalf("OpenPostgresRepository: %v", err) + } + defer repository.Close() + + suffix := fmt.Sprint(time.Now().UnixNano()) + user, err := repository.CreateUser(User{ID: "u-db-" + suffix, Username: "db-" + suffix, DisplayName: "DB User"}) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + team, err := repository.CreateTeam(Team{Name: "DB Team " + suffix, Leaders: []string{user.Username}}) + if err != nil { + t.Fatalf("CreateTeam without key: %v", err) + } + if team.Key == "" || team.Name == "" || len(team.Leaders) != 1 || len(team.Members) != 1 || team.Members[0] != user.Username { + t.Fatalf("CreateTeam generated unexpected team: %+v", team) + } + category, err := repository.CreateCategory(Category{ID: "cat-" + suffix, Key: "cat-" + suffix, Name: "DB Category"}) + if err != nil { + t.Fatalf("CreateCategory: %v", err) + } + module, err := repository.CreateModule(Module{ID: "m-db-" + suffix, ModuleKey: "module-" + suffix, Name: "DB Module", CategoryIDs: []string{category.ID}}) + if err != nil { + t.Fatalf("CreateModule: %v", err) + } + version, err := repository.CreateVersion(module.ModuleKey, Version{ID: "v-db-" + suffix, DocsVersion: "latest", IsDefault: true}) + if err != nil { + t.Fatalf("CreateVersion: %v", err) + } + _, err = repository.CreateEntry(module.ModuleKey, version.DocsVersion, Entry{ID: "e-db-" + suffix, EntryKey: "guide", Title: "Guide"}) + if err != nil { + t.Fatalf("CreateEntry: %v", err) + } + secondRepository, err := OpenPostgresRepository(ctx, databaseURL) + if err != nil { + t.Fatalf("open second repository: %v", err) + } + defer secondRepository.Close() + if _, err = secondRepository.Module(module.ModuleKey); err != nil { + t.Fatalf("second repository did not observe committed module: %v", err) + } + app, err := repository.CreateConnectedApp(ConnectedApp{ID: "app-db-" + suffix, Name: "Repository Test", ClientID: "repository-" + suffix, RedirectURIs: []string{"http://localhost/callback"}, CreatedBy: user.ID, Enabled: true}, "secret") + if err != nil { + t.Fatalf("CreateConnectedApp: %v", err) + } + t.Cleanup(func() { + cleanup, openErr := OpenPostgresRepository(context.Background(), databaseURL) + if openErr != nil { + return + } + defer cleanup.Close() + _ = cleanup.DeleteConnectedApp(app.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_page_view WHERE module_id=$1`, module.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM user_favorite WHERE module_key=$1`, module.ModuleKey) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM user_recent_doc WHERE module_key=$1`, module.ModuleKey) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_feedback WHERE module_key=$1`, module.ModuleKey) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_embedding WHERE module_id=$1`, module.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_page WHERE module_id=$1`, module.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_release WHERE module_id=$1`, module.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_entry WHERE module_id=$1`, module.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_site_file WHERE module_key=$1`, module.ModuleKey) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_nav WHERE module_key=$1`, module.ModuleKey) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_version WHERE id=$1`, version.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_module_category WHERE module_id=$1`, module.ID) + _, _ = cleanup.pool.Exec(context.Background(), `DELETE FROM docs_module WHERE id=$1`, module.ID) + _ = cleanup.DeleteCategory(category.ID) + _ = cleanup.DeleteTeam(team.Key) + _ = cleanup.DeleteUser(user.ID) + }) + + updatedUser, err := repository.SetUserMCPToken(user.ID, "mcp-direct") + if err != nil || updatedUser.MCPToken != "mcp-direct" { + t.Fatalf("SetUserMCPToken = %#v, %v", updatedUser, err) + } + loadedModule, err := repository.Module(module.ModuleKey) + if err != nil { + t.Fatalf("Module: %v", err) + } + if len(loadedModule.CategoryIDs) != 1 || loadedModule.CategoryIDs[0] != category.ID { + t.Fatalf("module categories = %#v", loadedModule.CategoryIDs) + } + if loadedModule.DeployToken == "" || !loadedModule.DeployTokenSet { + t.Fatal("module deploy token was not loaded for internal authentication") + } + if len(repository.Entries(module.ModuleKey, version.DocsVersion)) != 1 { + t.Fatal("entry was not read directly after insert") + } + if _, err := repository.VerifyConnectedAppSecret(app.ClientID, "secret"); err != nil { + t.Fatalf("VerifyConnectedAppSecret: %v", err) + } + + result, err := repository.IngestArtifact(DeployArtifact{ + ModuleKey: module.ModuleKey, ModuleName: module.Name, DocsVersion: "latest", + Authors: []string{user.Username}, CommitSHA: "abc123", + Entries: []DeployEntry{{Key: "guide", Title: "Guide", Type: "markdown"}}, + Documents: []DeployDocument{{DocID: module.ModuleKey + ":latest:guide", EntryKey: "guide", Title: "Guide", Content: "database-backed content"}}, + Nav: []NavItem{{Title: "Guide", Path: "/guide"}}, + SiteFiles: map[string][]byte{"site/guide/assets/app.css": []byte("body{}")}, + }) + if err != nil { + t.Fatalf("IngestArtifact: %v", err) + } + if result.PagesIndexed != 1 || len(repository.Releases()) == 0 { + t.Fatalf("ingest result = %#v", result) + } + docID := module.ModuleKey + ":latest:guide" + page, err := repository.Page(docID) + if err != nil || page.ContentText != "database-backed content" { + t.Fatalf("Page = %#v, %v", page, err) + } + if _, err := repository.SiteFile(module.ModuleKey, "latest", "guide", "assets/app.css"); err != nil { + t.Fatalf("SiteFile: %v", err) + } + view := repository.RecordPageView(PageView{DocID: docID, UserID: user.ID, SessionID: "session-" + suffix, ReadID: "read-" + suffix}) + view = repository.RecordReadProgress(docID, view.SessionID, view.ReadID, 42, 0.8) + if view.DurationSeconds != 42 || view.ScrollDepth != 0.8 { + t.Fatalf("read progress = %#v", view) + } + if _, err := repository.SetUserFavorite(user.ID, module.ModuleKey, true); err != nil { + t.Fatalf("SetUserFavorite: %v", err) + } + if _, err := repository.RecordUserRecentDoc(user.ID, UserRecentDoc{DocID: docID}); err != nil { + t.Fatalf("RecordUserRecentDoc: %v", err) + } + if len(repository.UserFavorites(user.ID)) != 1 || len(repository.UserRecentDocs(user.ID, 10)) != 1 { + t.Fatal("personal activity was not immediately readable") + } + + grant, err := repository.CreateOAuthCode(app.ID, user.ID, app.RedirectURIs[0], app.Scopes, "code-"+suffix, time.Minute) + if err != nil { + t.Fatalf("CreateOAuthCode: %v", err) + } + grant, _, _, err = repository.RedeemOAuthCode(app.ClientID, "code-"+suffix, app.RedirectURIs[0], "access-"+suffix, "refresh-"+suffix, time.Minute, time.Hour) + if err != nil || grant.AccessTokenHash == "" { + t.Fatalf("RedeemOAuthCode = %#v, %v", grant, err) + } + if _, _, _, err = repository.UserByOAuthAccessToken("access-" + suffix); err != nil { + t.Fatalf("UserByOAuthAccessToken: %v", err) + } + if _, _, _, err = repository.RefreshOAuthToken(app.ClientID, "refresh-"+suffix, "access-next-"+suffix, "refresh-next-"+suffix, time.Minute, time.Hour); err != nil { + t.Fatalf("RefreshOAuthToken: %v", err) + } + if !repository.RevokeOAuthToken(app.ClientID, "refresh-next-"+suffix) { + t.Fatal("RevokeOAuthToken did not update a row") + } +} diff --git a/backend/internal/store/postgres_search.go b/backend/internal/store/postgres_search.go new file mode 100644 index 0000000..5bf0876 --- /dev/null +++ b/backend/internal/store/postgres_search.go @@ -0,0 +1,103 @@ +package store + +import ( + "context" + "encoding/json" + "strings" +) + +// Pages reads the searchable document projection directly from PostgreSQL. +// It intentionally does not use PostgresRepository.load: request paths must +// not materialize an in-memory copy of the complete business store. +func (p *PostgresRepository) Pages() []Page { + var pages []Page + err := loadQuery(context.Background(), p.pool, ` + SELECT (to_jsonb(p)-'module_id'-'version_id'-'entry_id'-'release_id'-'last_verified_at'-'created_at') || + jsonb_build_object( + 'module_key',m.module_key, + 'module_name',m.name, + 'docs_version',v.docs_version, + 'package_version',v.package_version, + 'entry_key',COALESCE(e.entry_key,''), + 'entry_type',COALESCE(e.entry_type,'') + ) + FROM docs_page p + JOIN docs_module m ON m.id=p.module_id + JOIN docs_version v ON v.id=p.version_id + LEFT JOIN docs_entry e ON e.id=p.entry_id + ORDER BY p.id`, &pages) + if err != nil { + return []Page{} + } + return pages +} + +// Modules queries the catalog directly. Category membership is assembled by a +// correlated relational query rather than from a process-local snapshot. +func (p *PostgresRepository) Modules(categoryID, keyword string) []Module { + var modules []Module + args := []any{} + where := []string{"1=1"} + if categoryID != "" { + args = append(args, categoryID) + where = append(where, `EXISTS (SELECT 1 FROM docs_module_category f WHERE f.module_id=m.id AND f.category_id=$1)`) + } + if strings.TrimSpace(keyword) != "" { + args = append(args, "%"+strings.TrimSpace(keyword)+"%") + where = append(where, `(m.name ILIKE $`+itoa(len(args))+` OR m.module_key ILIKE $`+itoa(len(args))+` OR m.repo_url ILIKE $`+itoa(len(args))+`)`) + } + query := `SELECT (to_jsonb(m)-'default_version_id'-'created_at'-'deploy_token') || + jsonb_build_object( + 'category_ids',COALESCE((SELECT jsonb_agg(mc.category_id ORDER BY mc.is_primary DESC,mc.category_id) FROM docs_module_category mc WHERE mc.module_id=m.id),'[]'::jsonb), + 'category_path',COALESCE(NULLIF(m.category_path,''),(SELECT string_agg(c.name,' / ' ORDER BY mc.is_primary DESC,mc.category_id) FROM docs_module_category mc JOIN docs_category c ON c.id=mc.category_id WHERE mc.module_id=m.id),''), + 'deploy_token_set',COALESCE(m.deploy_token,'')<>'' + ) + FROM docs_module m WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.updated_at DESC,m.id` + if err := loadQuery(context.Background(), p.pool, query, &modules, args...); err != nil { + return []Module{} + } + for i := range modules { + modules[i].AvailableVers = p.Versions(modules[i].ModuleKey) + } + return modules +} + +func (p *PostgresRepository) Settings() Settings { + var raw []byte + if err := p.pool.QueryRow(context.Background(), `SELECT value_json FROM platform_settings WHERE key='main'`).Scan(&raw); err != nil { + return Settings{} + } + var settings Settings + if err := json.Unmarshal(raw, &settings); err != nil { + return Settings{} + } + return settings +} + +// Embeddings are owned by vectorstore.Postgres in production. These methods +// satisfy the fallback contract used by unit tests and non-vector deployments; +// a PostgreSQL application always configures the external vector store. +func (p *PostgresRepository) Embedding(string) ([]float32, bool) { return nil, false } +func (p *PostgresRepository) SetEmbedding(string, []float32) {} +func (p *PostgresRepository) ClearEmbeddings() { + _, _ = p.pool.Exec(context.Background(), `TRUNCATE docs_embedding`) +} +func (p *PostgresRepository) EmbeddingCount() int { + var count int + _ = p.pool.QueryRow(context.Background(), `SELECT count(*) FROM docs_embedding`).Scan(&count) + return count +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + var digits [20]byte + i := len(digits) + for value > 0 { + i-- + digits[i] = byte('0' + value%10) + value /= 10 + } + return string(digits[i:]) +} diff --git a/backend/internal/store/postgres_settings.go b/backend/internal/store/postgres_settings.go new file mode 100644 index 0000000..9ebf9fc --- /dev/null +++ b/backend/internal/store/postgres_settings.go @@ -0,0 +1,246 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +func (p *PostgresRepository) updateSettings(mutator func(*Settings) error) (Settings, error) { + ctx := context.Background() + tx, err := p.pool.Begin(ctx) + if err != nil { + return Settings{}, err + } + defer tx.Rollback(ctx) + var raw []byte + err = tx.QueryRow(ctx, `SELECT value_json FROM platform_settings WHERE key='main' FOR UPDATE`).Scan(&raw) + settings := Settings{} + if err == nil { + if err = json.Unmarshal(raw, &settings); err != nil { + return Settings{}, err + } + } + if err != nil { + _, err = tx.Exec(ctx, `INSERT INTO platform_settings(key,value_json) VALUES('main','{}'::jsonb) ON CONFLICT(key) DO NOTHING`) + if err != nil { + return Settings{}, err + } + } + if err = mutator(&settings); err != nil { + return Settings{}, err + } + if _, err = tx.Exec(ctx, `INSERT INTO platform_settings(key,value_json) VALUES('main',$1::jsonb) ON CONFLICT(key) DO UPDATE SET value_json=EXCLUDED.value_json`, mustJSON(settings)); err != nil { + return Settings{}, err + } + return settings, tx.Commit(ctx) +} + +func (p *PostgresRepository) SaveAISettings(ai AISettings) Settings { + settings, _ := p.updateSettings(func(current *Settings) error { + if strings.TrimSpace(ai.AskAPIKey) == "" { + ai.AskAPIKey = current.AI.AskAPIKey + } + if strings.TrimSpace(ai.EmbeddingAPIKey) == "" { + ai.EmbeddingAPIKey = current.AI.EmbeddingAPIKey + } + if strings.TrimSpace(ai.RerankAPIKey) == "" { + ai.RerankAPIKey = current.AI.RerankAPIKey + } + ai.UpdatedAt = time.Now().UTC() + current.AI = ai + return nil + }) + return settings +} + +func (p *PostgresRepository) PluginStates() []PluginState { + settings := p.Settings() + return mergePlugins(settings.Plugins, settings.UploadedPlugins) +} +func (p *PostgresRepository) PluginEffective() map[string]PluginSetting { + states := p.PluginStates() + result := make(map[string]PluginSetting, len(states)) + for _, state := range states { + result[state.Key] = PluginSetting{Enabled: state.Enabled, Config: state.Config} + } + return result +} + +func cleanPluginSettings(overrides map[string]PluginSetting, uploaded []UploadedPlugin) map[string]PluginSetting { + allowed := map[string]map[string]bool{} + for _, def := range pluginCatalog { + fields := map[string]bool{} + for _, field := range def.Fields { + fields[field.Key] = true + } + allowed[def.Key] = fields + } + for _, plugin := range uploaded { + if _, ok := allowed[plugin.Key]; !ok { + allowed[plugin.Key] = map[string]bool{} + } + } + clean := map[string]PluginSetting{} + for key, override := range overrides { + fields, known := allowed[key] + if !known { + continue + } + config := map[string]string{} + for name, value := range override.Config { + if fields[name] && strings.TrimSpace(value) != "" { + config[name] = strings.TrimSpace(value) + } + } + clean[key] = PluginSetting{Enabled: override.Enabled, Config: config} + } + return clean +} + +func (p *PostgresRepository) SavePluginSettings(overrides map[string]PluginSetting) []PluginState { + settings, _ := p.updateSettings(func(current *Settings) error { + current.Plugins = cleanPluginSettings(overrides, current.UploadedPlugins) + return nil + }) + return mergePlugins(settings.Plugins, settings.UploadedPlugins) +} +func (p *PostgresRepository) UploadedPlugins() []UploadedPlugin { + return append([]UploadedPlugin(nil), p.Settings().UploadedPlugins...) +} +func (p *PostgresRepository) EnabledUploadedPlugins() []UploadedPlugin { + settings := p.Settings() + result := []UploadedPlugin{} + for _, plugin := range settings.UploadedPlugins { + if override, ok := settings.Plugins[plugin.Key]; ok && override.Enabled { + result = append(result, plugin) + } + } + return result +} + +func validateUploadedPlugin(plugin UploadedPlugin) (UploadedPlugin, error) { + plugin.Key = strings.TrimSpace(plugin.Key) + plugin.Name = strings.TrimSpace(plugin.Name) + plugin.Tag = strings.TrimSpace(plugin.Tag) + plugin.Lang = strings.TrimSpace(plugin.Lang) + plugin.Category = strings.TrimSpace(plugin.Category) + if plugin.Category == "" { + plugin.Category = "custom" + } + plugin.Format = "jsx" + if !keyRe.MatchString(plugin.Key) { + return UploadedPlugin{}, fmt.Errorf("key 需为小写字母/数字/连字符(如 my-plugin)") + } + for _, definition := range pluginCatalog { + if definition.Key == plugin.Key { + return UploadedPlugin{}, fmt.Errorf("key 与内置插件冲突:%s", plugin.Key) + } + } + if plugin.Name == "" { + return UploadedPlugin{}, fmt.Errorf("name 不能为空") + } + if strings.TrimSpace(plugin.Code) == "" { + return UploadedPlugin{}, fmt.Errorf("code 不能为空") + } + switch plugin.Kind { + case "component": + if !tagRe.MatchString(plugin.Tag) { + return UploadedPlugin{}, fmt.Errorf("component 插件需要大写开头的 tag(如 Figma)") + } + plugin.Lang = "" + case "fence": + if !lngRe.MatchString(plugin.Lang) { + return UploadedPlugin{}, fmt.Errorf("fence 插件需要小写的 lang(如 figma)") + } + plugin.Tag = "" + default: + return UploadedPlugin{}, fmt.Errorf("kind 必须是 component 或 fence") + } + return plugin, nil +} +func (p *PostgresRepository) SaveUploadedPlugin(plugin UploadedPlugin) (UploadedPlugin, error) { + validated, err := validateUploadedPlugin(plugin) + if err != nil { + return UploadedPlugin{}, err + } + settings, err := p.updateSettings(func(current *Settings) error { + validated.UpdatedAt = time.Now().UTC() + for index, existing := range current.UploadedPlugins { + if existing.Key == validated.Key { + validated.Version = existing.Version + 1 + current.UploadedPlugins[index] = validated + return nil + } + } + validated.Version = 1 + current.UploadedPlugins = append(current.UploadedPlugins, validated) + return nil + }) + if err != nil { + return UploadedPlugin{}, err + } + for _, saved := range settings.UploadedPlugins { + if saved.Key == validated.Key { + return saved, nil + } + } + return UploadedPlugin{}, ErrNotFound +} +func (p *PostgresRepository) DeleteUploadedPlugin(key string) bool { + deleted := false + _, err := p.updateSettings(func(current *Settings) error { + for index, plugin := range current.UploadedPlugins { + if plugin.Key == key { + current.UploadedPlugins = append(current.UploadedPlugins[:index], current.UploadedPlugins[index+1:]...) + delete(current.Plugins, key) + deleted = true + break + } + } + return nil + }) + return err == nil && deleted +} + +func cleanSnippetData(snippets []Snippet, variables map[string]string) ([]Snippet, map[string]string) { + cleanSnippets := make([]Snippet, 0, len(snippets)) + seen := map[string]int{} + for _, snippet := range snippets { + key := strings.TrimSpace(snippet.Key) + if key == "" { + continue + } + entry := Snippet{Key: key, Name: strings.TrimSpace(snippet.Name), Content: snippet.Content} + if index, ok := seen[key]; ok { + cleanSnippets[index] = entry + } else { + seen[key] = len(cleanSnippets) + cleanSnippets = append(cleanSnippets, entry) + } + } + cleanVariables := map[string]string{} + for key, value := range variables { + key = strings.TrimSpace(key) + if key != "" { + cleanVariables[key] = strings.TrimSpace(value) + } + } + return cleanSnippets, cleanVariables +} +func (p *PostgresRepository) SnippetData() ([]Snippet, map[string]string) { + settings := p.Settings() + snippets := append([]Snippet(nil), settings.Snippets...) + variables := map[string]string{} + for key, value := range settings.Variables { + variables[key] = value + } + return snippets, variables +} +func (p *PostgresRepository) SaveSnippetData(snippets []Snippet, variables map[string]string) ([]Snippet, map[string]string) { + snippets, variables = cleanSnippetData(snippets, variables) + _, _ = p.updateSettings(func(current *Settings) error { current.Snippets = snippets; current.Variables = variables; return nil }) + return snippets, variables +} diff --git a/backend/internal/store/schema.sql b/backend/internal/store/schema.sql new file mode 100644 index 0000000..cbd485f --- /dev/null +++ b/backend/internal/store/schema.sql @@ -0,0 +1,443 @@ +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS auth_session ( + key TEXT PRIMARY KEY, + value_json JSONB NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + email TEXT, + department TEXT, + avatar TEXT, + roles_json JSONB NOT NULL DEFAULT '[]'::jsonb, + managed_categories_json JSONB NOT NULL DEFAULT '[]'::jsonb, + source TEXT, + status TEXT, + is_super_admin BOOLEAN NOT NULL DEFAULT false, + mcp_token TEXT, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Teams: document maintenance teams with leader + members. +-- A team can be assigned as responsible_team on docs_category (领域 owner). +-- Members/leaders get implicit management rights on assigned domains (see canManageViaResponsibleTeam). +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + leaders JSONB NOT NULL DEFAULT '[]'::jsonb, + members JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS connected_app ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + client_id TEXT NOT NULL UNIQUE, + client_secret_hash TEXT, + redirect_uris JSONB DEFAULT '[]'::jsonb, + scopes JSONB DEFAULT '[]'::jsonb, + trusted BOOLEAN DEFAULT false, + enabled BOOLEAN DEFAULT true, + created_by TEXT REFERENCES users(id), + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Built-in public OAuth client for Codex MCP OAuth login. Codex CLI supports +-- `--oauth-client-id` but does not take a client secret for MCP login, so this +-- client intentionally has an empty secret and only allows local loopback +-- callbacks. +INSERT INTO connected_app(id,name,description,client_id,client_secret_hash,redirect_uris,scopes,trusted,enabled,created_at,updated_at) +VALUES( + 'app-codex-cli', + 'Codex CLI MCP OAuth', + 'Built-in public OAuth client for Codex MCP login.', + 'codex-cli', + '', + '["http://localhost","http://127.0.0.1","http://[::1]"]'::jsonb, + '["modex:mcp:read","modex:docs:read"]'::jsonb, + true, + true, + now(), + now() +) +ON CONFLICT(client_id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS oauth_grant ( + id TEXT PRIMARY KEY, + app_id TEXT REFERENCES connected_app(id), + user_id TEXT REFERENCES users(id), + code_hash TEXT, + access_token_hash TEXT, + refresh_token_hash TEXT, + redirect_uri TEXT, + scopes JSONB DEFAULT '[]'::jsonb, + code_expires_at TIMESTAMPTZ, + access_expires_at TIMESTAMPTZ, + refresh_expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_oauth_grant_code_hash ON oauth_grant(code_hash); +CREATE INDEX IF NOT EXISTS idx_oauth_grant_access_token_hash ON oauth_grant(access_token_hash); +CREATE INDEX IF NOT EXISTS idx_oauth_grant_refresh_token_hash ON oauth_grant(refresh_token_hash); + +CREATE TABLE IF NOT EXISTS docs_category ( + id TEXT PRIMARY KEY, + parent_id TEXT REFERENCES docs_category(id), + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + sort_order INT DEFAULT 0, + status TEXT DEFAULT 'active', + responsible_team TEXT, -- team key owning this 领域 (domain); members get mgmt rights + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_module ( + id TEXT PRIMARY KEY, + module_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + owner_group TEXT, + repo_type TEXT, + repo_url TEXT, + default_version TEXT, + visibility TEXT DEFAULT 'internal', + status TEXT DEFAULT 'active', + package_name TEXT, + package_version TEXT, + channel TEXT, + edition TEXT, + keywords JSONB DEFAULT '[]'::jsonb, + maintainers JSONB DEFAULT '[]'::jsonb, + category_path TEXT, + source_type TEXT, + doc_type TEXT, + mount TEXT, + gitlab_branch TEXT, + gitlab_path TEXT, + deploy_token TEXT, + last_synced_commit TEXT, + last_synced_at TIMESTAMPTZ, + created_by TEXT REFERENCES users(id), + reads_7d INT NOT NULL DEFAULT 0, + reads_30d INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS docs_module_deploy_token_uidx + ON docs_module(deploy_token) + WHERE deploy_token IS NOT NULL AND deploy_token <> ''; + +CREATE TABLE IF NOT EXISTS docs_module_category ( + module_id TEXT REFERENCES docs_module(id), + category_id TEXT REFERENCES docs_category(id), + is_primary BOOLEAN DEFAULT false, + PRIMARY KEY (module_id, category_id) +); + +CREATE TABLE IF NOT EXISTS docs_version ( + id TEXT PRIMARY KEY, + module_id TEXT REFERENCES docs_module(id), + docs_version TEXT NOT NULL, + display_name TEXT, + version_type TEXT, + is_default BOOLEAN DEFAULT false, + status TEXT DEFAULT 'active', + source_branch TEXT, + package_version TEXT, + channel TEXT, + edition TEXT, + support_status TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(module_id, docs_version) +); + +CREATE TABLE IF NOT EXISTS docs_entry ( + id TEXT PRIMARY KEY, + module_id TEXT REFERENCES docs_module(id), + version_id TEXT REFERENCES docs_version(id), + entry_key TEXT NOT NULL, + title TEXT NOT NULL, + entry_type TEXT NOT NULL, + builder TEXT, + source TEXT, + storage_uri TEXT, + nav_uri TEXT, + index_status TEXT, + is_primary BOOLEAN DEFAULT false, + sort_order INT DEFAULT 0, + status TEXT DEFAULT 'active', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_release ( + id TEXT PRIMARY KEY, + module_id TEXT REFERENCES docs_module(id), + version_id TEXT REFERENCES docs_version(id), + release_id TEXT NOT NULL UNIQUE, + commit_sha TEXT, + branch TEXT, + publisher TEXT, + pipeline_url TEXT, + build_system TEXT, + build_id TEXT, + trigger_type TEXT, + source_ip TEXT, + artifact_version TEXT, + package_version TEXT, + storage_uri TEXT, + status TEXT, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_page ( + id TEXT PRIMARY KEY, + module_id TEXT REFERENCES docs_module(id), + version_id TEXT REFERENCES docs_version(id), + entry_id TEXT REFERENCES docs_entry(id), + release_id TEXT REFERENCES docs_release(id), + doc_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT, + path TEXT, + source_file TEXT, + doc_type TEXT, + status TEXT DEFAULT 'active', + owner_group TEXT, + tags JSONB DEFAULT '[]'::jsonb, + category_ids JSONB DEFAULT '[]'::jsonb, + content_text TEXT, + content_html TEXT, + content_md TEXT, + updated_at TIMESTAMPTZ, + last_verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_page_view ( + id TEXT PRIMARY KEY, + page_id TEXT REFERENCES docs_page(id), + module_id TEXT REFERENCES docs_module(id), + version_id TEXT REFERENCES docs_version(id), + doc_id TEXT, + module_key TEXT, + module_name TEXT, + docs_version TEXT, + entry_key TEXT, + title TEXT, + path TEXT, + user_id TEXT REFERENCES users(id), + session_id TEXT, + read_id TEXT, + duration_seconds INT, + scroll_depth NUMERIC, + viewed_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_favorite ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + module_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(user_id, module_key) +); + +CREATE TABLE IF NOT EXISTS user_recent_doc ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + doc_id TEXT NOT NULL, + title TEXT, + module_key TEXT, + module_name TEXT, + docs_version TEXT, + entry_key TEXT, + href TEXT, + viewed_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(user_id, doc_id) +); + +CREATE TABLE IF NOT EXISTS docs_search_log ( + id TEXT PRIMARY KEY, + user_id TEXT, + ip_address TEXT, + query TEXT, + mode TEXT, + filters_json JSONB, + result_count INT, + clicked_doc_id TEXT, + searched_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_embedding ( + id BIGSERIAL PRIMARY KEY, + page_id TEXT REFERENCES docs_page(id), + doc_id TEXT, + chunk_id TEXT, + module_id TEXT REFERENCES docs_module(id), + version_id TEXT REFERENCES docs_version(id), + entry_id TEXT REFERENCES docs_entry(id), + content TEXT, + embedding vector(1024), + embedding_json JSONB, + metadata_json JSONB, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +DROP INDEX IF EXISTS idx_docs_embedding_doc_id; +CREATE UNIQUE INDEX IF NOT EXISTS idx_docs_embedding_chunk_id ON docs_embedding(chunk_id); + +CREATE TABLE IF NOT EXISTS docs_mcp_log ( + id TEXT PRIMARY KEY, + tool_name TEXT, + user_id TEXT, + query TEXT, + input_json JSONB, + result_count INT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_feedback ( + id TEXT PRIMARY KEY, + doc_id TEXT NOT NULL, + page_id TEXT, + module_key TEXT, + title TEXT, + rating TEXT, + comment TEXT, + user_id TEXT, + session_id TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_nav ( + module_key TEXT NOT NULL, + docs_version TEXT NOT NULL, + items_json JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (module_key, docs_version) +); + +CREATE TABLE IF NOT EXISTS docs_site_file ( + module_key TEXT NOT NULL, + docs_version TEXT NOT NULL, + entry_key TEXT NOT NULL, + name TEXT NOT NULL, + content BYTEA NOT NULL, + content_type TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (module_key, docs_version, entry_key, name) +); + +CREATE TABLE IF NOT EXISTS platform_settings ( + key TEXT PRIMARY KEY, + value_json JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DROP TABLE IF EXISTS store_metadata; +DROP TABLE IF EXISTS modex_store_snapshot; +-- Upgrade databases created by earlier releases. CREATE TABLE IF NOT EXISTS +-- does not add or change columns on an existing installation. +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS roles_json JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE users ADD COLUMN IF NOT EXISTS managed_categories_json JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE users ADD COLUMN IF NOT EXISTS source TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS status TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_super_admin BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE users ADD COLUMN IF NOT EXISTS mcp_token TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; + +-- Older deployments briefly allowed non-array JSON values in these fields. +-- Keep them normalized so login upserts and user serialization can safely treat +-- them as string arrays. +UPDATE users SET roles_json='[]'::jsonb WHERE jsonb_typeof(roles_json) <> 'array'; +UPDATE users SET managed_categories_json='[]'::jsonb WHERE jsonb_typeof(managed_categories_json) <> 'array'; + +-- Drop the legacy Keycloak group mirror. Team membership (teams + responsible_team) +-- is the only authorization model; SSO groups were never consulted for access. +DROP TABLE IF EXISTS user_groups; +DROP TABLE IF EXISTS groups; +ALTER TABLE users DROP COLUMN IF EXISTS groups_json; + +ALTER TABLE teams ADD COLUMN IF NOT EXISTS leaders JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE teams ADD COLUMN IF NOT EXISTS members JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS default_version TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS category_path TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS source_type TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS doc_type TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS mount TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS gitlab_branch TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS gitlab_path TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS deploy_token TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS last_synced_commit TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS created_by TEXT REFERENCES users(id); +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS reads_7d INT NOT NULL DEFAULT 0; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS reads_30d INT NOT NULL DEFAULT 0; + +CREATE UNIQUE INDEX IF NOT EXISTS docs_module_deploy_token_uidx + ON docs_module(deploy_token) + WHERE deploy_token IS NOT NULL AND deploy_token <> ''; + +ALTER TABLE docs_page ADD COLUMN IF NOT EXISTS category_ids JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE docs_page ADD COLUMN IF NOT EXISTS content_html TEXT; +ALTER TABLE docs_page ADD COLUMN IF NOT EXISTS content_md TEXT; + +ALTER TABLE docs_page_view ALTER COLUMN id DROP DEFAULT; +ALTER TABLE docs_page_view ALTER COLUMN id TYPE TEXT USING id::text; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS doc_id TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS module_key TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS module_name TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS docs_version TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS entry_key TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS title TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS path TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS read_id TEXT; +ALTER TABLE docs_search_log ALTER COLUMN id DROP DEFAULT; +ALTER TABLE docs_search_log ALTER COLUMN id TYPE TEXT USING id::text; +ALTER TABLE docs_search_log ADD COLUMN IF NOT EXISTS ip_address TEXT; +ALTER TABLE docs_mcp_log ALTER COLUMN id DROP DEFAULT; +ALTER TABLE docs_mcp_log ALTER COLUMN id TYPE TEXT USING id::text; + +-- Remove JSON mirrors from development builds. Rows are reconstructed only +-- from typed columns and foreign-key relationships. +ALTER TABLE users DROP COLUMN IF EXISTS record_json; +ALTER TABLE teams DROP COLUMN IF EXISTS record_json; +ALTER TABLE connected_app DROP COLUMN IF EXISTS record_json; +ALTER TABLE oauth_grant DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_category DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_module DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_version DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_entry DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_release DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_release ADD COLUMN IF NOT EXISTS trigger_type TEXT; +ALTER TABLE docs_release ADD COLUMN IF NOT EXISTS source_ip TEXT; +ALTER TABLE docs_page DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_page_view DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_search_log DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_mcp_log DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_feedback DROP COLUMN IF EXISTS record_json; diff --git a/backend/internal/store/seed.go b/backend/internal/store/seed.go new file mode 100644 index 0000000..947af3f --- /dev/null +++ b/backend/internal/store/seed.go @@ -0,0 +1,13 @@ +package store + +import _ "embed" + +// Seed markdown keeps the optional local development dataset self-contained. +var ( + //go:embed seeddata/demo-guide.md + seedDemoGuideMD string + //go:embed seeddata/demo-maintenance.md + seedDemoMaintenanceMD string + //go:embed seeddata/cbb-build-cache.md + seedCBBBuildCacheMD string +) diff --git a/backend/internal/store/seeddata/cbb-build-cache.md b/backend/internal/store/seeddata/cbb-build-cache.md new file mode 100644 index 0000000..4ace66f --- /dev/null +++ b/backend/internal/store/seeddata/cbb-build-cache.md @@ -0,0 +1,37 @@ +--- +title: 构建缓存清理 +description: CBB 构建缓存清理和常见构建问题排查。 +--- + +当依赖缓存、编译缓存或 CI 工作区残留导致构建异常时,可按本页步骤清理。 + +清理缓存会导致下一次构建变慢,请在确认存在缓存污染时再执行。 + +## 清理步骤 + + + + ```bash + cbb cache clean --all + ``` + + + ```bash + cbb deps sync --force + ``` + + + ```bash + cbb build --no-cache + ``` + + + +## 不同环境 + + + 删除 `.cbb/cache` 目录后重新构建。 + 在流水线中清理 runner 工作区缓存卷。 + + +构建成功后产物哈希应与上一个稳定版本一致(除非源码变更)。 diff --git a/backend/internal/store/seeddata/demo-guide.md b/backend/internal/store/seeddata/demo-guide.md new file mode 100644 index 0000000..c1bf6e4 --- /dev/null +++ b/backend/internal/store/seeddata/demo-guide.md @@ -0,0 +1,185 @@ +--- +title: 模块落地指导 +description: 面向业务开发人员的 DemoModule 接入、部署、接口和异常处理说明。 +--- + +DemoModule 提供统一的业务接入能力。本页演示 Modex 内置的 Mintlify 风格组件渲染引擎,涵盖提示框、卡片、标签页、步骤、代码组等全部组件。 + + + 本文档由 Modex 的 MDX 渲染引擎生成,组件与 Mintlify 保持一致。你可以在任意 `.md` / `.mdx` 文档中直接书写这些组件。 + + +## 提示框 Callouts + +这是一条信息提示,用于补充背景说明。 +这是一条技巧提示,给出最佳实践建议。 +这是一条警告提示,提醒潜在风险。 +这是一条成功提示,表示校验通过。 +这是一条普通注解提示。 + +## 卡片 Cards + + + + 三步完成 DemoModule 接入。 + + + 查看请求与响应字段定义。 + + + 了解模块的总体架构与时序。 + + + 在 GitLab 查看实现细节。 + + + +## 多列布局 Columns + + + P99 < 50ms 的接口响应。 + 多副本部署,自动故障转移。 + 内置指标、日志与链路追踪。 + + +## 步骤 Steps + + + + 使用包管理器安装 DemoModule SDK。 + + ```bash + npm install @demo/module + ``` + + + 填入服务地址与密钥即可创建客户端。 + + 初始化成功后会打印 `client ready`。 + + + 调用 `submit()` 完成业务接入。 + + + +## 标签页 Tabs + + + + ```js + import { DemoClient } from "@demo/module"; + const client = new DemoClient({ token: process.env.TOKEN }); + await client.submit({ id: 1 }); + ``` + + + ```python + from demo import DemoClient + client = DemoClient(token=os.environ["TOKEN"]) + client.submit(id=1) + ``` + + + ```bash + curl -X POST https://api.example.com/submit \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"id": 1}' + ``` + + + +## 代码组 CodeGroup + + + ```ts config.ts + export const config = { + endpoint: "https://api.example.com", + timeout: 5000, + }; + ``` + + ```yaml config.yaml + endpoint: https://api.example.com + timeout: 5000 + ``` + + +## 折叠面板 Accordion + + + + 在控制台「凭据管理」页面创建,令牌具备最小权限。 + + + 支持 Node.js 18+、Python 3.9+,以及任意可发起 HTTPS 请求的环境。 + + + +## 字段 Fields + + + 业务实体的唯一标识。 + + + 是否以异步方式提交。 + + + + 处理结果,取值 `ok` 或 `failed`。 + + +## 可展开 Expandable + + + + 失败重试次数。 + + + 重试退避策略。 + + + +## 图片框 Frame + + + ![控制台](https://placehold.co/720x360/eef2ff/4f46e5?text=DemoModule+Console) + + +## 行内组件 + +支持 SLA 悬浮提示、状态徽标 Beta 稳定,以及颜色样本 #4f46e5。 + +## 更新日志 Update + + + 新增异步提交能力,优化错误码体系。 + + +## 文件树 Tree + + + - src + - index.ts + - client.ts + - package.json + + +## 流程图 Mermaid + + +graph LR + A[业务系统] --> B[DemoModule SDK] + B --> C[网关] + C --> D[(数据存储)] + + +## 普通 Markdown + +支持标准 Markdown:**加粗**、*斜体*、`行内代码`、[链接](https://example.com)、列表与表格。 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | string | 实体标识 | +| async | boolean | 异步提交 | + +> 引用块同样按 Mintlify 风格渲染。 diff --git a/backend/internal/store/seeddata/demo-maintenance.md b/backend/internal/store/seeddata/demo-maintenance.md new file mode 100644 index 0000000..a4554f0 --- /dev/null +++ b/backend/internal/store/seeddata/demo-maintenance.md @@ -0,0 +1,102 @@ +--- +title: 模块维护说明 +description: 面向维护开发人员的架构、设计、流程和维护说明。 +--- + +本页面向维护人员,说明 DemoModule 的总体架构、核心流程与质量要求。 + +维护前请先阅读[模块落地指导](/docs/DemoModule/latest/guide),了解对外接口契约。 + +## 总体架构 + +```mermaid +graph TD + GW[网关层] --> SVC[业务服务] + SVC --> CACHE[(缓存)] + SVC --> DB[(数据库)] +``` + +## 设计原则 + + + 每个子模块只负责一个清晰的能力边界。 + 关键路径埋点指标、日志、链路追踪三件套。 + + +## 核心流程 + + + 网关完成鉴权与限流后转发到业务服务。 + 业务服务执行校验、写入数据库并更新缓存。 + 统一错误码与响应结构返回调用方。 + + +## 常见维护操作 + + + + 调整副本数即可水平扩容,服务无状态。 + + + 查看链路追踪定位耗时阶段,再结合数据库慢查询日志分析。 + + + +变更数据库 Schema 前必须经过评审并准备回滚脚本。 + +## 渲染能力示例 + +[[toc]] + +### 时序图(PlantUML) + +```plantuml +@startuml +actor 调用方 +调用方 -> 网关 : 请求 +网关 -> 业务服务 : 转发 +业务服务 -> 数据库 : 写入 +数据库 --> 业务服务 : ok +业务服务 --> 调用方 : 响应 +@enduml +``` + +### 依赖关系(Graphviz) + +```graphviz +digraph G { + rankdir=LR; + 网关 -> 业务服务 -> 数据库; + 业务服务 -> 缓存; +} +``` + +### GitHub 风格提示块 + +> [!NOTE] +> 这些提示块用 `> [!NOTE]`、`> [!WARNING]` 等写法,会自动渲染为 callout。 + +> [!CAUTION] +> 删除生产数据前务必二次确认。 + +### 数学公式 + +行内公式 $E = mc^2$,以及块级公式: + +$$ +P_{99} = \min\{x : F(x) \ge 0.99\} +$$ + +### 脚注 + +服务按无状态设计部署[^1],便于水平扩容。 + +[^1]: 无状态指实例不保存会话数据,请求可被任意副本处理。 + +### API 调试台 + + + +也可从 OpenAPI 规范生成接口参考(在「插件管理」配置默认规范地址,或用 `spec` 属性): + + diff --git a/backend/internal/store/snippets.go b/backend/internal/store/snippets.go new file mode 100644 index 0000000..d2a0ba4 --- /dev/null +++ b/backend/internal/store/snippets.go @@ -0,0 +1,59 @@ +package store + +import "strings" + +// Snippet is a reusable Markdown partial referenced from docs as +// . Together with Variables it powers Mintlify-style +// reusable content. Both live in Settings, so they snapshot automatically. +type Snippet struct { + Key string `json:"key"` + Name string `json:"name"` + Content string `json:"content"` +} + +// SnippetData returns the snippet library and the global variable map. +func (s *MemoryStore) SnippetData() ([]Snippet, map[string]string) { + s.mu.RLock() + defer s.mu.RUnlock() + snips := make([]Snippet, len(s.settings.Snippets)) + copy(snips, s.settings.Snippets) + vars := make(map[string]string, len(s.settings.Variables)) + for k, v := range s.settings.Variables { + vars[k] = v + } + return snips, vars +} + +// SaveSnippetData replaces the snippet library and variables. Snippets with a +// blank key are dropped; keys are trimmed and de-duplicated (last wins). Blank +// variable keys are dropped and keys/values trimmed. +func (s *MemoryStore) SaveSnippetData(snips []Snippet, vars map[string]string) ([]Snippet, map[string]string) { + cleanSnips := make([]Snippet, 0, len(snips)) + seen := map[string]int{} + for _, sn := range snips { + key := strings.TrimSpace(sn.Key) + if key == "" { + continue + } + entry := Snippet{Key: key, Name: strings.TrimSpace(sn.Name), Content: sn.Content} + if idx, ok := seen[key]; ok { + cleanSnips[idx] = entry + continue + } + seen[key] = len(cleanSnips) + cleanSnips = append(cleanSnips, entry) + } + cleanVars := map[string]string{} + for k, v := range vars { + key := strings.TrimSpace(k) + if key == "" { + continue + } + cleanVars[key] = strings.TrimSpace(v) + } + s.mu.Lock() + s.settings.Snippets = cleanSnips + s.settings.Variables = cleanVars + s.mu.Unlock() + return cleanSnips, cleanVars +} diff --git a/backend/internal/store/snippets_test.go b/backend/internal/store/snippets_test.go new file mode 100644 index 0000000..43720a9 --- /dev/null +++ b/backend/internal/store/snippets_test.go @@ -0,0 +1,31 @@ +package store + +import "testing" + +func TestSaveSnippetDataCleansAndDedupes(t *testing.T) { + st := NewTestStore() + snips, vars := st.SaveSnippetData( + []Snippet{ + {Key: " intro ", Name: " 介绍 ", Content: "hello {{product}}"}, + {Key: "", Name: "blank", Content: "dropped"}, + {Key: "intro", Name: "override", Content: "world"}, + }, + map[string]string{" product ": " Modex ", "": "dropped"}, + ) + + if len(snips) != 1 { + t.Fatalf("snippets = %d, want 1 (blank dropped, dup merged)", len(snips)) + } + if snips[0].Key != "intro" || snips[0].Content != "world" { + t.Errorf("snippet not trimmed/overridden: %+v", snips[0]) + } + if len(vars) != 1 || vars["product"] != "Modex" { + t.Errorf("variables not cleaned: %+v", vars) + } + + // Round-trips through the store. + gotSnips, gotVars := st.SnippetData() + if len(gotSnips) != 1 || gotVars["product"] != "Modex" { + t.Errorf("SnippetData round-trip mismatch: %+v %+v", gotSnips, gotVars) + } +} diff --git a/backend/internal/store/uploaded_plugins.go b/backend/internal/store/uploaded_plugins.go new file mode 100644 index 0000000..c24808d --- /dev/null +++ b/backend/internal/store/uploaded_plugins.go @@ -0,0 +1,126 @@ +package store + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// UploadedPlugin is an admin-imported plugin whose JSX source is rendered inside +// a sandboxed iframe on the frontend. Kind "component" registers an MDX tag; +// "fence" handles a fenced code language. Enable/disable goes through the shared +// Plugins overrides (uploaded plugins default to disabled). +type UploadedPlugin struct { + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Kind string `json:"kind"` // "component" | "fence" + Tag string `json:"tag,omitempty"` // component tag, e.g. "Figma" + Lang string `json:"lang,omitempty"` // fence language, e.g. "figma" + Code string `json:"code"` // JSX source defining a Plugin component + Format string `json:"format"` // "jsx" + Version int `json:"version"` + UpdatedAt time.Time `json:"updated_at"` +} + +var ( + keyRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + tagRe = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) + lngRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) +) + +// UploadedPlugins returns a copy of the imported plugin list. +func (s *MemoryStore) UploadedPlugins() []UploadedPlugin { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]UploadedPlugin, len(s.settings.UploadedPlugins)) + copy(out, s.settings.UploadedPlugins) + return out +} + +// EnabledUploadedPlugins returns only imported plugins currently toggled on. +// Consumed by the renderer (uploaded plugins default to disabled). +func (s *MemoryStore) EnabledUploadedPlugins() []UploadedPlugin { + s.mu.RLock() + defer s.mu.RUnlock() + out := []UploadedPlugin{} + for _, up := range s.settings.UploadedPlugins { + if ov, ok := s.settings.Plugins[up.Key]; ok && ov.Enabled { + out = append(out, up) + } + } + return out +} + +// SaveUploadedPlugin validates and upserts an imported plugin (by key). It never +// changes the enabled flag — imports stay disabled until an admin turns them on. +func (s *MemoryStore) SaveUploadedPlugin(p UploadedPlugin) (UploadedPlugin, error) { + p.Key = strings.TrimSpace(p.Key) + p.Name = strings.TrimSpace(p.Name) + p.Tag = strings.TrimSpace(p.Tag) + p.Lang = strings.TrimSpace(p.Lang) + p.Category = strings.TrimSpace(p.Category) + if p.Category == "" { + p.Category = "custom" + } + p.Format = "jsx" + + if !keyRe.MatchString(p.Key) { + return UploadedPlugin{}, fmt.Errorf("key 需为小写字母/数字/连字符(如 my-plugin)") + } + for _, def := range pluginCatalog { + if def.Key == p.Key { + return UploadedPlugin{}, fmt.Errorf("key 与内置插件冲突:%s", p.Key) + } + } + if p.Name == "" { + return UploadedPlugin{}, fmt.Errorf("name 不能为空") + } + if strings.TrimSpace(p.Code) == "" { + return UploadedPlugin{}, fmt.Errorf("code 不能为空") + } + switch p.Kind { + case "component": + if !tagRe.MatchString(p.Tag) { + return UploadedPlugin{}, fmt.Errorf("component 插件需要大写开头的 tag(如 Figma)") + } + p.Lang = "" + case "fence": + if !lngRe.MatchString(p.Lang) { + return UploadedPlugin{}, fmt.Errorf("fence 插件需要小写的 lang(如 figma)") + } + p.Tag = "" + default: + return UploadedPlugin{}, fmt.Errorf("kind 必须是 component 或 fence") + } + + s.mu.Lock() + defer s.mu.Unlock() + p.UpdatedAt = time.Now().UTC() + for i, ex := range s.settings.UploadedPlugins { + if ex.Key == p.Key { + p.Version = ex.Version + 1 + s.settings.UploadedPlugins[i] = p + return p, nil + } + } + p.Version = 1 + s.settings.UploadedPlugins = append(s.settings.UploadedPlugins, p) + return p, nil +} + +// DeleteUploadedPlugin removes an imported plugin and its enable override. +func (s *MemoryStore) DeleteUploadedPlugin(key string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for i, ex := range s.settings.UploadedPlugins { + if ex.Key == key { + s.settings.UploadedPlugins = append(s.settings.UploadedPlugins[:i], s.settings.UploadedPlugins[i+1:]...) + delete(s.settings.Plugins, key) + return true + } + } + return false +} diff --git a/backend/internal/store/uploaded_plugins_test.go b/backend/internal/store/uploaded_plugins_test.go new file mode 100644 index 0000000..3c65e60 --- /dev/null +++ b/backend/internal/store/uploaded_plugins_test.go @@ -0,0 +1,58 @@ +package store + +import "testing" + +func TestSaveUploadedPluginValidation(t *testing.T) { + st := NewTestStore() + cases := []struct { + name string + p UploadedPlugin + ok bool + }{ + {"good component", UploadedPlugin{Key: "demo", Name: "Demo", Kind: "component", Tag: "Demo", Code: "x"}, true}, + {"good fence", UploadedPlugin{Key: "echart", Name: "EChart", Kind: "fence", Lang: "echart", Code: "x"}, true}, + {"bad key", UploadedPlugin{Key: "Demo!", Name: "Demo", Kind: "component", Tag: "Demo", Code: "x"}, false}, + {"clashes builtin", UploadedPlugin{Key: "kroki", Name: "K", Kind: "component", Tag: "K", Code: "x"}, false}, + {"component no tag", UploadedPlugin{Key: "d2", Name: "D", Kind: "component", Code: "x"}, false}, + {"fence no lang", UploadedPlugin{Key: "d3", Name: "D", Kind: "fence", Code: "x"}, false}, + {"empty code", UploadedPlugin{Key: "d4", Name: "D", Kind: "component", Tag: "D", Code: " "}, false}, + {"bad kind", UploadedPlugin{Key: "d5", Name: "D", Kind: "widget", Code: "x"}, false}, + } + for _, c := range cases { + _, err := st.SaveUploadedPlugin(c.p) + if (err == nil) != c.ok { + t.Errorf("%s: ok=%v err=%v", c.name, c.ok, err) + } + } +} + +func TestUploadedPluginEnableFlow(t *testing.T) { + st := NewTestStore() + if _, err := st.SaveUploadedPlugin(UploadedPlugin{Key: "demo", Name: "Demo", Kind: "component", Tag: "Demo", Code: "code"}); err != nil { + t.Fatal(err) + } + // Appears in admin states, disabled by default, flagged uploaded. + states := st.PluginStates() + demo, ok := findState(states, "demo") + if !ok || !demo.Uploaded || demo.Enabled || demo.Kind != "component" || demo.Tag != "Demo" { + t.Fatalf("unexpected state: %+v ok=%v", demo, ok) + } + // Not visible to the renderer while disabled. + if len(st.EnabledUploadedPlugins()) != 0 { + t.Error("disabled plugin should not be enabled-listed") + } + // Enable via the shared override path. + st.SavePluginSettings(map[string]PluginSetting{"demo": {Enabled: true}}) + en := st.EnabledUploadedPlugins() + if len(en) != 1 || en[0].Key != "demo" || en[0].Code != "code" { + t.Fatalf("enabled list wrong: %+v", en) + } + // Re-import bumps version; delete also clears the override. + saved, _ := st.SaveUploadedPlugin(UploadedPlugin{Key: "demo", Name: "Demo2", Kind: "component", Tag: "Demo", Code: "code2"}) + if saved.Version != 2 { + t.Errorf("version = %d, want 2", saved.Version) + } + if !st.DeleteUploadedPlugin("demo") || len(st.UploadedPlugins()) != 0 { + t.Error("delete failed") + } +} diff --git a/backend/internal/vectorstore/postgres.go b/backend/internal/vectorstore/postgres.go new file mode 100644 index 0000000..065c079 --- /dev/null +++ b/backend/internal/vectorstore/postgres.go @@ -0,0 +1,192 @@ +package vectorstore + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "modex/backend/internal/embedding" +) + +type Postgres struct { + pool *pgxpool.Pool +} + +func Open(ctx context.Context, databaseURL string) (*Postgres, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, err + } + for { + if err := pool.Ping(ctx); err == nil { + break + } else { + select { + case <-ctx.Done(): + pool.Close() + return nil, fmt.Errorf("connect PostgreSQL: %w", err) + case <-time.After(500 * time.Millisecond): + } + } + } + if _, err := pool.Exec(ctx, `DROP INDEX IF EXISTS idx_docs_embedding_doc_id`); err != nil { + pool.Close() + return nil, fmt.Errorf("drop legacy docs_embedding index: %w", err) + } + if _, err := pool.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_docs_embedding_chunk_id ON docs_embedding(chunk_id)`); err != nil { + pool.Close() + return nil, fmt.Errorf("ensure docs_embedding index: %w", err) + } + return &Postgres{pool: pool}, nil +} + +func (p *Postgres) Close() { + p.pool.Close() +} + +func (p *Postgres) Existing(ctx context.Context, docIDs []string) (map[string]bool, error) { + out := make(map[string]bool, len(docIDs)) + if len(docIDs) == 0 { + return out, nil + } + rows, err := p.pool.Query(ctx, `SELECT DISTINCT doc_id FROM docs_embedding WHERE doc_id = ANY($1)`, docIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var docID string + if err := rows.Scan(&docID); err != nil { + return nil, err + } + out[docID] = true + } + return out, rows.Err() +} + +func (p *Postgres) Similarities(ctx context.Context, query []float32, docIDs []string, limit int) (map[string]float64, error) { + out := make(map[string]float64, len(docIDs)) + if len(query) == 0 || len(docIDs) == 0 || limit <= 0 { + return out, nil + } + if len(query) != embedding.Dim { + return nil, fmt.Errorf("query embedding dimension mismatch: got %d, want %d", len(query), embedding.Dim) + } + rows, err := p.pool.Query(ctx, ` + SELECT doc_id, max(1 - ((embedding <=> $1::vector) / 2.0)) AS score + FROM docs_embedding + WHERE doc_id = ANY($2) AND embedding IS NOT NULL + GROUP BY doc_id + ORDER BY score DESC + LIMIT $3`, vectorLiteral(query), docIDs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var docID string + var score float64 + if err := rows.Scan(&docID, &score); err != nil { + return nil, err + } + out[docID] = score + } + return out, rows.Err() +} + +func (p *Postgres) UpsertChunk(ctx context.Context, docID, chunkID, content string, vector []float32) error { + if len(vector) != embedding.Dim { + return fmt.Errorf("embedding dimension mismatch: model produced %d dims but the vector store requires %d; configure an embedding model with %d-dimensional output", len(vector), embedding.Dim, embedding.Dim) + } + if strings.TrimSpace(chunkID) == "" { + return fmt.Errorf("chunk_id is required") + } + raw, err := json.Marshal(vector) + if err != nil { + return err + } + vectorValue := vectorLiteral(vector) + tag, err := p.pool.Exec(ctx, ` + INSERT INTO docs_embedding ( + page_id, doc_id, chunk_id, module_id, version_id, entry_id, content, + embedding, embedding_json, metadata_json, updated_at + ) + SELECT + p.id, p.doc_id, $2, p.module_id, p.version_id, p.entry_id, + $3, + $4::vector, + $5::jsonb, + jsonb_build_object( + 'title', p.title, + 'path', p.path, + 'source_file', p.source_file, + 'doc_type', p.doc_type + ), + now() + FROM docs_page p + WHERE p.doc_id=$1 + ON CONFLICT (chunk_id) DO UPDATE + SET doc_id = EXCLUDED.doc_id, + page_id = EXCLUDED.page_id, + module_id = EXCLUDED.module_id, + version_id = EXCLUDED.version_id, + entry_id = EXCLUDED.entry_id, + content = EXCLUDED.content, + embedding = EXCLUDED.embedding, + embedding_json = EXCLUDED.embedding_json, + metadata_json = EXCLUDED.metadata_json, + updated_at = now()`, docID, chunkID, content, vectorValue, string(raw)) + if err != nil || tag.RowsAffected() > 0 { + return err + } + _, err = p.pool.Exec(ctx, ` + INSERT INTO docs_embedding (doc_id, chunk_id, content, embedding, embedding_json, updated_at) + VALUES ($1, $2, $3, $4::vector, $5::jsonb, now()) + ON CONFLICT (chunk_id) DO UPDATE + SET doc_id = EXCLUDED.doc_id, + embedding = EXCLUDED.embedding, + embedding_json = EXCLUDED.embedding_json, + content = EXCLUDED.content, + updated_at = now()`, docID, chunkID, content, vectorValue, string(raw)) + return err +} + +func (p *Postgres) Clear(ctx context.Context) error { + _, err := p.pool.Exec(ctx, `DELETE FROM docs_embedding`) + return err +} + +func (p *Postgres) DeletePrefix(ctx context.Context, docIDPrefix string) error { + _, err := p.pool.Exec(ctx, `DELETE FROM docs_embedding WHERE doc_id LIKE $1 ESCAPE E'\\'`, escapeLike(docIDPrefix)+"%") + return err +} + +func (p *Postgres) Count(ctx context.Context) (int, error) { + var count int + err := p.pool.QueryRow(ctx, `SELECT count(*) FROM docs_embedding`).Scan(&count) + return count, err +} + +func vectorLiteral(vector []float32) string { + var b strings.Builder + b.WriteByte('[') + for i, value := range vector { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(strconv.FormatFloat(float64(value), 'g', -1, 32)) + } + b.WriteByte(']') + return b.String() +} + +func escapeLike(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `%`, `\%`) + return strings.ReplaceAll(value, `_`, `\_`) +} diff --git a/deploy/.env.example b/deploy/.env.example index 8fe3024..404e33d 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,49 +1,55 @@ -# Go module proxy used during `docker compose build` (container builds don't -# inherit the host proxy). Default goproxy.cn is reachable from CN without a VPN. -# Set GOPROXY=https://proxy.golang.org,direct if you build outside China. +# --------------------------------------------------------------------------- +# Modex docker-compose environment +# Keep common deployment settings near the top. Full *_URL variables are +# advanced overrides; leave them empty to build URLs from host/port/user fields. +# --------------------------------------------------------------------------- + +# --- Build --- +# Go module proxy for `docker compose build` (use proxy.golang.org outside CN). GOPROXY=https://goproxy.cn,direct +# --- Public URLs and service ports --- +APP_BASE_URL=http://localhost:8671 +FRONTEND_BASE_URL=http://localhost:3456 +MODEX_PUBLIC_API_BASE_URL=http://localhost:8671 +INTERNAL_API_BASE_URL=http://backend:8671 +CORS_ALLOW_ORIGINS=http://localhost:3456 +BACKEND_PORT=8671 +FRONTEND_PORT=3456 + +# --- PostgreSQL --- +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_PUBLISHED_PORT=5432 POSTGRES_DB=modex POSTGRES_USER=modex POSTGRES_PASSWORD=modex -POSTGRES_PORT=5432 +POSTGRES_SSLMODE=disable +DATABASE_URL= + +# --- Redis --- +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_USER= +REDIS_PASSWORD= +REDIS_URL= +# --- Object storage (MinIO / S3-compatible) --- +MINIO_ENDPOINT=http://minio:9000 +MINIO_PUBLIC_ENDPOINT=http://localhost:9000 +MINIO_BUCKET=modex MINIO_ROOT_USER=modex MINIO_ROOT_PASSWORD=modex-password MINIO_API_PORT=9000 MINIO_CONSOLE_PORT=9001 -MINIO_ENDPOINT=http://minio:9000 -MINIO_PUBLIC_ENDPOINT=http://localhost:9000 - -MEILI_ENV=development -MEILI_MASTER_KEY=modex-dev-key -MEILI_PORT=7700 -MEILISEARCH_URL=http://meilisearch:7700 -MEILISEARCH_PUBLIC_URL=http://localhost:7700 +MINIO_BUCKET_LOOKUP=path +MINIO_REGION= +MINIO_TRACE=false -BACKEND_PORT=8671 -FRONTEND_PORT=3000 -# Directory for the durable store snapshot. In Compose this is a named volume -# mounted at /data so registry state survives restarts. Leave empty to run a -# pure in-memory store (state lost on restart). -DATA_DIR=/data -DATA_SAVE_INTERVAL_SECONDS=60 -APP_BASE_URL=http://localhost:8671 -FRONTEND_BASE_URL=http://localhost:3000 -NEXT_PUBLIC_API_BASE_URL=http://localhost:8671 -INTERNAL_API_BASE_URL=http://backend:8671 -CORS_ALLOW_ORIGINS=http://localhost:3000 - -# mock for local development, oidc for Keycloak. "keycloak" is accepted as an alias. -AUTH_MODE=mock -# Comma-separated usernames/emails granted super-admin (manage all platforms + -# user/permission management). Matched against OIDC/mock login identities. +# --- OIDC login --- +AUTO_LOGIN=false SUPER_ADMIN_USERS=dev - -# Optional external LLM endpoint for the "Ask AI" flow. When empty, /api/ask -# returns an extractive answer built from the top search matches. -ASK_HTTP_URL= -ASK_HTTP_API_KEY= KEYCLOAK_BASE_URL=https://keycloak.example.com KEYCLOAK_REALM=dev OIDC_ISSUER_URL= @@ -54,24 +60,45 @@ OIDC_END_SESSION_URL= OIDC_CLIENT_ID=modex OIDC_CLIENT_SECRET=change-me OIDC_REDIRECT_URL=http://localhost:8671/api/auth/callback -OIDC_SCOPES=openid profile email +OIDC_SCOPES='openid profile email' + +# --- Session, cookies, and HTTP server --- SESSION_COOKIE_NAME=modex_session OAUTH_STATE_COOKIE_NAME=modex_oauth_state COOKIE_DOMAIN= COOKIE_SAME_SITE=lax COOKIE_SECURE=false +SESSION_TTL=8h +TRUST_PROXY_HEADERS=false +HTTP_READ_HEADER_TIMEOUT=5s +HTTP_READ_TIMEOUT=30s +HTTP_WRITE_TIMEOUT=60s +HTTP_IDLE_TIMEOUT=2m +HTTP_MAX_HEADER_BYTES=1048576 +HTTP_MAX_BODY_BYTES=2097152 -EMBEDDING_PROVIDER=mock -EMBEDDING_HTTP_URL= -EMBEDDING_HTTP_API_KEY= -EMBEDDING_DIM=384 -HYBRID_KEYWORD_WEIGHT=0.6 -HYBRID_SEMANTIC_WEIGHT=0.4 +# --- Rate limits and deploy ingestion --- +RATE_LIMIT_AUTH_PER_MINUTE=10 +RATE_LIMIT_TOKEN_PER_MINUTE=30 +RATE_LIMIT_SEARCH_PER_MINUTE=60 +RATE_LIMIT_AI_PER_MINUTE=20 +RATE_LIMIT_DEPLOY_PER_MINUTE=10 +DOCS_DEPLOY_MAX_BYTES=536870912 +DEPLOY_MAX_CONCURRENT=2 +DEPLOY_QUEUE_WAIT_SECONDS=15 +DEPLOY_BUSY_RETRY_SECONDS=30 +DEPLOY_SLOT_TTL_SECONDS=600 -MCP_ENABLED=true -MCP_TOKEN=dev-token +# --- Optional application config --- +# Set to /app/config.yaml after mounting deploy/config.yaml in docker-compose.yml. +CONFIG_FILE= -# PostHog (frontend analytics). Leave empty to keep analytics in console-debug -# mode during MVP development. See frontend/lib/analytics.ts. -NEXT_PUBLIC_POSTHOG_KEY= -NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com +# --- Optional features and integrations --- +MODEX_PUBLIC_GITLAB_CI_TEMPLATE_INCLUDE= +MODEX_PUBLIC_KROKI_URL=https://kroki.io +# KROKI_PORT=8000 +POSTHOG_HOST=https://app.posthog.com +POSTHOG_PROJECT_API_KEY= +POSTHOG_PERSONAL_API_KEY= +POSTHOG_PROJECT_ID= +POSTHOG_ENABLE_LOCAL=false diff --git a/deploy/branding/README.md b/deploy/branding/README.md new file mode 100644 index 0000000..fc39b9e --- /dev/null +++ b/deploy/branding/README.md @@ -0,0 +1,44 @@ +# Branding Assets + +Put deployment logos and favicon files in this directory when you want to replace the default Modex brand assets. + +Supported logo filenames: + +- `logo.svg` +- `logo.png` +- `logo.webp` +- `logo.jpg` +- `logo.jpeg` + +For theme-specific logos, use: + +- `logo-light.svg` / `logo-light.png` / `logo-light.webp` / `logo-light.jpg` / `logo-light.jpeg` +- `logo-dark.svg` / `logo-dark.png` / `logo-dark.webp` / `logo-dark.jpg` / `logo-dark.jpeg` + +The frontend uses `logo-light.*` in light mode and `logo-dark.*` in dark mode. If either file is missing, it falls back to `logo.*`, then to the built-in `/logo.svg`. + +Supported favicon filenames: + +- `favicon.ico` +- `favicon.svg` +- `favicon.png` +- `favicon.webp` +- `favicon.jpg` +- `favicon.jpeg` + +The frontend container mounts this directory to `/app/public/brand`. If the corresponding environment variables are not set, the startup script uses the first matching file above as `/brand/`. + +You can also set `MODEX_PUBLIC_LOGO_URL` explicitly, for example: + +```env +MODEX_PUBLIC_LOGO_URL=/brand/company-logo.svg +MODEX_PUBLIC_LOGO_LIGHT_URL=/brand/company-logo-dark-text.svg +MODEX_PUBLIC_LOGO_DARK_URL=/brand/company-logo-light-text.svg +MODEX_PUBLIC_FAVICON_URL=/brand/favicon.ico +``` + +Set `MODEX_PUBLIC_APP_TITLE` to customize the browser title and in-page product name: + +```env +MODEX_PUBLIC_APP_TITLE=Docs Hub +``` diff --git a/deploy/ci/modex-docs.gitlab-ci.yml b/deploy/ci/modex-docs.gitlab-ci.yml index 48962fa..1939389 100644 --- a/deploy/ci/modex-docs.gitlab-ci.yml +++ b/deploy/ci/modex-docs.gitlab-ci.yml @@ -1,49 +1,24 @@ -# ───────────────────────────────────────────────────────────────────────────── -# modex 文档同步 · GitLab CI 模板(B 模式:仓库 CI 编译再推送) -# -# 用法:在你的文档仓库 .gitlab-ci.yml 顶部 include 本文件,并设置变量即可, -# 无需在仓库里维护 docs.yaml(docsctl 会从下列变量合成配置)。 -# -# include: -# - remote: 'https://raw.githubusercontent.com//modex/main/deploy/ci/modex-docs.gitlab-ci.yml' -# -# variables: -# MODEX_MODULE_KEY: "rd-doc" # 必填:modex 后台登记的文档源 key -# DOCS_BUILDER: "vitepress" # vitepress | vuepress | fumadocs | markdown -# DOCS_BUILD: "npm ci && npm run docs:build" # markdown 型可留空 -# DOCS_OUTPUT: "docs/.vitepress/dist" # 框架 build 产物目录;markdown 型可留空 -# MODEX_DEPLOY_URL: "https://modex.example.com/api/deploy" -# # MODEX_DEPLOY_TOKEN: 在 GitLab → Settings → CI/CD → Variables 设置(Masked + Protected),勿写进仓库 -# -# docsctl 不需要预装:CI 会按 MODEX_DOCSCTL_URL 下载预编译二进制(见 before_script)。 -# 默认镜像 node:20-bookworm 自带 node / npm / curl / git,足以构建前端文档框架。 -# -# 说明: -# · 文档归属哪个能力域(锚点)在 modex 后台配置,CI 不关心,只带 MODULE_KEY 推产物。 -# · 编译型(vitepress/vuepress/fumadocs)固定 single 挂载;split 仅对 markdown 型有意义。 -# ───────────────────────────────────────────────────────────────────────────── +# Modex docs deploy template. +# Usage and optional variables are shown in the in-app Modex usage guide. variables: DOCS_VERSION: "latest" DOCS_SOURCE_DIR: "." - # 预编译 docsctl 二进制下载地址(可覆盖为你自托管的路径 / 内网 GitLab Release / 制品库)。 + MODEX_DOCS_IMAGE: "node:20-bookworm" MODEX_DOCSCTL_URL: "https://github.com/modex/modex/releases/latest/download/docsctl-linux-amd64" .modex-docs-base: - image: node:20-bookworm + image: "$MODEX_DOCS_IMAGE" variables: - DOCS_MODULE: "$MODEX_MODULE_KEY" DOCS_DEPLOY_URL: "$MODEX_DEPLOY_URL" DOCS_DEPLOY_TOKEN: "$MODEX_DEPLOY_TOKEN" - # Source metadata shown read-only in modex (filled from GitLab CI predefined vars). DOCS_REPO_URL: "$CI_PROJECT_URL" DOCS_REPO_TYPE: "git" DOCS_BRANCH: "$CI_COMMIT_REF_NAME" DOCS_COMMIT_SHA: "$CI_COMMIT_SHA" before_script: - - 'test -n "$MODEX_MODULE_KEY" || { echo "MODEX_MODULE_KEY 未设置"; exit 1; }' - 'test -n "$MODEX_DEPLOY_URL" || { echo "MODEX_DEPLOY_URL 未设置"; exit 1; }' - - 'echo "下载 docsctl: $MODEX_DOCSCTL_URL"' + - 'test -n "$MODEX_DEPLOY_TOKEN" || { echo "MODEX_DEPLOY_TOKEN 未设置"; exit 1; }' - 'curl -fsSL "$MODEX_DOCSCTL_URL" -o /usr/local/bin/docsctl' - 'chmod +x /usr/local/bin/docsctl' - 'docsctl version || true' @@ -53,7 +28,6 @@ variables: - docsctl package - docsctl deploy -# 默认:推到默认分支时自动同步。需要别的触发条件可在仓库侧覆盖 rules。 modex-docs-deploy: extends: .modex-docs-base stage: deploy diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index bcc0923..ef96f27 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1,41 +1,18 @@ -# Example application configuration for Modex. -# This file is intended for settings that describe *how the application behaves* -# with external systems (especially identity data), as opposed to per-deployment -# infrastructure and secrets (which belong in environment variables). -# -# How to use with docker-compose (recommended): -# cd deploy -# cp config.example.yaml config.yaml -# # edit config.yaml (especially auth.user_mapping) -# # then edit docker-compose.yml and UNCOMMENT the volume line under backend: -# # - ./config.yaml:/app/config.yaml:ro -# # and make sure CONFIG_FILE=/app/config.yaml is active (either in .env or in the compose file) -# -# You can also run the binary directly and point CONFIG_FILE at this file. -# -# Precedence (lowest → highest): -# 1. Hardcoded defaults in the code -# 2. This YAML file -# 3. Environment variables (OIDC_CLAIM_* etc.) — these win for quick overrides +# Modex application behavior. Infrastructure, secrets, and ports stay in .env. +# For compose: copy to config.yaml and mount it to /app/config.yaml. auth: user_mapping: - # Which claim's *value* is used as the user's stable unique identifier. - # The company standard is to use email as the cross-system primary key for users. - # If your Keycloak realm uses a more stable employee ID / sub-attribute, change it here. + # Stable user id claim. unique_id_claim: email - - # Claim containing the avatar / profile photo URL. - # "picture" is the standard OIDC claim. Many enterprises use custom mappers - # such as "wxPhotoURL", "avatar", "photo", or "user.attributes.avatar". + # Avatar URL claim. avatar_claim: picture - # avatar_claim: wxPhotoURL - - # The primary display name shown in headers, menus, and user lists. - # Common choices: "name", "given_name", "preferred_username", or a custom full-name mapper. + # Primary display name claim. display_name_claim: name - - # Information displayed below the primary name (e.g. in the user menu and admin user table). - # In the current UI this value is stored in the "department" field for compatibility. - # You can point it at "department", "org", "organization", "title", "company", etc. + # Secondary text shown under the name. secondary_info_claim: department + +search: + scoring: + keyword_weight: 0.6 + semantic_weight: 0.4 diff --git a/deploy/db/001_init.sql b/deploy/db/001_init.sql index 95aacb0..ad8ad3b 100644 --- a/deploy/db/001_init.sql +++ b/deploy/db/001_init.sql @@ -1,20 +1,26 @@ CREATE EXTENSION IF NOT EXISTS vector; +CREATE TABLE IF NOT EXISTS auth_session ( + key TEXT PRIMARY KEY, + value_json JSONB NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, email TEXT, department TEXT, - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS groups ( - id TEXT PRIMARY KEY, - group_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, + avatar TEXT, + roles_json JSONB NOT NULL DEFAULT '[]'::jsonb, + managed_categories_json JSONB NOT NULL DEFAULT '[]'::jsonb, source TEXT, + status TEXT, + is_super_admin BOOLEAN NOT NULL DEFAULT false, + mcp_token TEXT, + last_login_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); @@ -27,18 +33,69 @@ CREATE TABLE IF NOT EXISTS teams ( key TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT, - leader TEXT, - members JSONB DEFAULT '[]'::jsonb, + leaders JSONB NOT NULL DEFAULT '[]'::jsonb, + members JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS connected_app ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + client_id TEXT NOT NULL UNIQUE, + client_secret_hash TEXT, + redirect_uris JSONB DEFAULT '[]'::jsonb, + scopes JSONB DEFAULT '[]'::jsonb, + trusted BOOLEAN DEFAULT false, + enabled BOOLEAN DEFAULT true, + created_by TEXT REFERENCES users(id), + last_used_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -CREATE TABLE IF NOT EXISTS user_groups ( +-- Built-in public OAuth client for Codex MCP OAuth login. Codex CLI supports +-- `--oauth-client-id` but does not take a client secret for MCP login, so this +-- client intentionally has an empty secret and only allows local loopback +-- callbacks. +INSERT INTO connected_app(id,name,description,client_id,client_secret_hash,redirect_uris,scopes,trusted,enabled,created_at,updated_at) +VALUES( + 'app-codex-cli', + 'Codex CLI MCP OAuth', + 'Built-in public OAuth client for Codex MCP login.', + 'codex-cli', + '', + '["http://localhost","http://127.0.0.1","http://[::1]"]'::jsonb, + '["modex:mcp:read","modex:docs:read"]'::jsonb, + true, + true, + now(), + now() +) +ON CONFLICT(client_id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS oauth_grant ( + id TEXT PRIMARY KEY, + app_id TEXT REFERENCES connected_app(id), user_id TEXT REFERENCES users(id), - group_id TEXT REFERENCES groups(id), - PRIMARY KEY (user_id, group_id) + code_hash TEXT, + access_token_hash TEXT, + refresh_token_hash TEXT, + redirect_uri TEXT, + scopes JSONB DEFAULT '[]'::jsonb, + code_expires_at TIMESTAMPTZ, + access_expires_at TIMESTAMPTZ, + refresh_expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() ); +CREATE INDEX IF NOT EXISTS idx_oauth_grant_code_hash ON oauth_grant(code_hash); +CREATE INDEX IF NOT EXISTS idx_oauth_grant_access_token_hash ON oauth_grant(access_token_hash); +CREATE INDEX IF NOT EXISTS idx_oauth_grant_refresh_token_hash ON oauth_grant(refresh_token_hash); + CREATE TABLE IF NOT EXISTS docs_category ( id TEXT PRIMARY KEY, parent_id TEXT REFERENCES docs_category(id), @@ -61,7 +118,7 @@ CREATE TABLE IF NOT EXISTS docs_module ( owner_group TEXT, repo_type TEXT, repo_url TEXT, - default_version_id TEXT, + default_version TEXT, visibility TEXT DEFAULT 'internal', status TEXT DEFAULT 'active', package_name TEXT, @@ -70,6 +127,18 @@ CREATE TABLE IF NOT EXISTS docs_module ( edition TEXT, keywords JSONB DEFAULT '[]'::jsonb, maintainers JSONB DEFAULT '[]'::jsonb, + category_path TEXT, + source_type TEXT, + doc_type TEXT, + mount TEXT, + gitlab_branch TEXT, + gitlab_path TEXT, + deploy_token TEXT, + last_synced_commit TEXT, + last_synced_at TIMESTAMPTZ, + created_by TEXT REFERENCES users(id), + reads_7d INT NOT NULL DEFAULT 0, + reads_30d INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); @@ -129,6 +198,8 @@ CREATE TABLE IF NOT EXISTS docs_release ( pipeline_url TEXT, build_system TEXT, build_id TEXT, + trigger_type TEXT, + source_ip TEXT, artifact_version TEXT, package_version TEXT, storage_uri TEXT, @@ -152,27 +223,61 @@ CREATE TABLE IF NOT EXISTS docs_page ( status TEXT DEFAULT 'active', owner_group TEXT, tags JSONB DEFAULT '[]'::jsonb, + category_ids JSONB DEFAULT '[]'::jsonb, content_text TEXT, + content_html TEXT, + content_md TEXT, updated_at TIMESTAMPTZ, last_verified_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE IF NOT EXISTS docs_page_view ( - id BIGSERIAL PRIMARY KEY, + id TEXT PRIMARY KEY, page_id TEXT REFERENCES docs_page(id), module_id TEXT REFERENCES docs_module(id), version_id TEXT REFERENCES docs_version(id), + doc_id TEXT, + module_key TEXT, + module_name TEXT, + docs_version TEXT, + entry_key TEXT, + title TEXT, + path TEXT, user_id TEXT REFERENCES users(id), session_id TEXT, + read_id TEXT, duration_seconds INT, scroll_depth NUMERIC, viewed_at TIMESTAMPTZ DEFAULT now() ); +CREATE TABLE IF NOT EXISTS user_favorite ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + module_key TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(user_id, module_key) +); + +CREATE TABLE IF NOT EXISTS user_recent_doc ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + doc_id TEXT NOT NULL, + title TEXT, + module_key TEXT, + module_name TEXT, + docs_version TEXT, + entry_key TEXT, + href TEXT, + viewed_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(user_id, doc_id) +); + CREATE TABLE IF NOT EXISTS docs_search_log ( - id BIGSERIAL PRIMARY KEY, + id TEXT PRIMARY KEY, user_id TEXT, + ip_address TEXT, query TEXT, mode TEXT, filters_json JSONB, @@ -190,15 +295,18 @@ CREATE TABLE IF NOT EXISTS docs_embedding ( version_id TEXT REFERENCES docs_version(id), entry_id TEXT REFERENCES docs_entry(id), content TEXT, - embedding vector(384), + embedding vector(1024), embedding_json JSONB, metadata_json JSONB, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); +DROP INDEX IF EXISTS idx_docs_embedding_doc_id; +CREATE UNIQUE INDEX IF NOT EXISTS idx_docs_embedding_chunk_id ON docs_embedding(chunk_id); + CREATE TABLE IF NOT EXISTS docs_mcp_log ( - id BIGSERIAL PRIMARY KEY, + id TEXT PRIMARY KEY, tool_name TEXT, user_id TEXT, query TEXT, @@ -206,3 +314,120 @@ CREATE TABLE IF NOT EXISTS docs_mcp_log ( result_count INT, created_at TIMESTAMPTZ DEFAULT now() ); + +CREATE TABLE IF NOT EXISTS docs_feedback ( + id TEXT PRIMARY KEY, + doc_id TEXT NOT NULL, + page_id TEXT, + module_key TEXT, + title TEXT, + rating TEXT, + comment TEXT, + user_id TEXT, + session_id TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS docs_nav ( + module_key TEXT NOT NULL, + docs_version TEXT NOT NULL, + items_json JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (module_key, docs_version) +); + +CREATE TABLE IF NOT EXISTS docs_site_file ( + module_key TEXT NOT NULL, + docs_version TEXT NOT NULL, + entry_key TEXT NOT NULL, + name TEXT NOT NULL, + content BYTEA NOT NULL, + content_type TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (module_key, docs_version, entry_key, name) +); + +CREATE TABLE IF NOT EXISTS platform_settings ( + key TEXT PRIMARY KEY, + value_json JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Upgrade databases created by earlier releases. CREATE TABLE IF NOT EXISTS +-- does not add or change columns on an existing installation. +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS roles_json JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE users ADD COLUMN IF NOT EXISTS managed_categories_json JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE users ADD COLUMN IF NOT EXISTS source TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS status TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_super_admin BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE users ADD COLUMN IF NOT EXISTS mcp_token TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; + +-- Older deployments briefly allowed non-array JSON values in these fields. +-- Keep them normalized so login upserts and user serialization can safely treat +-- them as string arrays. +UPDATE users SET roles_json='[]'::jsonb WHERE jsonb_typeof(roles_json) <> 'array'; +UPDATE users SET managed_categories_json='[]'::jsonb WHERE jsonb_typeof(managed_categories_json) <> 'array'; + +-- Drop the legacy Keycloak group mirror. Team membership (teams + responsible_team) +-- is the only authorization model; SSO groups were never consulted for access. +DROP TABLE IF EXISTS user_groups; +DROP TABLE IF EXISTS groups; +ALTER TABLE users DROP COLUMN IF EXISTS groups_json; + +ALTER TABLE teams ADD COLUMN IF NOT EXISTS leaders JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE teams ADD COLUMN IF NOT EXISTS members JSONB NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS default_version TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS category_path TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS source_type TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS doc_type TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS mount TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS gitlab_branch TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS gitlab_path TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS deploy_token TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS last_synced_commit TEXT; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS created_by TEXT REFERENCES users(id); +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS reads_7d INT NOT NULL DEFAULT 0; +ALTER TABLE docs_module ADD COLUMN IF NOT EXISTS reads_30d INT NOT NULL DEFAULT 0; + +ALTER TABLE docs_page ADD COLUMN IF NOT EXISTS category_ids JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE docs_page ADD COLUMN IF NOT EXISTS content_html TEXT; +ALTER TABLE docs_page ADD COLUMN IF NOT EXISTS content_md TEXT; + +ALTER TABLE docs_page_view ALTER COLUMN id DROP DEFAULT; +ALTER TABLE docs_page_view ALTER COLUMN id TYPE TEXT USING id::text; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS doc_id TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS module_key TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS module_name TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS docs_version TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS entry_key TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS title TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS path TEXT; +ALTER TABLE docs_page_view ADD COLUMN IF NOT EXISTS read_id TEXT; +ALTER TABLE docs_search_log ALTER COLUMN id DROP DEFAULT; +ALTER TABLE docs_search_log ALTER COLUMN id TYPE TEXT USING id::text; +ALTER TABLE docs_search_log ADD COLUMN IF NOT EXISTS ip_address TEXT; +ALTER TABLE docs_mcp_log ALTER COLUMN id DROP DEFAULT; +ALTER TABLE docs_mcp_log ALTER COLUMN id TYPE TEXT USING id::text; + +-- Remove JSON mirrors from development builds. Rows are reconstructed only +-- from typed columns and foreign-key relationships. +ALTER TABLE users DROP COLUMN IF EXISTS record_json; +ALTER TABLE teams DROP COLUMN IF EXISTS record_json; +ALTER TABLE connected_app DROP COLUMN IF EXISTS record_json; +ALTER TABLE oauth_grant DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_category DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_module DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_version DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_entry DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_release DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_release ADD COLUMN IF NOT EXISTS trigger_type TEXT; +ALTER TABLE docs_release ADD COLUMN IF NOT EXISTS source_ip TEXT; +ALTER TABLE docs_page DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_page_view DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_search_log DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_mcp_log DROP COLUMN IF EXISTS record_json; +ALTER TABLE docs_feedback DROP COLUMN IF EXISTS record_json; diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f0a9bbb..b2e1e57 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -6,10 +6,30 @@ services: POSTGRES_USER: ${POSTGRES_USER:-modex} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-modex} ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${POSTGRES_PUBLISHED_PORT:-5432}:5432" volumes: - postgres-data:/var/lib/postgresql/data - ./db:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-modex} -d ${POSTGRES_DB:-modex}"] + interval: 3s + timeout: 3s + retries: 20 + + redis: + image: redis:7-alpine + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then + redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; + else + redis-server --appendonly yes; + fi' + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data minio: image: minio/minio:RELEASE.2024-06-29T01-20-47Z @@ -23,16 +43,6 @@ services: volumes: - minio-data:/data - meilisearch: - image: getmeili/meilisearch:v1.9 - environment: - MEILI_ENV: ${MEILI_ENV:-development} - MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-modex-dev-key} - ports: - - "${MEILI_PORT:-7700}:7700" - volumes: - - meili-data:/meili_data - backend: build: context: ../backend @@ -42,19 +52,33 @@ services: GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} environment: PORT: ${BACKEND_PORT:-8671} - DATA_DIR: ${DATA_DIR:-/data} + DOCS_DEPLOY_MAX_BYTES: ${DOCS_DEPLOY_MAX_BYTES:-536870912} APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8671} - FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-http://localhost:3000} - CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://localhost:3000} - DATABASE_URL: ${DATABASE_URL:-postgres://modex:modex@postgres:5432/modex?sslmode=disable} + FRONTEND_BASE_URL: ${FRONTEND_BASE_URL:-http://localhost:3456} + CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://localhost:3456} + DATABASE_URL: ${DATABASE_URL:-} + POSTGRES_HOST: ${POSTGRES_HOST:-postgres} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: ${POSTGRES_DB:-modex} + POSTGRES_USER: ${POSTGRES_USER:-modex} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-modex} + POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-disable} + REDIS_URL: ${REDIS_URL:-} + REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_PORT: ${REDIS_PORT:-6379} + REDIS_DB: ${REDIS_DB:-0} + REDIS_USER: ${REDIS_USER:-} + REDIS_PASSWORD: ${REDIS_PASSWORD:-} MINIO_ENDPOINT: ${MINIO_ENDPOINT:-http://minio:9000} MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000} - MEILISEARCH_URL: ${MEILISEARCH_URL:-http://meilisearch:7700} - MEILISEARCH_PUBLIC_URL: ${MEILISEARCH_PUBLIC_URL:-http://localhost:7700} - AUTH_MODE: ${AUTH_MODE:-mock} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-modex} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-modex-password} + MINIO_BUCKET: ${MINIO_BUCKET:-modex} + MINIO_BUCKET_LOOKUP: ${MINIO_BUCKET_LOOKUP:-path} + MINIO_REGION: ${MINIO_REGION:-} + MINIO_TRACE: ${MINIO_TRACE:-false} + AUTO_LOGIN: ${AUTO_LOGIN:-false} SUPER_ADMIN_USERS: ${SUPER_ADMIN_USERS:-} - ASK_HTTP_URL: ${ASK_HTTP_URL:-} - ASK_HTTP_API_KEY: ${ASK_HTTP_API_KEY:-} KEYCLOAK_BASE_URL: ${KEYCLOAK_BASE_URL:-} KEYCLOAK_REALM: ${KEYCLOAK_REALM:-} OIDC_ISSUER_URL: ${OIDC_ISSUER_URL:-} @@ -71,75 +95,89 @@ services: COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} COOKIE_SAME_SITE: ${COOKIE_SAME_SITE:-lax} COOKIE_SECURE: ${COOKIE_SECURE:-false} - EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-mock} - EMBEDDING_HTTP_URL: ${EMBEDDING_HTTP_URL:-} - EMBEDDING_HTTP_API_KEY: ${EMBEDDING_HTTP_API_KEY:-} - EMBEDDING_DIM: ${EMBEDDING_DIM:-384} - HYBRID_KEYWORD_WEIGHT: ${HYBRID_KEYWORD_WEIGHT:-0.6} - HYBRID_SEMANTIC_WEIGHT: ${HYBRID_SEMANTIC_WEIGHT:-0.4} - MCP_ENABLED: ${MCP_ENABLED:-true} - MCP_TOKEN: ${MCP_TOKEN:-dev-token} - # CONFIG_FILE: set this (e.g. to /app/config.yaml) when you mount a config file below. - # Leave empty or unset to use only environment variables (the old behavior). + SESSION_TTL: ${SESSION_TTL:-8h} + HTTP_READ_HEADER_TIMEOUT: ${HTTP_READ_HEADER_TIMEOUT:-5s} + HTTP_READ_TIMEOUT: ${HTTP_READ_TIMEOUT:-30s} + HTTP_WRITE_TIMEOUT: ${HTTP_WRITE_TIMEOUT:-60s} + HTTP_IDLE_TIMEOUT: ${HTTP_IDLE_TIMEOUT:-2m} + HTTP_MAX_HEADER_BYTES: ${HTTP_MAX_HEADER_BYTES:-1048576} + HTTP_MAX_BODY_BYTES: ${HTTP_MAX_BODY_BYTES:-2097152} + TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false} + RATE_LIMIT_AUTH_PER_MINUTE: ${RATE_LIMIT_AUTH_PER_MINUTE:-10} + RATE_LIMIT_TOKEN_PER_MINUTE: ${RATE_LIMIT_TOKEN_PER_MINUTE:-30} + RATE_LIMIT_SEARCH_PER_MINUTE: ${RATE_LIMIT_SEARCH_PER_MINUTE:-60} + RATE_LIMIT_AI_PER_MINUTE: ${RATE_LIMIT_AI_PER_MINUTE:-20} + RATE_LIMIT_DEPLOY_PER_MINUTE: ${RATE_LIMIT_DEPLOY_PER_MINUTE:-10} + # Directory the backend serves the MCP npx package from (intranet download). + # The package is bind-mounted read-only below. + MCP_DIST_DIR: ${MCP_DIST_DIR:-/app/mcp-dist} + MODEX_SKILL_DIST_DIR: ${MODEX_SKILL_DIST_DIR:-/app/modex-skill} + POSTHOG_HOST: ${POSTHOG_HOST:-} + POSTHOG_PERSONAL_API_KEY: ${POSTHOG_PERSONAL_API_KEY:-} + POSTHOG_PROJECT_ID: ${POSTHOG_PROJECT_ID:-} + # Set when mounting deploy/config.yaml below. CONFIG_FILE: ${CONFIG_FILE:-} ports: - "${BACKEND_PORT:-8671}:${BACKEND_PORT:-8671}" volumes: - - backend-data:/data - # === Optional Application Config (config.yaml) === - # This is the recommended way to configure things like OIDC claim mappings - # (unique_id_claim, avatar_claim, display_name_claim, secondary_info_claim). - # - # Steps to enable: - # 1. cp deploy/config.example.yaml deploy/config.yaml - # 2. Edit deploy/config.yaml (especially the auth.user_mapping section) - # 3. Uncomment the volume mount below - # 4. Make sure CONFIG_FILE is set to /app/config.yaml (you can put it in your .env) + # MCP npx package served for intranet download (GET /api/mcp/dist/*). + - ../mcp/npx:/app/mcp-dist:ro + - ../mcp/skill:/app/modex-skill:ro + # Optional application behavior config: + # cp deploy/config.example.yaml deploy/config.yaml + # set CONFIG_FILE=/app/config.yaml + # uncomment the mount below # # - ./config.yaml:/app/config.yaml:ro - # - # You can also mount to other paths and set CONFIG_FILE accordingly, e.g.: - # - ./my-oidc-config.yaml:/etc/modex/config.yaml:ro - # and CONFIG_FILE=/etc/modex/config.yaml - # - # The app will also auto-discover common locations if CONFIG_FILE is not set. healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:${BACKEND_PORT:-8671}/healthz"] interval: 10s timeout: 3s retries: 6 depends_on: - - postgres - - minio - - meilisearch + postgres: + condition: service_healthy + redis: + condition: service_started + minio: + condition: service_started frontend: build: context: ../frontend - args: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8671} - NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} - NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://app.posthog.com} environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8671} - INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8671} - NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} - NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-https://app.posthog.com} + PORT: 3456 + INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:${BACKEND_PORT:-8671}} + MODEX_PUBLIC_APP_TITLE: ${MODEX_PUBLIC_APP_TITLE:-Modex} + MODEX_PUBLIC_LOGO_URL: ${MODEX_PUBLIC_LOGO_URL:-} + MODEX_PUBLIC_LOGO_LIGHT_URL: ${MODEX_PUBLIC_LOGO_LIGHT_URL:-} + MODEX_PUBLIC_LOGO_DARK_URL: ${MODEX_PUBLIC_LOGO_DARK_URL:-} + MODEX_PUBLIC_FAVICON_URL: ${MODEX_PUBLIC_FAVICON_URL:-} + MODEX_PUBLIC_API_BASE_URL: ${MODEX_PUBLIC_API_BASE_URL:-http://localhost:8671} + MODEX_PUBLIC_GITLAB_CI_TEMPLATE_INCLUDE: '${MODEX_PUBLIC_GITLAB_CI_TEMPLATE_INCLUDE:-include:\n - project: "songkwon/modex-fscut"\n ref: "main"\n file: "deploy/ci/modex-docs.gitlab-ci.yml"}' + MODEX_PUBLIC_DOCSCTL_URL: ${MODEX_PUBLIC_DOCSCTL_URL:-https://github.com/modex/modex/releases/latest/download/docsctl-linux-amd64} + MODEX_PUBLIC_POSTHOG_KEY: ${POSTHOG_PROJECT_API_KEY:-} + MODEX_PUBLIC_POSTHOG_HOST: ${POSTHOG_HOST:-https://app.posthog.com} + MODEX_PUBLIC_POSTHOG_ENABLE_LOCAL: ${POSTHOG_ENABLE_LOCAL:-false} + # Diagram-as-code (PlantUML/Graphviz/C4/…) render server. Defaults to the + # public kroki.io; set to http://localhost:8000 (the `kroki` profile below) + # to keep diagram source on-prem. + MODEX_PUBLIC_KROKI_URL: ${MODEX_PUBLIC_KROKI_URL:-https://kroki.io} ports: - - "${FRONTEND_PORT:-3000}:3000" + - "${FRONTEND_PORT:-3456}:3456" + volumes: + - ./branding:/app/public/brand:ro depends_on: backend: condition: service_healthy - # MCP server (stdio JSON-RPC) — proxies tool calls to the backend API. - # MCP is a stdio protocol, so it is launched per-session by an AI client - # rather than served as an always-on port. It builds alongside the stack and - # is run on demand: + # MCP server (streamable HTTP JSON-RPC) — proxies tool calls to the backend API. + # It can be exposed to MCP clients that support remote streamable HTTP: # - # docker compose --profile mcp run --rm mcp + # http://localhost:8787/mcp # - # Most developers instead use the npx package (mcp/npx, `npx modex-docs-mcp`) - # pointed at this deployment's backend URL + MCP_TOKEN. + # The npx package (mcp/npx, `npx modex-mcp`) remains available for clients + # that only support launching local stdio MCP servers. mcp: build: context: ../mcp @@ -147,16 +185,38 @@ services: GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} profiles: ["mcp"] environment: - DOCS_API_BASE_URL: ${MCP_API_BASE_URL:-http://backend:8671} - MCP_TOKEN: ${MCP_TOKEN:-dev-token} - stdin_open: true - tty: true + MODEX_API_BASE_URL: ${MCP_API_BASE_URL:-http://backend:${BACKEND_PORT:-8671}} + MODEX_MCP_TOKEN: ${MODEX_MCP_TOKEN:-} + MODEX_MCP_TRANSPORT: http + MODEX_MCP_ADDR: :8787 + MODEX_MCP_PATH: /mcp + ports: + - "${MCP_PORT:-8787}:8787" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8787/healthz"] + interval: 10s + timeout: 3s + retries: 6 depends_on: backend: condition: service_healthy + # Self-hosted Kroki diagram renderer (PlantUML / Graphviz / C4 / DITAA / …). + # Optional — only needed if you don't want diagram source sent to public + # kroki.io. Enable with the `kroki` profile and point the frontend at it: + # + # MODEX_PUBLIC_KROKI_URL=http://localhost:8000 docker compose --profile kroki up -d + # + # The base yuzutech/kroki image covers PlantUML, Graphviz, C4, DITAA, BPMN, + # Vega, D2 and more; Mermaid/Excalidraw need companion containers (see kroki docs). + kroki: + image: yuzutech/kroki:0.27.1 + profiles: ["kroki"] + ports: + - "${KROKI_PORT:-8000}:8000" + restart: unless-stopped + volumes: postgres-data: + redis-data: minio-data: - meili-data: - backend-data: diff --git a/docs/design/domain-doc-sync.md b/docs/design/domain-doc-sync.md deleted file mode 100644 index 460c488..0000000 --- a/docs/design/domain-doc-sync.md +++ /dev/null @@ -1,163 +0,0 @@ -# 领域 × 文档仓库同步方案 - -> 状态:设计已确认,待实现 -> 关联:`tools/docsctl`、`backend/internal/store`(Category/Module)、`backend/internal/api/server.go`(deploy/webhook)、`docs/pipeline/docs-deploy.example.yml` - -把外部文档/代码仓库(VitePress、VuePress、Fumadocs、纯 Markdown…)接入 modex,挂到能力域树的某个节点下,点击领域即可阅读,并自动进入搜索/AI 索引。设计目标:**modex 本身开源、通用,不依赖文档仓库自带 modex 配置文件。** - ---- - -## 1. 概念模型(两层) - -**第 1 层 · 领域树(平台 IA)** —— 完全在 modex admin 维护,任意深度,与任何仓库无关。对应 `store.Category`(已是 `ParentID` + 递归 `Children` 的树,dotted-key 约定如 `standards.tools.version`)。 - -**第 2 层 · 文档源(一个仓库)** —— 在 admin 登记:`RepoURL + Branch + Type + DeployToken + 一个锚点领域节点`。对应 `store.Module`(已有 `RepoURL/GitLabBranch/GitLabPath/DeployToken/CategoryIDs/CategoryPath`,形状基本就位)。 - -``` -领域树 (Category, 平台维护) -└─ 研发规范 (standards) - ├─ 基础规范 (standards.standard) ← 锚点:绑定 repo-A - └─ 工具规范 (standards.tools) - └─ 版本管理 (standards.tools.version) ← 锚点:绑定 repo-B -应用 (app) ← 锚点:绑定 repo-C -``` - ---- - -## 2. 绑定规则(已确认) - -1. **一个仓库 → 一个锚点节点。** 锚点可以是顶层领域、子领域或更深层级,深度任意可配(admin 里"领域下创建子领域"已支持)。 -2. **不允许一个仓库散射到多个顶层能力域。** 也不做"任意子树 → 任意域"的子页面级散射绑定(太碎、不可解释)。 -3. **`mount` 开关**控制锚点下如何展开仓库内容: - - `single`:整个仓库是锚点下的一篇文档,仓库自身导航作为文档内部目录。 - - `split`:仓库的顶层文件夹各自变成锚点下自动创建(upsert)的子域。 -4. **`mount` 与渲染方式的交互(关键约束):** - - **编译型仓库固定 `single`** —— 框架 build 出来是一个整体站点(自带路由),无法切成多个子域。 - - **`split` 只对 `markdown` 型有意义** —— 此时渲染权在 modex,才能把文件夹拆成子域。 - -> rd-doc 是 VitePress → 只能 `single`,绑到某个领域锚点,点进去靠它自己的 sidebar 走内部(standard/tools/sparklers 是站内页面,不是 modex 子域)。想让它们成为独立子域,需把 rd-doc 拆成多个仓库分别绑定。 - ---- - -## 3. 文档源类型与渲染 - -每个文档源有一个 `type`,决定同步时的处理: - -| type | 同步动作 | 展示渲染 | 内部导航来源 | -|---|---|---|---| -| `vitepress` / `vuepress` / `fumadocs` | 跑框架 `build` | 静态 HTML 托管 MinIO,点领域 = 进站点 | **框架自带**(手写分组/排序原样保留)| -| `markdown` | 不编译 | modex 自带阅读器渲染 | modex 按文件夹 + frontmatter(`title`/`order`)自动生成 | - -**两条路都额外吐一份纯文本索引**(`documents.jsonl` + `llms.txt`)给搜索与 AI——一次同步两个产物:给人看的(HTML / modex 渲染)+ 给检索的(纯文本)。这二者解耦,docsctl 现已具备。 - ---- - -## 4. 同步方向:默认 B(仓库 CI 推),A(modex 拉取)可选 - -| | A. modex 拉取编译 | **B. 仓库 CI 编译再推(默认)** | -|---|---|---| -| 触发 | webhook/定时 → modex clone → 跑框架 build | 仓库 push → 仓库 CI 跑 docsctl → `POST /api/deploy` | -| modex 负担 | 需装齐各框架工具链 + 沙箱跑别人构建脚本(RCE 面)、镜像重 | **零工具链、不执行外部代码** | -| 仓库负担 | 零 | 加一个 `include` 的 CI job | - -选 B 的原因:开源用户零额外运维即可用;modex 不背多框架工具链与沙箱。A 留作"想要 webhook 全自动"的可选 worker(后续再做)。 - -### B 模式时序 - -``` -push → GitLab CI(include 模板) - └─ docsctl build (按 DOCS_BUILDER 跑框架 build 或直接收集 markdown) - └─ docsctl package (打 zip:site/ + documents.jsonl + llms.txt + manifest) - └─ docsctl deploy (POST /api/deploy, 带 X-Modex-Deploy-Token) -modex /api/deploy - └─ ParseZip → SiteHTML/SiteFiles 进 MinIO;records 进搜索/向量索引 - └─ 按 Module 绑定的锚点 CategoryIDs 归域 -前端:点领域锚点 → 若有托管站点则深链进 MinIO 静态站;markdown 型则进 modex 阅读器 -``` - ---- - -## 5. GitLab CI 模板(B 模式的核心交付) - -目标:仓库 `include` 一个模板 + 设几个变量即可,**不需要在仓库里维护 `docs.yaml`**(docsctl 从环境变量合成单 entry 配置)。 - -`deploy/ci/modex-docs.gitlab-ci.yml`(modex 仓库提供,开源用户镜像/引用): - -```yaml -# 仓库侧 .gitlab-ci.yml -include: - - remote: 'https://raw.githubusercontent.com//modex/main/deploy/ci/modex-docs.gitlab-ci.yml' - -variables: - MODEX_MODULE_KEY: "rd-doc" # 对应 modex 后台登记的文档源 key(锚点在后台配,CI 不管) - DOCS_BUILDER: "vitepress" # vitepress | vuepress | fumadocs | markdown - DOCS_SOURCE_DIR: "docs" - DOCS_BUILD: "npm ci && npm run docs:build" # markdown 型留空 - DOCS_OUTPUT: "docs/.vitepress/dist" # markdown 型留空 - MODEX_DEPLOY_URL: "https://modex.example.com/api/deploy" - # MODEX_DEPLOY_TOKEN: 在 GitLab CI 变量里设 (Masked + Protected),勿写进仓库 -``` - -模板内部(modex 维护)大致: - -```yaml -modex-docs-deploy: - image: ghcr.io//docsctl:latest # 预装 docsctl + node,免去用户装工具链 - rules: - - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' - script: - - docsctl build - - docsctl package - - docsctl deploy - variables: - DOCS_DEPLOY_URL: "$MODEX_DEPLOY_URL" - DOCS_DEPLOY_TOKEN: "$MODEX_DEPLOY_TOKEN" - DOCS_MODULE: "$MODEX_MODULE_KEY" -``` - -> 锚点领域绑定在 **modex 后台**完成(文档源 → 选锚点节点 + mount),CI 只携带 `MODULE_KEY` 推产物,二者解耦。 - ---- - -## 6. 需要的代码改动(增量,模型基本不动) - -### docsctl -- **新增 `vitepress` builder**:复用现有 `vuepress`/`fumadocs` 的"跑 `Build` 命令 + 拷 `Output`"路径([build.go:88 buildCommandEntry](../../tools/docsctl/internal/docs/build.go))。 -- **env 合成配置**:无 `docs.yaml` 时,从 `DOCS_BUILDER/DOCS_SOURCE_DIR/DOCS_BUILD/DOCS_OUTPUT` 合成单 entry,免仓库维护配置文件。 -- **修 `deploy()` 缺少 token 头**:当前 [main.go:63](../../tools/docsctl/cmd/docsctl/main.go) 未发送 `X-Modex-Deploy-Token`,B 模式会 403,必补。 -- `markdown` 型:递归收集 `.md`,按文件夹 + frontmatter 生成 nav(已有 `extractMDFilesSummary` 雏形,需扩展为带层级的 nav)。 - -### backend -- **`/api/deploy`**:已支持 per-module DeployToken 校验与 SiteFiles→MinIO([server.go:524](../../backend/internal/api/server.go))。需确保入库时用 Module 锚点的 `CategoryIDs` 归域。 -- **Module 增字段**:`Type`(vitepress/…/markdown)、`Mount`(single/split)、`AnchorCategoryID`(可直接复用 `CategoryIDs[0]` 作锚点)。 -- **`split` 落地**:markdown 型同步时,按仓库顶层文件夹 upsert 子 Category(锚点的子域),各文件夹内容归对应子域。 -- **`/api/webhooks/gitlab`**(已存在):B 模式下仅用于记录/触发审计;真正的 build 在仓库 CI。A 模式才用它触发 modex 拉取。 - -### admin 前端 -- 文档源登记表单:RepoURL / Branch / Type / 生成 DeployToken / **锚点领域选择器(树形,任意深度)** / mount 开关(编译型禁用为 single)。 - ---- - -## 7. 层级深度策略 - -- **数据层不设硬上限**:`Category` 与 `NavItem` 均已递归。 -- **UX 建议**:领域树 ≤ 4 层、文档内 nav 分组 ≤ 3 层(与现有 VitePress `sidebarDepth: 5` 一致的"引擎无限、推荐浅"思路,对齐 Mintlify/GitBook)。 - ---- - -## 8. rd-doc 落地示例 - -- rd-doc 是 VitePress、内容异构(规范+工具+sparklers app 模块)。按规则:**整仓 `single`,绑到一个锚点**(如 `研发规范`)。 -- CI:`DOCS_BUILDER=vitepress`、`DOCS_BUILD=npm ci && npm run docs:build`、`DOCS_OUTPUT=docs/.vitepress/dist`、`MODEX_MODULE_KEY=rd-doc`。 -- 点"研发规范"领域 → 进 rd-doc 的 VitePress 站,standard/tools/sparklers 走站内 sidebar。 -- 若想让 version-control / workflow / sparklers 成为 modex 独立子域 → 需把 rd-doc 拆成多仓库分别绑各子域(内容组织问题,非产品限制)。 - ---- - -## 9. 分期 - -1. **P0**:docsctl 修 deploy token 头 + env 合成配置 + vitepress builder;CI 模板 `deploy/ci/modex-docs.gitlab-ci.yml`;deploy 入库按锚点归域。→ rd-doc 可端到端 `single` 接入。 -2. **P1**:Module 增 `Type/Mount`;admin 文档源表单 + 锚点选择器。 -3. **P2**:markdown 型 modex 自渲染 + 文件夹 nav 自动生成 + `split` 子域 upsert。 -4. **P3(可选)**:A 模式 webhook → modex 拉取编译(沙箱 worker)。 - diff --git a/docs/examples/fumadocs/cbb.toml b/docs/examples/fumadocs/cbb.toml deleted file mode 100644 index e6a1e15..0000000 --- a/docs/examples/fumadocs/cbb.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "FumadocsKit" -version = "0.2.0" -channel = "docs" -description = "Fumadocs 文档站示例" -authors = ["frontend-docs"] -edition = "2025" -keywords = ["fumadocs", "nextjs", "mdx", "frontend"] diff --git a/docs/examples/fumadocs/content/docs/index.mdx b/docs/examples/fumadocs/content/docs/index.mdx deleted file mode 100644 index 754942a..0000000 --- a/docs/examples/fumadocs/content/docs/index.mdx +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Fumadocs 文档站接入 -description: 使用 Next.js App Router 和 MDX 维护现代文档站。 ---- - -# Fumadocs 文档站接入 - -## 适用场景 - -Fumadocs 适合需要 MDX、组件化内容和现代前端体验的文档站。 - -## 接入 Modex - -在 `docs.yaml` 中声明 `type: fumadocs`,由 docsctl 执行构建命令并复制静态输出目录。 diff --git a/docs/examples/fumadocs/docs.yaml b/docs/examples/fumadocs/docs.yaml deleted file mode 100644 index 700964d..0000000 --- a/docs/examples/fumadocs/docs.yaml +++ /dev/null @@ -1,7 +0,0 @@ -entries: - - key: guide - title: Fumadocs 文档站接入 - type: fumadocs - source: content/docs - build: node scripts/build-fumadocs-sample.mjs - output: out diff --git a/docs/examples/fumadocs/out/index.html b/docs/examples/fumadocs/out/index.html deleted file mode 100644 index 040c0b5..0000000 --- a/docs/examples/fumadocs/out/index.html +++ /dev/null @@ -1,15 +0,0 @@ -Fumadocs Kit
---
-title: Fumadocs 文档站接入
-description: 使用 Next.js App Router 和 MDX 维护现代文档站。
----
-
-# Fumadocs 文档站接入
-
-## 适用场景
-
-Fumadocs 适合需要 MDX、组件化内容和现代前端体验的文档站。
-
-## 接入 Modex
-
-在 `docs.yaml` 中声明 `type: fumadocs`,由 docsctl 执行构建命令并复制静态输出目录。
-
\ No newline at end of file diff --git a/docs/examples/fumadocs/scripts/build-fumadocs-sample.mjs b/docs/examples/fumadocs/scripts/build-fumadocs-sample.mjs deleted file mode 100644 index d4492f9..0000000 --- a/docs/examples/fumadocs/scripts/build-fumadocs-sample.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; - -const root = resolve(process.cwd()); -const mdx = readFileSync(resolve(root, "content/docs/index.mdx"), "utf8"); -const out = resolve(root, "out/index.html"); -mkdirSync(dirname(out), { recursive: true }); -writeFileSync(out, `Fumadocs Kit
${mdx.replace(/[<&>]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c])}
`); diff --git a/docs/examples/markdown/cbb.toml b/docs/examples/markdown/cbb.toml deleted file mode 100644 index 8ad3884..0000000 --- a/docs/examples/markdown/cbb.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "DemoModule" -version = "1.2.3" -description = "示例模块" -authors = ["alice"] -edition = "2025" -keywords = ["demo", "cad"] diff --git a/docs/examples/markdown/docs.yaml b/docs/examples/markdown/docs.yaml deleted file mode 100644 index d4b425d..0000000 --- a/docs/examples/markdown/docs.yaml +++ /dev/null @@ -1,10 +0,0 @@ -entries: - - key: guide - title: 模块落地指导 - type: markdown - source: docs/integration-guide.md - - - key: maintenance - title: 模块维护说明 - type: markdown - source: docs/maintenance-guide.md diff --git a/docs/examples/markdown/docs/integration-guide.md b/docs/examples/markdown/docs/integration-guide.md deleted file mode 100644 index ae21dea..0000000 --- a/docs/examples/markdown/docs/integration-guide.md +++ /dev/null @@ -1,13 +0,0 @@ -# 模块落地指导 - -## 模块概述 - -DemoModule 用于演示 Modex 文档发布协议。 - -## 功能边界 - -文档包含模块接入、部署、接口和错误处理说明。 - -## 部署与运行 - -业务项目可以通过公共 GitLab Pipeline 调用 docsctl 构建和发布文档包。 diff --git a/docs/examples/markdown/docs/maintenance-guide.md b/docs/examples/markdown/docs/maintenance-guide.md deleted file mode 100644 index c980345..0000000 --- a/docs/examples/markdown/docs/maintenance-guide.md +++ /dev/null @@ -1,13 +0,0 @@ -# 模块维护说明 - -## 总体架构 - -模块文档随代码仓库维护,发布后由 Modex Registry 统一治理。 - -## 核心流程 - -docsctl validate、build、package、deploy 依次完成校验、构建、打包和发布。 - -## 质量与可维护性 - -标准文档包必须包含 site、manifest.json、metadata.json、nav.json、documents.jsonl 和 llms.txt。 diff --git a/docs/examples/vuepress/cbb.toml b/docs/examples/vuepress/cbb.toml deleted file mode 100644 index fa3aa3f..0000000 --- a/docs/examples/vuepress/cbb.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "VuePressGuide" -version = "0.4.0" -channel = "docs" -description = "VuePress 文档站示例" -authors = ["frontend-docs"] -edition = "2025" -keywords = ["vuepress", "frontend", "markdown"] diff --git a/docs/examples/vuepress/docs.yaml b/docs/examples/vuepress/docs.yaml deleted file mode 100644 index 6a347a5..0000000 --- a/docs/examples/vuepress/docs.yaml +++ /dev/null @@ -1,7 +0,0 @@ -entries: - - key: guide - title: VuePress 文档站接入 - type: vuepress - source: docs - build: node scripts/build-vuepress-sample.mjs - output: docs/.vuepress/dist diff --git a/docs/examples/vuepress/docs/.vuepress/dist/index.html b/docs/examples/vuepress/docs/.vuepress/dist/index.html deleted file mode 100644 index 94ebc8c..0000000 --- a/docs/examples/vuepress/docs/.vuepress/dist/index.html +++ /dev/null @@ -1,12 +0,0 @@ -VuePress Guide
# VuePress 文档站接入
-
-## 概览
-
-这个示例模拟一个 VuePress 文档站。真实项目可以把 `build` 改成 `pnpm docs:build`。
-
-## 发布流程
-
-1. 在仓库维护 `docs.yaml`。
-2. 执行 VuePress 构建。
-3. docsctl 复制 `docs/.vuepress/dist` 到标准文档包。
-
\ No newline at end of file diff --git a/docs/examples/vuepress/docs/README.md b/docs/examples/vuepress/docs/README.md deleted file mode 100644 index 2e68227..0000000 --- a/docs/examples/vuepress/docs/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# VuePress 文档站接入 - -## 概览 - -这个示例模拟一个 VuePress 文档站。真实项目可以把 `build` 改成 `pnpm docs:build`。 - -## 发布流程 - -1. 在仓库维护 `docs.yaml`。 -2. 执行 VuePress 构建。 -3. docsctl 复制 `docs/.vuepress/dist` 到标准文档包。 diff --git a/docs/examples/vuepress/scripts/build-vuepress-sample.mjs b/docs/examples/vuepress/scripts/build-vuepress-sample.mjs deleted file mode 100644 index 6e74783..0000000 --- a/docs/examples/vuepress/scripts/build-vuepress-sample.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; - -const root = resolve(process.cwd()); -const markdown = readFileSync(resolve(root, "docs/README.md"), "utf8"); -const out = resolve(root, "docs/.vuepress/dist/index.html"); -mkdirSync(dirname(out), { recursive: true }); -writeFileSync(out, `VuePress Guide
${markdown.replace(/[<&>]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c])}
`); diff --git a/docs/i18n-weblate.md b/docs/i18n-weblate.md new file mode 100644 index 0000000..f539361 --- /dev/null +++ b/docs/i18n-weblate.md @@ -0,0 +1,95 @@ +# Internationalization and Weblate + +Modex uses simple JSON message catalogs so translation tools can work without +understanding the React component tree. + +## Catalog Files + +Source files: + +- `frontend/messages/zh-CN.json` +- `frontend/messages/en-US.json` + +`zh-CN` is the source language. Add new keys there first, then mirror the same +keys in every other locale file. + +Keys are flat dot-separated strings: + +```json +{ + "home.title": "文档中心", + "search.placeholder": "搜索文档,或向 AI 提问…" +} +``` + +Use `{{name}}` placeholders for runtime values: + +```json +{ + "home.loginFailed": "登录失败:{{error}}" +} +``` + +In React components: + +```tsx +const { t } = useI18n(); +t("home.loginFailed", { error: loginError }); +``` + +## Runtime Behavior + +The frontend wraps the app in `I18nProvider` from `frontend/lib/i18n.tsx`. + +- Default locale: browser language when supported, otherwise `zh-CN`. +- User choice is stored in `localStorage` as `modex_locale`. +- The language switcher lives in the signed-in user menu and switches locale + without changing routes. + +This keeps the current URL model stable while still allowing Weblate-managed +catalogs. If route-prefixed locales are needed later, the catalog format can stay +the same. + +## Weblate Setup Notes + +Recommended component settings: + +- File mask: `frontend/messages/*.json` +- Base file: `frontend/messages/zh-CN.json` +- Source language: `zh-CN` +- Translation files: one JSON file per locale, for example `en-US.json` +- JSON format: flat key-value catalog + +Before importing into Weblate, make sure every locale has the same key set: + +```bash +cd frontend +npm run i18n:extract +npm run i18n:check +npm run lint +``` + +TypeScript imports the JSON catalogs, so missing or malformed JSON is caught by +the frontend checks. + +## Contributor Rules + +- Do not hard-code new user-facing strings in migrated components. +- Prefer clear, stable keys such as `user.logout`, not sentence fragments. +- Keep placeholders identical across locales. +- Do not translate product names such as `Modex`, `MCP`, `docsctl`, or `Deploy Token`. +- Avoid putting HTML in message strings; compose markup in React and translate + the visible text only. + +## Migration compatibility + +New and actively maintained screens should use semantic keys through +`useI18n().t(...)`. Existing UI literals are covered by deterministic +`legacy.` entries generated from the TypeScript syntax tree. The +provider translates those text nodes and accessibility attributes at runtime, +so switching locales covers the full existing interface while components move +to semantic keys incrementally. + +`npm run i18n:check` fails when a generated entry is untranslated or when its +placeholders differ between locales. Code examples are deliberately excluded so +translation never changes executable snippets. diff --git a/docs/modex_codex_prompt.md b/docs/modex_codex_prompt.md deleted file mode 100644 index 40f389a..0000000 --- a/docs/modex_codex_prompt.md +++ /dev/null @@ -1,1502 +0,0 @@ -# Modex 研发文档平台 Codex 开发任务说明 - -> 项目暂定名:**Modex** -> 定位:Module Documentation Experience,面向公司内部多模块、多技术栈、多仓库的研发文档平台。 -> 目标:先实现一个可运行的 MVP,然后逐步扩展成完整的研发文档门户、搜索和 AI/MCP 文档访问平台。 - ---- - -## 1. 项目背景 - -公司存在多个研发领域和技术栈,包括: - -- NC -- CAD -- KDC -- 应用 -- 工程化 -- Delphi -- C++ -- Java -- Go -- 前端 -- 测试 -- 运维 - -现有 CBB 平台主要负责 Delphi / C++ 模块构建,不适合作为全公司的文档平台底座。 -因此需要独立建设一个研发文档平台,用于统一管理各模块文档的: - -- 发布 -- 展示 -- 分类 -- 版本 -- 搜索 -- 语义搜索 -- MCP / AI 读取 -- 阅读统计 -- SSO 登录 -- 后续多种文档 builder 扩展 - -第一阶段不是做一个大而全的知识平台,而是做一个具备核心闭环的 MVP。 - ---- - -## 2. 核心设计原则 - -1. 文档平台独立于 CBB。 -2. CBB 只是文档发布来源之一。 -3. 文档内容随代码仓库维护。 -4. 文档平台负责展示、搜索、统计和治理。 -5. 不强制统一所有文档框架。 -6. 强制统一发布产物协议。 -7. GitLab 项目通过公共 Pipeline 自助发布。 -8. SVN / 老项目后续通过中心 Builder Server 拉代码构建发布。 -9. 第一阶段支持 Markdown、Static HTML、VuePress。 -10. 第一阶段支持关键词搜索、语义搜索和 Hybrid Search。 -11. 第一阶段支持 MCP Server。 -12. 门户不直接扫描对象存储,而是以 PostgreSQL 中的 Docs Registry 为唯一事实源。 -13. docs.yaml 只声明文档入口,不重复声明模块、版本、Owner、分类、权限。 -14. cbb.toml 作为模块工程元数据来源。 -15. Registry 负责平台治理字段。 - ---- - -## 3. 技术栈 - -### 3.1 后端 - -- Go -- REST API -- PostgreSQL -- MinIO -- Meilisearch -- pgvector 可选,如果实现方便则使用 PostgreSQL + pgvector 存储 embedding -- OIDC SSO 预留,MVP 可以先实现 mock login -- PostHog 预留 -- MCP Server,Go 实现 -- Docker Compose - -### 3.2 前端 - -- Next.js -- TypeScript -- shadcn/ui -- Tailwind CSS -- TanStack Query -- TanStack Table 可选 -- PostHog JS SDK 预留 - -### 3.3 工具 - -- docsctl,Go CLI -- 支持 markdown builder -- 支持 static html importer -- 支持 vuepress builder - ---- - -## 4. 仓库结构 - -请创建 monorepo: - -```text -modex/ - backend/ - frontend/ - deploy/ - docs/ - tools/ - docsctl/ - mcp/ -``` - -说明: - -- `backend/`:Go 后端 API。 -- `frontend/`:Next.js 前端门户。 -- `deploy/`:docker-compose、数据库初始化、部署配置。 -- `docs/`:项目说明文档。 -- `tools/docsctl/`:文档构建、打包、发布 CLI。 -- `mcp/`:MCP Server,可以是单独 Go module,也可以共享 backend 代码。 - ---- - -## 5. 总体架构 - -```text -GitLab 项目 - └─ include 公共 docs pipeline - └─ docsctl validate/build/package/deploy - └─ docs-deploy-api - ├─ PostgreSQL: Docs Registry - ├─ MinIO: HTML / 静态资源 / 文档包 - ├─ Meilisearch: 关键词搜索 / Facet - ├─ Vector Store: 语义搜索 - ├─ Docs Portal: 前端门户 - └─ Docs MCP Server: AI 读取入口 - -SVN / 老项目,后续阶段 - └─ docs-builder-server - └─ 使用只读 SVN 凭据拉取代码 - └─ docsctl build/package/deploy - └─ docs-deploy-api -``` - ---- - -## 6. 配置文件边界 - -### 6.1 cbb.toml - -如果项目中存在 `cbb.toml`,它作为模块工程元数据来源。 - -示例: - -```toml -[package] -name = "DemoModule" -version = "1.2.3" -channel = "default" -description = "示例模块" -authors = ["alice"] -edition = "2025" -keywords = ["demo", "cad"] -``` - -映射关系: - -| cbb.toml 字段 | 文档平台字段 | -|---|---| -| package.name | module_key / module_name | -| package.version | package_version | -| package.channel | channel | -| package.description | description | -| package.authors | maintainers | -| package.edition | edition | -| package.keywords | keywords / tags | - -注意: - -- `package.version` 是工程包版本。 -- 文档平台自己的版本叫 `docs_version`,例如 `latest`、`main`、`v1.2`、`legacy`。 -- 不要把 `package.version` 和 `docs_version` 混为一个字段。 -- `authors` 可以作为 maintainers 候选,但不要直接等同平台 Owner。 -- `keywords` 可以作为搜索标签,但不要自动决定平台分类。 - ---- - -### 6.2 docs.yaml - -`docs.yaml` 只声明文档入口,不重复声明模块名、版本、Owner、分类、权限。 - -最小示例: - -```yaml -entries: - - key: guide - title: 模块落地指导 - type: markdown - source: docs/integration-guide.md - - - key: maintenance - title: 模块维护说明 - type: markdown - source: docs/maintenance-guide.md -``` - -静态 HTML 老文档示例: - -```yaml -entries: - - key: legacy - title: 历史文档 - type: static - source: legacy/docomatic/html -``` - -VuePress 文档示例: - -```yaml -entries: - - key: guide - title: VuePress 使用说明 - type: vuepress - source: docs - build: pnpm docs:build - output: docs/.vuepress/dist -``` - -后续可扩展示例: - -```yaml -entries: - - key: api - title: 接口文档 - type: openapi - source: docs/api/openapi.yaml - - - key: reference - title: C++ API Reference - type: doxygen - source: Doxyfile - output: build/doxygen/html -``` - ---- - -### 6.3 Registry - -平台 Registry 负责治理字段,不让项目仓库自己随意控制。 - -Registry 管: - -- 分类 -- 路径 -- Owner -- 权限 -- 默认版本 -- 模块展示信息 -- 发布权限 -- 阅读权限 -- 旧路径 redirect,后续阶段做 - ---- - -## 7. 元数据合并规则 - -`docsctl` 需要支持读取: - -1. CI 环境变量 -2. cbb.toml -3. docs.yaml -4. Registry 默认配置 - -优先级: - -```text -CI 变量 > cbb.toml > docs.yaml > Registry -``` - -例如: - -```bash -DOCS_MODULE=DemoModule DOCS_VERSION=latest docsctl build -``` - -生成的 `metadata.json` 应该类似: - -```json -{ - "module_key": "DemoModule", - "module_name": "DemoModule", - "docs_version": "latest", - "package_version": "1.2.3", - "channel": "default", - "description": "示例模块", - "authors": ["alice"], - "edition": "2025", - "keywords": ["demo", "cad"], - "source": { - "metadata_file": "cbb.toml" - } -} -``` - ---- - -## 8. 标准文档包 - -`docsctl package` 最终生成标准文档包: - -```text -docs-artifact.zip - ├─ site/ - │ └─ index.html - ├─ manifest.json - ├─ metadata.json - ├─ nav.json - ├─ documents.jsonl - ├─ embeddings.jsonl # 可选 - ├─ llms.txt - ├─ llms-full.txt # 可选 - └─ assets/ -``` - -说明: - -- `site/`:给用户阅读的 HTML 静态站。 -- `manifest.json`:描述文档包包含哪些 entry。 -- `metadata.json`:模块、版本、发布来源等元数据。 -- `nav.json`:文档目录。 -- `documents.jsonl`:关键词搜索、语义搜索和 MCP 精细检索使用。 -- `embeddings.jsonl`:如果在 docsctl 阶段生成 embedding,则放这里;MVP 可不生成。 -- `llms.txt`:给 LLM 快速理解当前模块文档结构的入口摘要。 -- `llms-full.txt`:可选,将所有正文聚合为 AI 友好的完整文本;大文档可以跳过。 -- `assets/`:图片、图表、附件。 - -平台发布的主展示产物是 HTML,但不能只发布 HTML。 - ---- - -## 9. llms.txt 规范 - -`llms.txt` 必须生成。 - -它不替代 `documents.jsonl`。 - -两者定位: - -| 文件 | 用途 | -|---|---| -| documents.jsonl | 搜索、语义检索、MCP 精细召回 | -| llms.txt | LLM 快速理解模块文档结构和重要入口 | -| llms-full.txt | 可选,聚合完整正文,适合小型文档站 | - -`llms.txt` 建议内容: - -```text -# DemoModule - -Description: 示例模块 -Docs Version: latest -Package Version: 1.2.3 -Channel: default -Keywords: demo, cad - -## Entries - -- 模块落地指导: /guide - Type: markdown - Source: docs/integration-guide.md - Summary: 面向业务开发人员的模块接入、部署、接口和异常处理说明。 - -- 模块维护说明: /maintenance - Type: markdown - Source: docs/maintenance-guide.md - Summary: 面向维护开发人员的架构、设计、流程和维护说明。 - -## Recommended Reading - -1. 模块落地指导 -2. 模块维护说明 - -## Notes for AI - -Use documents.jsonl for precise retrieval. -Use this file only as a high-level map of the documentation. -``` - ---- - -## 10. 第一阶段支持的文档类型 - -MVP 支持: - -1. Markdown -2. Static HTML import -3. VuePress - -暂时不做: - -- OpenAPI builder -- Doxygen builder -- Javadoc builder -- Fumadocs builder -- Docusaurus builder -- SVN builder-server - ---- - -## 11. 文档编写规范 - -每个新模块推荐维护两份 Markdown: - -```text -docs/ - integration-guide.md - maintenance-guide.md - assets/ - diagrams/ -docs.yaml -``` - -### 11.1 integration-guide.md - -模块落地指导,面向业务开发人员。 - -建议章节: - -1. 模块概述 -2. 功能边界 -3. 接口设计 -4. 部署与运行 -5. 异常与错误处理 -6. 已知风险与影响面 - -### 11.2 maintenance-guide.md - -模块维护说明,面向维护开发人员。 - -建议章节: - -1. 模块概述 -2. 功能边界 -3. 总体架构 -4. 核心设计思路与设计原则 -5. 模块结构 -6. 核心流程与时序逻辑 -7. 前后端设计 -8. 质量与可维护性 - ---- - -## 12. 门户展示设计 - -### 12.1 首页 - -首页包含: - -- 顶部全局搜索框 -- 层级分类树 / 分类 Tab -- 模块卡片 -- 最近更新 -- 热门文档 -- 我的关注 -- 管理入口 -- 用户头像 - -分类支持层级,例如: - -```text -NC - - NC 基础平台 - - NC 加工 - - NC 后处理 - -CAD - - CAD 内核 - - CAD 插件 - - 图形渲染 - -KDC - - KDC 平台 - - KDC 数据服务 - -应用 - - PMS - - 设备联网 - - 订单服务 - -工程化 - - CBB - - CI/CD - - Review Board - - SonarQube -``` - -### 12.2 模块卡片 - -模块卡片展示: - -- 模块名称 -- 分类 -- 默认版本 -- 最近更新 -- 状态 -- 标签 / keywords -- Info 按钮 - -示例: - -```text -DemoModule ⓘ -CAD / 示例模块 -默认版本:latest -工程版本:1.2.3 -最近更新:2026-06-09 -标签:demo / cad -``` - -交互: - -- 点击卡片主体:进入默认版本文档。 -- 点击 Info:打开模块信息抽屉。 -- 点击收藏:加入我的关注。 - -### 12.3 模块 Info 抽屉 - -展示: - -- 模块名称 -- 描述 -- 分类 -- Owner -- maintainers / authors -- 来源仓库 -- 默认文档版本 -- package_version -- channel -- edition -- keywords -- 可用版本 -- 最近发布 -- 阅读量 -- 发布记录入口 -- 查看源码入口 - -### 12.4 文档阅读页 - -布局: - -```text -顶部: - 面包屑 / 模块名 / 版本选择 / 搜索本模块 / 查看源码 / 提交反馈 - -左侧: - 当前 Entry 文档目录 - -中间: - 文档正文 - -右侧: - 本文目录 - 文档元数据 - 阅读统计 -``` - ---- - -## 13. 搜索设计 - -MVP 必须支持: - -1. 关键词搜索 -2. 条件过滤 -3. Facet -4. 分页 -5. 排序 -6. 语义搜索 -7. Hybrid Search - -### 13.1 搜索引擎 - -第一阶段使用: - -- Meilisearch:关键词搜索、Facet、过滤 -- Vector Store:语义搜索 - -Vector Store 可选实现: - -1. PostgreSQL + pgvector,推荐。 -2. 如果 pgvector 接入复杂,可以先用 PostgreSQL JSONB 存向量 + 简单 cosine 计算作为 MVP。 -3. 不要把向量搜索逻辑写死,抽象为 `EmbeddingStore` 接口。 - -### 13.2 Embedding Provider - -实现可插拔 embedding provider: - -```text -EmbeddingProvider - - Name() - - EmbedText(ctx, text) ([]float32, error) - - EmbedBatch(ctx, texts) ([][]float32, error) -``` - -第一阶段至少支持: - -1. `MockEmbeddingProvider`:本地 deterministic embedding,用于开发测试。 -2. `HTTPEmbeddingProvider`:调用外部 embedding 服务,配置 URL 和 API Key。 - -配置示例: - -```env -EMBEDDING_PROVIDER=mock -EMBEDDING_HTTP_URL= -EMBEDDING_HTTP_API_KEY= -EMBEDDING_DIM=384 -``` - -### 13.3 搜索模式 - -`POST /api/search` 支持: - -```json -{ - "query": "构建缓存怎么清理", - "mode": "hybrid", - "filters": { - "category_ids": ["engineering"], - "modules": ["cbb"], - "docs_versions": ["latest"], - "entry_types": ["markdown"], - "keywords": ["cad"] - }, - "page": 1, - "page_size": 20 -} -``` - -`mode` 支持: - -- `keyword` -- `semantic` -- `hybrid` - -Hybrid 排序建议: - -```text -final_score = keyword_score * 0.6 + semantic_score * 0.4 -``` - -可配置: - -```env -HYBRID_KEYWORD_WEIGHT=0.6 -HYBRID_SEMANTIC_WEIGHT=0.4 -``` - -### 13.4 搜索筛选条件 - -- 分类 -- 模块 -- 文档版本 -- package_version -- Entry 类型 -- 文档类型 -- keywords -- 状态 -- Owner -- 是否 legacy - -### 13.5 搜索结果展示 - -- 标题 -- 摘要 -- 模块 -- 分类 -- docs_version -- package_version -- entry_type -- owner -- 更新时间 -- 状态 -- score -- search_mode - -### 13.6 搜索索引字段示例 - -```json -{ - "doc_id": "DemoModule:latest:guide", - "module_key": "DemoModule", - "module_name": "DemoModule", - "docs_version": "latest", - "package_version": "1.2.3", - "channel": "default", - "category_ids": ["cad", "cad.demo"], - "entry_key": "guide", - "entry_type": "markdown", - "title": "模块落地指导", - "description": "示例模块落地指导", - "content": "正文内容", - "path": "/docs/cad/demo-module/latest/guide", - "source_file": "docs/integration-guide.md", - "keywords": ["demo", "cad"], - "owner_group": "cad-team", - "status": "active", - "is_default_version": true, - "updated_at": "2026-06-09T10:00:00+09:00" -} -``` - ---- - -## 14. MCP Server 设计 - -第一阶段必须实现 MCP Server。 - -MCP Server 用于让 AI 工具安全读取文档平台内容。 - -MCP Server 不能直接读 MinIO 或 HTML 文件,必须通过平台 API / Registry / Search Service。 - -### 14.1 MCP 工具 - -实现以下工具。 - -#### list_modules - -输入: - -```json -{ - "category_id": "cad", - "keyword": "demo" -} -``` - -输出模块列表: - -```json -[ - { - "module_key": "DemoModule", - "name": "DemoModule", - "description": "示例模块", - "default_version": "latest", - "package_version": "1.2.3", - "keywords": ["demo", "cad"] - } -] -``` - -#### list_versions - -输入: - -```json -{ - "module_key": "DemoModule" -} -``` - -输出版本列表。 - -#### search_docs - -输入: - -```json -{ - "query": "模块如何落地", - "mode": "hybrid", - "module_key": "DemoModule", - "docs_version": "latest", - "limit": 5 -} -``` - -输出搜索结果,包括: - -- doc_id -- title -- snippet -- path -- score -- module_key -- docs_version - -#### get_doc_page - -输入: - -```json -{ - "doc_id": "DemoModule:latest:guide" -} -``` - -输出: - -- 文档正文内容 -- 标题 -- 来源路径 -- 模块信息 -- 版本信息 - -### 14.2 MCP 权限 - -MVP 可以使用 mock user 或 service token。 -但接口设计要预留真实用户权限过滤。 - -配置: - -```env -MCP_ENABLED=true -MCP_TOKEN=dev-token -``` - -### 14.3 MCP 与搜索共用能力 - -MCP 的 `search_docs` 必须调用同一个 Search Service,不能单独实现另一套搜索逻辑。 - ---- - -## 15. SSO 设计 - -MVP 预留 OIDC 配置,但可以先实现 mock login。 - -后续 OIDC 登录流程: - -```text -用户访问平台 - → 未登录跳转 SSO - → 登录成功回调 - → 后端校验 token - → 创建 session - → 同步用户信息和用户组 -``` - -用户信息字段: - -- user_id -- username -- display_name -- email -- department -- groups -- roles - ---- - -## 16. PostHog 和阅读统计 - -MVP 预留 PostHog 初始化代码。 - -第一阶段埋点事件: - -- docs_home_view -- docs_module_click -- docs_module_info_open -- docs_page_view -- docs_search -- docs_search_result_click -- docs_version_switch -- docs_source_click -- docs_mcp_search -- docs_mcp_get_page - -登录后预留: - -```ts -posthog.identify(user.id, { - name: user.displayName, - email: user.email, - department: user.department, - groups: user.groups, -}) -``` - -平台自身数据库也记录: - -- 文档 PV -- 文档 UV -- 近 7 天阅读量 -- 近 30 天阅读量 -- 搜索关键词 -- 无结果搜索词 -- 搜索点击 -- 热门文档 -- 最近更新文档 -- MCP 查询日志 - ---- - -## 17. 数据库表设计 - -请实现以下 MVP 表。 - -### users - -```text -id -username -display_name -email -department -created_at -updated_at -``` - -### groups - -```text -id -group_key -name -source -created_at -updated_at -``` - -### user_groups - -```text -user_id -group_id -``` - -### docs_category - -```text -id -parent_id -key -name -description -icon -sort_order -status -created_at -updated_at -``` - -### docs_module - -```text -id -module_key -name -description -owner_group -repo_type -repo_url -default_version_id -visibility -status -package_name -package_version -channel -edition -keywords -maintainers -created_at -updated_at -``` - -### docs_module_category - -```text -module_id -category_id -is_primary -``` - -### docs_version - -```text -id -module_id -docs_version -display_name -version_type -is_default -status -source_branch -package_version -channel -edition -support_status -created_at -updated_at -``` - -### docs_entry - -```text -id -module_id -version_id -entry_key -title -entry_type -builder -source -storage_uri -nav_uri -index_status -is_primary -sort_order -status -created_at -updated_at -``` - -### docs_release - -```text -id -module_id -version_id -release_id -commit_sha -branch -publisher -pipeline_url -build_system -build_id -artifact_version -package_version -storage_uri -status -published_at -created_at -``` - -### docs_page - -```text -id -module_id -version_id -entry_id -release_id -doc_id -title -description -path -source_file -doc_type -status -owner_group -tags -content_text -updated_at -last_verified_at -created_at -``` - -### docs_page_view - -```text -id -page_id -module_id -version_id -user_id -session_id -duration_seconds -scroll_depth -viewed_at -``` - -### docs_search_log - -```text -id -user_id -query -mode -filters_json -result_count -clicked_doc_id -searched_at -``` - -### docs_embedding - -如果使用 pgvector: - -```text -id -page_id -doc_id -chunk_id -module_id -version_id -entry_id -content -embedding vector -metadata_json -created_at -updated_at -``` - -如果不用 pgvector,先用 JSONB 存 embedding: - -```text -embedding_json -``` - -### docs_mcp_log - -```text -id -tool_name -user_id -query -input_json -result_count -created_at -``` - ---- - -## 18. API 设计 MVP - -### 18.1 Auth - -```http -GET /api/auth/me -POST /api/auth/mock-login -POST /api/auth/logout -``` - -预留: - -```http -GET /api/auth/login -GET /api/auth/callback -``` - -### 18.2 Category - -```http -GET /api/categories/tree -POST /api/admin/categories -PUT /api/admin/categories/{id} -DELETE /api/admin/categories/{id} -``` - -### 18.3 Module - -```http -GET /api/modules -GET /api/modules/{module_key} -GET /api/modules/{module_key}/info -POST /api/admin/modules -PUT /api/admin/modules/{module_key} -``` - -### 18.4 Version - -```http -GET /api/modules/{module_key}/versions -POST /api/admin/modules/{module_key}/versions -PUT /api/admin/modules/{module_key}/versions/{docs_version} -``` - -### 18.5 Entry - -```http -GET /api/modules/{module_key}/versions/{docs_version}/entries -POST /api/admin/modules/{module_key}/versions/{docs_version}/entries -PUT /api/admin/entries/{entry_id} -DELETE /api/admin/entries/{entry_id} -``` - -### 18.6 Document - -```http -GET /api/docs/{module_key} -GET /api/docs/{module_key}/{docs_version} -GET /api/docs/{module_key}/{docs_version}/{entry_key} -GET /api/docs/{module_key}/{docs_version}/{entry_key}/nav -GET /api/docs/{module_key}/{docs_version}/{entry_key}/* -GET /api/docs/page/{doc_id} -``` - -### 18.7 Search - -```http -POST /api/search -GET /api/search/facets -POST /api/search/reindex -``` - -### 18.8 Embedding - -```http -POST /api/embeddings/reindex -POST /api/embeddings/embed-text -``` - -### 18.9 Deploy - -```http -POST /api/deploy -GET /api/admin/releases -GET /api/admin/releases/{release_id} -POST /api/admin/releases/{release_id}/rollback -``` - -### 18.10 Analytics - -```http -POST /api/analytics/page-view -POST /api/analytics/read-progress -GET /api/admin/analytics/pages -GET /api/admin/analytics/search -GET /api/admin/analytics/mcp -``` - ---- - -## 19. MCP Server API - -MCP Server 需要以单独进程或 backend 子命令方式运行。 - -实现: - -```bash -docs-mcp-server -``` - -配置: - -```env -DOCS_API_BASE_URL=http://backend:8080 -MCP_TOKEN=dev-token -``` - -MCP Server 通过 HTTP 调用后端 API。 - -工具: - -- list_modules -- list_versions -- search_docs -- get_doc_page - ---- - -## 20. 前端页面 - -用户侧: - -```text -/ -首页 - -/search -搜索页,支持 keyword / semantic / hybrid 模式切换 - -/docs/:moduleKey -跳转默认版本 - -/docs/:moduleKey/:docsVersion -模块版本入口页 - -/docs/:moduleKey/:docsVersion/:entryKey/* -文档阅读页 - -/me/recent -最近访问 - -/me/favorites -我的关注 -``` - -管理侧: - -```text -/admin -管理首页 - -/admin/categories -分类管理 - -/admin/modules -模块管理 - -/admin/modules/:moduleKey -模块详情 - -/admin/releases -发布记录 - -/admin/analytics -阅读统计 - -/admin/search-logs -搜索日志 - -/admin/mcp-logs -MCP 日志 -``` - ---- - -## 21. docsctl MVP - -请在 `tools/docsctl` 实现 Go CLI。 - -命令: - -```bash -docsctl validate -docsctl build -docsctl package -docsctl deploy -``` - -### 21.1 validate - -检查: - -- docs.yaml 是否存在。 -- entries 是否存在。 -- entries 中 key/title/type/source 是否存在。 -- source 文件或目录是否存在。 -- 如果存在 cbb.toml,检查 [package] 是否可解析。 -- VuePress entry 如果有 build/output,检查字段存在。 - -### 21.2 build - -第一阶段支持: - -- markdown -- static -- vuepress - -#### Markdown builder - -- 读取 Markdown 文件。 -- 转成基础 HTML。 -- 生成 nav.json。 -- 生成 documents.jsonl。 -- 生成 llms.txt。 -- 尝试生成 llms-full.txt。 -- 复制 assets。 - -#### Static builder - -- 复制已有 HTML 目录。 -- 尝试生成简单 nav.json。 -- 尝试抽取 documents.jsonl。 -- 生成 llms.txt。 -- 如果抽取失败,至少生成 entry-level document 记录,确保搜索可见。 - -#### VuePress builder - -- 读取 entry.build 命令。 -- 执行 build 命令。 -- 从 entry.output 读取构建后的 HTML。 -- 复制到标准 site 目录。 -- 尝试从源 Markdown 或构建结果抽取 nav.json 和 documents.jsonl。 -- 生成 llms.txt。 -- 如果抽取失败,至少生成一个 entry-level document 记录,确保搜索可见。 - -### 21.3 package - -生成: - -```text -docs-artifact.zip - site/ - manifest.json - metadata.json - nav.json - documents.jsonl - embeddings.jsonl 可选 - llms.txt - llms-full.txt 可选 - assets/ -``` - -metadata.json 需要合并: - -- CI 环境变量 -- cbb.toml -- docs.yaml entries - -### 21.4 deploy - -调用: - -```http -POST /api/deploy -``` - -上传 docs-artifact.zip。 - ---- - -## 22. GitLab Pipeline 模板,暂时只预留 - -MVP 可以先不完整实现 Pipeline 模板,但请预留示例文件: - -```yaml -include: - - project: 'devops/docs-ci-templates' - ref: main - file: '/templates/docs-deploy.yml' - -variables: - DOCS_MODULE: "DemoModule" - DOCS_VERSION: "latest" - DOCS_BUILDER: "markdown" - DOCS_SOURCE_DIR: "docs" -``` - -公共 Pipeline 未来执行: - -```text -docsctl validate -docsctl build -docsctl package -docsctl deploy -``` - ---- - -## 23. MVP 范围 - -请先实现: - -1. Monorepo 项目结构。 -2. Go 后端服务。 -3. Next.js 前端。 -4. docker-compose,包含 PostgreSQL、MinIO、Meilisearch。 -5. pgvector 可选,如果方便则加入。 -6. 数据库迁移。 -7. Mock 登录。 -8. 分类树 API 和页面。 -9. 模块 API 和模块卡片。 -10. 模块 Info 抽屉。 -11. 版本、Entry、Release 的基础管理 API。 -12. 文档阅读页占位。 -13. 搜索 API 和搜索页,支持 keyword / semantic / hybrid 三种模式。 -14. EmbeddingProvider 抽象。 -15. MockEmbeddingProvider。 -16. HTTPEmbeddingProvider。 -17. MCP Server,支持 list_modules / list_versions / search_docs / get_doc_page。 -18. MCP 查询日志。 -19. PostHog 初始化位置。 -20. docsctl 的 validate/build/package 基础能力。 -21. cbb.toml 解析能力。 -22. markdown builder。 -23. static html builder。 -24. vuepress builder。 -25. 标准文档包中必须包含 llms.txt。 -26. README,说明如何本地启动。 - -暂时不要实现: - -- SVN builder-server -- OpenAPI builder -- Doxygen builder -- Javadoc builder -- Fumadocs/Docusaurus builder -- 复杂权限 -- 文档质量评分 -- 旧路径 redirect - ---- - -## 24. 代码要求 - -1. 结构清晰。 -2. 后端 API 有统一错误处理。 -3. 数据库迁移可重复执行。 -4. 前端组件拆分合理。 -5. docker-compose 一键启动。 -6. README 写清楚本地启动步骤。 -7. Mock 数据要能展示首页、分类、模块卡片、Info 抽屉、搜索页、MCP 示例。 -8. 不要过度设计,但语义搜索、MCP、VuePress builder、llms.txt 的架构必须在第一阶段跑通。 - ---- - -## 25. 第一轮交付目标 - -第一轮交付时,请确保以下内容可以运行: - -1. `docker-compose up` 可以启动 PostgreSQL、MinIO、Meilisearch、backend、frontend。 -2. 打开前端首页可以看到分类树、模块卡片、Info 抽屉。 -3. 可以 mock 登录。 -4. 可以访问搜索页,并切换 keyword / semantic / hybrid。 -5. 后端有对应 REST API。 -6. MCP Server 可以启动,并能调用 list_modules、list_versions、search_docs、get_doc_page。 -7. docsctl 可以解析 cbb.toml 和 docs.yaml。 -8. docsctl 可以构建 Markdown 示例文档。 -9. docsctl 可以生成 docs-artifact.zip。 -10. docs-artifact.zip 包含 site、manifest.json、metadata.json、nav.json、documents.jsonl、llms.txt。 diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..4cfce8c --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,46 @@ +# Production upgrades and rollback + +## Compatibility policy + +Modex uses semantic version tags. Patch releases should be drop-in fixes. Minor +releases may add configuration and backward-compatible schema changes. A major +release may require an explicit migration described in its release notes. + +The backend, frontend, MCP package, and `docsctl` from one release tag are tested +as a set. Mixing versions is unsupported unless release notes explicitly allow +it. Go 1.23 and Node.js 20 are the supported build toolchains. + +## Before upgrading + +1. Read the changelog and release notes. +2. Verify release checksums, Sigstore bundles, provenance, and SBOMs. +3. Back up PostgreSQL and the MinIO bucket. Record the currently deployed image + digests and environment/configuration revisions. +4. Test the upgrade against a restored staging copy, including login, search, + document deploy, MCP access, and rollback of a document release. + +## Rolling upgrade + +Stop writes from document CI when a release includes schema changes. Upgrade the +backend first, wait for `/healthz` to report healthy sessions and dependencies, +then upgrade the frontend and MCP clients. Resume document deploys after a smoke +test. Do not run mixed backend versions during schema transitions. + +## Application rollback + +Re-deploy the previous immutable image digest and matching frontend/MCP release. +Restore the previous configuration revision. Schema changes are forward-only; +do not run an older binary against a changed database unless the release notes +state that it is compatible. When it is not compatible, restore the PostgreSQL +and MinIO backups together to keep metadata and artifacts consistent. + +Document content releases can be rolled back independently from the admin +release page; this does not roll back the Modex application itself. + +## Data consistency + +The API reads and writes business data directly through PostgreSQL on every +request. There is no process-local business cache, periodic autosave, or whole +store snapshot. Committed changes are immediately visible to every API +instance. Static documentation assets use MinIO when configured and otherwise +fall back to the `docs_site_file` PostgreSQL table. diff --git a/docs/pipeline/docs-deploy.example.yml b/docs/pipeline/docs-deploy.example.yml deleted file mode 100644 index a69abf3..0000000 --- a/docs/pipeline/docs-deploy.example.yml +++ /dev/null @@ -1,22 +0,0 @@ -# 文档仓库侧 .gitlab-ci.yml 示例(B 模式:仓库 CI 编译再推送到 modex) -# -# 推荐做法是直接 include modex 提供的 CI 模板,只设变量即可: -include: - - remote: 'https://raw.githubusercontent.com//modex/main/deploy/ci/modex-docs.gitlab-ci.yml' - -variables: - MODEX_MODULE_KEY: "rd-doc" # 必填:modex 后台登记的文档源 key - DOCS_BUILDER: "vitepress" # vitepress | vuepress | fumadocs | markdown - DOCS_BUILD: "npm ci && npm run docs:build" # markdown 型可留空 - DOCS_OUTPUT: "docs/.vitepress/dist" # 框架 build 产物目录;markdown 型可留空 - MODEX_DEPLOY_URL: "https://modex.example.com/api/deploy" - # MODEX_DEPLOY_TOKEN: 在 GitLab CI/CD Variables 里设置(Masked + Protected),勿写进仓库 - -# GitLab 对接说明 -# 1. 在 modex 后台登记“文档源”:填 RepoURL + Branch + Type,生成 Deploy Token, -# 并选择它在能力域树中的【锚点节点】(顶层域 / 子域 / 更深子域,深度任意)。 -# 2. 把上面的 Deploy Token 作为 GitLab CI 变量 MODEX_DEPLOY_TOKEN(Masked + Protected)。 -# 3. 推送到默认分支后,CI 会自动 build + package + deploy 到 modex。 -# 4. 文档归属由后台锚点决定,CI 不关心。 -# 5. 约束:一个仓库只绑定一个锚点(不跨多个顶层能力域)。编译型仓库整站 single 挂在锚点下; -# 需要把子目录拆成多个子域时,请拆成多个仓库分别绑定,或改用 markdown 型 + split。 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..d745670 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,73 @@ +# Testing Modex + +This project has four independently testable surfaces: + +- `backend/`: Go REST API, auth, persistence, search, deploy ingest. +- `tools/docsctl/`: documentation discovery/build/package/deploy CLI. +- `mcp/`: stdio MCP server and HTTP client wrapper. +- `frontend/`: Next.js portal. + +## Go Tests + +Run each Go module from its own directory: + +```bash +cd backend && go test ./... +cd tools/docsctl && go test ./... +cd mcp && go test ./... +``` + +These are the fastest regression checks and should run on every pull request. + +## Frontend Checks + +```bash +cd frontend +npm ci +npm run lint +npm run build +``` + +`npm run lint` currently runs `next typegen && tsc --noEmit`, so it is a type +and route-contract check rather than an ESLint rule set. + +## Playwright E2E + +The Playwright suite lives in `frontend/e2e`. It starts the Next.js dev server +and mocks the backend API at the browser network layer, so the smoke tests do +not require PostgreSQL, MinIO, or the Go API. + +```bash +cd frontend +npm run e2e +``` + +Current smoke coverage: + +- home page renders from mocked category/module data, with the login entry when logged out +- an authenticated user can switch the locale from `zh-CN` to `en-US` +- an authenticated admin sees the admin console entry + +Install browsers on a new CI runner or developer machine if Playwright asks for +them: + +```bash +npx playwright install chromium +``` + +## Recommended CI Order + +1. Go tests for `backend`, `tools/docsctl`, and `mcp`. +2. Frontend `npm ci`. +3. Frontend `npm run lint`. +4. Frontend `npm run build`. +5. Frontend `npm run e2e`. + +Keep E2E tests focused on user journeys. Use backend Go tests for API edge cases +and docsctl/MCP Go tests for protocol and CLI behavior. + +`store.MemoryStore` is an explicit unit-test fake only. Production assembly +injects `PostgresRepository`. Set `TEST_DATABASE_URL` to run the repository +integration test, which covers request-level CRUD, publishing, analytics, +OAuth token rotation, static assets, and visibility across two repository +instances. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 288c78c..ab02276 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,26 +1,31 @@ -FROM node:20-alpine AS deps +FROM node:26-alpine AS deps WORKDIR /app COPY package.json ./ RUN npm install -FROM node:20-alpine AS build +FROM node:26-alpine AS build WORKDIR /app -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8671 -ARG NEXT_PUBLIC_POSTHOG_KEY= -ARG NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY -ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build -FROM node:20-alpine +FROM node:26-alpine WORKDIR /app ENV NODE_ENV=production +ENV PORT=3456 ENV INTERNAL_API_BASE_URL=http://backend:8671 +ENV MODEX_PUBLIC_APP_TITLE=Modex +ENV MODEX_PUBLIC_LOGO_URL=/logo.svg +ENV MODEX_PUBLIC_LOGO_LIGHT_URL=/logo.svg +ENV MODEX_PUBLIC_LOGO_DARK_URL=/logo.svg +ENV MODEX_PUBLIC_FAVICON_URL=/icon.svg +ENV MODEX_PUBLIC_API_BASE_URL=http://localhost:8671 +ENV MODEX_PUBLIC_KROKI_URL=https://kroki.io COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/public ./public -EXPOSE 3000 +COPY docker-entrypoint.sh /usr/local/bin/modex-frontend-entrypoint +RUN chmod +x /usr/local/bin/modex-frontend-entrypoint +EXPOSE 3456 +ENTRYPOINT ["modex-frontend-entrypoint"] CMD ["node", "server.js"] diff --git a/frontend/app/.well-known/agent-skills/index.json/route.ts b/frontend/app/.well-known/agent-skills/index.json/route.ts new file mode 100644 index 0000000..5450c9b --- /dev/null +++ b/frontend/app/.well-known/agent-skills/index.json/route.ts @@ -0,0 +1,3 @@ +import { skillDiscoveryGET } from "../../skill-discovery"; + +export const GET = skillDiscoveryGET; diff --git a/frontend/app/.well-known/skill-discovery.ts b/frontend/app/.well-known/skill-discovery.ts new file mode 100644 index 0000000..ad56cc1 --- /dev/null +++ b/frontend/app/.well-known/skill-discovery.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; + +type SkillEntry = { + url?: string; + [key: string]: unknown; +}; + +type SkillDiscovery = { + skills?: SkillEntry[]; + [key: string]: unknown; +}; + +const DISCOVERY_PATH = "/.well-known/agent-skills/index.json"; + +function internalApiBaseURL() { + return (process.env.INTERNAL_API_BASE_URL || process.env.MODEX_PUBLIC_API_BASE_URL || "http://localhost:8671").replace( + /\/+$/, + "" + ); +} + +function absoluteURL(req: NextRequest, path: string) { + if (/^https?:\/\//i.test(path)) return path; + return new URL(path, req.nextUrl.origin).toString(); +} + +export async function skillDiscoveryGET(req: NextRequest) { + const res = await fetch(`${internalApiBaseURL()}${DISCOVERY_PATH}`, { cache: "no-store" }); + if (!res.ok) { + return NextResponse.json( + { + error: "skill_discovery_unavailable", + message: await res.text() + }, + { status: res.status } + ); + } + + const body = (await res.json()) as SkillDiscovery; + const skills = Array.isArray(body.skills) + ? body.skills.map((skill) => ({ + ...skill, + url: typeof skill.url === "string" ? absoluteURL(req, skill.url) : skill.url + })) + : body.skills; + + return NextResponse.json( + { + ...body, + skills + }, + { + headers: { + "Cache-Control": "public, max-age=300" + } + } + ); +} diff --git a/frontend/app/.well-known/skills/index.json/route.ts b/frontend/app/.well-known/skills/index.json/route.ts new file mode 100644 index 0000000..5450c9b --- /dev/null +++ b/frontend/app/.well-known/skills/index.json/route.ts @@ -0,0 +1,3 @@ +import { skillDiscoveryGET } from "../../skill-discovery"; + +export const GET = skillDiscoveryGET; diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx deleted file mode 100644 index 2222b54..0000000 --- a/frontend/app/admin/analytics/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { getPageAnalytics } from "@/lib/api"; -import { AdminShell } from "@/components/admin-shell"; - -export default async function AnalyticsPage() { - const data = await getPageAnalytics(); - return ( - -
-
-
-
总 PV
-
{data.total_pv}
-
-
-
近 7 天阅读
-
{data.reads_7d}
-
-
-
页面数
-
{data.popular_pages.length}
-
-
-
事件类型
-
{data.events.length}
-
-
- -
- - - - - - - - - - - - - - - {data.popular_pages.map((p) => ( - - - - - - - - - - - ))} - -
文档模块版本PVUV近7天近30天平均时长(s)
- {p.title} -
{p.doc_id}
-
{p.module_name}{p.docs_version}{p.pv}{p.uv}{p.reads_7d}{p.reads_30d}{p.avg_duration_seconds}
-
- -

埋点事件:{data.events.join(" / ")}

-
-
- ); -} diff --git a/frontend/app/admin/categories/page.tsx b/frontend/app/admin/categories/page.tsx index 622a9d7..07bc5ec 100644 --- a/frontend/app/admin/categories/page.tsx +++ b/frontend/app/admin/categories/page.tsx @@ -1,13 +1,15 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { ChevronRight, GripVertical, Pencil, Plus, Trash2 } from "lucide-react"; +import { ChevronRight, FolderTree, GripVertical, Pencil, Plus, Trash2 } from "lucide-react"; import { AdminShell } from "@/components/admin-shell"; import { Modal } from "@/components/ui/modal"; import { Combobox, type ComboOption } from "@/components/ui/combobox"; +import { EmptyState } from "@/components/ui/empty-state"; import { CategoryIcon, IconPicker } from "@/components/ui/icon-picker"; -import { getCategories, getTeams, createCategory, updateCategory, deleteCategory, moveCategory } from "@/lib/api"; +import { getManagedCategories, getMe, getTeams, createCategory, updateCategory, deleteCategory, moveCategory } from "@/lib/api"; import type { Category, Team } from "@/types/modex"; +import { useI18n } from "@/lib/i18n"; type FlatRow = { category: Category; depth: number }; type DropPos = "before" | "after" | "into"; @@ -49,6 +51,7 @@ function TreeNode({ onDrop: (e: React.DragEvent, id: string) => void; }; }) { + const { t } = useI18n(); const hasChildren = !!category.children?.length; const open = expanded.has(category.id); const dt = drag.dropTarget; @@ -66,7 +69,7 @@ function TreeNode({ onDragOver={(e) => drag.onDragOver(e, category.id)} onDrop={(e) => drag.onDrop(e, category.id)} > - + {hasChildren ? ( toggle(category.id)}> @@ -76,14 +79,11 @@ function TreeNode({ )} {category.name} - {category.key} - {category.responsible_team ? 负责: {category.responsible_team} : null} - {category.description ? {category.description} : null}
- - - + + +
{hasChildren && open ? ( @@ -98,6 +98,7 @@ function TreeNode({ } export default function AdminCategoriesPage() { + const { t } = useI18n(); const [categories, setCategories] = useState([]); const [teams, setTeams] = useState([]); const [error, setError] = useState(""); @@ -107,14 +108,21 @@ export default function AdminCategoriesPage() { const [draggingId, setDraggingId] = useState(null); const [dropTarget, setDropTarget] = useState(null); + const [isSuper, setIsSuper] = useState(false); const byId = useRef>(new Map()); + useEffect(() => { + getMe().then((me) => setIsSuper(!!me.is_super_admin)).catch(() => {}); + // Teams are only used for responsible-team labels/selector and are + // super-admin-only; tolerate a 403 for team admins instead of blanking the tree. + getTeams().then((ts) => setTeams(ts || [])).catch(() => {}); + }, []); + async function refresh() { try { - const [tree, ts] = await Promise.all([getCategories(), getTeams()]); + const tree = await getManagedCategories(); const safe = tree || []; setCategories(safe); - setTeams(ts || []); byId.current = new Map(flatten(safe).map((r) => [r.category.id, r.category])); setExpanded((prev) => (prev.size ? prev : new Set(flatten(safe).map((r) => r.category.id)))); setError(""); @@ -213,7 +221,7 @@ export default function AdminCategoriesPage() { } async function remove(id: string, name: string) { - if (!confirm(`删除分类「${name}」?子分类必须先删除。`)) return; + if (!confirm(t("admin.categories.delete_category_value1_subcategories_must_be_deleted_first", { value1: name }))) return; try { await deleteCategory(id); await refresh(); @@ -233,27 +241,26 @@ export default function AdminCategoriesPage() { return ( {error ?
{error}
: null}
-
{flatten(categories).length} 个领域节点 · 拖动卡片排序或改层级
+
{flatten(categories).length} {t("admin.categories.category_nodes_drag_cards_to_reorder_or_change")}
- + {isSuper ? : null}
{categories.length === 0 && !error ? ( -
-
-
暂无领域
-

点击「新增顶级领域」开始创建层级结构。支持任意嵌套,可绑定负责团队。

-
-
+ ) : (
{categories.map((cat) => ( @@ -266,35 +273,35 @@ export default function AdminCategoriesPage() { setModalOpen(false)} - title={data.id ? "编辑领域" : data.parent_id ? "新增子领域" : "新增顶级领域"} - subtitle="顶层领域需超管权限,子领域可由父领域管理员或负责团队创建" + title={data.id ? t("admin.categories.edit_category") : data.parent_id ? t("admin.categories.add_subcategory") : t("admin.categories.add_top_level_category")} + subtitle={t("admin.categories.top_level_categories_require_admin_privileges_subcategories_can")} footer={ <> - - + + } >
- - setData({ ...data, name: e.target.value })} /> - {data.id ? 标识 {data.key}(系统生成,不可修改) : 标识由系统自动生成。} + + setData({ ...data, name: e.target.value })} /> + {data.id ? {t("admin.categories.id")} {data.key}{t("admin.categories.system_generated_immutable")} : {t("admin.categories.id_is_auto_generated_by_the_system")}}
- - setData({ ...data, description: e.target.value })} /> + + setData({ ...data, description: e.target.value })} />
- - setData({ ...data, parent_id: v[0] || "" })} multiple={false} placeholder="选择父领域…" /> + + setData({ ...data, parent_id: v[0] || "" })} multiple={false} placeholder={t("admin.categories.select_parent_category")} />
- + setData({ ...data, icon })} />
- - setData({ ...data, responsible_team: v[0] || "" })} multiple={false} placeholder="选择负责团队…" /> + + setData({ ...data, responsible_team: v[0] || "" })} multiple={false} placeholder={t("admin.categories.select_owning_team")} />
diff --git a/frontend/app/admin/connected-apps/page.tsx b/frontend/app/admin/connected-apps/page.tsx new file mode 100644 index 0000000..9ee7272 --- /dev/null +++ b/frontend/app/admin/connected-apps/page.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Check, Copy, KeyRound, Link2, Loader2, Pencil, Plus, Trash2 } from "lucide-react"; +import { AdminShell } from "@/components/admin-shell"; +import { Modal } from "@/components/ui/modal"; +import { Switch } from "@/components/ui/switch"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Combobox, type ComboOption } from "@/components/ui/combobox"; +import { + createConnectedApp, + deleteConnectedApp, + getConnectedApps, + updateConnectedApp, + type ConnectedApp, + type ConnectedAppDraft, +} from "@/lib/api"; +import { useI18n } from "@/lib/i18n"; + +const DEFAULT_SCOPES = ["modex:mcp:read", "modex:docs:read"]; + +type Draft = ConnectedAppDraft & { + id?: string; + redirect_text: string; +}; + +const emptyDraft: Draft = { + name: "", + description: "", + client_id: "", + redirect_uris: [], + scopes: [...DEFAULT_SCOPES], + trusted: false, + enabled: true, + redirect_text: "", +}; + +function lines(value: string) { + return value + .split(/\r?\n|,/) + .map((v) => v.trim()) + .filter(Boolean); +} + +function toDraft(app?: ConnectedApp): Draft { + if (!app) return emptyDraft; + return { + id: app.id, + name: app.name, + description: app.description || "", + client_id: app.client_id, + redirect_uris: app.redirect_uris || [], + scopes: app.scopes || [], + trusted: !!app.trusted, + enabled: !!app.enabled, + redirect_text: (app.redirect_uris || []).join("\n"), + }; +} + +function payload(draft: Draft): ConnectedAppDraft { + return { + name: draft.name.trim(), + description: draft.description?.trim(), + redirect_uris: lines(draft.redirect_text), + scopes: draft.scopes, + trusted: draft.trusted, + enabled: draft.enabled, + }; +} + +async function copy(text: string, done: (v: boolean) => void) { + try { + await navigator.clipboard.writeText(text); + done(true); + setTimeout(() => done(false), 1500); + } catch { + done(false); + } +} + +export default function AdminConnectedAppsPage() { + const { t } = useI18n(); + const SCOPE_OPTIONS: ComboOption[] = [ + { value: "modex:mcp:read", label: "modex:mcp:read", hint: t("admin.connectedApps.allow_mcp_tools_to_read_documents") }, + { value: "modex:docs:read", label: "modex:docs:read", hint: t("admin.connectedApps.allow_document_reading_api") }, + ]; + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [draft, setDraft] = useState(emptyDraft); + const [createdSecret, setCreatedSecret] = useState(""); + const [copied, setCopied] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + const res = await getConnectedApps(); + setApps(res.apps || []); + } catch (e) { + setError(String(e)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + function openCreate() { + setDraft(emptyDraft); + setCreatedSecret(""); + setError(""); + setModalOpen(true); + } + + function openEdit(app: ConnectedApp) { + setDraft(toDraft(app)); + setCreatedSecret(""); + setError(""); + setModalOpen(true); + } + + async function submit() { + const body = payload(draft); + if (!body.name || !body.redirect_uris.length) { + setError(t("admin.connectedApps.name_and_redirect_uri_are_required")); + return; + } + setSaving(true); + setError(""); + try { + if (draft.id) { + await updateConnectedApp(draft.id, body); + setModalOpen(false); + } else { + const created = await createConnectedApp(body); + setCreatedSecret(created.client_secret || ""); + setDraft(toDraft(created)); + } + await load(); + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + } + + async function remove(app: ConnectedApp) { + if (!confirm(t("admin.connectedApps.delete_app_value1_existing_authorizations_will_be_revoked", { value1: app.name }))) return; + setError(""); + try { + await deleteConnectedApp(app.id); + await load(); + } catch (e) { + setError(String(e)); + } + } + + return ( + + {error ?
{error}
: null} + +
+
+
OAuth Applications
+

+ {t("admin.connectedApps.recommended_scope")}modex:mcp:readmodex:docs:read +

+
+
+ +
+
+ +
+
+ + + + + + + + + + + + + {apps.map((app) => ( + + + + + + + + + ))} + {!apps.length && !loading ? ( + + + + ) : null} + {loading ? ( + + + + ) : null} + +
{t("admin.connectedApps.apply")}Client IDScopes{t("admin.releases.status")}{t("admin.connectedApps.recently_used")}{t("admin.modules.actions")}
+
{app.name}
+ {app.description ?
{app.description}
: null} +
{app.client_id} +
+ {(app.scopes || []).map((scope) => {scope})} +
+
+ + {app.enabled ? t("admin.connectedApps.enable") : t("admin.connectedApps.deactivate")} + + {app.trusted ? Trusted : null} + + {app.last_used_at ? new Date(app.last_used_at).toLocaleString() : "—"} + +
+ + +
+
+ +
{t("component.docReadStats.loading")}
+
+
+ + setModalOpen(false)} + title={draft.id ? t("admin.connectedApps.edit_app_value1", { value1: draft.name }) : t("admin.connectedApps.create_application_link")} + subtitle={t("admin.connectedApps.the_client_secret_is_displayed_only_once_upon")} + width={720} + footer={ + <> + + + + } + > + {createdSecret ? ( +
+ +
+ + +
+ {t("admin.connectedApps.save_this_securely_in_your_external_application_immediately")} +
+ ) : null} + +
+ + setDraft({ ...draft, name: e.target.value })} /> + {t("admin.connectedApps.client_id_is_auto_generated_and_displayed_in")} +
+ + {draft.id ? ( +
+ + + {t("admin.connectedApps.client_id_cannot_be_modified_after_app_creation")} +
+ ) : null} + +
+ + setDraft({ ...draft, description: e.target.value })} /> +
+ +
+ +