From 723b46926aaded85395beb2f4f4c1f14f2085363 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 28 Jul 2026 14:12:14 +0200 Subject: [PATCH] harden v2 custom widgets and workshop --- .coderabbit.yaml | 8 + .dockerignore | 1 - .env.example | 29 +- .github/workflows/automatic-approval.yml | 2 +- .github/workflows/ci.yml | 185 +- .../workflows/crowdin-schedule-download.yml | 2 +- .github/workflows/deployment-beta-release.yml | 4 +- .github/workflows/deployment-docker-image.yml | 4 +- .github/workflows/deployment-docs.yml | 140 +- .../workflows/deployment-weekly-release.yml | 4 +- .github/workflows/docs-algolia-recrawl.yml | 14 +- .github/workflows/on-release.yml | 35 +- .github/workflows/update-contributors.yml | 2 +- .github/workflows/update-integration-list.yml | 2 +- .github/workflows/workshop.yml | 45 +- Dockerfile | 2 +- README.md | 92 + apps/docs/README.md | 10 +- .../advanced/development/getting-started.mdx | 114 +- .../advanced/environment-variables/index.mdx | 34 +- .../custom-widgets/agent-authoring.mdx | 8 +- .../management/custom-widgets/custom-jsx.mdx | 3 +- .../docs/management/custom-widgets/index.mdx | 51 +- .../custom-widgets/requests-and-security.mdx | 13 +- .../custom-widgets/troubleshooting.mdx | 29 +- apps/docs/docs/management/mcp.mdx | 18 +- apps/docs/docs/management/users/index.mdx | 5 + apps/docs/docs/management/workshop/index.mdx | 52 +- apps/docs/docs/widgets/custom-api/index.mdx | 3 +- apps/docs/docusaurus.config.ts | 21 +- .../src/components/workshop/DetailPage.tsx | 14 +- .../src/components/workshop/WorkshopAdmin.tsx | 13 +- .../src/components/workshop/WorkshopApp.tsx | 11 +- apps/docs/src/lib/runtime-config.ts | 21 + apps/docs/src/pages/workshop/admin/index.tsx | 5 +- apps/docs/src/pages/workshop/index.tsx | 3 +- apps/docs/static/workshop-runtime-config.js | 1 + apps/nextjs/README.md | 48 +- .../boards/[name]/settings/_customCss.tsx | 5 +- .../[token]/page.tsx | 8 + apps/nextjs/src/app/[locale]/layout.tsx | 15 +- .../custom-widgets/_custom-widget-actions.tsx | 37 +- .../manage/custom-widgets/edit/[id]/page.tsx | 5 +- .../manage/custom-widgets/new/page.tsx | 5 +- .../[locale]/manage/custom-widgets/page.tsx | 5 +- .../custom-widgets/preview/[id]/page.tsx | 6 +- .../nextjs/src/app/[locale]/manage/layout.tsx | 8 +- .../src/app/[locale]/manage/workshop/page.tsx | 29 +- apps/nextjs/src/app/api/[...trpc]/route.ts | 4 +- .../src/app/api/custom-widgets/admin.spec.ts | 40 + .../src/app/api/custom-widgets/admin.ts | 19 + .../custom-widgets/authoring-prompt/route.ts | 6 +- .../api/custom-widgets/components/route.ts | 6 +- .../configuration-request/[token]/route.ts | 9 +- .../app/api/custom-widgets/schema/route.ts | 6 +- .../src/app/api/custom-widgets/skill/route.ts | 6 +- .../src/app/api/mcp/[transport]/route.spec.ts | 114 +- .../src/app/api/mcp/[transport]/route.ts | 131 +- apps/nextjs/src/app/api/trpc/[trpc]/route.ts | 4 +- .../src/components/board/items/item-menu.tsx | 10 +- .../board/items/item-select-modal.tsx | 123 +- .../board/items/widget-context-menu.tsx | 20 +- .../custom-widgets-unavailable.tsx | 16 + .../workshop/workshop-css-import-button.tsx | 4 + .../workshop/workshop-install-button.tsx | 4 + .../workshop/workshop-publish-modal.tsx | 9 +- apps/nextjs/src/env.ts | 20 +- apps/nextjs/src/hooks/use-runtime-feature.ts | 12 + apps/workshop/.env.example | 14 + apps/workshop/Dockerfile | 41 +- apps/workshop/Dockerfile.dockerignore | 7 - apps/workshop/README.md | 188 +- apps/workshop/docker-compose.yml | 18 +- apps/workshop/entrypoint.sh | 52 + .../pb_hooks/submission-deleted-email.html | 2 +- .../pb_hooks/widget-validator.bundle.cjs | 29020 ++++++++++++++++ apps/workshop/pb_hooks/widget-validator.js | 11 + apps/workshop/pb_hooks/workshop-utils.js | 65 +- apps/workshop/pb_hooks/workshop.pb.js | 119 +- .../1784240000_workshop_widgets.js | 13 +- .../1784240001_workshop_write_rate_limits.js | 54 + .../1784240002_workshop_access_hardening.js | 66 + apps/workshop/test.sh | 160 +- apps/workshop/tests/entrypoint-url.mjs | 49 + apps/workshop/tests/workshop-contracts.mjs | 95 + apps/workshop/tests/workshop-identity.mjs | 63 + apps/workshop/tests/workshop-migration.mjs | 18 +- apps/workshop/tests/workshop-oauth-hook.mjs | 111 + .../tests/workshop-rollback.integration.mjs | 317 + .../tests/workshop-validator-corpus.mts | 332 + apps/workshop/tests/workshop.integration.mjs | 90 +- apps/workshop/workshop-url.awk | 108 + e2e/custom-widgets-custom-jsx-v2.spec.ts | 393 +- package.json | 5 +- packages/api/package.json | 1 + .../api/src/custom-widget-configuration.ts | 1 + packages/api/src/custom-widget-state-mode.ts | 4 + packages/api/src/env.ts | 22 + packages/api/src/router/board.ts | 22 + .../board/custom-widget-placement-access.ts | 96 + .../custom-widget-placement-access.spec.ts | 137 + .../custom-widget/configuration-requests.ts | 4 +- .../custom-widget/custom-widget-router.ts | 26 +- .../src/router/custom-widget/feature-flags.ts | 32 + .../router/custom-widget/legacy-migration.ts | 17 +- .../custom-widget/management-queries.ts | 58 +- .../custom-widget/management-query-mappers.ts | 7 +- .../custom-widget/metadata-procedures.ts | 11 +- .../preview-action-procedures.ts | 73 +- .../custom-widget/preview-base-procedures.ts | 13 +- .../custom-widget/preview-query-procedures.ts | 95 +- .../router/custom-widget/preview-sessions.ts | 3 +- .../router/custom-widget/request-executor.ts | 73 +- .../router/custom-widget/request-limits.ts | 14 +- .../router/custom-widget/response-cache.ts | 296 + .../router/custom-widget/secret-procedures.ts | 132 +- .../custom-widget/template-procedures.ts | 9 +- .../custom-widget/transfer-procedures.ts | 43 +- .../custom-widget/workshop-procedures.ts | 54 +- packages/api/src/router/test/board.spec.ts | 57 + .../definition-persistence.spec.ts | 152 +- .../custom-widget/legacy-migration.spec.ts | 40 +- .../request-execution-limits.spec.ts | 118 + .../response-cache-redis.spec.ts | 58 + .../test/custom-widget/response-cache.spec.ts | 91 + .../test/custom-widget/state-mode.spec.ts | 16 + .../workshop-error-privacy.spec.ts | 113 + .../api/src/router/test/trpc-context.spec.ts | 46 + .../router/test/widgets/custom-api-v2.spec.ts | 243 +- .../src/router/test/widgets/options.spec.ts | 124 + packages/api/src/router/widgets/custom-api.ts | 180 +- packages/api/src/router/widgets/options.ts | 12 +- packages/api/src/test/mcp.spec.ts | 51 +- packages/api/src/trpc.ts | 4 +- packages/common/src/request.ts | 30 +- packages/custom-widgets/src/core/ai-prompt.ts | 61 +- .../src/core/custom-jsx-schema.ts | 11 +- .../src/core/definition-security.ts | 216 +- .../custom-widgets/src/core/request-schema.ts | 23 +- .../custom-widgets/src/core/url-policy.ts | 108 + .../src/runtime/refresh-button.tsx | 3 +- packages/custom-widgets/src/runtime/types.ts | 2 + packages/custom-widgets/src/server/errors.ts | 6 +- packages/custom-widgets/src/server/index.ts | 8 +- .../src/server/network-policy.ts | 47 +- .../src/server/request-executor.ts | 246 +- .../src/server/request-limits.ts | 66 +- .../custom-widgets/src/test/ai-prompt.spec.ts | 22 + .../src/test/request-deadline.spec.ts | 186 + .../src/test/request-limits.spec.ts | 45 + .../custom-widgets/src/test/runtime.spec.tsx | 21 +- .../custom-widgets/src/test/schema.spec.ts | 149 +- .../src/test/server-policy.spec.ts | 148 +- .../0045_custom_widget_v1_compatibility.sql | 22 + .../migrations/mysql/meta/0045_snapshot.json | 2543 ++ .../db/migrations/mysql/meta/_journal.json | 7 + .../0011_custom_widget_v1_compatibility.sql | 23 + .../postgresql/meta/0011_snapshot.json | 2403 ++ .../migrations/postgresql/meta/_journal.json | 7 + .../0043_custom_widget_v1_compatibility.sql | 5 + .../migrations/sqlite/meta/0043_snapshot.json | 2448 ++ .../db/migrations/sqlite/meta/_journal.json | 7 + packages/db/schema/mysql.ts | 16 +- packages/db/schema/postgresql.ts | 16 +- packages/db/schema/sqlite.ts | 16 +- packages/db/test/mysql-migration.spec.ts | 100 +- packages/db/test/postgresql-migration.spec.ts | 106 +- packages/db/test/sqlite-migration.spec.ts | 105 +- packages/definitions/src/docs/index.ts | 13 +- packages/definitions/src/permissions.ts | 3 - packages/definitions/src/test/docs.spec.ts | 11 + packages/translation/src/lang/en.json | 14 +- ...widget-custom-widget-select-input.spec.tsx | 29 + .../widget-custom-widget-select-input.tsx | 15 +- packages/widgets/src/custom-api/component.tsx | 33 +- .../src/custom-api/custom-jsx-display.tsx | 1 + .../custom-api/widget-definition-context.tsx | 99 +- .../custom-api/widget-invalidation.spec.ts | 58 + .../src/custom-api/widget-invalidation.ts | 27 + .../widgets/src/modals/widget-edit-modal.tsx | 52 +- packages/workshop/package.json | 3 + packages/workshop/src/backend.ts | 25 +- packages/workshop/src/client.spec.ts | 37 +- packages/workshop/src/pocketbase-url.spec.ts | 207 + packages/workshop/src/pocketbase-url.ts | 216 + packages/workshop/src/pocketbase-validator.ts | 35 + packages/workshop/src/schema.spec.ts | 47 +- packages/workshop/src/schema.ts | 70 +- packages/workshop/src/tr46.d.ts | 13 + pnpm-lock.yaml | 9 + scripts/build-workshop-validator.mjs | 20 + scripts/ci/check-actions.sh | 17 + scripts/dev-workshop.mjs | 144 + turbo.json | 14 +- 194 files changed, 44862 insertions(+), 1265 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 README.md create mode 100644 apps/docs/src/lib/runtime-config.ts create mode 100644 apps/docs/static/workshop-runtime-config.js create mode 100644 apps/nextjs/src/app/api/custom-widgets/admin.spec.ts create mode 100644 apps/nextjs/src/app/api/custom-widgets/admin.ts create mode 100644 apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx create mode 100644 apps/nextjs/src/hooks/use-runtime-feature.ts create mode 100644 apps/workshop/.env.example delete mode 100644 apps/workshop/Dockerfile.dockerignore create mode 100644 apps/workshop/pb_hooks/widget-validator.bundle.cjs create mode 100644 apps/workshop/pb_hooks/widget-validator.js create mode 100644 apps/workshop/pb_migrations/1784240001_workshop_write_rate_limits.js create mode 100644 apps/workshop/pb_migrations/1784240002_workshop_access_hardening.js create mode 100644 apps/workshop/tests/entrypoint-url.mjs create mode 100644 apps/workshop/tests/workshop-identity.mjs create mode 100644 apps/workshop/tests/workshop-oauth-hook.mjs create mode 100644 apps/workshop/tests/workshop-rollback.integration.mjs create mode 100644 apps/workshop/tests/workshop-validator-corpus.mts create mode 100644 apps/workshop/workshop-url.awk create mode 100644 packages/api/src/custom-widget-state-mode.ts create mode 100644 packages/api/src/router/board/custom-widget-placement-access.ts create mode 100644 packages/api/src/router/board/test/custom-widget-placement-access.spec.ts create mode 100644 packages/api/src/router/custom-widget/feature-flags.ts create mode 100644 packages/api/src/router/custom-widget/response-cache.ts create mode 100644 packages/api/src/router/test/custom-widget/request-execution-limits.spec.ts create mode 100644 packages/api/src/router/test/custom-widget/response-cache-redis.spec.ts create mode 100644 packages/api/src/router/test/custom-widget/response-cache.spec.ts create mode 100644 packages/api/src/router/test/custom-widget/state-mode.spec.ts create mode 100644 packages/api/src/router/test/custom-widget/workshop-error-privacy.spec.ts create mode 100644 packages/api/src/router/test/trpc-context.spec.ts create mode 100644 packages/api/src/router/test/widgets/options.spec.ts create mode 100644 packages/custom-widgets/src/core/url-policy.ts create mode 100644 packages/custom-widgets/src/test/request-deadline.spec.ts create mode 100644 packages/db/migrations/mysql/0045_custom_widget_v1_compatibility.sql create mode 100644 packages/db/migrations/mysql/meta/0045_snapshot.json create mode 100644 packages/db/migrations/postgresql/0011_custom_widget_v1_compatibility.sql create mode 100644 packages/db/migrations/postgresql/meta/0011_snapshot.json create mode 100644 packages/db/migrations/sqlite/0043_custom_widget_v1_compatibility.sql create mode 100644 packages/db/migrations/sqlite/meta/0043_snapshot.json create mode 100644 packages/widgets/src/custom-api/widget-invalidation.spec.ts create mode 100644 packages/widgets/src/custom-api/widget-invalidation.ts create mode 100644 packages/workshop/src/pocketbase-url.spec.ts create mode 100644 packages/workshop/src/pocketbase-url.ts create mode 100644 packages/workshop/src/pocketbase-validator.ts create mode 100644 packages/workshop/src/tr46.d.ts create mode 100644 scripts/build-workshop-validator.mjs create mode 100644 scripts/ci/check-actions.sh create mode 100644 scripts/dev-workshop.mjs diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..b04edea28 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +reviews: + auto_review: + enabled: true + drafts: false + base_branches: + - "v2" diff --git a/.dockerignore b/.dockerignore index 521c55109..532d857ed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ coverage dev .build e2e -apps/docs apps/nextjs/.next .env .env.* diff --git a/.env.example b/.env.example index cec6e8c36..881cb42c6 100644 --- a/.env.example +++ b/.env.example @@ -61,9 +61,32 @@ TURBO_TELEMETRY_DISABLED=1 # Enable mock integration UNSAFE_ENABLE_MOCK_INTEGRATION=true -# Public Workshop API origin used by Homarr's Workshop browser. The default is https://homarr.dev. -# This is read when the server starts, including in the production Docker image. -# WORKSHOP_API_URL=http://127.0.0.1:3003 +# Public URL contract. Values must be absolute HTTP(S) URLs without credentials, +# query strings, or fragments. Trailing slashes are normalized at startup. +HOMARR_WEBSITE_URL=https://homarr.dev +# PocketBase API used by Homarr and by the standalone documentation site. +# Defaults to HOMARR_WEBSITE_URL. +# WORKSHOP_API_URL=https://homarr.dev +# Public Workshop page opened from Homarr. Defaults to HOMARR_WEBSITE_URL/workshop. +# WORKSHOP_WEB_URL=https://homarr.dev/workshop + +# Emergency switches preserve stored data. They are enabled by default. +CUSTOM_WIDGETS_ENABLED=true +WORKSHOP_ENABLED=true + +# Documentation and PocketBase deployment settings. +# DOCS_BASE_URL=/ +# WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev +# PB_ALLOWED_ORIGINS=* +# WORKSHOP_REQUIRE_OAUTH=false + +# Local Workshop stack (pnpm dev:workshop): +# HOMARR_WEBSITE_URL=http://127.0.0.1:3003 +# WORKSHOP_API_URL=http://127.0.0.1:8090 +# WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop +# WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003 +# Optional: GITHUB_CLIENT_ID= +# Optional: GITHUB_CLIENT_SECRET= # Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration) # DEMO_MODE=true diff --git a/.github/workflows/automatic-approval.yml b/.github/workflows/automatic-approval.yml index 9e246f838..8a28050f8 100644 --- a/.github/workflows/automatic-approval.yml +++ b/.github/workflows/automatic-approval.yml @@ -23,7 +23,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} permission-pull-requests: write # required to approve pull request - name: Install GitHub CLI diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57745bd94..bdc4078cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,24 @@ env: PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test jobs: + formatting: + name: Formatting + if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + if: "!env.ACT" + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + + - name: Setup + uses: ./tooling/github/setup + + - name: Check formatting + run: pnpm format + fast-gate: name: Fast gate if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') @@ -89,6 +107,9 @@ jobs: - name: Validate workspace configuration run: pnpm lint:ws + - name: Validate GitHub Actions + run: pnpm check:actions + - name: Validate Custom Widget architecture run: pnpm check:custom-widgets @@ -100,12 +121,63 @@ jobs: pnpm --filter nextjs build pnpm check:custom-widget-bundle + unit: + name: Full unit suite + if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + if: "!env.ACT" + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + + - name: Setup + uses: ./tooling/github/setup + + - name: Copy environment defaults + run: cp .env.example .env + - name: Run unit tests run: pnpm test + migration-matrix: + name: Database migrations (${{ matrix.database }}) + if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup') + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - database: SQLite + test_file: packages/db/test/sqlite-migration.spec.ts + - database: MySQL + test_file: packages/db/test/mysql-migration.spec.ts + - database: PostgreSQL + test_file: packages/db/test/postgresql-migration.spec.ts + steps: + - uses: actions/checkout@v7 + if: "!env.ACT" + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} + + - name: Setup + uses: ./tooling/github/setup + + - name: Copy environment defaults + run: cp .env.example .env + + - name: Run ${{ matrix.database }} migration tests + env: + MIGRATION_TEST_FILE: ${{ matrix.test_file }} + run: pnpm exec vitest run --project db-node "$MIGRATION_TEST_FILE" + container: name: Container and E2E - needs: fast-gate + needs: [formatting, fast-gate, unit, migration-matrix] if: | github.event_name == 'merge_group' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || @@ -238,11 +310,12 @@ jobs: run: echo "Act validated the publication path; external GHCR writes are disabled locally." preview-arm64: - name: Build arm64 preview image - needs: fast-gate + name: Arm64 image + needs: [formatting, fast-gate, unit, migration-matrix] if: | ( - (github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'merge_group' || + (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-24.04-arm @@ -260,7 +333,12 @@ jobs: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} - name: Log in to GHCR - if: "!env.ACT" + if: | + !env.ACT && + ( + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') + ) uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} @@ -273,7 +351,12 @@ jobs: - name: Build and push arm64 preview image by digest id: preview-build - if: "!env.ACT" + if: | + !env.ACT && + ( + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') + ) uses: docker/build-push-action@v7 with: context: . @@ -288,6 +371,27 @@ jobs: TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + - name: Build arm64 image without publication + if: | + !env.ACT && + ( + github.event_name == 'merge_group' || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) + ) + uses: docker/build-push-action@v7 + with: + context: . + network: host + platforms: linux/arm64 + outputs: type=cacheonly + cache-from: type=gha,scope=homarr-linux-arm64 + cache-to: type=gha,scope=homarr-linux-arm64,mode=max + secrets: | + TURBO_API=${{ secrets.TURBO_API }} + TURBO_TEAM=${{ secrets.TURBO_TEAM }} + TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + publish-preview: name: Publish multi-platform preview image needs: [container, preview-arm64] @@ -318,6 +422,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Verify PR head before publication + id: publication-preflight if: github.event_name == 'pull_request' uses: actions/github-script@v8 env: @@ -330,15 +435,21 @@ jobs: pull_number: context.payload.pull_request.number, }); if (pull.state !== "open") { - core.setFailed(`PR #${pull.number} is ${pull.state}; refusing to publish its preview.`); + core.notice(`PR #${pull.number} is ${pull.state}; preview publication is no longer needed.`); + core.setOutput("publish", "false"); return; } if (pull.head.sha !== process.env.EXPECTED_HEAD_SHA) { - core.setFailed(`PR #${pull.number} moved from ${process.env.EXPECTED_HEAD_SHA} to ${pull.head.sha}.`); + core.notice(`PR #${pull.number} moved to ${pull.head.sha}; a newer run owns preview publication.`); + core.setOutput("publish", "false"); + return; } + core.setOutput("publish", "true"); - name: Publish preview manifest - if: "!env.ACT" + if: | + !env.ACT && + (github.event_name != 'pull_request' || steps.publication-preflight.outputs.publish == 'true') env: AMD64_DIGEST: ${{ needs.container.outputs.digest }} ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} @@ -348,7 +459,9 @@ jobs: "${PREVIEW_IMAGE}@${ARM64_DIGEST}" - name: Verify preview manifest platforms - if: "!env.ACT" + if: | + !env.ACT && + (github.event_name != 'pull_request' || steps.publication-preflight.outputs.publish == 'true') run: | image="${PREVIEW_IMAGE}:${PREVIEW_TAG}" manifest="$(docker buildx imagetools inspect "$image" --raw)" @@ -383,24 +496,42 @@ jobs: const packageName = "homarr-test"; const org = context.repo.owner; const tag = process.env.IMAGE_TAG; - const versions = await github.paginate( - github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, - { package_type: "container", package_name: packageName, org, per_page: 100 }, - ); - const target = versions.find((version) => - version.metadata?.container?.tags?.includes(tag), - ); - if (!target) { - core.info(`No package version found with tag "${tag}"`); - return; + const attempts = context.eventName === "pull_request_target" ? 7 : 1; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + let versions; + try { + versions = await github.paginate( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, + { package_type: "container", package_name: packageName, org, per_page: 100 }, + ); + } catch (error) { + if (error.status === 404) { + core.info(`Package "${packageName}" does not exist; cleanup is already complete.`); + return; + } + throw error; + } + const targets = versions.filter((version) => + version.metadata?.container?.tags?.includes(tag), + ); + if (targets.length > 0) { + for (const target of targets) { + await github.rest.packages.deletePackageVersionForOrg({ + package_type: "container", + package_name: packageName, + org, + package_version_id: target.id, + }); + core.info(`Deleted package version ${target.id} (${tag})`); + } + return; + } + if (attempt < attempts) { + core.info(`No "${tag}" version found yet; retrying cleanup after publication settles.`); + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } } - await github.rest.packages.deletePackageVersionForOrg({ - package_type: "container", - package_name: packageName, - org, - package_version_id: target.id, - }); - core.info(`Deleted package version ${target.id} (${tag})`); + core.info(`No package version found with tag "${tag}"; cleanup is already complete.`); - name: Show Act cleanup result if: env.ACT diff --git a/.github/workflows/crowdin-schedule-download.yml b/.github/workflows/crowdin-schedule-download.yml index 22cb2b943..7b783f4e5 100644 --- a/.github/workflows/crowdin-schedule-download.yml +++ b/.github/workflows/crowdin-schedule-download.yml @@ -22,7 +22,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.CROWDIN_APP_PRIVATE_KEY }} - client-id: ${{ secrets.CROWDIN_APP_ID }} + app-id: ${{ secrets.CROWDIN_APP_ID }} permission-contents: write # required to commit to crowdin branch permission-pull-requests: write # required to create pull request diff --git a/.github/workflows/deployment-beta-release.yml b/.github/workflows/deployment-beta-release.yml index ba3bde3a2..0f0725bef 100644 --- a/.github/workflows/deployment-beta-release.yml +++ b/.github/workflows/deployment-beta-release.yml @@ -52,7 +52,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} permission-pull-requests: write - name: Approve PR env: @@ -64,7 +64,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # write to beta branch (due to merge) permission-pull-requests: write # merge pull request - id: automerge diff --git a/.github/workflows/deployment-docker-image.yml b/.github/workflows/deployment-docker-image.yml index 9ea0c2a09..3acae9b6a 100644 --- a/.github/workflows/deployment-docker-image.yml +++ b/.github/workflows/deployment-docker-image.yml @@ -54,7 +54,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release - uses: actions/checkout@v7 @@ -246,7 +246,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # required to publish the release - name: Log in to the Container registry diff --git a/.github/workflows/deployment-docs.yml b/.github/workflows/deployment-docs.yml index eeb53d101..d67047c9b 100644 --- a/.github/workflows/deployment-docs.yml +++ b/.github/workflows/deployment-docs.yml @@ -1,56 +1,134 @@ -name: "[Deployment] Documentation" +name: "[Publish] Documentation and Workshop" on: push: - branches: ["dev"] + branches: ["v2"] paths: - "apps/docs/**" - - "packages/definitions/src/docs/**" + - "apps/workshop/**" + - "packages/custom-widgets/**" + - "packages/workshop/**" + - "packages/definitions/**" + - "tooling/typescript/**" + - "patches/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - ".dockerignore" - ".github/workflows/deployment-docs.yml" workflow_dispatch: permissions: contents: read - pages: write + packages: write id-token: write concurrency: - group: "pages" - cancel-in-progress: false + group: workshop-image-${{ github.ref }} + cancel-in-progress: true env: - SKIP_ENV_VALIDATION: true + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/homarr-labs/homarr-workshop TURBO_API: ${{ secrets.TURBO_API }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} jobs: - build: + publish: + name: Publish immutable multi-platform image runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 45 + outputs: + digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v7 - - name: Setup - uses: ./tooling/github/setup - - name: Build docs - run: pnpm turbo build --filter=@homarr/docs - env: - CI: "true" - - name: Setup Pages - uses: actions/configure-pages@v6 - - name: Upload pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: "apps/docs/build" - deploy: - runs-on: ubuntu-latest - needs: build - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish by immutable commit tag + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: apps/workshop/Dockerfile + target: production + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + provenance: mode=max + sbom: true + cache-from: type=gha,scope=workshop-production + cache-to: type=gha,scope=workshop-production,mode=max + secrets: | + TURBO_API=${{ secrets.TURBO_API }} + TURBO_TEAM=${{ secrets.TURBO_TEAM }} + TURBO_TOKEN=${{ secrets.TURBO_TOKEN }} + TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + + - name: Smoke-test the published digest + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + manifest="$(docker buildx imagetools inspect "${IMAGE_NAME}@${IMAGE_DIGEST}" --raw)" + for architecture in amd64 arm64; do + jq --exit-status --arg architecture "$architecture" \ + 'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \ + <<<"$manifest" >/dev/null + done + docker run --detach --name workshop-smoke --platform linux/amd64 \ + --publish 127.0.0.1:18090:8090 \ + --env HOMARR_WEBSITE_URL=http://127.0.0.1:18090 \ + --env WORKSHOP_API_URL=http://127.0.0.1:18090 \ + --env WORKSHOP_WEB_URL=http://127.0.0.1:18090/workshop \ + --env WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:18090 \ + --env WORKSHOP_REQUIRE_OAUTH=false \ + "${IMAGE_NAME}@${IMAGE_DIGEST}" + for attempt in $(seq 1 60); do + if curl --fail --silent http://127.0.0.1:18090/api/health >/dev/null; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs workshop-smoke + exit 1 + fi + sleep 1 + done + curl --fail --silent --show-error http://127.0.0.1:18090/ >/dev/null + curl --location --fail --silent --show-error http://127.0.0.1:18090/workshop >/dev/null + curl --fail --silent --show-error \ + 'http://127.0.0.1:18090/api/collections/workshop_listings/records?page=1&perPage=1' >/dev/null + curl --fail --silent --show-error http://127.0.0.1:18090/workshop-runtime-config.js \ + | grep --fixed-strings 'workshopApiUrl: "http://127.0.0.1:18090"' + + - name: Stop smoke container + if: always() + run: docker rm --force workshop-smoke >/dev/null 2>&1 || true + + - name: Record immutable promotion input + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + { + echo "### Workshop image candidate" + echo + echo "\`${IMAGE_NAME}@${IMAGE_DIGEST}\`" + echo + echo "This workflow publishes but does not deploy. Promote this exact digest only after staging acceptance and rollback rehearsal." + } >>"$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/deployment-weekly-release.yml b/.github/workflows/deployment-weekly-release.yml index cafa7b30d..a35235308 100644 --- a/.github/workflows/deployment-weekly-release.yml +++ b/.github/workflows/deployment-weekly-release.yml @@ -55,7 +55,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_APPROVE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} + app-id: ${{ secrets.RENOVATE_APPROVE_APP_ID }} permission-pull-requests: write - name: Approve PR env: @@ -67,7 +67,7 @@ jobs: uses: actions/create-github-app-token@v3 with: private-key: ${{ secrets.RENOVATE_MERGE_PRIVATE_KEY }} - client-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} + app-id: ${{ secrets.RENOVATE_MERGE_APP_ID }} permission-contents: write # write to main branch (due to merge) permission-pull-requests: write # merge pull request - id: automerge diff --git a/.github/workflows/docs-algolia-recrawl.yml b/.github/workflows/docs-algolia-recrawl.yml index 1d8bdd42d..3764eab74 100644 --- a/.github/workflows/docs-algolia-recrawl.yml +++ b/.github/workflows/docs-algolia-recrawl.yml @@ -1,10 +1,9 @@ name: "[Docs] Algolia Recrawl" +# Automatic recrawls are intentionally disabled until the external homarr.dev +# host can dispatch this workflow after promoting an immutable Workshop image. +# Publishing an image to GHCR does not make its documentation live. on: - workflow_run: - workflows: ["[Deployment] Documentation"] - types: [completed] - branches: [dev] workflow_dispatch: permissions: @@ -12,11 +11,6 @@ permissions: jobs: recrawl: - if: >- - ${{ - (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'pull_request') - || github.event_name == 'workflow_dispatch' - }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -43,7 +37,7 @@ jobs: ALGOLIA_CRAWLER_USER_ID: ${{ secrets.ALGOLIA_CRAWLER_USER_ID }} ALGOLIA_CRAWLER_API_KEY: ${{ secrets.ALGOLIA_CRAWLER_API_KEY }} - - name: Wait for GitHub Pages to serve the new deployment + - name: Allow the promoted documentation host to settle run: sleep 60 - name: Trigger Algolia DocSearch recrawl diff --git a/.github/workflows/on-release.yml b/.github/workflows/on-release.yml index 644f4a74d..80a94bd1f 100644 --- a/.github/workflows/on-release.yml +++ b/.github/workflows/on-release.yml @@ -5,39 +5,6 @@ on: types: [released] jobs: - trigger-docs-release: - name: Trigger Documentation Release - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Obtain token - id: obtainToken - uses: actions/create-github-app-token@v3 - with: - private-key: ${{ secrets.HOMARR_DOCS_RELEASE_APP_PRIVATE_KEY }} - client-id: ${{ vars.HOMARR_DOCS_RELEASE_APP_ID }} - owner: homarr-labs - repositories: | - documentation - permission-contents: write # required to dispatch repository workflow - - name: Trigger documentation release - env: - GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} - SOURCE_TAG: ${{ github.event.release.tag_name }} - run: | - curl -X POST \ - -H "Authorization: token $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - https://api.github.com/repos/homarr-labs/documentation/dispatches \ - -d @- </dev/null; then + break + fi + if [ "$attempt" -eq 60 ]; then + docker logs workshop-base-path-smoke + exit 1 + fi + sleep 1 + done + curl --fail --silent --show-error http://127.0.0.1:18093/docs-site/ \ + | grep --fixed-strings '/docs-site/assets/' + curl --location --fail --silent --show-error http://127.0.0.1:18093/docs-site/workshop/ \ + | grep --fixed-strings '/docs-site/workshop/' + curl --fail --silent --show-error \ + http://127.0.0.1:18093/docs-site/workshop-runtime-config.js \ + | grep --fixed-strings 'workshopWebUrl: "http://127.0.0.1:18093/docs-site/workshop"' + curl --fail --silent --show-error \ + 'http://127.0.0.1:18093/api/collections/workshop_listings/records?page=1&perPage=1' >/dev/null + - name: Stop non-root documentation smoke container + if: always() + run: docker rm --force workshop-base-path-smoke >/dev/null 2>&1 || true diff --git a/Dockerfile b/Dockerfile index 8f4c5e49a..40ba325ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \ --mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \ - TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build + TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm turbo build --filter='!@homarr/docs' FROM base AS runner WORKDIR /app diff --git a/README.md b/README.md new file mode 100644 index 000000000..f535383ad --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Homarr development + +This repository contains the Homarr application, task and WebSocket services, the Docusaurus documentation site, and +the PocketBase-backed Community Workshop. + +## Prerequisites + +- Node.js 24.18 or newer +- Corepack with pnpm 11.11 +- Docker with Docker Compose +- Git + +Enable the repository's pnpm version and install dependencies: + +```sh +corepack enable +corepack install +pnpm install --frozen-lockfile +``` + +## Run Homarr locally + +Copy the environment template and choose a writable absolute SQLite path: + +```sh +cp .env.example .env +``` + +Keep the development infrastructure running in the first terminal: + +```sh +pnpm docker:dev +``` + +Then apply migrations and start Homarr in a second terminal: + +```sh +pnpm db:migration:sqlite:run +pnpm dev +``` + +Homarr is available at `http://127.0.0.1:3000`. The foreground infrastructure command starts Redis and the optional +database services from `development/development.docker-compose.yml`; stop it with Ctrl+C after stopping Homarr. + +## Run PocketBase and documentation locally + +The integrated command starts persistent PocketBase on port 8090, waits for its health endpoint and Workshop +collections, then starts Docusaurus on port 3003: + +```sh +pnpm dev:workshop +``` + +Open the PocketBase administration UI at `http://127.0.0.1:8090/_/` to create the first superuser. Open the Workshop at +`http://127.0.0.1:3003/workshop`. + +To run Homarr against this local stack, set these values in `.env` and restart `pnpm dev`: + +```dotenv +HOMARR_WEBSITE_URL=http://127.0.0.1:3003 +WORKSHOP_API_URL=http://127.0.0.1:8090 +WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop +``` + +Stopping `pnpm dev:workshop` stops the services but retains the `homarr-workshop_pb_data` volume. See +[`apps/workshop/README.md`](apps/workshop/README.md) for OAuth, moderator access, logs, backup, restore, and intentional +reset instructions. + +## Common checks + +```sh +pnpm format +pnpm lint +pnpm typecheck +pnpm test +pnpm test:workshop +pnpm turbo build --filter=@homarr/docs +``` + +Use `pnpm format:fix` and `pnpm lint:fix` to apply mechanical fixes. User-facing code changes must include the matching +documentation under `apps/docs/docs/`. + +## Repository map + +- `apps/nextjs` — main Homarr web application +- `apps/tasks` — scheduled task runner and API +- `apps/websocket` — WebSocket server +- `apps/docs` — Docusaurus documentation and public Workshop UI +- `apps/workshop` — PocketBase image, migrations, hooks, and integration tests +- `packages` — shared application, API, database, UI, widget, and integration packages +- `development` — local Docker infrastructure +- `e2e` — end-to-end tests diff --git a/apps/docs/README.md b/apps/docs/README.md index f704c92f0..01eaf6842 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -21,8 +21,14 @@ pnpm dev:docs # Or from within this directory pnpm dev + +# PocketBase on 8090 plus docs on 3003 +pnpm dev:workshop ``` +The standalone docs use `WORKSHOP_API_URL` for PocketBase, `HOMARR_WEBSITE_URL` for the public site origin, and +`DOCS_BASE_URL` for the deployment path. The old `WORKSHOP_URL` name is a deprecated one-release alias. + ## Build ```bash @@ -40,7 +46,7 @@ Uses oxlint and oxfmt (same as the rest of the monorepo): ```bash pnpm lint # oxlint pnpm format # oxfmt (check) -pnpm format -- --write # oxfmt (fix) +pnpm format:fix # oxfmt (fix) ``` ## Typecheck @@ -93,7 +99,7 @@ pnpm verify:search | Layer | Technology | | -------------- | ------------------------------------------- | | Site generator | Docusaurus 3 (`@docusaurus/preset-classic`) | -| Styling | Tailwind CSS 3 + Docusaurus Infima | +| Styling | Tailwind CSS 4 + Docusaurus Infima | | Icons | `@tabler/icons-react` | | Diagrams | Mermaid (`@docusaurus/theme-mermaid`) | | Search | Algolia DocSearch | diff --git a/apps/docs/docs/advanced/development/getting-started.mdx b/apps/docs/docs/advanced/development/getting-started.mdx index fb2cb9560..e18559dc4 100644 --- a/apps/docs/docs/advanced/development/getting-started.mdx +++ b/apps/docs/docs/advanced/development/getting-started.mdx @@ -8,62 +8,102 @@ tags: sidebar_position: 1 --- -## Setting up your development environment - -This guide will help you set up your development environment to start contributing to homarr. +## Set up your development environment ### Prerequisites -- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/). -- **pnpm (latest)**: You can install it by running `npm install -g pnpm`. -- **Git**: You can download it from the [official website](https://git-scm.com/). -- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/). -- **A GitHub account**: You can create one on the [official website](https://github.com/). +- Node.js 24.18 or newer +- Corepack and pnpm 11.11 +- Git +- Docker with Docker Compose -### Setting up the project +The versions in the root `package.json` are the supported baseline. Let Corepack select the repository version instead +of installing an unrelated global pnpm: -1. Fork the repository by clicking on the "Fork" button on the top right corner of the repository page. +```sh +corepack enable +corepack install +pnpm install --frozen-lockfile +``` -![Fork button](./img/fork.png) +### Run the Homarr application -2. Clone the repository to your local machine with `git clone`. You can find the url to use by clicking on the "Code" button on the top right corner of your forks repository page. +1. Copy `.env.example` to `.env`. +2. Set `DB_URL` to an absolute writable SQLite file path. +3. Keep Redis and the optional local databases running in one terminal. +4. Apply migrations and start Next.js in another terminal. -![Clone button](./img/clone.png) +```sh +cp .env.example .env +``` -3. Change directory to the project folder with `cd homarr`. +Terminal 1: -4. Install the dependencies with `pnpm install`. +```sh +pnpm docker:dev +``` -5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service. +Terminal 2: -6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'` +```sh +pnpm db:migration:sqlite:run +pnpm dev +``` -7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`. +The infrastructure command remains in the foreground. Open `http://127.0.0.1:3000`; the first user created through +Homarr becomes the instance administrator. -8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s` +### Run PocketBase and documentation -9. Open your browser and navigate to [http://localhost:3000](http://localhost:3000). (Loading the page might take up to 2 minutes the first time) +Use the integrated local Workshop command in a second terminal: -### Useful npm scripts +```sh +pnpm dev:workshop +``` -#### During development +It starts persistent PocketBase on port 8090, waits for health and the required collections, then starts Docusaurus on +port 3003. It also propagates shutdown without deleting the PocketBase volume. -- `pnpm dev`: Start the development server. -- `pnpm cli`: Run cli commands. -- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file. -- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`. -- `pnpm db:studio`: Open a visual studio for the database. -- `pnpm package:new`: Create a new package in the monorepo. +Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at +`http://127.0.0.1:3003/workshop`. -#### Checking code quality +Point Homarr at the local services with: -- `pnpm lint`: Run the linter to check for code quality issues. -- `pnpm format:fix`: Run the formatter to fix code style issues. -- `pnpm test`: Run the tests. You can also use `pnpm test:ui` to get a UI for the tests. -- `pnpm typecheck`: Run the typescript type checker. +```dotenv +HOMARR_WEBSITE_URL=http://127.0.0.1:3003 +WORKSHOP_API_URL=http://127.0.0.1:8090 +WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop +``` -#### Building the project +Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See +[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth, +Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset. -- `pnpm build`: Build the project for production. -- `docker build -t homarr .`: Build a docker image for the project. -- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image. +### URL and base-path testing + +Docusaurus reads `HOMARR_WEBSITE_URL`, `WORKSHOP_API_URL`, `WORKSHOP_WEB_URL`, and `DOCS_BASE_URL` at startup/build. +Homarr renders its public URL values into the page at server startup, so Docker overrides do not rely on +`NEXT_PUBLIC_*` build variables. Test a separate API origin and non-root documentation path before release: + +```sh +HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \ +WORKSHOP_API_URL=http://127.0.0.1:8090 \ +WORKSHOP_WEB_URL=http://127.0.0.1:3003/docs-site/workshop \ +DOCS_BASE_URL=/docs-site/ \ +pnpm dev:docs +``` + +### Useful commands + +- `pnpm dev` — Next.js on port 3000. +- `pnpm dev:docs` — Docusaurus only on port 3003. +- `pnpm dev:workshop` — persistent PocketBase plus Docusaurus. +- `pnpm cli` — Homarr operations CLI. +- `pnpm db:migration:sqlite:run` — create or update a SQLite database. +- `pnpm db:studio` — inspect the database. +- `pnpm format` / `pnpm format:fix` — check or apply oxfmt. +- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint. +- `pnpm typecheck` — typecheck all workspaces. +- `pnpm test` — unit and contract suites. +- `pnpm test:workshop` — disposable Workshop integration suite. +- `pnpm build` — production monorepo build. diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index d45a19472..2f682f09d 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -25,13 +25,18 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation. ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- | -| `PUID` | User ID to run the container as | Any valid user ID | `0` | -| `PGID` | Group ID to run the container as | Any valid group ID | `0` | -| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | -| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` | -| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | +| Environment Variable | Description | Possible values | Default | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- | +| `PUID` | User ID to run the container as | Any valid user ID | `0` | +| `PGID` | Group ID to run the container as | Any valid group ID | `0` | +| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | +| `NO_EXTERNAL_CONNECTION` | Disables some requests that need internet connection | `true` or `false` | `false` | +| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | +| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` | +| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` | +| `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` | +| `CUSTOM_WIDGETS_ENABLED` | Emergency switch. `false` preserves definitions and placements but blocks Custom Widget authoring and execution. | `true` or `false` | `true` | +| `WORKSHOP_ENABLED` | Emergency switch. `false` hides and blocks Workshop operations without disabling installed widgets. | `true` or `false` | `true` | ## Authentication @@ -105,7 +110,14 @@ External redis is currently not supported with Proxmox Community Scripts The advanced deployments environment variables should only be used if you know what you are doing. ::: -| Environment Variable | Description | -| ------------------------ | --------------------------------------------------------------------------------------------------------------- | -| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts | -| `WORKSHOP_API_URL` | Runtime public origin of the separate Workshop PocketBase service. Homarr uses `https://homarr.dev` by default. | +| Environment Variable | Description | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. | +| `DOCS_BASE_URL` | Docusaurus deployment path. It must start with `/` and is normalized with a trailing slash. Default: `/`. | +| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. | +| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. | +| `WORKSHOP_REQUIRE_OAUTH` | Fail Workshop production readiness when GitHub OAuth is disabled. Both GitHub OAuth credentials are always required together. | + +All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize +trailing slashes at startup. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for +`WORKSHOP_API_URL` for one release. `WORKSHOP_API_URL` takes precedence. diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index 1f3129b4e..2aa7562d8 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -4,6 +4,9 @@ An external agent creates one widget at a time with the Homarr MCP server. The a Homarr does not create widget packs or drafts for multiple widgets. +The MCP API key must belong to a Homarr administrator. Non-administrator keys do not list Custom Widget tools, prompts, +or resources. The same administrator requirement applies to the matching HTTP authoring endpoints. + View the Custom Widget Skill on skills.sh @@ -67,7 +70,10 @@ These interfaces do not return an archive URL or archive hash. 12. Update the saved definition if the final verification finds a problem. 13. Repeat the procedure for the next widget. -Each source-configuration URL applies to one definition or preview and one source. The user confirms the server URL, network scope, and required credential fields directly in Homarr. The agent receives only request status and never receives plaintext credentials. Preview-scoped values expire with that preview; definition-scoped values persist. +Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and +can only be opened and completed by an authenticated Homarr administrator. The administrator confirms the server URL, +network scope, and required credential fields directly in Homarr. The agent receives only request status and never +receives plaintext credentials. Preview-scoped values expire with that preview; definition-scoped values persist. Agents can install existing widgets without regenerating them: diff --git a/apps/docs/docs/management/custom-widgets/custom-jsx.mdx b/apps/docs/docs/management/custom-widgets/custom-jsx.mdx index bb59f114d..a8bb3bd85 100644 --- a/apps/docs/docs/management/custom-widgets/custom-jsx.mdx +++ b/apps/docs/docs/management/custom-widgets/custom-jsx.mdx @@ -38,7 +38,8 @@ Bounded regex literals are supported for string matching, splitting, search, and - `SubFetch` runs a manual query with its `params`. Set `triggerContent` to a card or image to make that content the accessible click and keyboard trigger instead of showing the default load button. - `ActionButton` and `ToggleSwitch` run actions. -- `RefreshButton` renders a compact, accessible icon control that refreshes load queries. +- `RefreshButton` renders a compact, accessible icon control that refreshes load queries for authenticated viewers. It + is disabled for anonymous public-board viewers because refreshing invalidates shared server cache state. - `Icon`/`TablerIcon` resolves installed safe icons. Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile. diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index d58b73162..eb96c949e 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -49,6 +49,21 @@ interface HomarrCustomWidgetV2 { The browser renders JSX. Homarr's server executes named requests, applies board permissions and network protections, and injects credentials. The browser never receives source credentials. +## Permissions + +Custom Widget management is an administrator-only surface. Only a Homarr administrator can create, import, migrate, +preview, publish, install, configure, or write secrets, including through HTTP authoring resources or MCP. An API key +only lists Custom Widget MCP tools, prompts, and resources when its owner is an administrator. + +Adding or configuring a Custom Widget placement requires both administrator access and permission to modify that +board. A non-administrator with board-modify access can move, resize, change presentation settings, or remove an +existing placement, but cannot add one, change its definition, refresh interval, or Custom Widget options. Duplicating +a board that contains Custom Widgets also requires an administrator. + +Once an administrator has placed a widget, ordinary board permissions apply to rendering and requests. Authorized +non-administrators can use it on private boards, and queries can render for anonymous viewers on public boards. +Mutations remain authenticated and enforce each request's view, modify, or full permission. + ## Authoring paths - [Create a Custom Widget](./authoring) visually or with one copied AI prompt. @@ -60,9 +75,23 @@ The browser renders JSX. Homarr's server executes named requests, applies board The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected. -Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the legacy format remain visible with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. Homarr keeps the legacy record until the replacement validates and is confirmed; credentials are never included in the prompt. +Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the v1 format remain +visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the +redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. The prompt keeps safe request +metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain hard-coded +credentials. Add a credential-free description of the widget's visible layout and behavior when sending the prompt. -When adding widgets while editing a board, the Custom Widgets section always includes **Install from Workshop**. The Workshop and API source review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile waits for the board to be saved before server requests run and loads automatically after a successful save; no page reload is required. +Homarr keeps the original v1 definition and encrypted secret as a compatibility backup even after the validated v2 +replacement is created. The v2 record uses the same stable ID and takes precedence. The archived v1 copy stays hidden +while the replacement exists; deleting the v2 record surfaces the v1 entry as migration-required again. Automatic +legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts, +logs, or exports. + +For administrators adding widgets while editing a board, the Custom Widgets section includes **Install from +Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. The Workshop and API source +review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile +waits for the board to be saved before server requests run and loads automatically after a successful save; no page +reload is required. ## Import and Workshop @@ -70,10 +99,26 @@ Import review shows origins, authentication modes, network scopes, methods, acti For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work. -Workshop publishes technically valid widgets immediately; users can vote or report them, and administrators can delete submissions. Publishers should replace personal private-network hosts with a clear service placeholder before publishing. +Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can +delete submissions. Community moderation is separate from Homarr administration and is not a security boundary. +Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr +performs definitive validation again at install time. Connected agents can search, inspect, and install Workshop widgets with `customWidget_workshopSearch`, `customWidget_workshopGet`, and `customWidget_workshopInstall`. After installation they can set a source URL and credentials with `customWidget_sourceConfigure`, or create a secure user-facing setup URL with `customWidget_configurationRequestUser`. Secret values are never returned through MCP. +These tools are only exposed to API keys owned by Homarr administrators. Opening and completing a source-configuration +URL also requires the same authenticated administrator; the URL is short-lived, single-use, and redacted. + +## Reliability and rollback + +Custom Widget response caches and action invalidation use shared Redis state, so multiple Homarr replicas see the same +versioned cache generation. Deployments must point every replica at the same Redis service. + +Set `CUSTOM_WIDGETS_ENABLED=false` to stop authoring and runtime execution while preserving definitions, secrets, board +placements, and v1 backups. Re-enable it after correcting the incident. Before upgrading, back up the database and +`SECRET_ENCRYPTION_KEY`; a v1 binary can still read preserved v1 tables during application rollback. Widgets created +only in v2 may be unavailable to the rolled-back binary, but do not replace the v1 backup. + ## Bundled examples Homarr seeds five disabled examples without credentials and never overwrites an edited seed. They use the same public contract. The Tautulli example is the highest-scoring widget preserved from Homarr's AI authoring evaluation: diff --git a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx index cc784cbe9..1cae3ced1 100644 --- a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx +++ b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx @@ -12,7 +12,9 @@ Requests are keyed by ID. `source`, `kind`, `method`, `trigger`, `auth`, and `pe The request `path` is appended to the source `baseUrl`, including a base path such as `/api/v2`. -GET, POST, PUT, PATCH, and DELETE work for queries and actions. Kind—not method—controls behavior. Load queries run automatically, manual queries run through `SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`. +GET, POST, PUT, and PATCH work for queries and actions. DELETE is restricted to actions so it can never run while a +board loads. Kind controls the remaining behavior: load queries run automatically, manual queries run through +`SubFetch`, and actions run only from `ActionButton` or `ToggleSwitch`. Bind values directly where they are used: @@ -40,3 +42,12 @@ Paths and query-string values must resolve to text, numbers, or booleans. JSON b Every request uses Homarr's existing server executor: same-origin source enforcement, network scope, DNS and redirect validation, SSRF protection, board permissions, rate and request limits, body and response limits, timeouts, and encrypted credential injection. Static headers cannot override credentials, cookies, routing, proxy, forwarding, or hop-by-hop headers. Queries default to view permission and actions to modify. DELETE always requires full permission and confirmation. Actions are never cached, retried, or run on load. + +Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and +configures the widget. After placement, query and action authorization follows the request's board permission. Public +boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache +invalidation: `RefreshButton` is disabled for anonymous viewers, while ordinary scheduled query rendering remains +available. + +Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every +Homarr replica stops serving the previous response without scanning or deleting a key prefix. diff --git a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx index 544654718..9a8b11422 100644 --- a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx +++ b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx @@ -4,7 +4,15 @@ The definition does not exist. If it was deleted, remove the unavailable tile from the board. -If the Custom Widgets page shows the definition with a **Migration required** badge, open its action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON from the same menu. Homarr keeps the legacy definition until the replacement validates and you confirm the migration. +If the Custom Widgets page shows the definition with a **Migration required** badge, an administrator can open its +action menu and select **Copy migration prompt**. Give that redacted prompt to an LLM, then import the resulting v2 JSON +from the same menu. Because arbitrary v1 display text or JSX may contain credentials, the prompt omits that display +configuration; describe the old widget's visible layout and behavior without including secrets. Other viewers see +**Contact an administrator** instead of a migration link. + +Homarr keeps the v1 definition and encrypted secret after migration. The validated v2 replacement uses the same ID and +takes precedence; the archived v1 record reappears if the v2 replacement is deleted. There is no v1 permanent-delete +action during the v2 stabilization period. Otherwise, install or create a Custom JSX v2 definition. @@ -34,6 +42,15 @@ An invalid definition disables widget preview and Save/Create. The Data, Options The Diagnostics tab shows the current schema errors. +## Feature unavailable + +If every Custom Widget reports that the feature is unavailable, check `CUSTOM_WIDGETS_ENABLED`. Setting it to `false` +is an emergency stop that preserves definitions, secrets, legacy backups, and board placements while blocking authoring +and execution. + +If Workshop alone is unavailable, check `WORKSHOP_ENABLED` and `WORKSHOP_API_URL`. Disabling Workshop does not disable +widgets that are already installed. + ## Correct request errors Check the API source network scope. Check the base URL and credentials. @@ -44,6 +61,9 @@ For “invalid placeholder,” use `{option:name}` or `{param:name}`. Load queri The journal shows the method, request ID, path template, status, and duration. +In a multi-instance deployment, all Homarr replicas must share Redis. Versioned cache invalidation is distributed +through Redis; replicas connected to different Redis databases can serve stale query responses after an action. + ## Correct a Mantine prop warning `UNKNOWN_MANTINE_PROP` is a warning. Homarr passes the prop to Mantine. @@ -57,3 +77,10 @@ Use safe widget options, temporary inputs, or named requests instead. The widget option definitions changed and the saved values are invalid. Open the item settings. Correct the marked configuration values. Homarr does not infer widget option migrations. + +## Roll back safely + +Before an application upgrade, back up the database and preserve the exact `SECRET_ENCRYPTION_KEY`. The v2 schema keeps +the physical v1 definition and secret tables readable by a v1 binary. Roll back the application without running +manual cleanup, verify legacy widgets, and restore the database backup if the migration itself must be reversed. +PocketBase Workshop data is a separate backup and restore operation. diff --git a/apps/docs/docs/management/mcp.mdx b/apps/docs/docs/management/mcp.mdx index 35a17b52b..34a5ac61a 100644 --- a/apps/docs/docs/management/mcp.mdx +++ b/apps/docs/docs/management/mcp.mdx @@ -92,6 +92,10 @@ MCP supports two authentication methods: API key authentication is simpler and recommended for most setups. The API key inherits the permissions of the user who created it. Tools that require higher privileges will return permission errors if the key lacks access. +Custom Widget and Workshop authoring are different: their tools, prompts, and resources are exposed only when the API +key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an +API key never elevates the owner. + ## Testing the Connection Verify your MCP endpoint is working: @@ -168,6 +172,9 @@ The tools table on the MCP tab is dynamically generated - it always reflects the An agent creates one `homarr-custom-widget-v2` definition at a time. Start with the native prompt `homarr-custom-widget-author`. +The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview, +secrets, publication, Workshop installation, and the HTTP authoring resources. + Then, read the live schema and component resources. These resources describe the installed Mantine components. 1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`. @@ -194,7 +201,8 @@ Discovery resources: - `homarr://custom-widgets/examples/{name}` - `homarr://custom-widgets/skill` -An HTTP-only harness can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`, and `/skill` endpoints instead of MCP resource URIs. +An authenticated administrator can use the matching `/api/custom-widgets/authoring-prompt`, `/schema`, `/components`, +and `/skill` endpoints instead of MCP resource URIs. These endpoints are not public schema downloads. The Custom Widget tools are: @@ -219,7 +227,13 @@ The Custom Widget tools are: - `customWidget_getAuthoringPrompt` - `customWidget_getSkill` -The `custom-widget-manage` permission permits authoring. A direct credential write also requires the `custom-widget-secret-write` permission. +Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete +authoring surface. A source-configuration URL is short-lived and single-use, requires the same logged-in administrator +to open and complete it, and never returns plaintext credentials to the agent. + +Adding a `customApi` item through `board_addItem`, changing its definition or options, and duplicating a board that +contains one also require an administrator with board-modify access. Existing placements continue to render and run +authorized requests according to normal board permissions. Homarr applies its request security controls during request execution. These controls include SSRF, DNS, redirect, size, timeout, rate, and permission controls. diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 6170a02eb..a7181a1f5 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -177,6 +177,11 @@ Currently, the following permissions are available: - **Full integration access**: Allow members to manage, use and interact with any integration - **Administrator**: Members with this permission have full access to all features and settings +Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved +for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change +presentation settings, or remove an existing Custom Widget placement, and board viewers can use placed widgets as +allowed by each request. + When you changed permissions, you must confirm your changes by clicking the banner at the bottom: ![](./img/changing-group-permissions.png) diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 887430e1e..77f4d89ca 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -17,9 +17,14 @@ The [Community Workshop](pathname:///workshop) is where Homarr users share and d **custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones you like, report abuse, and install entries directly into your own Homarr instance. +The central `homarr.dev` Workshop is a public community. Browsing it on the website is public. Browsing, installing, +publishing, or importing Workshop content **inside a Homarr instance is restricted to Homarr administrators**. + :::warning -The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures. +The Workshop contains community-made content. Moderation is not a security boundary. Always review widget capabilities, +network origins, actions, and CSS before installing. Homarr's installation-time validator is definitive and can reject +content that the public service accepted earlier. Back up your instance before installing untrusted content. ::: @@ -29,12 +34,12 @@ The workshop contains community-made content. Widgets may contain unwanted or ma - Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission. - Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation. - A new submission receives one real upvote owned by its author. Votes remain unique per user and submission. -- Report details and reporter identities are visible only to Workshop administrators. Reported submissions are highlighted in the administration page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission. +- Report details and reporter identities are visible only to Workshop moderators. Reported submissions are highlighted in the moderation page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission. - The manual **Outdated** badge warns visitors that a submission may need an update. ## Comments -Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop administrators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail. +Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop moderators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail. ## Submitting @@ -49,7 +54,14 @@ Comments are publicly readable and appear in chronological conversation bubbles Submissions are published immediately. Reported entries are reviewed and may be removed. Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL. -When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop administrator receives an email when a report is submitted. If an administrator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email. +When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop moderator receives an email when a report is submitted. If a moderator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email. + +:::info Separate roles + +A Workshop moderator is a user with PocketBase's internal `isAdmin` field. This role moderates the central community +and is unrelated to a Homarr instance administrator. + +::: ## Submission guidelines @@ -61,9 +73,9 @@ that primarily promotes unrelated products or services. You can install workshop entries without leaving Homarr: -- **Custom widgets** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the +- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Install from Workshop** in the board widget picker. Pick an entry, review its capabilities, and enter any required local secrets before import. -- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and +- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and pick a theme to load it into the editor before saving. Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code. @@ -74,13 +86,33 @@ Custom Widget imports detect required bearer, basic, header, or query credential Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions. -Run `pnpm docker:workshop` for the PocketBase development service or `pnpm docker:docs` for the built documentation and Workshop site. Both use `apps/workshop/docker-compose.yml` and the persistent `pb_data` volume. Normal start and stop commands retain data; `docker compose -f apps/workshop/docker-compose.yml down --volumes` intentionally recreates the database. +Run `pnpm dev:workshop` for the recommended local stack: persistent PocketBase on port 8090 and Docusaurus on port 3003. The command waits for health and collections and preserves the `pb_data` volume on shutdown. `pnpm docker:docs` +builds the combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional, +irreversible development reset. -Because Workshop is still unreleased, reset an older development volume if it predates the current schema and comments report that their collection context is missing. No compatibility migrations are maintained for these pre-release databases. +GitHub needs only the central callback `https:///api/oauth2-redirect`. Keep PocketBase origins unrestricted +unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and +`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Production also requires +`WORKSHOP_PUBLIC_ORIGIN`. Configure SMTP for comment, report, and removal notices. -GitHub needs only the central callback `https:///api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices. +The public URL contract is: -Homarr instances use the server-side `WORKSHOP_API_URL` variable to reach the central API. The Docusaurus site uses `WORKSHOP_URL` when its development server runs separately; the combined production image always uses its own origin. Restart the corresponding process after changing either value because both are read at startup. +| Variable | Purpose | Default | +| ------------------------ | ------------------------------------------------- | ----------------------------- | +| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` | +| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` | +| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` | +| `DOCS_BASE_URL` | Docusaurus deployment path | `/` | +| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production | +| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community | + +The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. Homarr +server-renders runtime values for the browser, and the combined image writes a public runtime config at container +startup. Restart services after changes. + +Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the +exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest. The +`WORKSHOP_ENABLED=false` switch disables Homarr's Workshop entry points without affecting installed widgets. ## Client architecture diff --git a/apps/docs/docs/widgets/custom-api/index.mdx b/apps/docs/docs/widgets/custom-api/index.mdx index b3d1e15ab..62d557b9a 100644 --- a/apps/docs/docs/widgets/custom-api/index.mdx +++ b/apps/docs/docs/widgets/custom-api/index.mdx @@ -38,7 +38,8 @@ Enable the definition before you use it. The definition must also be available t Each named request declares its minimum board permission. This permission does not depend on the HTTP method. -Queries and actions can use GET, POST, PUT, PATCH, or DELETE. The `kind` value controls runtime behavior. +Queries and actions can use GET, POST, PUT, or PATCH. DELETE is valid only for actions so it can never run +automatically while a board loads. The `kind` value controls the remaining runtime behavior. A DELETE request requires full board permission and confirmation. An anonymous viewer cannot run an action. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index d4518fbaf..6aa775d91 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -1,20 +1,35 @@ import type * as Preset from "@docusaurus/preset-classic"; import type { Config } from "@docusaurus/types"; import { themes as prismThemes } from "prism-react-renderer"; +import { normalizeDocsBaseUrl, resolveHomarrUrlConfig } from "@homarr/workshop/schema"; const a11yEmoji = require("@fec/remark-a11y-emoji"); +const deprecatedWorkshopUrl = process.env.WORKSHOP_URL; +if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) { + console.warn("WORKSHOP_URL is deprecated; use WORKSHOP_API_URL instead."); +} + +const publicUrls = resolveHomarrUrlConfig({ + homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL, + workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl, + workshopWebUrl: process.env.WORKSHOP_WEB_URL, +}); +const docsBaseUrl = normalizeDocsBaseUrl(process.env.DOCS_BASE_URL); + const config: Config = { title: "Homarr documentation", tagline: "A simple yet powerful dashboard for your server.", - url: "https://homarr.dev", - baseUrl: "/", + url: publicUrls.homarrWebsiteUrl, + baseUrl: docsBaseUrl, trailingSlash: undefined, favicon: "img/logo.png", organizationName: "homarr-labs", projectName: "homarr", customFields: { - workshopUrl: process.env.WORKSHOP_URL ?? "", + workshopUrl: publicUrls.workshopApiUrl, + workshopWebUrl: publicUrls.workshopWebUrl, }, + scripts: [{ src: `${docsBaseUrl}workshop-runtime-config.js` }], i18n: { defaultLocale: "en", locales: ["en"], diff --git a/apps/docs/src/components/workshop/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx index 452534ddc..69452a002 100644 --- a/apps/docs/src/components/workshop/DetailPage.tsx +++ b/apps/docs/src/components/workshop/DetailPage.tsx @@ -2,8 +2,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import BrowserOnly from "@docusaurus/BrowserOnly"; import Link from "@docusaurus/Link"; import { useLocation } from "@docusaurus/router"; +import useBaseUrl from "@docusaurus/useBaseUrl"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; + +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; import { IconArrowBigDown, IconArrowBigUp, @@ -176,6 +179,7 @@ const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => { const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { const location = useLocation(); + const workshopHomeUrl = useBaseUrl("/workshop/"); const submissionId = parseSubmissionId(location.pathname); const backend = useMemo(() => getWorkshopBackend(workshopUrl), [workshopUrl]); @@ -325,7 +329,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { if (!submission) return; try { await backend.delete(submission.id); - window.location.href = "/workshop/"; + window.location.href = workshopHomeUrl; } catch (caught) { setError(errorMessage(caught, "Failed to delete submission")); } @@ -335,7 +339,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { if (!submission) return; try { const updated = await backend.toggleOutdated(submission.id, !submission.outdated); - setSubmission({ ...submission, outdated: updated.outdated }); + setSubmission(updated); setError(null); toast.success(updated.outdated ? "Submission marked as outdated" : "Submission marked as current"); } catch (caught) { @@ -453,7 +457,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { setReportOpen(false); setReportExplanation(""); setError(null); - toast.success("Report submitted", { description: "A Workshop administrator can now review it." }); + toast.success("Report submitted", { description: "A Workshop moderator can now review it." }); } catch (caught) { setReportError(errorMessage(caught, "Failed to submit report")); } finally { @@ -863,7 +867,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { Report submission - Only Workshop administrators can review report details and reporter identities. + Only Workshop moderators can review report details and reporter identities. {reportError && ( @@ -1023,7 +1027,7 @@ export default function MarketplaceDetailPage() { }> {() => ( - + )} diff --git a/apps/docs/src/components/workshop/WorkshopAdmin.tsx b/apps/docs/src/components/workshop/WorkshopAdmin.tsx index c5132e3fb..e5fd61f99 100644 --- a/apps/docs/src/components/workshop/WorkshopAdmin.tsx +++ b/apps/docs/src/components/workshop/WorkshopAdmin.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "@docusaurus/Link"; import { IconBrandGithub, IconExternalLink, @@ -52,7 +53,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) { setSubmissions(nextSubmissions); setError(""); } catch (cause) { - setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration"); + setError(cause instanceof Error ? cause.message : "Unable to load Workshop moderation"); } finally { setLoading(false); } @@ -118,13 +119,13 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) { Restricted area -

Workshop administration

+

Workshop moderation

Review reports and remove unsafe or outdated submissions. PocketBase rules enforce every action.

- {!user && ( @@ -145,14 +146,14 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) { {user && !user.isAdmin && ( - Administrator access required + Workshop moderator access required This GitHub account cannot access Workshop moderation. )} {error && ( - Workshop administration failed + Workshop moderation failed {error} )} @@ -265,7 +266,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) { variant="outline" size="sm" nativeButton={false} - render={} + render={} > Inspect submission diff --git a/apps/docs/src/components/workshop/WorkshopApp.tsx b/apps/docs/src/components/workshop/WorkshopApp.tsx index ced2158cf..a8c316626 100644 --- a/apps/docs/src/components/workshop/WorkshopApp.tsx +++ b/apps/docs/src/components/workshop/WorkshopApp.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from "react"; +import Link from "@docusaurus/Link"; import { IconAlertCircle, IconArrowBigDown, @@ -323,7 +324,7 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar return ( - + {hasScreenshots ? (
)} -
+
- + {submission.title} - + {!hasScreenshots && ( @@ -423,7 +424,7 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar className="h-10 sm:h-7" size="sm" nativeButton={false} - render={} + render={} > View details diff --git a/apps/docs/src/lib/runtime-config.ts b/apps/docs/src/lib/runtime-config.ts new file mode 100644 index 000000000..7d1498f49 --- /dev/null +++ b/apps/docs/src/lib/runtime-config.ts @@ -0,0 +1,21 @@ +interface HomarrRuntimeConfig { + homarrWebsiteUrl?: string; + workshopApiUrl?: string; + workshopWebUrl?: string; +} + +declare global { + interface Window { + homarrRuntimeConfig?: Readonly; + } +} + +export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => { + if (typeof window === "undefined") return configuredUrl; + return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin; +}; + +export const getRuntimeWorkshopWebUrl = (configuredUrl: string): string => { + if (typeof window === "undefined") return configuredUrl; + return window.homarrRuntimeConfig?.workshopWebUrl || configuredUrl || `${window.location.origin}/workshop`; +}; diff --git a/apps/docs/src/pages/workshop/admin/index.tsx b/apps/docs/src/pages/workshop/admin/index.tsx index 5d05480b2..10244048a 100644 --- a/apps/docs/src/pages/workshop/admin/index.tsx +++ b/apps/docs/src/pages/workshop/admin/index.tsx @@ -3,13 +3,14 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; export default function WorkshopAdminRoute() { const { siteConfig } = useDocusaurusContext(); const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? ""; return ( - - + + ); } diff --git a/apps/docs/src/pages/workshop/index.tsx b/apps/docs/src/pages/workshop/index.tsx index f7fadabf8..e7b8e3a82 100644 --- a/apps/docs/src/pages/workshop/index.tsx +++ b/apps/docs/src/pages/workshop/index.tsx @@ -5,6 +5,7 @@ import Layout from "@theme/Layout"; import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp"; import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary"; +import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config"; export default function WorkshopPage() { const { siteConfig } = useDocusaurusContext(); @@ -16,7 +17,7 @@ export default function WorkshopPage() { }> {() => ( - + )} diff --git a/apps/docs/static/workshop-runtime-config.js b/apps/docs/static/workshop-runtime-config.js new file mode 100644 index 000000000..88207dea8 --- /dev/null +++ b/apps/docs/static/workshop-runtime-config.js @@ -0,0 +1 @@ +globalThis.homarrRuntimeConfig = Object.freeze({}); diff --git a/apps/nextjs/README.md b/apps/nextjs/README.md index 437b0b8e9..f4a6543bf 100644 --- a/apps/nextjs/README.md +++ b/apps/nextjs/README.md @@ -1,28 +1,42 @@ -# Create T3 App +# Homarr web application -This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. +This workspace contains Homarr's Next.js application. It consumes the shared API, authentication, database, board, +widget, translation, and UI packages from this monorepo. -## What's next? How do I make an app with this? +Run commands from the repository root: -We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. +```sh +pnpm install --frozen-lockfile +cp .env.example .env +``` -If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. +Keep infrastructure running in the first terminal: -- [Next.js](https://nextjs.org) -- [NextAuth.js](https://next-auth.js.org) -- [Drizzle](https://orm.drizzle.team) -- [Tailwind CSS](https://tailwindcss.com) -- [tRPC](https://trpc.io) +```sh +pnpm docker:dev +``` -## Learn More +Apply migrations and start the application in a second terminal: -To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: +```sh +pnpm db:migration:sqlite:run +pnpm dev +``` -- [Documentation](https://create.t3.gg/) -- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) — Check out these awesome tutorials +The app listens on `http://127.0.0.1:3000`. Configure an absolute writable `DB_URL`, `AUTH_SECRET`, and +`SECRET_ENCRYPTION_KEY` in `.env` before first startup. -You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) — your feedback and contributions are welcome! +To test Custom Widgets against a local Community Workshop, run `pnpm dev:workshop` in another terminal and set: -## How do I deploy this? +```dotenv +HOMARR_WEBSITE_URL=http://127.0.0.1:3003 +WORKSHOP_API_URL=http://127.0.0.1:8090 +WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop +``` -Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. +Server-rendered runtime configuration exposes those URLs to the browser, so changing a production container's +environment does not require rebuilding the Next.js bundle. + +See the repository [`README.md`](../../README.md) for validation commands and +[`apps/docs/docs/advanced/development/getting-started.mdx`](../docs/docs/advanced/development/getting-started.mdx) for +the full contributor setup. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx index 415742176..42e64a3ab 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_customCss.tsx @@ -9,6 +9,7 @@ import "~/styles/prismjs.scss"; import { IconInfoCircle } from "@tabler/icons-react"; import { useForm } from "@homarr/form"; +import { useSession } from "@homarr/auth/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button"; @@ -22,6 +23,8 @@ interface Props { } export const CustomCssSettingsContent = ({ board }: Props) => { + const { data: session } = useSession(); + const isAdmin = session?.user.permissions.includes("admin") ?? false; const t = useI18n(); const customCssT = useScopedI18n("board.field.customCss"); const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board); @@ -48,7 +51,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => { - form.setFieldValue("customCss", css)} /> + {isAdmin ? form.setFieldValue("customCss", css)} /> : } diff --git a/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx index a6cdb5ee0..97b20e274 100644 --- a/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx +++ b/apps/nextjs/src/app/[locale]/custom-widget-configuration/[token]/page.tsx @@ -1,5 +1,10 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@homarr/auth/next"; + +import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; +import { env } from "~/env"; import { CustomWidgetConfigurationEntry } from "./configuration-entry"; export const metadata: Metadata = { @@ -8,6 +13,9 @@ export const metadata: Metadata = { }; export default async function CustomWidgetConfigurationPage({ params }: { params: Promise<{ token: string }> }) { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login"); + if (env.CUSTOM_WIDGETS_ENABLED === false) return ; const { token } = await params; return ; } diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index fba095ce4..2d37c089d 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -24,7 +24,7 @@ import { SettingsProvider } from "@homarr/settings"; import { SpotlightProvider } from "@homarr/spotlight"; import type { SupportedLanguage } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; -import { WORKSHOP_API_URL } from "@homarr/workshop/schema"; +import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { Analytics } from "~/components/layout/analytics"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; @@ -54,7 +54,7 @@ export const generateMetadata = async (): Promise => ({ title: "Homarr Dashboard", description: "A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", - url: "https://homarr.dev", + url: env.HOMARR_WEBSITE_URL, siteName: "Homarr", }, icons: { @@ -89,6 +89,11 @@ export default async function Layout(props: { const serverSettings = await getServerSettingsAsync(db); const colorScheme = await getCurrentColorSchemeAsync(); const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr"; + const publicUrls = resolveHomarrUrlConfig({ + homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, + workshopApiUrl: env.WORKSHOP_API_URL, + workshopWebUrl: env.WORKSHOP_WEB_URL, + }); const StackedProvider = composeWrappers([ (innerProps) => { @@ -141,7 +146,11 @@ export default async function Layout(props: { suppressHydrationWarning > - + + + + + diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 66c6f6a77..4acb247ba 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -23,6 +23,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal"; +import { useRuntimeFeature } from "~/hooks/use-runtime-feature"; const iconProps = { size: 16, stroke: 1.5 }; @@ -36,9 +37,9 @@ interface WidgetRef { export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const t = useScopedI18n("customWidget"); + const workshopEnabled = useRuntimeFeature("workshop"); const { openConfirmModal } = useConfirmModal(); const deleteMutation = clientApi.customWidget.delete.useMutation(); - const deleteLegacyMutation = clientApi.customWidget.deleteLegacy.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); const utils = clientApi.useUtils(); const [publishOpened, publishControls] = useDisclosure(false); @@ -146,10 +147,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); }, }; - if (widget.migrationRequired) { - deleteLegacyMutation.mutate({ id: widget.id }, callbacks); - return; - } deleteMutation.mutate({ id: widget.id }, callbacks); }, }); @@ -176,9 +173,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { void handleExport()} leftSection={}> {t("action.export")} - }> - {t("action.publishWorkshop")} - + {workshopEnabled && ( + }> + {t("action.publishWorkshop")} + + )} )} @@ -196,17 +195,21 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} - } - onClick={handleDelete} - disabled={deleteMutation.isPending || deleteLegacyMutation.isPending} - > - {t("action.delete")} - + {!widget.migrationRequired && ( + } + onClick={handleDelete} + disabled={deleteMutation.isPending} + > + {t("action.delete")} + + )} - {widget.valid && } + {widget.valid && workshopEnabled && ( + + )} {widget.migrationRequired && ( <> ; const params = await props.params; const definition = await api.customWidget.get({ id: params.id }).catch(catchTrpcNotFound); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx index dec57701e..cad930b02 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/new/page.tsx @@ -5,15 +5,18 @@ import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; +import { env } from "~/env"; import { CustomWidgetBetaBanner } from "../_beta-banner"; import { CustomWidgetForm } from "../_custom-widget-form"; import { FormErrorBoundary } from "../_form-error-boundary"; export default async function NewCustomWidgetPage() { const session = await auth(); - if (!session || !session.user.permissions.includes("custom-widget-manage")) { + if (!session || !session.user.permissions.includes("admin")) { redirect("/manage/custom-widgets"); } + if (env.CUSTOM_WIDGETS_ENABLED === false) return ; const t = await getScopedI18n("customWidget"); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx index bcc20920b..be63ef3a8 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx @@ -9,6 +9,8 @@ import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; +import { env } from "~/env"; import { CustomWidgetBetaBanner } from "./_beta-banner"; import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; import { ImportCustomWidgetButton } from "./_import-custom-widget-button"; @@ -17,9 +19,10 @@ import { CustomWidgetList } from "./_custom-widget-list"; export default async function CustomWidgetsPage() { const session = await auth(); - if (!session || !session.user.permissions.includes("custom-widget-manage")) { + if (!session || !session.user.permissions.includes("admin")) { redirect(session ? "/" : "/auth/login"); } + if (env.CUSTOM_WIDGETS_ENABLED === false) return ; const definitions = await api.customWidget.list(); const t = await getScopedI18n("customWidget"); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx index 74f0b44b4..4760d320b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/preview/[id]/page.tsx @@ -5,9 +5,13 @@ import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import CustomJsxDisplay from "@homarr/widgets/custom-api/custom-jsx-display"; +import { CustomWidgetsUnavailable } from "~/components/custom-widgets/custom-widgets-unavailable"; +import { env } from "~/env"; + export default async function CustomWidgetPreviewPage({ params }: { params: Promise<{ id: string }> }) { const session = await auth(); - if (!session?.user.permissions.includes("custom-widget-manage")) redirect("/"); + if (!session?.user.permissions.includes("admin")) redirect("/"); + if (env.CUSTOM_WIDGETS_ENABLED === false) return ; const { id } = await params; const preview = await api.customWidget.previewGet({ sessionId: id }).catch(() => null); if (!preview) notFound(); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 60f3db605..992dbae6c 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -41,6 +41,7 @@ import type { NavigationLink } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; import { ManageTourProvider } from "~/components/onboarding/manage-tour"; +import { env as nextEnv } from "~/env"; export default async function ManageLayout({ children }: PropsWithChildren) { const t = await getScopedI18n("management.navbar"); @@ -79,13 +80,16 @@ export default async function ManageLayout({ children }: PropsWithChildren) { icon: IconApi, href: "/manage/custom-widgets", label: t("items.customWidgets"), - hidden: !session?.user.permissions.includes("custom-widget-manage"), + hidden: nextEnv.CUSTOM_WIDGETS_ENABLED === false || !session?.user.permissions.includes("admin"), }, { icon: IconBuildingStore, href: "/manage/workshop", label: t("items.workshop"), - hidden: !session?.user.permissions.includes("custom-widget-manage"), + hidden: + nextEnv.CUSTOM_WIDGETS_ENABLED === false || + nextEnv.WORKSHOP_ENABLED === false || + !session?.user.permissions.includes("admin"), }, { icon: IconSearch, diff --git a/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx b/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx index e52353c2e..09ea8700c 100644 --- a/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx @@ -1,17 +1,42 @@ import { redirect } from "next/navigation"; +import { Button } from "@mantine/core"; +import { IconExternalLink } from "@tabler/icons-react"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; +import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { WorkshopInstaller } from "~/components/workshop/workshop-installer"; +import { env } from "~/env"; export default async function WorkshopPage() { const session = await auth(); - if (!session?.user.permissions.includes("custom-widget-manage")) redirect(session ? "/" : "/auth/login"); + if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login"); + if (env.CUSTOM_WIDGETS_ENABLED === false || env.WORKSHOP_ENABLED === false) redirect("/"); const t = await getScopedI18n("workshop"); + const { workshopWebUrl } = resolveHomarrUrlConfig({ + homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, + workshopApiUrl: env.WORKSHOP_API_URL, + workshopWebUrl: env.WORKSHOP_WEB_URL, + }); return ( - + } + > + {t("openCommunity")} + + } + > ); diff --git a/apps/nextjs/src/app/api/[...trpc]/route.ts b/apps/nextjs/src/app/api/[...trpc]/route.ts index 1ae1e5e92..9ce92e1ac 100644 --- a/apps/nextjs/src/app/api/[...trpc]/route.ts +++ b/apps/nextjs/src/app/api/[...trpc]/route.ts @@ -5,7 +5,7 @@ import { createOpenApiFetchHandler } from "trpc-to-openapi"; import { createTRPCContext } from "@homarr/api"; import { openApiRouter } from "@homarr/api/open-api"; import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; -import { ipAddressFromHeaders } from "@homarr/common/server"; +import { rateLimitAddressFromHeaders } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { db } from "@homarr/db"; @@ -14,7 +14,7 @@ const logger = createLogger({ module: "trpcOpenApiRoute" }); const handlerAsync = async (req: NextRequest) => { const apiKeyHeaderValue = req.headers.get(API_KEY_HEADER_NAME); - const ipAddress = ipAddressFromHeaders(req.headers); + const ipAddress = rateLimitAddressFromHeaders(req.headers); const { ua } = userAgent(req); logger.info( diff --git a/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts b/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts new file mode 100644 index 000000000..593ca56e2 --- /dev/null +++ b/apps/nextjs/src/app/api/custom-widgets/admin.spec.ts @@ -0,0 +1,40 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + env: { CUSTOM_WIDGETS_ENABLED: true }, +})); + +vi.mock("@homarr/auth/next", () => ({ auth: mocks.auth })); +vi.mock("~/env", () => ({ env: mocks.env })); + +import { requireCustomWidgetAdmin } from "./admin"; + +beforeEach(() => { + mocks.env.CUSTOM_WIDGETS_ENABLED = true; + mocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } }); +}); + +describe("Custom Widget HTTP resource access", () => { + test("allows authenticated administrators", async () => { + await expect(requireCustomWidgetAdmin()).resolves.toBeNull(); + }); + + test.each([ + ["anonymous users", null], + ["authenticated non-admins", { user: { permissions: ["board-modify-all"] } }], + ])("denies %s", async (_label, session) => { + mocks.auth.mockResolvedValue(session); + const response = await requireCustomWidgetAdmin(); + expect(response?.status).toBe(403); + }); + + test("returns an unavailable response when the emergency switch is off", async () => { + mocks.env.CUSTOM_WIDGETS_ENABLED = false; + const response = await requireCustomWidgetAdmin(); + expect(response?.status).toBe(503); + await expect(response?.json()).resolves.toMatchObject({ error: expect.stringContaining("temporarily disabled") }); + }); +}); diff --git a/apps/nextjs/src/app/api/custom-widgets/admin.ts b/apps/nextjs/src/app/api/custom-widgets/admin.ts new file mode 100644 index 000000000..07d1cf93a --- /dev/null +++ b/apps/nextjs/src/app/api/custom-widgets/admin.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@homarr/auth/next"; + +import { env } from "~/env"; + +export const requireCustomWidgetAdmin = async (): Promise => { + const session = await auth(); + if (!session?.user.permissions.includes("admin")) { + return NextResponse.json({ error: "Administrator access is required." }, { status: 403 }); + } + if (env.CUSTOM_WIDGETS_ENABLED === false) { + return NextResponse.json( + { error: "Custom Widgets are temporarily disabled by the server administrator." }, + { status: 503 }, + ); + } + return null; +}; diff --git a/apps/nextjs/src/app/api/custom-widgets/authoring-prompt/route.ts b/apps/nextjs/src/app/api/custom-widgets/authoring-prompt/route.ts index 06bdf251c..f2199ba35 100644 --- a/apps/nextjs/src/app/api/custom-widgets/authoring-prompt/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/authoring-prompt/route.ts @@ -2,9 +2,11 @@ import { NextResponse } from "next/server"; import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt"; -export const dynamic = "force-static"; +import { requireCustomWidgetAdmin } from "../admin"; -export function GET() { +export async function GET() { + const denied = await requireCustomWidgetAdmin(); + if (denied) return denied; return NextResponse.json({ version: 2, prompt: CUSTOM_WIDGET_MCP_AUTHORING_PROMPT, diff --git a/apps/nextjs/src/app/api/custom-widgets/components/route.ts b/apps/nextjs/src/app/api/custom-widgets/components/route.ts index 2049f7cd2..700004e46 100644 --- a/apps/nextjs/src/app/api/custom-widgets/components/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/components/route.ts @@ -2,8 +2,10 @@ import { NextResponse } from "next/server"; import { getCustomWidgetComponentCatalog } from "@homarr/custom-widgets/authoring-resources"; -export const dynamic = "force-static"; +import { requireCustomWidgetAdmin } from "../admin"; -export function GET() { +export async function GET() { + const denied = await requireCustomWidgetAdmin(); + if (denied) return denied; return NextResponse.json(getCustomWidgetComponentCatalog()); } diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts index 2d5e58d10..5ea26b7d0 100644 --- a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts @@ -6,16 +6,17 @@ import { completeCustomWidgetConfigurationRequest, configurePreviewSessionSource, getCustomWidgetConfigurationRequest, + invalidateCustomWidgetResponseCache, parseStoredCustomWidgetDefinition, releaseCustomWidgetConfigurationRequest, serializeCustomWidgetDefinition, } from "@homarr/api/custom-widget-configuration"; import { encryptSecret } from "@homarr/common/server"; -import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server"; import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } from "@homarr/custom-widgets/core"; import { and, db, eq, handleTransactionsAsync } from "@homarr/db"; import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; +import { requireCustomWidgetAdmin } from "../../admin"; import { readConfigurationRequestBody } from "../body"; interface RouteContext { @@ -23,6 +24,8 @@ interface RouteContext { } export async function GET(_request: NextRequest, context: RouteContext) { + const denied = await requireCustomWidgetAdmin(); + if (denied) return denied; const { token } = await context.params; const request = await getCustomWidgetConfigurationRequest(token); if (!request) @@ -41,6 +44,8 @@ export async function GET(_request: NextRequest, context: RouteContext) { } export async function POST(request: NextRequest, context: RouteContext) { + const denied = await requireCustomWidgetAdmin(); + if (denied) return denied; const { token } = await context.params; const pending = await getCustomWidgetConfigurationRequest(token); if (!pending || pending.status !== "pending") { @@ -92,7 +97,7 @@ export async function POST(request: NextRequest, context: RouteContext) { sourceResult.data, secrets, ); - invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]); + await invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]); } else { const definition = await db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, claimed.target.id), diff --git a/apps/nextjs/src/app/api/custom-widgets/schema/route.ts b/apps/nextjs/src/app/api/custom-widgets/schema/route.ts index f900467cb..57e90b77c 100644 --- a/apps/nextjs/src/app/api/custom-widgets/schema/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/schema/route.ts @@ -2,8 +2,10 @@ import { NextResponse } from "next/server"; import { getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; -export const dynamic = "force-static"; +import { requireCustomWidgetAdmin } from "../admin"; -export function GET() { +export async function GET() { + const denied = await requireCustomWidgetAdmin(); + if (denied) return denied; return NextResponse.json(getCustomWidgetJsonSchema()); } diff --git a/apps/nextjs/src/app/api/custom-widgets/skill/route.ts b/apps/nextjs/src/app/api/custom-widgets/skill/route.ts index 227c67688..8dc577b8c 100644 --- a/apps/nextjs/src/app/api/custom-widgets/skill/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/skill/route.ts @@ -2,8 +2,10 @@ import { NextResponse } from "next/server"; import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources"; -export const dynamic = "force-static"; +import { requireCustomWidgetAdmin } from "../admin"; -export function GET() { +export async function GET() { + const denied = await requireCustomWidgetAdmin(); + if (denied) return denied; return NextResponse.json(getCustomWidgetSkill()); } diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts index 4621d0542..780a8d5a3 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -4,6 +4,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), + rateLimitAddress: vi.fn(() => "127.0.0.1"), + toolProcedure: vi.fn(), + loggerWarn: vi.fn(), + env: { CUSTOM_WIDGETS_ENABLED: true, WORKSHOP_ENABLED: true }, buildPrompt: vi.fn((request?: string, documentationUrl?: string) => [`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"), ), @@ -13,16 +17,16 @@ const mocks = vi.hoisted(() => ({ vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) })); vi.mock("@homarr/api/mcp", () => ({ createTRPCContext: vi.fn(() => ({})), - mcpRouter: { createCaller: vi.fn(() => ({})) }, + mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) }, })); vi.mock("@homarr/auth/api-key", () => ({ API_KEY_HEADER_NAME: "ApiKey", getSessionFromApiKeyAsync: mocks.authenticate, })); vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" })); -vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" })); +vi.mock("@homarr/common/server", () => ({ rateLimitAddressFromHeaders: mocks.rateLimitAddress })); vi.mock("@homarr/core/infrastructure/logs", () => ({ - createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), + createLogger: () => ({ info: vi.fn(), warn: mocks.loggerWarn, error: vi.fn() }), })); vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt })); vi.mock("@homarr/custom-widgets/authoring-resources", () => ({ @@ -35,8 +39,30 @@ vi.mock("@homarr/custom-widgets/core", () => ({ getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }), })); vi.mock("@homarr/db", () => ({ db: {} })); +vi.mock("~/env", () => ({ env: mocks.env })); vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" })); -vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] })); +vi.mock("../_extract-tools", () => ({ + extractMcpTools: () => [ + { + name: "board_getAllBoards", + description: "List boards", + pathInRouter: ["board", "getAllBoards"], + inputSchema: { type: "object", properties: {} }, + }, + { + name: "customWidget_list", + description: "List Custom Widgets", + pathInRouter: ["customWidget", "list"], + inputSchema: { type: "object", properties: {} }, + }, + { + name: "customWidget_workshopSearch", + description: "Search Workshop", + pathInRouter: ["customWidget", "workshopSearch"], + inputSchema: { type: "object", properties: {} }, + }, + ], +})); import { POST } from "./route"; @@ -48,9 +74,16 @@ interface JsonRpcResponse { } beforeEach(() => { - mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } }); + mocks.authenticate.mockResolvedValue({ user: { id: "user-1", permissions: ["admin"] } }); + mocks.env.CUSTOM_WIDGETS_ENABLED = true; + mocks.env.WORKSHOP_ENABLED = true; mocks.buildPrompt.mockClear(); mocks.getComponent.mockClear(); + mocks.rateLimitAddress.mockClear(); + mocks.rateLimitAddress.mockReturnValue("127.0.0.1"); + mocks.toolProcedure.mockReset(); + mocks.toolProcedure.mockResolvedValue([]); + mocks.loggerWarn.mockClear(); }); async function callMcp(method: string, params: Record = {}, id = 1) { @@ -60,6 +93,8 @@ async function callMcp(method: string, params: Record = {}, id Accept: "application/json, text/event-stream", ApiKey: "key.secret", "Content-Type": "application/json", + "X-Forwarded-For": "198.51.100.10", + "X-Real-IP": "127.0.0.1", }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), }); @@ -91,6 +126,7 @@ describe("authenticated MCP prompt protocol", () => { serverInfo: { name: "homarr", version: "test-version" }, }); expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test"); + expect(mocks.rateLimitAddress).toHaveBeenCalledWith(expect.any(Headers)); }); test("lists and interpolates the custom-widget authoring prompt", async () => { @@ -178,6 +214,55 @@ describe("authenticated MCP resource protocol", () => { }); }); +describe("non-admin MCP discovery", () => { + test("does not list or expose Custom Widget tools, prompts or resources", async () => { + mocks.authenticate.mockResolvedValue({ user: { id: "user-1", permissions: ["board-view-all"] } }); + + const tools = await callMcp("tools/list"); + expect(((tools.body.result?.tools ?? []) as Array<{ name: string }>).map(({ name }) => name)).toEqual([ + "board_getAllBoards", + ]); + + const prompts = await callMcp("prompts/list"); + expect(prompts.body.result).toEqual({ prompts: [] }); + + const resources = await callMcp("resources/list"); + expect(resources.body.result).toEqual({ resources: [] }); + + const templates = await callMcp("resources/templates/list"); + expect(templates.body.result).toEqual({ resourceTemplates: [] }); + + const prompt = await callMcp("prompts/get", { name: "homarr-custom-widget-author" }); + expect(prompt.body.result).toBeUndefined(); + expect(prompt.body.error).toBeDefined(); + + const resource = await callMcp("resources/read", { uri: "homarr://custom-widgets/schema" }); + expect(resource.body.result).toBeUndefined(); + expect(resource.body.error).toBeDefined(); + }); +}); + +describe("MCP emergency switches", () => { + test("removes Custom Widget authoring discovery when Custom Widgets are disabled", async () => { + mocks.env.CUSTOM_WIDGETS_ENABLED = false; + expect((await callMcp("prompts/list")).body.result).toEqual({ prompts: [] }); + expect((await callMcp("resources/list")).body.result).toEqual({ resources: [] }); + const tools = await callMcp("tools/list"); + expect(((tools.body.result?.tools ?? []) as Array<{ name: string }>).map(({ name }) => name)).toEqual([ + "board_getAllBoards", + ]); + }); + + test("removes only Workshop tools when Workshop is disabled", async () => { + mocks.env.WORKSHOP_ENABLED = false; + const tools = await callMcp("tools/list"); + expect(((tools.body.result?.tools ?? []) as Array<{ name: string }>).map(({ name }) => name)).toEqual([ + "board_getAllBoards", + "customWidget_list", + ]); + }); +}); + describe("MCP protocol errors", () => { test.each([ ["prompts/get", { name: "unknown" }], @@ -193,4 +278,23 @@ describe("MCP protocol errors", () => { expect(body.result).toBeUndefined(); expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) })); }); + + test("does not log sensitive tool error messages", async () => { + const failure = Object.assign(new Error("request to https://user:secret@example.test/private failed"), { + code: "BAD_GATEWAY", + }); + mocks.toolProcedure.mockRejectedValue(failure); + + const { body } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} }); + + expect(JSON.stringify(body)).not.toContain("user:secret"); + expect(mocks.loggerWarn).toHaveBeenCalledWith("MCP tool execution failed", { + tool: "board_getAllBoards", + errorName: "Error", + errorCode: "BAD_GATEWAY", + }); + const metadata = mocks.loggerWarn.mock.calls.at(-1)?.[1] as Record; + expect(metadata).not.toHaveProperty("error"); + expect(metadata).not.toHaveProperty("message"); + }); }); diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index acfff14d1..d4da9c8b1 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -16,7 +16,7 @@ import { import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; import { extractBaseUrlFromHeaders } from "@homarr/common"; -import { ipAddressFromHeaders } from "@homarr/common/server"; +import { rateLimitAddressFromHeaders } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt"; import { @@ -27,6 +27,7 @@ import { import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; import { db } from "@homarr/db"; +import { env } from "~/env"; import { getPackageVersion } from "~/versions/package-reader"; import { extractMcpTools } from "../_extract-tools"; @@ -85,7 +86,12 @@ function clearAuthFailures(ip: string | null) { authFailures.delete(ip ?? "unknown"); } -const callerStorage = new AsyncLocalStorage>(); +interface McpCallerContext { + caller: ReturnType; + isAdmin: boolean; +} + +const callerStorage = new AsyncLocalStorage(); const emptyObjectSchema = { type: "object" as const, @@ -113,6 +119,23 @@ function getTools() { return toolsCache; } +function getVisibleTools() { + const tools = getTools(); + if (!isCustomWidgetMcpEnabled()) { + return tools.filter((tool) => !tool.name.startsWith("customWidget_")); + } + return env.WORKSHOP_ENABLED === false + ? tools.filter((tool) => !tool.name.startsWith("customWidget_workshop")) + : tools; +} + +function requireCustomWidgetMcpAdmin() { + if (!isCustomWidgetMcpEnabled()) throw new Error("Resource not found"); +} + +const isCustomWidgetMcpEnabled = () => + Boolean(callerStorage.getStore()?.isAdmin) && env.CUSTOM_WIDGETS_ENABLED !== false; + const SAFE_ERROR_MESSAGES: Record = { UNAUTHORIZED: "You do not have permission to perform this action", FORBIDDEN: "Access denied", @@ -184,19 +207,22 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr const mcpHandler = createMcpHandler( (server) => { server.server.setRequestHandler(ListPromptsRequestSchema, () => ({ - prompts: [ - { - name: "homarr-custom-widget-author", - description: "Author and iterate on one Homarr Custom JSX v2 widget.", - arguments: [ - { name: "request", description: "The widget the user wants.", required: false }, - { name: "documentationUrl", description: "External API documentation URL.", required: false }, - ], - }, - ], + prompts: isCustomWidgetMcpEnabled() + ? [ + { + name: "homarr-custom-widget-author", + description: "Author and iterate on one Homarr Custom JSX v2 widget.", + arguments: [ + { name: "request", description: "The widget the user wants.", required: false }, + { name: "documentationUrl", description: "External API documentation URL.", required: false }, + ], + }, + ] + : [], })); server.server.setRequestHandler(GetPromptRequestSchema, (request) => { + requireCustomWidgetMcpAdmin(); if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found"); return { description: "Current Homarr Custom Widget authoring workflow.", @@ -216,36 +242,49 @@ const mcpHandler = createMcpHandler( }); server.server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [ - { uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" }, - { uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" }, - { uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" }, - ...customJsxExamples.map((example) => ({ - uri: `homarr://custom-widgets/examples/${example.id}`, - name: `Custom Widget example: ${example.title}`, - mimeType: "application/json", - })), - ], + resources: isCustomWidgetMcpEnabled() + ? [ + { + uri: "homarr://custom-widgets/schema", + name: "Custom Widget schema", + mimeType: "application/schema+json", + }, + { + uri: "homarr://custom-widgets/components", + name: "Custom Widget components", + mimeType: "application/json", + }, + { uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" }, + ...customJsxExamples.map((example) => ({ + uri: `homarr://custom-widgets/examples/${example.id}`, + name: `Custom Widget example: ${example.title}`, + mimeType: "application/json", + })), + ] + : [], })); server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({ - resourceTemplates: [ - { - uriTemplate: "homarr://custom-widgets/components/{name}", - name: "Custom Widget component", - description: "Metadata for one installed Custom Widget component.", - mimeType: "application/json", - }, - { - uriTemplate: "homarr://custom-widgets/examples/{name}", - name: "Custom Widget example", - description: "One canonical Custom Widget example.", - mimeType: "application/json", - }, - ], + resourceTemplates: isCustomWidgetMcpEnabled() + ? [ + { + uriTemplate: "homarr://custom-widgets/components/{name}", + name: "Custom Widget component", + description: "Metadata for one installed Custom Widget component.", + mimeType: "application/json", + }, + { + uriTemplate: "homarr://custom-widgets/examples/{name}", + name: "Custom Widget example", + description: "One canonical Custom Widget example.", + mimeType: "application/json", + }, + ] + : [], })); server.server.setRequestHandler(ReadResourceRequestSchema, (request) => { + requireCustomWidgetMcpAdmin(); const { uri } = request.params; let text: string; let mimeType = "application/json"; @@ -271,12 +310,12 @@ const mcpHandler = createMcpHandler( }); server.server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: getTools(), + tools: getVisibleTools(), })); server.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; - const tools = getTools(); + const tools = getVisibleTools(); const tool = tools.find((t) => t.name === name); if (!tool) { @@ -286,8 +325,8 @@ const mcpHandler = createMcpHandler( }; } - const caller = callerStorage.getStore(); - if (!caller) { + const callerContext = callerStorage.getStore(); + if (!callerContext) { return { content: [{ type: "text" as const, text: "Authentication context not available" }], isError: true, @@ -295,7 +334,7 @@ const mcpHandler = createMcpHandler( } try { - const procedure = tool.pathInRouter.reduce((acc, part) => acc?.[part], caller); + const procedure = tool.pathInRouter.reduce((acc, part) => acc?.[part], callerContext.caller); if (typeof procedure !== "function") { return { content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }], @@ -311,7 +350,9 @@ const mcpHandler = createMcpHandler( const message = sanitizeErrorMessage(error); logger.warn("MCP tool execution failed", { tool: name, - error: error instanceof Error ? error.message : String(error), + errorName: error instanceof Error ? error.name : "UnknownError", + errorCode: + error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined, }); return { content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }], @@ -353,7 +394,7 @@ function jsonErrorResponse(status: number, body: Record, extraHe const handler = async (req: NextRequest) => { const apiKeyValue = extractApiKeyValue(req); - const ipAddress = ipAddressFromHeaders(req.headers); + const ipAddress = rateLimitAddressFromHeaders(req.headers); const { ua } = userAgent(req); if (!checkAuthRateLimit(ipAddress)) { @@ -398,7 +439,9 @@ const handler = async (req: NextRequest) => { const ctx = createTRPCContext({ session, headers: req.headers }); const caller = mcpRouter.createCaller(ctx); - return callerStorage.run(caller, () => mcpHandler(req as unknown as Request)); + return callerStorage.run({ caller, isAdmin: session.user.permissions.includes("admin") }, () => + mcpHandler(req as unknown as Request), + ); }; export { handler as GET, handler as POST, handler as DELETE }; diff --git a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts index 735d83e7d..f0819e006 100644 --- a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts +++ b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts @@ -5,7 +5,7 @@ import { appRouter, createTRPCContext } from "@homarr/api"; import { trpcPath } from "@homarr/api/shared"; import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; import { auth } from "@homarr/auth/next"; -import { ipAddressFromHeaders } from "@homarr/common/server"; +import { rateLimitAddressFromHeaders } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { db } from "@homarr/db"; @@ -34,7 +34,7 @@ export function OPTIONS() { const handler = auth(async (req) => { // Try API key auth first, fall back to session cookie const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME); - const ipAddress = ipAddressFromHeaders(req.headers); + const ipAddress = rateLimitAddressFromHeaders(req.headers); const { ua } = userAgent(req); diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index b8a136bbb..55a7b1f1e 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -30,6 +30,8 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => { }; const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => { + const { data: session } = useSession(); + const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const refResetErrorBoundaryOnNextRender = useRef(false); const tItem = useScopedI18n("item"); const t = useI18n(); @@ -135,9 +137,11 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP > {tItem("action.moveResize")} - } onClick={() => duplicateItem({ itemId: item.id })}> - {tItem("action.duplicate")} - + {canDuplicate && ( + } onClick={() => duplicateItem({ itemId: item.id })}> + {tItem("action.duplicate")} + + )} {t("common.dangerZone")} } onClick={openRemoveModal}> diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index a7f94a293..4a177b9de 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -3,6 +3,7 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, import { IconApi } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import { createId, objectEntries } from "@homarr/common"; import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; @@ -15,15 +16,23 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import { WidgetEditModal } from "@homarr/widgets/modals"; import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; +import { useRuntimeFeature } from "~/hooks/use-runtime-feature"; import { useItemActions } from "./item-actions"; export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => { + const { data: session } = useSession(); + const isAdmin = session?.user.permissions.includes("admin") ?? false; + const customWidgetsEnabled = useRuntimeFeature("custom-widgets"); + const workshopEnabled = useRuntimeFeature("workshop"); const [search, setSearch] = useState(""); const t = useI18n(); const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); const { openModal: openEditModal } = useModalAction(WidgetEditModal); const { data: integrationData } = clientApi.integration.all.useQuery(); - const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId }); + const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery( + { boardId: innerProps.boardId }, + { enabled: isAdmin && customWidgetsEnabled }, + ); const settings = useSettings(); const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); @@ -160,61 +169,67 @@ export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, inne /> ))} - - {filteredCustomWidgets.map((def) => ( - e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} - > - - - {def.iconUrl ? ( - - ) : ( - - )} - - {def.name} - - - - {def.description ?? ""} - - - - - - - ))} + {isAdmin && customWidgetsEnabled && ( + <> + + {filteredCustomWidgets.map((def) => ( + e.currentTarget.style.setProperty("--_hover-opacity", "1")} + onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} + > + + + {def.iconUrl ? ( + + ) : ( + + )} + + {def.name} + + + + {def.description ?? ""} + + + + + + + ))} - - {t("workshop.installDialog")} - + {workshopEnabled && ( + + {t("workshop.installDialog")} + + )} + + )} - {filteredItems.length === 0 && filteredCustomWidgets.length === 0 && ( + {filteredItems.length === 0 && (!isAdmin || !customWidgetsEnabled || filteredCustomWidgets.length === 0) && (
{t("common.noResults")}
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 8520d6b89..8f97c6a2c 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -49,6 +49,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont const t = useI18n(); const settings = useSettings(); const isRightClickEnabled = settings.enableRightClickOnWidgets; + const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); const { openModal } = useModalAction(WidgetEditModal); const { openModal: openMoveModal } = useModalAction(ItemMoveModal); const { openConfirmModal } = useConfirmModal(); @@ -76,9 +77,10 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { + if (!canConfigureWidget) return []; const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); - }, [currentDefinition, settings]); + }, [canConfigureWidget, currentDefinition, settings]); const widgetContextActions = useMemo(() => { const def = currentDefinition as Record; @@ -227,13 +229,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont > {tItem("action.moveResize")}
- } - onClick={() => duplicateItem({ itemId: item.id })} - > - {tItem("action.duplicate")} - + {canConfigureWidget && ( + } + onClick={() => duplicateItem({ itemId: item.id })} + > + {tItem("action.duplicate")} + + )} )} diff --git a/apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx b/apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx new file mode 100644 index 000000000..63729107e --- /dev/null +++ b/apps/nextjs/src/components/custom-widgets/custom-widgets-unavailable.tsx @@ -0,0 +1,16 @@ +import { Alert, Container } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { getScopedI18n } from "@homarr/translation/server"; + +export async function CustomWidgetsUnavailable() { + const t = await getScopedI18n("customWidget.page.disabled"); + + return ( + + } title={t("title")}> + {t("message")} + + + ); +} diff --git a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx index 89ab01c59..ffbc00bda 100644 --- a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx +++ b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx @@ -5,10 +5,14 @@ import { Button, Modal } from "@mantine/core"; import { IconDownload } from "@tabler/icons-react"; import { WorkshopBrowser } from "./workshop-browser"; +import { useRuntimeFeature } from "~/hooks/use-runtime-feature"; export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) { + const workshopEnabled = useRuntimeFeature("workshop"); const [opened, setOpened] = useState(false); + if (!workshopEnabled) return null; + return ( <>