diff --git a/.github/workflows/cd-deploy-docs.yml b/.github/workflows/cd-deploy-docs.yml index bf8a1e8..d2f7ae1 100644 --- a/.github/workflows/cd-deploy-docs.yml +++ b/.github/workflows/cd-deploy-docs.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: 'Version (e.g. v1.0, v1.1)' + description: 'Version (e.g. v1.0, v1.1, v2.0.0-beta.1)' required: true concurrency: @@ -49,7 +49,7 @@ jobs: -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/zip" \ --data-binary @$GITHUB_WORKSPACE/docs.zip \ - "${URL}/api/products/shelf/versions/${VER}" + "${URL}/_api/products/shelf/versions/${VER}" - name: Deployment summary run: | diff --git a/.github/workflows/cd-deploy-production.yml b/.github/workflows/cd-deploy-production.yml index 74e0569..d5ce22a 100644 --- a/.github/workflows/cd-deploy-production.yml +++ b/.github/workflows/cd-deploy-production.yml @@ -29,8 +29,8 @@ jobs: exit 1 fi - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error ::Invalid release version format. Expected X.Y.Z (e.g., 1.2.3), got: $VERSION" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then + echo "::error ::Invalid release version format. Expected X.Y.Z or X.Y.Z-label (e.g., 1.2.3, 1.2.3-beta.1), got: $VERSION" exit 1 fi @@ -69,6 +69,15 @@ jobs: --password ${{ secrets.PERSONAL_PACKAGES_TOKEN }} \ --store-password-in-clear-text + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src @@ -196,9 +205,15 @@ jobs: env: VERSION: ${{ needs.validate-version.outputs.version }} run: | - MAJOR=$(echo "$VERSION" | cut -d. -f1) - MINOR=$(echo "$VERSION" | cut -d. -f2) - echo "value=v${MAJOR}.${MINOR}" >> $GITHUB_OUTPUT + if [[ "$VERSION" == *-* ]]; then + # Pre-release: use full version (e.g. v6.0.0-beta.1) + echo "value=v${VERSION}" >> $GITHUB_OUTPUT + else + # Stable: use major.minor (e.g. v5.2) + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + echo "value=v${MAJOR}.${MINOR}" >> $GITHUB_OUTPUT + fi - name: Upload to Shelf env: @@ -211,7 +226,7 @@ jobs: -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/zip" \ --data-binary @$GITHUB_WORKSPACE/docs.zip \ - "${URL}/api/products/shelf/versions/${VER}" + "${URL}/_api/products/shelf/versions/${VER}" - name: Docs deployment summary run: | diff --git a/.github/workflows/cd-deploy-staging.yml b/.github/workflows/cd-deploy-staging.yml index b3874b9..010ec8a 100644 --- a/.github/workflows/cd-deploy-staging.yml +++ b/.github/workflows/cd-deploy-staging.yml @@ -75,6 +75,15 @@ jobs: --password ${{ secrets.PERSONAL_PACKAGES_TOKEN }} \ --store-password-in-clear-text + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src diff --git a/.github/workflows/ci-develop.yml b/.github/workflows/ci-develop.yml index f8fc836..e7e12ae 100644 --- a/.github/workflows/ci-develop.yml +++ b/.github/workflows/ci-develop.yml @@ -48,6 +48,15 @@ jobs: - name: Log version run: echo "Building version ${{ steps.gv.outputs.SemVer }}" + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src diff --git a/.github/workflows/ci-pr-validation.yml b/.github/workflows/ci-pr-validation.yml index 381a4ac..7cdce15 100644 --- a/.github/workflows/ci-pr-validation.yml +++ b/.github/workflows/ci-pr-validation.yml @@ -48,6 +48,15 @@ jobs: - name: Log version run: echo "Building version ${{ steps.gv.outputs.SemVer }}" + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src diff --git a/.gitignore b/.gitignore index f5cf5ed..4b358f9 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,13 @@ nul local/ .local/ +# SPA build output (generated by Vite) +src/Cocoar.Shelf/wwwroot/assets/ +src/Cocoar.Shelf/wwwroot/index.html + +# Client dependencies +src/Cocoar.Shelf.Client/node_modules/ + # VitePress website/.vitepress/cache/ website/.vitepress/dist/ diff --git a/Dockerfile b/Dockerfile index c3df8c2..2810563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ +FROM node:22-alpine AS client-build +WORKDIR /client +COPY src/Cocoar.Shelf.Client/package.json src/Cocoar.Shelf.Client/package-lock.json ./ +RUN npm ci +COPY src/Cocoar.Shelf.Client/ . +RUN npx vite build --outDir /client/dist + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src @@ -5,6 +12,7 @@ COPY src/Cocoar.Shelf/Cocoar.Shelf.csproj Cocoar.Shelf/ RUN dotnet restore Cocoar.Shelf/Cocoar.Shelf.csproj COPY src/ . +COPY --from=client-build /client/dist/ Cocoar.Shelf/wwwroot/ RUN dotnet publish Cocoar.Shelf/Cocoar.Shelf.csproj -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:10.0 diff --git a/local-config/products/configuration.json b/local-config/products/configuration.json new file mode 100644 index 0000000..873bec9 --- /dev/null +++ b/local-config/products/configuration.json @@ -0,0 +1,6 @@ +{ + "name": "configuration", + "displayName": "Cocoar.Configuration", + "description": "Reactive configuration for .NET", + "source": "upload" +} diff --git a/src/Cocoar.Shelf.Client/index.html b/src/Cocoar.Shelf.Client/index.html new file mode 100644 index 0000000..31c8beb --- /dev/null +++ b/src/Cocoar.Shelf.Client/index.html @@ -0,0 +1,15 @@ + + + + + + Shelf + + + + +
+ + + + diff --git a/src/Cocoar.Shelf.Client/package-lock.json b/src/Cocoar.Shelf.Client/package-lock.json new file mode 100644 index 0000000..b96019b --- /dev/null +++ b/src/Cocoar.Shelf.Client/package-lock.json @@ -0,0 +1,2364 @@ +{ + "name": "@cocoar/shelf-admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cocoar/shelf-admin", + "version": "0.0.0", + "dependencies": { + "@cocoar/vue-ui": "0.1.0-beta.25", + "overlayscrollbars": "^2.14.0", + "pinia": "^2.3.1", + "vue": "^3.5.28", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.0", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/tsconfig": "^0.7.0", + "lucide-static": "^0.577.0", + "tailwindcss": "^4.2.0", + "typescript": "~5.9.0", + "vite": "^7.3.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cocoar/vue-fragment-parser": { + "version": "0.1.0-beta.25", + "resolved": "https://registry.npmjs.org/@cocoar/vue-fragment-parser/-/vue-fragment-parser-0.1.0-beta.25.tgz", + "integrity": "sha512-wy1DMw+XiFTbls5KSxdbH0E2bNHHqPWbsCfIdgDLdQDFZxGEwxuPSJQZkrc89s2Jf/+ih2ETWTGG15RTkXx0aA==", + "dependencies": { + "path-to-regexp": "^8.3.0" + } + }, + "node_modules/@cocoar/vue-localization": { + "version": "0.1.0-beta.25", + "resolved": "https://registry.npmjs.org/@cocoar/vue-localization/-/vue-localization-0.1.0-beta.25.tgz", + "integrity": "sha512-OIvrRN+OE3i9Jgu7PcTXp62c/GBuqkJWR0rvBCIT6v8R56JdVIeDoqPJtG7RS0rbXKDoD9TqA0R0BCww5uwQbA==", + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@cocoar/vue-ui": { + "version": "0.1.0-beta.25", + "resolved": "https://registry.npmjs.org/@cocoar/vue-ui/-/vue-ui-0.1.0-beta.25.tgz", + "integrity": "sha512-1nKV6JdnpZTDJ2cHbj5QMQxsmfarF0bvjQiOmOtKTw68ZK7lzldGW9bu+nh2Tc17GqqENlLqT1UjTMaHM3IMPQ==", + "dependencies": { + "@cocoar/vue-fragment-parser": "0.1.0-beta.25", + "@cocoar/vue-localization": "0.1.0-beta.25", + "@js-temporal/polyfill": "^0.5.1", + "@maskito/core": "^5.1.1", + "@maskito/kit": "^5.1.1", + "@maskito/vue": "^5.1.1", + "prismjs": "^1.30.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@maskito/core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@maskito/core/-/core-5.1.2.tgz", + "integrity": "sha512-eoeQ41uDu9AuhFQDzAPTNTr5VM+hMpRsrJjtHzCH3FM7u+/mOGLgtEeGE1+5Up5UCtY7h/N1hPaZ/qT5mcNWXQ==", + "license": "Apache-2.0" + }, + "node_modules/@maskito/kit": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-5.1.2.tgz", + "integrity": "sha512-inVxaa36dLQp1NQ/a5dM791qgDZUulPDs299pS6KNXKN7wrisybSIoRVrpjoZt/QIe2TMtku313sBgtf2LhFAQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@maskito/core": "^5.1.2" + } + }, + "node_modules/@maskito/vue": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@maskito/vue/-/vue-5.1.2.tgz", + "integrity": "sha512-HDkmxeIMWb+Nt/3duDQ+HvndILmA1sBhZT4hc5T+ClbP6k32txStvtAB20NZd/lYoa4J7Nvn666MH5wqJY7bdg==", + "license": "Apache-2.0", + "peerDependencies": { + "@maskito/core": "^5.1.2", + "vue": ">=3.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz", + "integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz", + "integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz", + "integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz", + "integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz", + "integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz", + "integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz", + "integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz", + "integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz", + "integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz", + "integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz", + "integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz", + "integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz", + "integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz", + "integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz", + "integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz", + "integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz", + "integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz", + "integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz", + "integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz", + "integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz", + "integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz", + "integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz", + "integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz", + "integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz", + "integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-static": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-0.577.0.tgz", + "integrity": "sha512-hx39J5Tq4JWF2ALY+5YRg+SxQLpeAmLJDXNcqiBJH/UuVwp43it9fyki/onZO7AVFgG5ZbB+fWwZR9mwGHE2XQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/overlayscrollbars": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.14.0.tgz", + "integrity": "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz", + "integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.1", + "@rollup/rollup-android-arm64": "4.59.1", + "@rollup/rollup-darwin-arm64": "4.59.1", + "@rollup/rollup-darwin-x64": "4.59.1", + "@rollup/rollup-freebsd-arm64": "4.59.1", + "@rollup/rollup-freebsd-x64": "4.59.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.1", + "@rollup/rollup-linux-arm-musleabihf": "4.59.1", + "@rollup/rollup-linux-arm64-gnu": "4.59.1", + "@rollup/rollup-linux-arm64-musl": "4.59.1", + "@rollup/rollup-linux-loong64-gnu": "4.59.1", + "@rollup/rollup-linux-loong64-musl": "4.59.1", + "@rollup/rollup-linux-ppc64-gnu": "4.59.1", + "@rollup/rollup-linux-ppc64-musl": "4.59.1", + "@rollup/rollup-linux-riscv64-gnu": "4.59.1", + "@rollup/rollup-linux-riscv64-musl": "4.59.1", + "@rollup/rollup-linux-s390x-gnu": "4.59.1", + "@rollup/rollup-linux-x64-gnu": "4.59.1", + "@rollup/rollup-linux-x64-musl": "4.59.1", + "@rollup/rollup-openbsd-x64": "4.59.1", + "@rollup/rollup-openharmony-arm64": "4.59.1", + "@rollup/rollup-win32-arm64-msvc": "4.59.1", + "@rollup/rollup-win32-ia32-msvc": "4.59.1", + "@rollup/rollup-win32-x64-gnu": "4.59.1", + "@rollup/rollup-win32-x64-msvc": "4.59.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/src/Cocoar.Shelf.Client/package.json b/src/Cocoar.Shelf.Client/package.json new file mode 100644 index 0000000..fd355fb --- /dev/null +++ b/src/Cocoar.Shelf.Client/package.json @@ -0,0 +1,28 @@ +{ + "name": "@cocoar/shelf-admin", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@cocoar/vue-ui": "0.1.0-beta.25", + "overlayscrollbars": "^2.14.0", + "pinia": "^2.3.1", + "vue": "^3.5.28", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.0", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/tsconfig": "^0.7.0", + "lucide-static": "^0.577.0", + "tailwindcss": "^4.2.0", + "typescript": "~5.9.0", + "vite": "^7.3.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/src/Cocoar.Shelf.Client/src/App.vue b/src/Cocoar.Shelf.Client/src/App.vue new file mode 100644 index 0000000..68eef6f --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/App.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts new file mode 100644 index 0000000..5bf0a15 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts @@ -0,0 +1,29 @@ +declare module '@cocoar/vue-ui' { + import type { Plugin, Component } from 'vue'; + + export const CoarIconPlugin: Plugin; + export const CoarOverlayPlugin: Plugin; + export const CORE_ICONS: unknown; + + export class CoarHttpIconSource { + constructor(resolver: (name: string) => string); + } + + export const CoarButton: Component; + export const CoarCard: Component; + export const CoarCheckbox: Component; + export const CoarIcon: Component; + export const CoarNote: Component; + export const CoarOverlayHost: Component; + export const CoarSelect: Component; + export const CoarSpinner: Component; + export const CoarTable: Component; + export const CoarTag: Component; + export const CoarTextInput: Component; +} + +declare module '@cocoar/vue-ui/styles' {} + +declare interface Window { + __SHELF_OPTIONS__: { pathBase: string }; +} diff --git a/src/Cocoar.Shelf.Client/src/composables/useUI.ts b/src/Cocoar.Shelf.Client/src/composables/useUI.ts new file mode 100644 index 0000000..6bf40ac --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/composables/useUI.ts @@ -0,0 +1,86 @@ +import { reactive } from 'vue'; + +export interface UIButton { + text?: string; + disabled?: boolean; + loading?: boolean; + visible?: boolean; + onClick?: () => void; +} + +export interface UIHeader { + show: boolean; + title?: string; + subTitle?: string; + icon?: string; +} + +export interface UIContent { + scrollable: boolean; + showLoadingBar: boolean; + container: boolean; + padding: boolean; +} + +export interface UIFooter { + show: boolean; + button1: UIButton; + button2: UIButton; + button3: UIButton; +} + +export interface UIContext { + header: UIHeader; + content: UIContent; + footer: UIFooter; +} + +function createDefaults(): UIContext { + return { + header: { + show: true, + title: undefined, + subTitle: undefined, + icon: undefined, + }, + content: { + scrollable: true, + showLoadingBar: false, + container: true, + padding: true, + }, + footer: { + show: false, + button1: { visible: false, disabled: false, loading: false }, + button2: { visible: false, disabled: false, loading: false }, + button3: { visible: false, disabled: false, loading: false }, + }, + }; +} + +const state = reactive(createDefaults()); + +export function useUI() { + function set(fn: (ctx: UIContext) => void) { + const defaults = createDefaults(); + Object.assign(state.header, defaults.header); + Object.assign(state.content, defaults.content); + Object.assign(state.footer.button1, defaults.footer.button1); + Object.assign(state.footer.button2, defaults.footer.button2); + Object.assign(state.footer.button3, defaults.footer.button3); + state.footer.show = defaults.footer.show; + fn(state); + } + + function reset() { + const defaults = createDefaults(); + Object.assign(state.header, defaults.header); + Object.assign(state.content, defaults.content); + Object.assign(state.footer.button1, defaults.footer.button1); + Object.assign(state.footer.button2, defaults.footer.button2); + Object.assign(state.footer.button3, defaults.footer.button3); + state.footer.show = defaults.footer.show; + } + + return { state, set, reset }; +} diff --git a/src/Cocoar.Shelf.Client/src/core/api/http.ts b/src/Cocoar.Shelf.Client/src/core/api/http.ts new file mode 100644 index 0000000..463b662 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/core/api/http.ts @@ -0,0 +1,61 @@ +import { useAuthStore } from '@/stores/auth.store'; +import { router } from '@/router'; + +export class ApiError extends Error { + constructor(public readonly status: number, public readonly body: unknown) { + const message = (body as { error?: string })?.error ?? `HTTP ${status}`; + super(message); + this.name = 'ApiError'; + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers: Record = { + ...(init.headers as Record), + }; + + if (init.body && typeof init.body === 'string') { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(`/_api${path}`, { + ...init, + headers, + credentials: 'include', + }); + + if (!response.ok) { + if (response.status === 401) { + const auth = useAuthStore(); + auth.isAuthenticated = false; + auth.userName = null; + router.push('/login'); + } + const contentType = response.headers.get('content-type') ?? ''; + const errData = contentType.includes('application/json') + ? await response.json().catch(() => null) + : await response.text().catch(() => null); + throw new ApiError(response.status, errData); + } + + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined as T; + } + + return await response.json() as T; +} + +export const http = { + get: (path: string) => request(path, { method: 'GET' }), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }), + put: (path: string, body?: unknown) => + request(path, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }), + delete: (path: string) => request(path, { method: 'DELETE' }), + upload: (path: string, file: File | Blob) => + request(path, { + method: 'POST', + body: file, + headers: { 'Content-Type': 'application/zip' }, + }), +}; diff --git a/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts new file mode 100644 index 0000000..43a084c --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts @@ -0,0 +1,17 @@ +import { http } from './http'; +import type { Product, ProductVersions, CreateProductRequest, UpdateProductRequest } from '../models/shelf.models'; + +export const shelfApi = { + getProducts: () => http.get('/products'), + getProduct: (name: string) => http.get(`/products/${name}`), + getVersions: (product: string) => http.get(`/products/${product}/versions`), + createProduct: (req: CreateProductRequest) => http.post('/products', req), + updateProduct: (name: string, req: UpdateProductRequest) => http.put(`/products/${name}`, req), + deleteProduct: (name: string, deleteData = false) => + http.delete(`/products/${name}${deleteData ? '?deleteData=true' : ''}`), + + deleteVersion: (product: string, version: string) => + http.delete(`/products/${product}/versions/${version}`), + uploadVersion: (product: string, version: string, file: File | Blob) => + http.upload(`/products/${product}/versions/${version}`, file), +}; diff --git a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts new file mode 100644 index 0000000..a9c3041 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts @@ -0,0 +1,36 @@ +export interface Product { + name: string; + displayName: string | null; + description: string | null; + source: string; + visibility: string; + tags: string[]; + showWhenEmpty: boolean; + latest: string | null; + versions: string[]; +} + +export interface ProductVersions { + name: string; + latest: string | null; + versions: string[]; +} + +export interface CreateProductRequest { + name: string; + displayName?: string; + description?: string; + source?: string; + visibility?: string; + tags?: string[]; + showWhenEmpty?: boolean; +} + +export interface UpdateProductRequest { + displayName?: string; + description?: string; + source?: string; + visibility?: string; + tags?: string[]; + showWhenEmpty?: boolean; +} diff --git a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..46ee765 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/main.ts b/src/Cocoar.Shelf.Client/src/main.ts new file mode 100644 index 0000000..8e4f882 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/main.ts @@ -0,0 +1,23 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import { CoarIconPlugin, CoarOverlayPlugin, CoarHttpIconSource, CORE_ICONS } from '@cocoar/vue-ui'; +import App from './App.vue'; +import { router } from './router'; +import '@cocoar/vue-ui/styles'; +import './styles.css'; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.use(CoarIconPlugin, { + sources: [ + CORE_ICONS, + { + key: 'lucide', + source: new CoarHttpIconSource((name) => `/icons/lucide/${name}.svg`), + }, + ], + defaultSource: 'lucide', +}); +app.use(CoarOverlayPlugin); +app.mount('#app'); diff --git a/src/Cocoar.Shelf.Client/src/router/index.ts b/src/Cocoar.Shelf.Client/src/router/index.ts new file mode 100644 index 0000000..ddc0473 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/router/index.ts @@ -0,0 +1,50 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { useAuthStore } from '@/stores/auth.store'; + +const pathBase = (window.__SHELF_OPTIONS__?.pathBase ?? '').replace(/\/$/, ''); + +export const router = createRouter({ + history: createWebHistory(pathBase + '/'), + routes: [ + { + path: '/', + component: () => import('@/views/LandingView.vue'), + meta: { public: true }, + }, + { + path: '/login', + component: () => import('@/views/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/admin', + component: () => import('@/layouts/AdminLayout.vue'), + children: [ + { path: '', component: () => import('@/views/DashboardView.vue') }, + { path: 'products', component: () => import('@/views/products/ProductListView.vue') }, + { path: 'products/create', component: () => import('@/views/products/ProductFormView.vue') }, + { path: 'products/:name', component: () => import('@/views/products/ProductDetailView.vue') }, + { path: 'products/:name/edit', component: () => import('@/views/products/ProductFormView.vue') }, + ], + }, + ], +}); + +let sessionChecked = false; + +router.beforeEach(async (to) => { + const auth = useAuthStore(); + + // Check session once on first navigation (handles page refresh) + if (!sessionChecked) { + sessionChecked = true; + await auth.checkSession(); + } + + if (!to.meta.public && !auth.isAuthenticated) { + return '/login'; + } + if (to.path === '/login' && auth.isAuthenticated) { + return '/admin'; + } +}); diff --git a/src/Cocoar.Shelf.Client/src/stores/auth.store.ts b/src/Cocoar.Shelf.Client/src/stores/auth.store.ts new file mode 100644 index 0000000..1414cfd --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/stores/auth.store.ts @@ -0,0 +1,61 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; + +export const useAuthStore = defineStore('auth', () => { + const isAuthenticated = ref(false); + const userName = ref(null); + + async function checkSession(): Promise { + try { + const response = await fetch('/_api/auth/me', { credentials: 'include' }); + if (response.ok) { + const data = await response.json(); + isAuthenticated.value = data.authenticated; + userName.value = data.name; + return true; + } + isAuthenticated.value = false; + userName.value = null; + return false; + } catch { + isAuthenticated.value = false; + userName.value = null; + return false; + } + } + + async function login(apiKey: string): Promise { + try { + const response = await fetch('/_api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ apiKey }), + }); + + if (response.ok) { + const data = await response.json(); + isAuthenticated.value = true; + userName.value = data.name; + return true; + } + return false; + } catch { + return false; + } + } + + async function logout() { + try { + await fetch('/_api/auth/logout', { + method: 'POST', + credentials: 'include', + }); + } finally { + isAuthenticated.value = false; + userName.value = null; + } + } + + return { isAuthenticated, userName, checkSession, login, logout }; +}); diff --git a/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts b/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts new file mode 100644 index 0000000..1e0b6f9 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia'; +import { ref, watch } from 'vue'; + +const STORAGE_KEY = 'shelf:preferences'; + +interface Preferences { + showPreview: boolean; + selectedTags: string[]; +} + +function load(): Preferences { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { ...defaults(), ...JSON.parse(raw) }; + } catch { /* ignore corrupt data */ } + return defaults(); +} + +function defaults(): Preferences { + return { showPreview: false, selectedTags: [] }; +} + +export const usePreferencesStore = defineStore('preferences', () => { + const saved = load(); + const showPreview = ref(saved.showPreview); + const selectedTags = ref(saved.selectedTags); + + function persist() { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + showPreview: showPreview.value, + selectedTags: selectedTags.value, + })); + } + + watch(showPreview, persist); + watch(selectedTags, persist, { deep: true }); + + function toggleTag(tag: string) { + const idx = selectedTags.value.indexOf(tag); + if (idx === -1) selectedTags.value.push(tag); + else selectedTags.value.splice(idx, 1); + } + + function clearTags() { + selectedTags.value = []; + } + + return { showPreview, selectedTags, toggleTag, clearTags }; +}); diff --git a/src/Cocoar.Shelf.Client/src/styles.css b/src/Cocoar.Shelf.Client/src/styles.css new file mode 100644 index 0000000..a2e723d --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/styles.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; + +html, body, #app { + height: 100%; + margin: 0; +} + +body { + background-color: var(--coar-background-neutral-secondary); +} diff --git a/src/Cocoar.Shelf.Client/src/views/DashboardView.vue b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue new file mode 100644 index 0000000..e82898b --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/LandingView.vue b/src/Cocoar.Shelf.Client/src/views/LandingView.vue new file mode 100644 index 0000000..1356db9 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/LandingView.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/LoginView.vue b/src/Cocoar.Shelf.Client/src/views/LoginView.vue new file mode 100644 index 0000000..d7200a6 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/LoginView.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue new file mode 100644 index 0000000..15f6438 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue new file mode 100644 index 0000000..09a129a --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue new file mode 100644 index 0000000..6c2c32e --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/tsconfig.json b/src/Cocoar.Shelf.Client/tsconfig.json new file mode 100644 index 0000000..aa7faa7 --- /dev/null +++ b/src/Cocoar.Shelf.Client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["src/**/*"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false + } +} diff --git a/src/Cocoar.Shelf.Client/vite.config.ts b/src/Cocoar.Shelf.Client/vite.config.ts new file mode 100644 index 0000000..0bb22fb --- /dev/null +++ b/src/Cocoar.Shelf.Client/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import tailwindcss from '@tailwindcss/postcss'; + +export default defineConfig({ + base: '/', + plugins: [vue()], + css: { + postcss: { + plugins: [tailwindcss()], + }, + }, + build: { + outDir: '../Cocoar.Shelf/wwwroot', + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + '/_api': { + target: 'http://localhost:5200', + changeOrigin: true, + }, + }, + }, + resolve: { + alias: { + '@': '/src', + }, + }, +}); diff --git a/src/Cocoar.Shelf/Cocoar.Shelf.csproj b/src/Cocoar.Shelf/Cocoar.Shelf.csproj index 97a625f..83366c4 100644 --- a/src/Cocoar.Shelf/Cocoar.Shelf.csproj +++ b/src/Cocoar.Shelf/Cocoar.Shelf.csproj @@ -5,7 +5,13 @@ + + + + + + diff --git a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs index 153c375..ca02adc 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs @@ -1,119 +1,491 @@ -using System.Text.RegularExpressions; -using Cocoar.Shelf.Services; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Options; - -namespace Cocoar.Shelf.Endpoints; - -public static class ApiEndpoints -{ - public static WebApplication MapApiEndpoints(this WebApplication app) - { - var api = app.MapGroup("/api"); - - api.MapGet("/products", GetProducts); - api.MapGet("/products/{product}/versions", GetVersions); - api.MapPost("/products/{product}/versions/{version}", UploadVersion) - .AddEndpointFilter(); - - return app; - } - - private static IResult GetProducts(IProductConfigService configService, IManifestService manifestService) - { - var products = configService.GetAll().Select(config => - { - var manifest = manifestService.GetManifest(config.Name); - return new - { - config.Name, - config.DisplayName, - config.Description, - config.Source, - Latest = manifest?.Latest, - Versions = manifest?.Versions ?? (IReadOnlyList)[] - }; - }); - - return Results.Ok(products); - } - - private static IResult GetVersions( - string product, - IProductConfigService configService, - IManifestService manifestService) - { - var config = configService.GetConfig(product); - if (config == null) - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); - - var manifest = manifestService.GetManifest(product); - - return Results.Ok(new - { - Name = product, - Latest = manifest?.Latest, - Versions = manifest?.Versions ?? (IReadOnlyList)[] - }); - } - - private static async Task UploadVersion( - string product, - string version, - HttpContext httpContext, - IProductConfigService configService, - IUploadService uploadService, - IOptions options, - CancellationToken ct) - { - var opts = options.Value; - - // Increase request body size limit for this endpoint - var maxSizeFeature = httpContext.Features.Get(); - if (maxSizeFeature is { IsReadOnly: false }) - maxSizeFeature.MaxRequestBodySize = opts.MaxUploadSizeBytes; - - // Check product is registered - var config = configService.GetConfig(product); - if (config == null) - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); - - // Validate version format - if (!Regex.IsMatch(version, opts.VersionPattern)) - return Results.Json(new { error = $"Invalid version format: '{version}'" }, statusCode: 400); - - // Check Content-Length if present - if (httpContext.Request.ContentLength > opts.MaxUploadSizeBytes) - return Results.Json(new { error = "Upload exceeds maximum allowed size" }, statusCode: 413); - - // Read body with size limit - using var ms = new MemoryStream(); - var buffer = new byte[81920]; - long totalRead = 0; - int bytesRead; - - while ((bytesRead = await httpContext.Request.Body.ReadAsync(buffer, ct)) > 0) - { - totalRead += bytesRead; - if (totalRead > opts.MaxUploadSizeBytes) - return Results.Json(new { error = "Upload exceeds maximum allowed size" }, statusCode: 413); - ms.Write(buffer, 0, bytesRead); - } - - ms.Position = 0; - - var result = await uploadService.UploadVersionAsync(product, version, ms, ct); - - return result.Status switch - { - UploadStatus.Success => Results.Created($"{httpContext.Request.PathBase}/api/products/{product}/versions/{version}", null), - UploadStatus.MissingIndexHtml => Results.Json( - new { error = result.Error ?? "Archive must contain an index.html at the root" }, statusCode: 400), - UploadStatus.VersionConflict => Results.Json( - new { error = result.Error ?? "Upload for this version is already in progress" }, statusCode: 409), - UploadStatus.InvalidArchive => Results.Json( - new { error = result.Error ?? "Invalid ZIP archive" }, statusCode: 400), - _ => Results.Json(new { error = "Internal error" }, statusCode: 500) - }; - } -} +using System.Text.RegularExpressions; +using Cocoar.Shelf.Models; +using Cocoar.Shelf.Services; +using Microsoft.AspNetCore.Http.Features; + +namespace Cocoar.Shelf.Endpoints; + +public static partial class ApiEndpoints +{ + private static readonly Regex ProductNameRegex = new("^[a-z0-9][a-z0-9-]*$", RegexOptions.Compiled); + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) { "admin", "api" }; + + public static WebApplication MapApiEndpoints(this WebApplication app) + { + var api = app.MapGroup("/_api"); + + // Auth endpoints (cookie-based) + api.MapAuthEndpoints(); + + // Public read endpoints + api.MapGet("/products", GetProducts); + api.MapGet("/products/{product}", GetProduct); + api.MapGet("/products/{product}/versions", GetVersions); + api.MapGet("/shelf-config", GetShelfConfig); + + // Protected write endpoints (cookie or Bearer API key) + api.MapPost("/products", CreateProduct) + .AddEndpointFilter(); + api.MapPut("/products/{product}", UpdateProduct) + .AddEndpointFilter(); + api.MapDelete("/products/{product}", DeleteProduct) + .AddEndpointFilter(); + api.MapPost("/products/{product}/versions/{version}", UploadVersion) + .AddEndpointFilter(); + api.MapDelete("/products/{product}/versions/{version}", DeleteVersion) + .AddEndpointFilter(); + + return app; + } + + private static IResult GetProducts( + IProductConfigService configService, + IManifestService manifestService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var products = configService.GetAll().Select(config => + { + var manifest = manifestService.GetManifest(config.Name); + return new + { + config.Name, + config.DisplayName, + config.Description, + config.Source, + config.Visibility, + config.Tags, + config.ShowWhenEmpty, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }; + }); + + return Results.Ok(products); + } + catch (Exception ex) + { + LogListProductsFailed(logger, ex); + return Results.Json(new { error = "Failed to list products" }, statusCode: 500); + } + } + + private static IResult GetVersions( + string product, + IProductConfigService configService, + IManifestService manifestService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + var manifest = manifestService.GetManifest(product); + + return Results.Ok(new + { + Name = product, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }); + } + catch (Exception ex) + { + LogGetVersionsFailed(logger, product, ex); + return Results.Json(new { error = $"Failed to get versions for product '{product}'" }, statusCode: 500); + } + } + + private static IResult GetProduct( + string product, + IProductConfigService configService, + IManifestService manifestService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + var manifest = manifestService.GetManifest(product); + return Results.Ok(new + { + config.Name, + config.DisplayName, + config.Description, + config.Source, + config.Visibility, + config.Tags, + config.ShowWhenEmpty, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }); + } + catch (Exception ex) + { + LogGetProductFailed(logger, product, ex); + return Results.Json(new { error = $"Failed to get product '{product}'" }, statusCode: 500); + } + } + + private static IResult GetShelfConfig(ShelfOptions options) => + Results.Ok(new { pathBase = options.PathBase }); + + private static async Task UploadVersion( + string product, + string version, + HttpContext httpContext, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions options, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + return await UploadVersionCore(product, version, httpContext, configService, uploadService, options, logger, ct); + } + catch (BadHttpRequestException ex) + { + LogUploadBadRequest(logger, product, version, ex); + return Results.Json(new { error = $"Bad request: {ex.Message}" }, statusCode: 400); + } + catch (OperationCanceledException) + { + LogUploadCancelled(logger, product, version); + return Results.Json(new { error = "Upload cancelled" }, statusCode: 499); + } + catch (Exception ex) + { + LogUploadFailed(logger, product, version, ex); + return Results.Json(new { error = $"Upload failed: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task UploadVersionCore( + string product, + string version, + HttpContext httpContext, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions opts, + ILogger logger, + CancellationToken ct) + { + // Increase request body size limit for this endpoint + var maxSizeFeature = httpContext.Features.Get(); + if (maxSizeFeature is { IsReadOnly: false }) + maxSizeFeature.MaxRequestBodySize = opts.MaxUploadSizeBytes; + + // Check product is registered + var config = configService.GetConfig(product); + if (config == null) + { + LogUploadProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + // Validate version format + if (string.IsNullOrWhiteSpace(version)) + { + LogUploadEmptyVersion(logger, product); + return Results.Json(new { error = "Version must not be empty" }, statusCode: 400); + } + + if (!Regex.IsMatch(version, opts.VersionPattern)) + { + LogUploadInvalidVersion(logger, version, product, opts.VersionPattern); + return Results.Json( + new { error = $"Invalid version format: '{version}'. Must match pattern: {opts.VersionPattern}" }, + statusCode: 400); + } + + // Check Content-Length if present + if (httpContext.Request.ContentLength > opts.MaxUploadSizeBytes) + { + LogUploadTooLarge(logger, httpContext.Request.ContentLength, opts.MaxUploadSizeBytes, product, version); + return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); + } + + // Read body with size limit + using var ms = new MemoryStream(); + var buffer = new byte[81920]; + long totalRead = 0; + int bytesRead; + + while ((bytesRead = await httpContext.Request.Body.ReadAsync(buffer, ct)) > 0) + { + totalRead += bytesRead; + if (totalRead > opts.MaxUploadSizeBytes) + { + LogUploadBodyTooLarge(logger, opts.MaxUploadSizeBytes, product, version); + return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); + } + ms.Write(buffer, 0, bytesRead); + } + + ms.Position = 0; + + LogUploadProcessing(logger, product, version, totalRead); + + var result = await uploadService.UploadVersionAsync(product, version, ms, ct); + + switch (result.Status) + { + case UploadStatus.Success: + return Results.Created($"{httpContext.Request.PathBase}/_api/products/{product}/versions/{version}", null); + + case UploadStatus.MissingIndexHtml: + LogUploadRejected(logger, product, version, "missing index.html"); + return Results.Json(new { error = result.Error ?? "Archive must contain an index.html at the root" }, statusCode: 400); + + case UploadStatus.VersionConflict: + LogUploadRejected(logger, product, version, "concurrent upload"); + return Results.Json(new { error = result.Error ?? "Upload for this version is already in progress" }, statusCode: 409); + + case UploadStatus.InvalidArchive: + LogUploadRejected(logger, product, version, result.Error ?? "invalid archive"); + return Results.Json(new { error = result.Error ?? "Invalid ZIP archive" }, statusCode: 400); + + default: + LogUploadUnexpectedStatus(logger, product, version, result.Status); + return Results.Json(new { error = result.Error ?? "Internal error" }, statusCode: 500); + } + } + + private static async Task CreateProduct( + CreateProductRequest request, + IProductConfigService configService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Results.Json(new { error = "Product name is required" }, statusCode: 400); + + if (!ProductNameRegex.IsMatch(request.Name)) + return Results.Json(new { error = "Product name must contain only lowercase letters, numbers, and hyphens" }, statusCode: 400); + + if (ReservedNames.Contains(request.Name)) + return Results.Json(new { error = $"'{request.Name}' is a reserved name" }, statusCode: 400); + + if (configService.GetConfig(request.Name) != null) + { + LogProductAlreadyExists(logger, request.Name); + return Results.Json(new { error = $"Product '{request.Name}' already exists" }, statusCode: 409); + } + + var config = new ProductConfig + { + Name = request.Name, + DisplayName = request.DisplayName, + Description = request.Description, + Source = request.Source ?? "upload", + Visibility = request.Visibility ?? "public", + Tags = NormalizeTags(request.Tags), + ShowWhenEmpty = request.ShowWhenEmpty ?? false + }; + + await configService.CreateAsync(config); + LogProductCreated(logger, request.Name); + return Results.Created($"/_api/products/{request.Name}", config); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "create", request.Name, ex); + return Results.Json(new { error = $"Failed to create product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task UpdateProduct( + string product, + UpdateProductRequest request, + IProductConfigService configService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var existing = configService.GetConfig(product); + if (existing == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + var config = new ProductConfig + { + Name = product, + DisplayName = request.DisplayName ?? existing.DisplayName, + Description = request.Description ?? existing.Description, + Source = request.Source ?? existing.Source, + Visibility = request.Visibility ?? existing.Visibility, + Tags = request.Tags != null ? NormalizeTags(request.Tags) : existing.Tags, + ShowWhenEmpty = request.ShowWhenEmpty ?? existing.ShowWhenEmpty + }; + + await configService.UpdateAsync(config); + LogProductUpdated(logger, product); + return Results.Ok(config); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "update", product, ex); + return Results.Json(new { error = $"Failed to update product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task DeleteProduct( + string product, + IProductConfigService configService, + IUploadService uploadService, + ILoggerFactory loggerFactory, + CancellationToken ct, + bool deleteData = false) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var existing = configService.GetConfig(product); + if (existing == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + await configService.DeleteAsync(product); + + if (deleteData) + await uploadService.DeleteProductDataAsync(product, ct); + + LogProductDeleted(logger, product, deleteData); + + return Results.NoContent(); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "delete", product, ex); + return Results.Json(new { error = $"Failed to delete product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task DeleteVersion( + string product, + string version, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions options, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + if (!Regex.IsMatch(version, options.VersionPattern)) + { + LogUploadInvalidVersion(logger, version, product, options.VersionPattern); + return Results.Json(new { error = $"Invalid version format: '{version}'" }, statusCode: 400); + } + + var deleted = await uploadService.DeleteVersionAsync(product, version, ct); + if (!deleted) + return Results.Json(new { error = $"Version '{version}' not found for product '{product}'" }, statusCode: 404); + + LogVersionDeleted(logger, product, version); + return Results.NoContent(); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "delete version", $"{product}/{version}", ex); + return Results.Json(new { error = $"Failed to delete version: {ex.Message}" }, statusCode: 500); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to list products")] + private static partial void LogListProductsFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Product not registered: {Product}")] + private static partial void LogProductNotRegistered(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to get versions for product {Product}")] + private static partial void LogGetVersionsFailed(ILogger logger, string product, Exception ex); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to get product {Product}")] + private static partial void LogGetProductFailed(ILogger logger, string product, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload bad request for {Product}/{Version}")] + private static partial void LogUploadBadRequest(ILogger logger, string product, string version, Exception ex); + + [LoggerMessage(Level = LogLevel.Information, Message = "Upload cancelled for {Product}/{Version}")] + private static partial void LogUploadCancelled(ILogger logger, string product, string version); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] + private static partial void LogUploadFailed(ILogger logger, string product, string version, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: product {Product} is not registered")] + private static partial void LogUploadProductNotRegistered(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: empty version for product {Product}")] + private static partial void LogUploadEmptyVersion(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: invalid version format {Version} for product {Product} (pattern: {Pattern})")] + private static partial void LogUploadInvalidVersion(ILogger logger, string version, string product, string pattern); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: content length {Length} exceeds limit {Limit} for {Product}/{Version}")] + private static partial void LogUploadTooLarge(ILogger logger, long? length, long limit, string product, string version); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: body exceeds limit {Limit} for {Product}/{Version}")] + private static partial void LogUploadBodyTooLarge(ILogger logger, long limit, string product, string version); + + [LoggerMessage(Level = LogLevel.Information, Message = "Processing upload for {Product}/{Version} ({Bytes} bytes)")] + private static partial void LogUploadProcessing(ILogger logger, string product, string version, long bytes); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected for {Product}/{Version}: {Reason}")] + private static partial void LogUploadRejected(ILogger logger, string product, string version, string reason); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed with unexpected status {Status} for {Product}/{Version}")] + private static partial void LogUploadUnexpectedStatus(ILogger logger, string product, string version, UploadStatus status); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Product already exists: {Product}")] + private static partial void LogProductAlreadyExists(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product created: {Product}")] + private static partial void LogProductCreated(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product updated: {Product}")] + private static partial void LogProductUpdated(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product deleted: {Product} (deleteData={DeleteData})")] + private static partial void LogProductDeleted(ILogger logger, string product, bool deleteData); + + [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] + private static partial void LogVersionDeleted(ILogger logger, string product, string version); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to {Operation} product {Product}")] + private static partial void LogProductOperationFailed(ILogger logger, string operation, string product, Exception ex); + + private static IReadOnlyList NormalizeTags(IReadOnlyList? tags) => + tags == null ? [] : tags.Select(t => t.Trim()).Where(t => t.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(); +} diff --git a/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs b/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs index 2d5a800..d6902de 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs @@ -1,27 +1,64 @@ -using Microsoft.Extensions.Options; - namespace Cocoar.Shelf.Endpoints; -public class ApiKeyFilter : IEndpointFilter +/// +/// Endpoint filter that accepts authentication via either: +/// - Cookie session (from Admin UI / browser) +/// - Bearer API key (from CI/CD pipelines) +/// +public partial class ApiKeyFilter(ILogger logger) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - var options = context.HttpContext.RequestServices - .GetRequiredService>().Value; + // Accept cookie-based authentication (Admin UI) + if (context.HttpContext.User.Identity?.IsAuthenticated == true) + return await next(context); + + // Accept Bearer API key (CI/CD) + ShelfOptions options; + try + { + options = context.HttpContext.RequestServices.GetRequiredService(); + } + catch (Exception ex) + { + LogConfigResolutionFailed(logger, ex); + return Results.Json(new { error = "Server configuration error" }, statusCode: 500); + } if (string.IsNullOrEmpty(options.ApiKey)) + { + LogNoApiKeyConfigured(logger); return Results.Json(new { error = "Upload is disabled (no API key configured)" }, statusCode: 503); + } var auth = context.HttpContext.Request.Headers.Authorization.ToString(); if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return Results.Json(new { error = "Missing API key" }, statusCode: 401); + { + LogUnauthorized(logger); + return Results.Json(new { error = "Authentication required" }, statusCode: 401); + } var provided = auth["Bearer ".Length..]; if (provided != options.ApiKey) + { + LogInvalidApiKey(logger); return Results.Json(new { error = "Invalid API key" }, statusCode: 401); + } return await next(context); } + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to resolve ShelfOptions — configuration may be invalid")] + private static partial void LogConfigResolutionFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: no API key configured")] + private static partial void LogNoApiKeyConfigured(ILogger logger); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Authentication required: no cookie or Bearer token")] + private static partial void LogUnauthorized(ILogger logger); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: invalid API key")] + private static partial void LogInvalidApiKey(ILogger logger); } diff --git a/src/Cocoar.Shelf/Endpoints/AuthEndpoints.cs b/src/Cocoar.Shelf/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..c810be5 --- /dev/null +++ b/src/Cocoar.Shelf/Endpoints/AuthEndpoints.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Cocoar.Shelf.Endpoints; + +public static partial class AuthEndpoints +{ + public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder api) + { + var auth = api.MapGroup("/auth"); + + auth.MapPost("/login", (Delegate)Login); + auth.MapPost("/logout", (Delegate)Logout); + auth.MapGet("/me", (Delegate)GetMe); + + return api; + } + + private static async Task Login( + HttpContext httpContext, + LoginRequest request, + ShelfOptions options, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Auth"); + + if (string.IsNullOrEmpty(options.ApiKey)) + { + LogLoginDisabled(logger); + return Results.Json(new { error = "Login is disabled (no API key configured)" }, statusCode: 503); + } + + if (string.IsNullOrEmpty(request.ApiKey) || request.ApiKey != options.ApiKey) + { + LogLoginFailed(logger); + return Results.Json(new { error = "Invalid API key" }, statusCode: 401); + } + + var claims = new List + { + new(ClaimTypes.Name, "admin"), + new(ClaimTypes.Role, "admin"), + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + + await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + + LogLoginSuccess(logger); + return Results.Ok(new { name = "admin", role = "admin" }); + } + + private static async Task Logout(HttpContext httpContext) + { + await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Ok(new { ok = true }); + } + + private static IResult GetMe(HttpContext httpContext) + { + if (httpContext.User.Identity?.IsAuthenticated != true) + return Results.Json(new { authenticated = false }, statusCode: 401); + + return Results.Ok(new + { + authenticated = true, + name = httpContext.User.Identity.Name, + role = httpContext.User.FindFirstValue(ClaimTypes.Role), + }); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Login disabled: no API key configured")] + private static partial void LogLoginDisabled(ILogger logger); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Login failed: invalid API key")] + private static partial void LogLoginFailed(ILogger logger); + + [LoggerMessage(Level = LogLevel.Information, Message = "Login successful")] + private static partial void LogLoginSuccess(ILogger logger); +} + +public record LoginRequest(string ApiKey); diff --git a/src/Cocoar.Shelf/Endpoints/LandingPageEndpoint.cs b/src/Cocoar.Shelf/Endpoints/LandingPageEndpoint.cs deleted file mode 100644 index c4857a0..0000000 --- a/src/Cocoar.Shelf/Endpoints/LandingPageEndpoint.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System.Globalization; -using System.Net; -using System.Text; -using Cocoar.Shelf.Services; - -namespace Cocoar.Shelf.Endpoints; - -public static class LandingPageEndpoint -{ - public static IResult Render( - HttpContext httpContext, - IProductConfigService configService, - IManifestService manifestService) - { - var pathBase = httpContext.Request.PathBase.Value ?? ""; - var products = configService.GetAll(); // sorted by name - - var cards = new StringBuilder(); - - foreach (var config in products) - { - var manifest = manifestService.GetManifest(config.Name); - if (manifest == null) continue; - - var latestUrl = $"{pathBase}/{config.Name}/"; - - var badges = new StringBuilder(); - foreach (var version in manifest.Versions) - { - var versionUrl = $"{pathBase}/{config.Name}/{version}/"; - var isLatest = version == manifest.Latest; - var cssClass = isLatest ? "version-badge latest" : "version-badge"; - badges.Append(CultureInfo.InvariantCulture, - $"""{Encode(version)}"""); - } - - cards.AppendLine(CultureInfo.InvariantCulture, $""" - - """); - } - - var html = Template.Replace("{cards}", cards.ToString()); - - return Results.Content(html, "text/html; charset=utf-8"); - } - - private static string Encode(string value) => WebUtility.HtmlEncode(value); - - private const string Template = """ - - - - - - Documentation - - - -
-

Documentation

-
-
{cards}
- - - """; -} diff --git a/src/Cocoar.Shelf/Endpoints/LlmsTxtEndpoint.cs b/src/Cocoar.Shelf/Endpoints/LlmsTxtEndpoint.cs new file mode 100644 index 0000000..e087f85 --- /dev/null +++ b/src/Cocoar.Shelf/Endpoints/LlmsTxtEndpoint.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Text; +using Cocoar.Shelf.Services; + +namespace Cocoar.Shelf.Endpoints; + +public static class LlmsTxtEndpoint +{ + public static WebApplication MapLlmsTxt(this WebApplication app) + { + app.MapGet("/llms.txt", Generate); + return app; + } + + private static IResult Generate( + HttpContext httpContext, + IProductConfigService configService, + IManifestService manifestService) + { + var pathBase = httpContext.Request.PathBase.Value ?? ""; + var ci = CultureInfo.InvariantCulture; + + var sb = new StringBuilder(); + sb.AppendLine("# Documentation Index"); + sb.AppendLine(); + sb.AppendLine(ci, $"> Documentation hosting for Cocoar products"); + sb.AppendLine(); + + var products = configService.GetAll(); + var hasProducts = false; + + foreach (var config in products) + { + if (!string.Equals(config.Visibility, "public", StringComparison.OrdinalIgnoreCase)) + continue; + + var manifest = manifestService.GetManifest(config.Name); + if (manifest == null) + continue; + + var stableVersions = manifest.Versions + .Where(v => !v.Contains('-')) + .ToList(); + + if (stableVersions.Count == 0) + continue; + + if (!hasProducts) + { + sb.AppendLine("## Products"); + sb.AppendLine(); + hasProducts = true; + } + + var latest = stableVersions[0]; + var docsUrl = $"{pathBase}/{config.Name}/{latest}/"; + + sb.AppendLine(ci, $"### {config.DisplayName ?? config.Name}"); + if (!string.IsNullOrEmpty(config.Description)) + sb.AppendLine(ci, $"- Description: {config.Description}"); + sb.AppendLine(ci, $"- Latest: {latest}"); + sb.AppendLine(ci, $"- Versions: {string.Join(", ", stableVersions)}"); + sb.AppendLine(ci, $"- Docs: {docsUrl}"); + sb.AppendLine(ci, $"- LLM Docs: {docsUrl}llms-full.txt"); + sb.AppendLine(); + } + + if (!hasProducts) + { + sb.AppendLine("No documentation available yet."); + sb.AppendLine(); + } + + return Results.Text(sb.ToString(), "text/plain; charset=utf-8"); + } +} diff --git a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs index 4a33a8e..d2d45ea 100644 --- a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs +++ b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs @@ -1,147 +1,164 @@ -using System.Text.RegularExpressions; -using Cocoar.Shelf.Services; -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.Options; - -namespace Cocoar.Shelf.Middleware; - -public partial class DocsRoutingMiddleware -{ - private readonly RequestDelegate _next; - private readonly IManifestService _manifestService; - private readonly BasePathDetector _basePathDetector; - private readonly ShelfOptions _options; - private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); - private readonly Regex _versionRegex; - - public DocsRoutingMiddleware( - RequestDelegate next, - IManifestService manifestService, - BasePathDetector basePathDetector, - IOptions options) - { - _next = next; - _manifestService = manifestService; - _basePathDetector = basePathDetector; - _options = options.Value; - _versionRegex = new Regex(_options.VersionPattern, RegexOptions.Compiled); - } - - public async Task InvokeAsync(HttpContext context) - { - if (context.Request.Method != HttpMethods.Get && context.Request.Method != HttpMethods.Head) - { - await _next(context); - return; - } - - var path = context.Request.Path.Value?.Trim('/') ?? ""; - - if (string.IsNullOrEmpty(path)) - { - await _next(context); - return; - } - - var segments = path.Split('/', 2); - var product = segments[0]; - var productDir = Path.Combine(_options.DocsRoot, product); - - if (!Directory.Exists(productDir)) - { - await _next(context); - return; - } - - var rest = segments.Length > 1 ? segments[1] : ""; - string resolvedPath; - string version; - - var restSegments = rest.Split('/', 2); - if (restSegments[0].Length > 0 && _versionRegex.IsMatch(restSegments[0])) - { - version = restSegments[0]; - resolvedPath = Path.Combine(productDir, rest); - } - else - { - // No explicit version — redirect to latest so the URL matches - // the VitePress base path and client-side routing works correctly - var manifest = _manifestService.GetManifest(product); - - if (manifest == null) - { - context.Response.StatusCode = 404; - return; - } - - var redirectPath = $"{context.Request.PathBase}/{product}/{manifest.Latest}/{rest}"; - context.Response.Redirect(redirectPath, permanent: false); - return; - } - - // Directory requests → serve index.html - // Note: Directory.Exists handles SemVer directories like "v0.1" where - // Path.GetExtension would incorrectly treat ".1" as a file extension - if (string.IsNullOrEmpty(Path.GetExtension(resolvedPath)) || Directory.Exists(resolvedPath)) - { - resolvedPath = Path.Combine(resolvedPath, "index.html"); - } - - resolvedPath = Path.GetFullPath(resolvedPath); - - // Security: prevent path traversal outside docs root - var docsRootFull = Path.GetFullPath(_options.DocsRoot); - if (!resolvedPath.StartsWith(docsRootFull, StringComparison.OrdinalIgnoreCase)) - { - context.Response.StatusCode = 400; - return; - } - - if (!File.Exists(resolvedPath)) - { - context.Response.StatusCode = 404; - return; - } - - if (!_contentTypeProvider.TryGetContentType(resolvedPath, out var contentType)) - { - contentType = "application/octet-stream"; - } - - // Immutable caching for hashed assets (e.g. style.a1b2c3d4.css) - if (HashedAssetRegex().IsMatch(Path.GetFileName(resolvedPath))) - { - context.Response.Headers.CacheControl = "public, max-age=31536000, immutable"; - } - - context.Response.ContentType = contentType; - - // Rewrite base path in text-based responses - if (IsTextContent(contentType)) - { - var versionDir = Path.Combine(productDir, version); - var originalBase = _basePathDetector.Detect(versionDir); - var targetBase = $"{context.Request.PathBase}/{product}/{version}/"; - - if (originalBase != targetBase) - { - var content = await File.ReadAllTextAsync(resolvedPath); - var rewritten = BasePathRewriter.Rewrite(content, originalBase, targetBase, contentType); - await context.Response.WriteAsync(rewritten); - return; - } - } - - await context.Response.SendFileAsync(resolvedPath); - } - - private static bool IsTextContent(string contentType) => - contentType.Contains("text/html") || - contentType.Contains("text/css") || - contentType.Contains("application/javascript") || - contentType.Contains("text/javascript"); - - [GeneratedRegex(@"\.[a-f0-9]{6,}\.(css|js)$")] - private static partial Regex HashedAssetRegex(); -} +using System.Text.RegularExpressions; +using Cocoar.Configuration.Reactive; +using Cocoar.Shelf.Services; +using Microsoft.AspNetCore.StaticFiles; + +namespace Cocoar.Shelf.Middleware; + +public partial class DocsRoutingMiddleware +{ + private readonly RequestDelegate _next; + private readonly IManifestService _manifestService; + private readonly BasePathDetector _basePathDetector; + private readonly IReactiveConfig _config; + private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + private (string Pattern, Regex Compiled) _versionRegexCache; + + public DocsRoutingMiddleware( + RequestDelegate next, + IManifestService manifestService, + BasePathDetector basePathDetector, + IReactiveConfig config) + { + _next = next; + _manifestService = manifestService; + _basePathDetector = basePathDetector; + _config = config; + var initialPattern = config.CurrentValue.VersionPattern; + _versionRegexCache = (initialPattern, new Regex(initialPattern, RegexOptions.Compiled)); + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Method != HttpMethods.Get && context.Request.Method != HttpMethods.Head) + { + await _next(context); + return; + } + + var path = context.Request.Path.Value?.Trim('/') ?? ""; + + if (string.IsNullOrEmpty(path)) + { + await _next(context); + return; + } + + // Reserved prefixes — never interpret as product names + if (path.StartsWith('_')) + { + await _next(context); + return; + } + + var segments = path.Split('/', 2); + var product = segments[0]; + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); + + if (!Directory.Exists(productDir)) + { + await _next(context); + return; + } + + var rest = segments.Length > 1 ? segments[1] : ""; + string resolvedPath; + string version; + + var restSegments = rest.Split('/', 2); + if (restSegments[0].Length > 0 && GetVersionRegex().IsMatch(restSegments[0])) + { + version = restSegments[0]; + resolvedPath = Path.Combine(productDir, rest); + } + else + { + // No explicit version — redirect to latest so the URL matches + // the VitePress base path and client-side routing works correctly + var manifest = _manifestService.GetManifest(product); + + if (manifest == null) + { + context.Response.StatusCode = 404; + return; + } + + var redirectPath = $"{context.Request.PathBase}/{product}/{manifest.Latest}/{rest}"; + context.Response.Redirect(redirectPath, permanent: false); + return; + } + + // Directory requests → serve index.html + // Note: Directory.Exists handles SemVer directories like "v0.1" where + // Path.GetExtension would incorrectly treat ".1" as a file extension + if (string.IsNullOrEmpty(Path.GetExtension(resolvedPath)) || Directory.Exists(resolvedPath)) + { + resolvedPath = Path.Combine(resolvedPath, "index.html"); + } + + resolvedPath = Path.GetFullPath(resolvedPath); + + // Security: prevent path traversal outside docs root + var docsRootFull = Path.GetFullPath(_config.CurrentValue.DocsRoot); + if (!resolvedPath.StartsWith(docsRootFull, StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = 400; + return; + } + + if (!File.Exists(resolvedPath)) + { + context.Response.StatusCode = 404; + return; + } + + if (!_contentTypeProvider.TryGetContentType(resolvedPath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + // Immutable caching for hashed assets (e.g. style.a1b2c3d4.css) + if (HashedAssetRegex().IsMatch(Path.GetFileName(resolvedPath))) + { + context.Response.Headers.CacheControl = "public, max-age=31536000, immutable"; + } + + context.Response.ContentType = contentType; + + // Rewrite base path in text-based responses + if (IsTextContent(contentType)) + { + var versionDir = Path.Combine(productDir, version); + var originalBase = _basePathDetector.Detect(versionDir); + var targetBase = $"{context.Request.PathBase}/{product}/{version}/"; + + if (originalBase != targetBase) + { + var content = await File.ReadAllTextAsync(resolvedPath); + var rewritten = BasePathRewriter.Rewrite(content, originalBase, targetBase, contentType); + await context.Response.WriteAsync(rewritten); + return; + } + } + + await context.Response.SendFileAsync(resolvedPath); + } + + private static bool IsTextContent(string contentType) => + contentType.Contains("text/html") || + contentType.Contains("text/css") || + contentType.Contains("application/javascript") || + contentType.Contains("text/javascript"); + + private Regex GetVersionRegex() + { + var pattern = _config.CurrentValue.VersionPattern; + if (pattern == _versionRegexCache.Pattern) return _versionRegexCache.Compiled; + var compiled = new Regex(pattern, RegexOptions.Compiled); + _versionRegexCache = (pattern, compiled); + return compiled; + } + + [GeneratedRegex(@"\.[a-f0-9]{6,}\.(css|js)$")] + private static partial Regex HashedAssetRegex(); +} diff --git a/src/Cocoar.Shelf/Models/ProductConfig.cs b/src/Cocoar.Shelf/Models/ProductConfig.cs index 1ba52aa..c9569b6 100644 --- a/src/Cocoar.Shelf/Models/ProductConfig.cs +++ b/src/Cocoar.Shelf/Models/ProductConfig.cs @@ -1,12 +1,18 @@ -namespace Cocoar.Shelf.Models; - -public class ProductConfig -{ - public required string Name { get; init; } - - public string? DisplayName { get; init; } - - public string? Description { get; init; } - - public string Source { get; init; } = "upload"; -} +namespace Cocoar.Shelf.Models; + +public class ProductConfig +{ + public required string Name { get; init; } + + public string? DisplayName { get; init; } + + public string? Description { get; init; } + + public string Source { get; init; } = "upload"; + + public string Visibility { get; init; } = "public"; + + public IReadOnlyList Tags { get; init; } = []; + + public bool ShowWhenEmpty { get; init; } = false; +} diff --git a/src/Cocoar.Shelf/Models/ProductRequests.cs b/src/Cocoar.Shelf/Models/ProductRequests.cs new file mode 100644 index 0000000..1fb4a7e --- /dev/null +++ b/src/Cocoar.Shelf/Models/ProductRequests.cs @@ -0,0 +1,5 @@ +namespace Cocoar.Shelf.Models; + +public record CreateProductRequest(string Name, string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty); + +public record UpdateProductRequest(string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty); diff --git a/src/Cocoar.Shelf/Program.cs b/src/Cocoar.Shelf/Program.cs index bb52f17..c464903 100644 --- a/src/Cocoar.Shelf/Program.cs +++ b/src/Cocoar.Shelf/Program.cs @@ -1,27 +1,109 @@ -using Cocoar.Shelf; -using Cocoar.Shelf.Endpoints; -using Cocoar.Shelf.Middleware; -using Cocoar.Shelf.Services; -using Microsoft.Extensions.Options; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.Configure(builder.Configuration.GetSection("Shelf")); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -var app = builder.Build(); - -var shelfOptions = app.Services.GetRequiredService>().Value; -if (!string.IsNullOrEmpty(shelfOptions.PathBase)) - app.UsePathBase(shelfOptions.PathBase); - -if (shelfOptions.EnableLandingPage) - app.MapGet("/", LandingPageEndpoint.Render); - -app.MapApiEndpoints(); -app.UseMiddleware(); - -app.Run(); +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.DI.Extensions; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; +using Cocoar.Shelf; +using Cocoar.Shelf.Endpoints; +using Cocoar.Shelf.Middleware; +using Cocoar.Shelf.Services; +using System.Globalization; +using Microsoft.AspNetCore.Authentication.Cookies; +using Serilog; +using Serilog.Sinks.SystemConsole.Themes; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .CreateBootstrapLogger(); + +var builder = WebApplication.CreateBuilder(args); + +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromFile("data/configuration.json"), + rules.For().FromEnvironment("Shelf__"), + ])); + +var configManager = builder.GetCocoarConfigManager(); +var config = configManager.GetConfig()!; + +builder.Services.AddSerilog(logConfig => +{ + foreach (var (key, level) in config.Logging.LogLevels) + { + if (key.Equals("default", StringComparison.OrdinalIgnoreCase) || + key.Equals("*", StringComparison.OrdinalIgnoreCase)) + { + logConfig.MinimumLevel.Is(level); + } + else + { + logConfig.MinimumLevel.Override(key, level); + } + } + + logConfig.WriteTo.Console(theme: AnsiConsoleTheme.Code, formatProvider: CultureInfo.InvariantCulture); +}); + +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.Name = "shelf.auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.ExpireTimeSpan = TimeSpan.FromHours(12); + options.SlidingExpiration = true; + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = 401; + return Task.CompletedTask; + }; + }); +builder.Services.AddAuthorization(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +if (!string.IsNullOrEmpty(config.PathBase)) + app.UsePathBase(config.PathBase); + +app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "SAMEORIGIN"; + context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + context.Response.Headers["X-XSS-Protection"] = "0"; + await next(); +}); + +app.UseSerilogRequestLogging(); +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapApiEndpoints(); +app.MapLlmsTxt(); +app.UseMiddleware(); +app.MapFallback(async (HttpContext ctx, IReactiveConfig shelfConfig) => +{ + var env = ctx.RequestServices.GetRequiredService(); + var indexPath = Path.Combine(env.WebRootPath, "index.html"); + if (!File.Exists(indexPath)) + { + ctx.Response.StatusCode = 404; + return; + } + var html = await File.ReadAllTextAsync(indexPath); + var pathBase = shelfConfig.CurrentValue.PathBase.TrimEnd('/'); + html = html.Replace( + "window.__SHELF_OPTIONS__ = {\"pathBase\":\"\"};", + $"window.__SHELF_OPTIONS__ = {{\"pathBase\":\"{pathBase}\"}};"); + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(html); +}); + +app.Run(config.AppUrl); + +public partial class Program; diff --git a/src/Cocoar.Shelf/Services/BasePathDetector.cs b/src/Cocoar.Shelf/Services/BasePathDetector.cs index 5f7ab5f..a0d6f54 100644 --- a/src/Cocoar.Shelf/Services/BasePathDetector.cs +++ b/src/Cocoar.Shelf/Services/BasePathDetector.cs @@ -1,46 +1,53 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; - -namespace Cocoar.Shelf.Services; - -public sealed partial class BasePathDetector -{ - private readonly ConcurrentDictionary _cache = new(); - - /// - /// Detects the base path used during the VitePress build by inspecting index.html. - /// Looks for the first href pointing to "assets/" and extracts the prefix. - /// - public string Detect(string versionDir) - { - var key = versionDir; - - if (_cache.TryGetValue(key, out var cached)) - return cached; - - var indexPath = Path.Combine(versionDir, "index.html"); - - if (!File.Exists(indexPath)) - { - _cache.TryAdd(key, "/"); - return "/"; - } - - var html = File.ReadAllText(indexPath); - - // Look for href="...assets/" — the part before "assets/" is the base path - var match = AssetHrefRegex().Match(html); - - var basePath = match.Success ? match.Groups[1].Value : "/"; - - _cache.TryAdd(key, basePath); - return basePath; - } - - public void InvalidateCache(string versionDir) => - _cache.TryRemove(versionDir, out _); - - // Matches href="/some/base/assets/ and captures the base part - [GeneratedRegex("""href="([^"]*?)assets/""")] - private static partial Regex AssetHrefRegex(); -} +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +namespace Cocoar.Shelf.Services; + +public sealed partial class BasePathDetector +{ + private readonly ConcurrentDictionary _cache = new(); + + /// + /// Detects the base path used during the VitePress build by inspecting index.html. + /// Looks for the first href pointing to "assets/" and extracts the prefix. + /// + public string Detect(string versionDir) + { + var key = versionDir; + + if (_cache.TryGetValue(key, out var cached)) + return cached; + + var indexPath = Path.Combine(versionDir, "index.html"); + + if (!File.Exists(indexPath)) + { + _cache.TryAdd(key, "/"); + return "/"; + } + + var html = File.ReadAllText(indexPath); + + // Look for href="...assets/" — the part before "assets/" is the base path + var match = AssetHrefRegex().Match(html); + + var basePath = match.Success ? match.Groups[1].Value : "/"; + + _cache.TryAdd(key, basePath); + return basePath; + } + + public void InvalidateCache(string versionDir) => + _cache.TryRemove(versionDir, out _); + + public void InvalidateProductCache(string productDir) + { + foreach (var key in _cache.Keys) + if (key.StartsWith(productDir, StringComparison.OrdinalIgnoreCase)) + _cache.TryRemove(key, out _); + } + + // Matches href="/some/base/assets/ and captures the base part + [GeneratedRegex("""href="([^"]*?)assets/""")] + private static partial Regex AssetHrefRegex(); +} diff --git a/src/Cocoar.Shelf/Services/IProductConfigService.cs b/src/Cocoar.Shelf/Services/IProductConfigService.cs index 4606ab0..43ed5a4 100644 --- a/src/Cocoar.Shelf/Services/IProductConfigService.cs +++ b/src/Cocoar.Shelf/Services/IProductConfigService.cs @@ -7,4 +7,10 @@ public interface IProductConfigService ProductConfig? GetConfig(string name); IReadOnlyList GetAll(); + + Task CreateAsync(ProductConfig config); + + Task UpdateAsync(ProductConfig config); + + Task DeleteAsync(string name); } diff --git a/src/Cocoar.Shelf/Services/IUploadService.cs b/src/Cocoar.Shelf/Services/IUploadService.cs index 3ea0ca3..03c4c53 100644 --- a/src/Cocoar.Shelf/Services/IUploadService.cs +++ b/src/Cocoar.Shelf/Services/IUploadService.cs @@ -1,16 +1,20 @@ -namespace Cocoar.Shelf.Services; - -public interface IUploadService -{ - Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default); -} - -public enum UploadStatus -{ - Success, - InvalidArchive, - MissingIndexHtml, - VersionConflict -} - -public record UploadResult(UploadStatus Status, string? Error = null); +namespace Cocoar.Shelf.Services; + +public interface IUploadService +{ + Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default); + + Task DeleteVersionAsync(string product, string version, CancellationToken ct = default); + + Task DeleteProductDataAsync(string product, CancellationToken ct = default); +} + +public enum UploadStatus +{ + Success, + InvalidArchive, + MissingIndexHtml, + VersionConflict +} + +public record UploadResult(UploadStatus Status, string? Error = null); diff --git a/src/Cocoar.Shelf/Services/ManifestService.cs b/src/Cocoar.Shelf/Services/ManifestService.cs index 893de40..12f941c 100644 --- a/src/Cocoar.Shelf/Services/ManifestService.cs +++ b/src/Cocoar.Shelf/Services/ManifestService.cs @@ -1,33 +1,33 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; +using Cocoar.Configuration.Reactive; using Cocoar.FileSystem; using Cocoar.Shelf.Models; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Services; public sealed partial class ManifestService : IManifestService, IDisposable { - private readonly ShelfOptions _options; + private readonly IReactiveConfig _config; private readonly ILogger _logger; private readonly ConcurrentDictionary _cache = new(); private readonly ResilientFileSystemMonitor? _monitor; private readonly Regex _versionRegex; - public ManifestService(IOptions options, ILogger logger) + public ManifestService(IReactiveConfig config, ILogger logger) { - _options = options.Value; + _config = config; _logger = logger; - _versionRegex = new Regex(_options.VersionPattern, RegexOptions.Compiled); + _versionRegex = new Regex(_config.CurrentValue.VersionPattern, RegexOptions.Compiled); - if (!Directory.Exists(_options.DocsRoot)) + if (!Directory.Exists(_config.CurrentValue.DocsRoot)) { - LogDocsRootMissing(_options.DocsRoot); + LogDocsRootMissing(_config.CurrentValue.DocsRoot); return; } _monitor = ResilientFileSystemMonitor - .Watch(_options.DocsRoot) + .Watch(_config.CurrentValue.DocsRoot) .IncludeSubdirectories(2) .WithDebounce(500) .OnCreated((_, e) => InvalidateCache(e.FullPath)) @@ -51,7 +51,7 @@ public ManifestService(IOptions options, ILogger if (_cache.TryGetValue(product, out var cached)) return cached; - var productDir = Path.Combine(_options.DocsRoot, product); + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); if (!Directory.Exists(productDir)) return null; @@ -67,10 +67,10 @@ public ManifestService(IOptions options, ILogger public IReadOnlyList GetProducts() { - if (!Directory.Exists(_options.DocsRoot)) + if (!Directory.Exists(_config.CurrentValue.DocsRoot)) return []; - return Directory.GetDirectories(_options.DocsRoot) + return Directory.GetDirectories(_config.CurrentValue.DocsRoot) .Select(Path.GetFileName) .Where(name => name != null) .Cast() @@ -124,7 +124,7 @@ internal static (int Major, int Minor, int Patch, bool IsStable, string Pre) Par private void InvalidateCache(string fullPath) { - var docsRootFull = Path.GetFullPath(_options.DocsRoot); + var docsRootFull = Path.GetFullPath(_config.CurrentValue.DocsRoot); var changedFull = Path.GetFullPath(fullPath); if (!changedFull.StartsWith(docsRootFull, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Cocoar.Shelf/Services/ProductConfigService.cs b/src/Cocoar.Shelf/Services/ProductConfigService.cs index 0d3009e..812c77d 100644 --- a/src/Cocoar.Shelf/Services/ProductConfigService.cs +++ b/src/Cocoar.Shelf/Services/ProductConfigService.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; using System.Text.Json; +using Cocoar.Configuration.Reactive; using Cocoar.FileSystem; using Cocoar.Shelf.Models; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Services; @@ -20,10 +20,10 @@ public sealed partial class ProductConfigService : IProductConfigService, IDispo AllowTrailingCommas = true }; - public ProductConfigService(IOptions options, ILogger logger) + public ProductConfigService(IReactiveConfig config, ILogger logger) { _logger = logger; - _productsDir = Path.Combine(options.Value.ConfigRoot, "products"); + _productsDir = Path.Combine(config.CurrentValue.ConfigRoot, "products"); if (!Directory.Exists(_productsDir)) { @@ -122,6 +122,56 @@ private void TryLoadFile(string fullPath) } } + private static readonly JsonSerializerOptions WriteJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public async Task CreateAsync(ProductConfig config) + { + ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + + Directory.CreateDirectory(_productsDir); + + var filePath = Path.Combine(_productsDir, $"{config.Name}.json"); + if (File.Exists(filePath)) + throw new InvalidOperationException($"Product '{config.Name}' already exists"); + + var json = JsonSerializer.Serialize(config, WriteJsonOptions); + await File.WriteAllTextAsync(filePath, json); + _cache[config.Name] = config; + LogConfigLoaded(config.Name); + } + + public async Task UpdateAsync(ProductConfig config) + { + ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + + var filePath = Path.Combine(_productsDir, $"{config.Name}.json"); + if (!File.Exists(filePath)) + throw new KeyNotFoundException($"Product '{config.Name}' not found"); + + var json = JsonSerializer.Serialize(config, WriteJsonOptions); + await File.WriteAllTextAsync(filePath, json); + _cache[config.Name] = config; + LogConfigLoaded(config.Name); + } + + public Task DeleteAsync(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var filePath = Path.Combine(_productsDir, $"{name}.json"); + if (!File.Exists(filePath)) + return Task.FromResult(false); + + File.Delete(filePath); + _cache.TryRemove(name, out _); + LogConfigRemoved(name); + return Task.FromResult(true); + } + public void Dispose() { _monitor?.Dispose(); diff --git a/src/Cocoar.Shelf/Services/UploadService.cs b/src/Cocoar.Shelf/Services/UploadService.cs index 5a8c451..57f7724 100644 --- a/src/Cocoar.Shelf/Services/UploadService.cs +++ b/src/Cocoar.Shelf/Services/UploadService.cs @@ -1,112 +1,164 @@ -using System.Collections.Concurrent; -using System.IO.Compression; -using Microsoft.Extensions.Options; - -namespace Cocoar.Shelf.Services; - -public sealed partial class UploadService : IUploadService -{ - private readonly ShelfOptions _options; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _locks = new(); - - public UploadService(IOptions options, ILogger logger) - { - _options = options.Value; - _logger = logger; - } - - public async Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default) - { - var key = $"{product}/{version}"; - var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - - if (!await semaphore.WaitAsync(0, ct)) - return new UploadResult(UploadStatus.VersionConflict, "Upload for this version is already in progress"); - - var tempDir = Path.Combine(_options.DocsRoot, ".shelf-tmp", Guid.NewGuid().ToString("N")); - - try - { - Directory.CreateDirectory(tempDir); - var tempDirFull = Path.GetFullPath(tempDir); - - // Extract ZIP - try - { - using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); - - foreach (var entry in archive.Entries) - { - if (string.IsNullOrEmpty(entry.Name)) - continue; - - // Normalize backslashes from Windows-created ZIPs - var entryPath = entry.FullName.Replace('\\', '/'); - var destPath = Path.GetFullPath(Path.Combine(tempDir, entryPath)); - - // ZIP-Slip protection - if (!destPath.StartsWith(tempDirFull, StringComparison.OrdinalIgnoreCase)) - return new UploadResult(UploadStatus.InvalidArchive, "Archive contains path traversal entries"); - - var destDir = Path.GetDirectoryName(destPath); - if (destDir != null) - Directory.CreateDirectory(destDir); - - entry.ExtractToFile(destPath, overwrite: true); - } - } - catch (InvalidDataException) - { - return new UploadResult(UploadStatus.InvalidArchive, "Invalid or corrupt ZIP archive"); - } - - // Validate: index.html must exist at the root - if (!File.Exists(Path.Combine(tempDir, "index.html"))) - return new UploadResult(UploadStatus.MissingIndexHtml, "Archive must contain an index.html at the root"); - - // Ensure product directory exists - var productDir = Path.Combine(_options.DocsRoot, product); - Directory.CreateDirectory(productDir); - - // Atomic move: swap existing version if present - var destVersionDir = Path.Combine(_options.DocsRoot, product, version); - - if (Directory.Exists(destVersionDir)) - { - var oldDir = Path.Combine(_options.DocsRoot, ".shelf-tmp", $"old-{Guid.NewGuid():N}"); - Directory.Move(destVersionDir, oldDir); - - try { Directory.Delete(oldDir, recursive: true); } - catch { /* best-effort cleanup */ } - } - - Directory.Move(tempDir, destVersionDir); - - LogVersionDeployed(product, version); - return new UploadResult(UploadStatus.Success); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - LogUploadFailed(product, version, ex.Message); - return new UploadResult(UploadStatus.InvalidArchive, $"Upload failed: {ex.Message}"); - } - finally - { - semaphore.Release(); - - // Clean up temp dir if it still exists (failure path) - if (Directory.Exists(tempDir)) - { - try { Directory.Delete(tempDir, recursive: true); } - catch { /* best-effort cleanup */ } - } - } - } - - [LoggerMessage(Level = LogLevel.Information, Message = "Version deployed: {Product}/{Version}")] - private partial void LogVersionDeployed(string product, string version); - - [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}: {Error}")] - private partial void LogUploadFailed(string product, string version, string error); -} +using System.Collections.Concurrent; +using System.IO.Compression; +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Shelf.Services; + +public sealed partial class UploadService : IUploadService +{ + private readonly IReactiveConfig _config; + private readonly ILogger _logger; + private readonly BasePathDetector _basePathDetector; + private readonly ConcurrentDictionary _locks = new(); + + public UploadService(IReactiveConfig config, ILogger logger, BasePathDetector basePathDetector) + { + _config = config; + _logger = logger; + _basePathDetector = basePathDetector; + } + + public async Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default) + { + var key = $"{product}/{version}"; + var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + + if (!await semaphore.WaitAsync(0, ct)) + return new UploadResult(UploadStatus.VersionConflict, "Upload for this version is already in progress"); + + var tempDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(tempDir); + var tempDirFull = Path.GetFullPath(tempDir); + + // Extract ZIP + try + { + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + continue; + + // Normalize backslashes from Windows-created ZIPs + var entryPath = entry.FullName.Replace('\\', '/'); + var destPath = Path.GetFullPath(Path.Combine(tempDir, entryPath)); + + // ZIP-Slip protection + if (!destPath.StartsWith(tempDirFull, StringComparison.OrdinalIgnoreCase)) + return new UploadResult(UploadStatus.InvalidArchive, "Archive contains path traversal entries"); + + var destDir = Path.GetDirectoryName(destPath); + if (destDir != null) + Directory.CreateDirectory(destDir); + + entry.ExtractToFile(destPath, overwrite: true); + } + } + catch (InvalidDataException) + { + return new UploadResult(UploadStatus.InvalidArchive, "Invalid or corrupt ZIP archive"); + } + + // Validate: index.html must exist at the root + if (!File.Exists(Path.Combine(tempDir, "index.html"))) + return new UploadResult(UploadStatus.MissingIndexHtml, "Archive must contain an index.html at the root"); + + // Ensure product directory exists + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); + Directory.CreateDirectory(productDir); + + // Atomic move: swap existing version if present + var destVersionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); + + if (Directory.Exists(destVersionDir)) + { + var oldDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", $"old-{Guid.NewGuid():N}"); + Directory.Move(destVersionDir, oldDir); + + try { Directory.Delete(oldDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + Directory.Move(tempDir, destVersionDir); + + // Invalidate cached base path so a redeployed version picks up changes + _basePathDetector.InvalidateCache(destVersionDir); + + LogVersionDeployed(product, version); + return new UploadResult(UploadStatus.Success); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogUploadFailed(product, version, ex); + return new UploadResult(UploadStatus.InvalidArchive, $"Upload failed: {ex.Message}"); + } + finally + { + semaphore.Release(); + if (semaphore.CurrentCount == 1) + _locks.TryRemove(new KeyValuePair(key, semaphore)); + + // Clean up temp dir if it still exists (failure path) + if (Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + } + } + + public async Task DeleteVersionAsync(string product, string version, CancellationToken ct = default) + { + var key = $"{product}/{version}"; + var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + + if (!await semaphore.WaitAsync(0, ct)) + return false; + + try + { + var versionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); + if (!Directory.Exists(versionDir)) + return false; + + _basePathDetector.InvalidateCache(versionDir); + Directory.Delete(versionDir, recursive: true); + LogVersionDeleted(product, version); + return true; + } + finally + { + semaphore.Release(); + if (semaphore.CurrentCount == 1) + _locks.TryRemove(new KeyValuePair(key, semaphore)); + } + } + + public Task DeleteProductDataAsync(string product, CancellationToken ct = default) + { + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); + if (!Directory.Exists(productDir)) + return Task.FromResult(false); + + _basePathDetector.InvalidateProductCache(productDir); + Directory.Delete(productDir, recursive: true); + LogProductDataDeleted(product); + return Task.FromResult(true); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Version deployed: {Product}/{Version}")] + private partial void LogVersionDeployed(string product, string version); + + [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] + private partial void LogVersionDeleted(string product, string version); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product data deleted: {Product}")] + private partial void LogProductDataDeleted(string product); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] + private partial void LogUploadFailed(string product, string version, Exception ex); +} diff --git a/src/Cocoar.Shelf/ShelfOptions.cs b/src/Cocoar.Shelf/ShelfOptions.cs index 81d2b51..db0ba25 100644 --- a/src/Cocoar.Shelf/ShelfOptions.cs +++ b/src/Cocoar.Shelf/ShelfOptions.cs @@ -1,20 +1,33 @@ +using Serilog.Events; + namespace Cocoar.Shelf; public class ShelfOptions { + public string AppUrl { get; set; } = "http://0.0.0.0:8080"; + public string DocsRoot { get; set; } = "/data/docs"; public string ConfigRoot { get; set; } = "/data/config"; public string PathBase { get; set; } = ""; - public string VersionPattern { get; set; } = @"^v?\d+(\.\d+(\.\d+(-[\w.]+)?)?)?$"; + public string VersionPattern { get; set; } = @"^v?\d+(\.\d+(\.\d+(-[\w.-]+)?)?)?$"; public string BasePlaceholder { get; set; } = "/__shelf__/"; - public bool EnableLandingPage { get; set; } - public string ApiKey { get; set; } = ""; public long MaxUploadSizeBytes { get; set; } = 104_857_600; // 100 MB + + public ShelfLogging Logging { get; set; } = new(); +} + +public class ShelfLogging +{ + public Dictionary LogLevels { get; set; } = new() + { + ["Default"] = LogEventLevel.Information, + ["Microsoft.AspNetCore"] = LogEventLevel.Warning, + }; } diff --git a/src/Cocoar.Shelf/appsettings.json b/src/Cocoar.Shelf/appsettings.json index 98d3b93..2c63c08 100644 --- a/src/Cocoar.Shelf/appsettings.json +++ b/src/Cocoar.Shelf/appsettings.json @@ -1,15 +1,2 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Shelf": { - "DocsRoot": "/data/docs", - "ConfigRoot": "/data/config", - "ApiKey": "", - "PathBase": "", - "EnableLandingPage": false - } } diff --git a/src/Cocoar.Shelf/data/configuration.json b/src/Cocoar.Shelf/data/configuration.json new file mode 100644 index 0000000..aba7b9e --- /dev/null +++ b/src/Cocoar.Shelf/data/configuration.json @@ -0,0 +1,14 @@ +{ + "AppUrl": "http://0.0.0.0:8080", + "DocsRoot": "/data/docs", + "ConfigRoot": "/data/config", + "PathBase": "", + "ApiKey": "dev-key", + "MaxUploadSizeBytes": 104857600, + "Logging": { + "LogLevels": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 75c6d81..dc5ad79 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,11 +6,14 @@ + + + diff --git a/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs b/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs index 38625d3..4e43ea1 100644 --- a/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; namespace Cocoar.Shelf.Tests; @@ -55,7 +55,7 @@ public async Task PassesThrough_WhenKeyMatches() private static async Task InvokeFilter(string apiKey, string? authHeader) { var services = new ServiceCollection(); - services.Configure(o => o.ApiKey = apiKey); + services.AddScoped(_ => new ShelfOptions { ApiKey = apiKey }); var httpContext = new DefaultHttpContext { @@ -66,7 +66,7 @@ public async Task PassesThrough_WhenKeyMatches() httpContext.Request.Headers.Authorization = authHeader; var context = new DefaultEndpointFilterInvocationContext(httpContext); - var filter = new ApiKeyFilter(); + var filter = new ApiKeyFilter(NullLogger.Instance); return await filter.InvokeAsync(context, _ => ValueTask.FromResult("passed")); } diff --git a/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj b/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj index b79460f..00ac667 100644 --- a/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj +++ b/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/AdminApiTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/AdminApiTests.cs new file mode 100644 index 0000000..eed93ba --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/AdminApiTests.cs @@ -0,0 +1,333 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class AdminApiTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public AdminApiTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + private HttpRequestMessage Auth(HttpRequestMessage request) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + return request; + } + + private static StringContent Json(object body) => + new(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + + private async Task CreateProductViaApi(string name, string? displayName = null, string? description = null) + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name, displayName, description }) + }); + return await _client.SendAsync(request); + } + + #region Auth + + [Fact] + public async Task Login_Returns200_WithValidKey() + { + var response = await _client.PostAsync("/_api/auth/login", Json(new { apiKey = _fixture.ApiKey })); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task Login_Returns401_WithWrongKey() + { + var response = await _client.PostAsync("/_api/auth/login", Json(new { apiKey = "wrong" })); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Me_Returns401_WhenNotLoggedIn() + { + var response = await _client.GetAsync("/_api/auth/me"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task BearerToken_StillWorksForCiCd() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "bearer-test-1" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + #endregion + + #region Create Product + + [Fact] + public async Task CreateProduct_Returns201_WithValidData() + { + var response = await CreateProductViaApi("admin-create-1", "Create Test", "A test product"); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("admin-create-1", doc.GetProperty("name").GetString()); + Assert.Equal("Create Test", doc.GetProperty("displayName").GetString()); + } + + [Fact] + public async Task CreateProduct_Returns409_WhenAlreadyExists() + { + await CreateProductViaApi("admin-dup-1"); + + var response = await CreateProductViaApi("admin-dup-1"); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_Returns400_WithEmptyName() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_Returns400_WithInvalidName() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "Invalid Name!" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_Returns400_WithReservedName() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "admin" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("reserved", json); + } + + [Fact] + public async Task CreateProduct_Returns401_WithoutAuth() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "admin-unauth-1" }) + }; + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_DefaultsSourceToUpload() + { + var response = await CreateProductViaApi("admin-default-src-1"); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("upload", doc.GetProperty("source").GetString()); + } + + #endregion + + #region Update Product + + [Fact] + public async Task UpdateProduct_Returns200_WithValidData() + { + await CreateProductViaApi("admin-update-1", "Original"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Put, "/_api/products/admin-update-1") + { + Content = Json(new { displayName = "Updated Name", description = "New desc" }) + }); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("Updated Name", doc.GetProperty("displayName").GetString()); + } + + [Fact] + public async Task UpdateProduct_Returns404_WhenNotFound() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Put, "/_api/products/admin-nonexistent-update") + { + Content = Json(new { displayName = "Nope" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task UpdateProduct_PreservesExistingFields_WhenNotProvided() + { + await CreateProductViaApi("admin-partial-1", "Original", "Original Desc"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Put, "/_api/products/admin-partial-1") + { + Content = Json(new { displayName = "Changed" }) + }); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("Changed", doc.GetProperty("displayName").GetString()); + Assert.Equal("Original Desc", doc.GetProperty("description").GetString()); + } + + #endregion + + #region Delete Product + + [Fact] + public async Task DeleteProduct_Returns204_WhenExists() + { + await CreateProductViaApi("admin-delete-1"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, "/_api/products/admin-delete-1")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task DeleteProduct_Returns404_WhenNotFound() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, "/_api/products/admin-nonexistent-del")); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + #endregion + + #region Delete Version + + [Fact] + public async Task DeleteVersion_Returns204_WhenExists() + { + await CreateProductViaApi("admin-verdel-1"); + _fixture.CreateVersionDirectory("admin-verdel-1", "v1", ""); + _fixture.CreateVersionDirectory("admin-verdel-1", "v2", ""); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-verdel-1/versions/v1")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.False(Directory.Exists(Path.Combine(_fixture.DocsRoot, "admin-verdel-1", "v1"))); + Assert.True(Directory.Exists(Path.Combine(_fixture.DocsRoot, "admin-verdel-1", "v2"))); + } + + [Fact] + public async Task DeleteVersion_Returns404_WhenVersionNotFound() + { + await CreateProductViaApi("admin-ver404-1"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-ver404-1/versions/v99")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DeleteVersion_Returns404_WhenProductNotRegistered() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-unregistered-verdel/versions/v1")); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DeleteVersion_Returns400_WithInvalidVersionFormat() + { + await CreateProductViaApi("admin-badver-1"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-badver-1/versions/not-a-version")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + #endregion + + #region SPA Fallback + + [Fact] + public async Task AdminRoute_ReturnsHtml_ForClientSideRoutes() + { + var response = await _client.GetAsync("/admin/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task AdminRoute_ReturnsHtml_ForNestedRoutes() + { + var response = await _client.GetAsync("/admin/products/some-product"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task AdminRoute_DoesNotInterfereWithApi() + { + var response = await _client.GetAsync("/_api/products"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + } + + #endregion +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs new file mode 100644 index 0000000..d5cbb39 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class ApiProductsTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public ApiProductsTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + [Fact] + public async Task GetProducts_ReturnsRegisteredProduct() + { + await _fixture.RegisterProductViaApi(_client, "test-list", "Test Product", "A test"); + _fixture.CreateVersionDirectory("test-list", "v1", ""); + + var response = await _client.GetAsync("/_api/products"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var products = JsonDocument.Parse(json).RootElement; + + var product = products.EnumerateArray().FirstOrDefault(p => p.GetProperty("name").GetString() == "test-list"); + Assert.Equal("Test Product", product.GetProperty("displayName").GetString()); + } + + [Fact] + public async Task GetVersions_Returns404_WhenProductNotRegistered() + { + var response = await _client.GetAsync("/_api/products/nonexistent/versions"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("not registered", json); + } + + [Fact] + public async Task GetVersions_ReturnsVersions_ForRegisteredProduct() + { + await _fixture.RegisterProductViaApi(_client, "test-versions"); + _fixture.CreateVersionDirectory("test-versions", "v1", ""); + _fixture.CreateVersionDirectory("test-versions", "v2", ""); + + var response = await _client.GetAsync("/_api/products/test-versions/versions"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + + var versions = doc.GetProperty("versions").EnumerateArray().Select(v => v.GetString()).ToList(); + Assert.Contains("v1", versions); + Assert.Contains("v2", versions); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/DocsServingTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/DocsServingTests.cs new file mode 100644 index 0000000..784e198 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/DocsServingTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class DocsServingTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public DocsServingTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(new() { AllowAutoRedirect = false }); + _fixture.RegisterProductViaApi(_client, "docs-test").GetAwaiter().GetResult(); + } + + private async Task UploadVersion(string product, string version, params (string name, string content)[] entries) + { + using var zip = ZipHelper.Create(entries); + var request = new HttpRequestMessage(HttpMethod.Post, $"/_api/products/{product}/versions/{version}") + { + Content = ZipHelper.ToContent(zip) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + var response = await _client.SendAsync(request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task ServesIndexHtml_ForVersionRoot() + { + await UploadVersion("docs-test", "v20", ("index.html", "v20")); + + var response = await _client.GetAsync("/docs-test/v20/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("v20", content); + } + + [Fact] + public async Task ServesSubPage() + { + await UploadVersion("docs-test", "v21", + ("index.html", ""), + ("guide/getting-started.html", "Guide")); + + var response = await _client.GetAsync("/docs-test/v21/guide/getting-started.html"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Guide", content); + } + + [Fact] + public async Task RedirectsToLatest_WhenNoVersionSpecified() + { + await UploadVersion("docs-test", "v22", ("index.html", "")); + + var response = await _client.GetAsync("/docs-test/"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Contains("/docs-test/", response.Headers.Location?.ToString() ?? ""); + } + + [Fact] + public async Task Returns404_ForNonExistentFile() + { + await UploadVersion("docs-test", "v23", ("index.html", "")); + + var response = await _client.GetAsync("/docs-test/v23/nonexistent.html"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task RewritesBasePath_InHtml() + { + await UploadVersion("docs-test", "v24", + ("index.html", """Test""")); + + var response = await _client.GetAsync("/docs-test/v24/"); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains("href=\"/docs-test/v24/assets/style.css\"", content); + Assert.DoesNotContain("href=\"/assets/style.css\"", content); + } + + [Fact] + public async Task SetsImmutableCacheHeaders_ForHashedAssets() + { + await UploadVersion("docs-test", "v25", + ("index.html", ""), + ("assets/style.a1b2c3.css", "body{}")); + + var response = await _client.GetAsync("/docs-test/v25/assets/style.a1b2c3.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var cacheControl = response.Headers.CacheControl; + Assert.NotNull(cacheControl); + Assert.True(cacheControl.Public); + Assert.Equal(TimeSpan.FromSeconds(31536000), cacheControl.MaxAge); + } + + [Fact] + public async Task ServesPlainTextFiles() + { + await UploadVersion("docs-test", "v26", + ("index.html", ""), + ("llms.txt", "# Documentation for LLMs")); + + var response = await _client.GetAsync("/docs-test/v26/llms.txt"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("# Documentation for LLMs", content); + } + + [Fact] + public async Task LandingPage_Returns200() + { + var autoRedirectClient = _fixture.CreateClient(); + + var response = await autoRedirectClient.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("html", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task LatestRedirect_PrefersStableOverPreRelease() + { + await _fixture.RegisterProductViaApi(_client, "stable-test"); + await UploadVersion("stable-test", "v2.0.0", ("index.html", "stable")); + await UploadVersion("stable-test", "v3.0.0-beta.1", ("index.html", "beta")); + + var response = await _client.GetAsync("/stable-test/"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var location = response.Headers.Location?.ToString() ?? ""; + Assert.Contains("v2.0.0", location); + Assert.DoesNotContain("beta", location); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/IntegrationTestCollection.cs b/src/tests/Cocoar.Shelf.Tests/Integration/IntegrationTestCollection.cs new file mode 100644 index 0000000..83408fc --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/IntegrationTestCollection.cs @@ -0,0 +1,4 @@ +namespace Cocoar.Shelf.Tests.Integration; + +[CollectionDefinition("Integration")] +public class IntegrationTestCollection : ICollectionFixture; diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ShelfFixture.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ShelfFixture.cs new file mode 100644 index 0000000..e2a6711 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ShelfFixture.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Cocoar.Shelf.Tests.Integration; + +public class ShelfFixture : WebApplicationFactory, IAsyncLifetime +{ + public string DocsRoot { get; private set; } = null!; + public string ConfigRoot { get; private set; } = null!; + public string ApiKey => "test-api-key"; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + + public Task InitializeAsync() + { + var baseDir = Path.Combine(Path.GetTempPath(), $"shelf-integration-{Guid.NewGuid():N}"); + DocsRoot = Path.Combine(baseDir, "docs"); + ConfigRoot = Path.Combine(baseDir, "config"); + + Directory.CreateDirectory(DocsRoot); + Directory.CreateDirectory(Path.Combine(ConfigRoot, "products")); + + // Set env vars that FromEnvironment("Shelf__") picks up (overrides appsettings.json) + Environment.SetEnvironmentVariable("Shelf__DocsRoot", DocsRoot); + Environment.SetEnvironmentVariable("Shelf__ConfigRoot", ConfigRoot); + Environment.SetEnvironmentVariable("Shelf__ApiKey", ApiKey); + + return Task.CompletedTask; + } + + public new Task DisposeAsync() + { + base.Dispose(); + + Environment.SetEnvironmentVariable("Shelf__DocsRoot", null); + Environment.SetEnvironmentVariable("Shelf__ConfigRoot", null); + Environment.SetEnvironmentVariable("Shelf__ApiKey", null); + + try + { + var baseDir = Path.GetDirectoryName(DocsRoot)!; + Directory.Delete(baseDir, recursive: true); + } + catch { /* best-effort */ } + + return Task.CompletedTask; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Production"); + } + + public async Task RegisterProductViaApi(HttpClient client, string name, string? displayName = null, string? description = null) + { + var body = new { name, displayName = displayName ?? name, description = description ?? "", source = "upload" }; + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = new StringContent(JsonSerializer.Serialize(body, JsonOptions), System.Text.Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ApiKey); + var response = await client.SendAsync(request); + + // 201 = created, 409 = already exists (both OK for test setup) + if (response.StatusCode != System.Net.HttpStatusCode.Created && + response.StatusCode != System.Net.HttpStatusCode.Conflict) + { + throw new InvalidOperationException($"Failed to register product '{name}': {response.StatusCode}"); + } + } + + public void RegisterProduct(string name, string? displayName = null, string? description = null) + { + var config = new { name, displayName = displayName ?? name, description = description ?? "", source = "upload" }; + File.WriteAllText( + Path.Combine(ConfigRoot, "products", $"{name}.json"), + JsonSerializer.Serialize(config, JsonOptions)); + } + + public void CreateVersionDirectory(string product, string version, string? indexHtml = null) + { + var dir = Path.Combine(DocsRoot, product, version); + Directory.CreateDirectory(dir); + + if (indexHtml != null) + File.WriteAllText(Path.Combine(dir, "index.html"), indexHtml); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/UploadApiTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/UploadApiTests.cs new file mode 100644 index 0000000..40bcf09 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/UploadApiTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class UploadApiTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public UploadApiTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + _fixture.RegisterProductViaApi(_client, "upload-test").GetAwaiter().GetResult(); + } + + private HttpRequestMessage CreateUploadRequest(string product, string version, HttpContent content) + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/_api/products/{product}/versions/{version}") + { + Content = content + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + return request; + } + + [Fact] + public async Task Upload_Returns201_WithValidZip() + { + using var zip = ZipHelper.Create(("index.html", ""), ("assets/app.js", "console.log('hi');")); + var request = CreateUploadRequest("upload-test", "v10", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.True(File.Exists(Path.Combine(_fixture.DocsRoot, "upload-test", "v10", "index.html"))); + } + + [Fact] + public async Task Upload_Returns401_WithoutAuth() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products/upload-test/versions/v11") + { + Content = ZipHelper.ToContent(zip) + }; + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Upload_Returns401_WithWrongKey() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products/upload-test/versions/v12") + { + Content = ZipHelper.ToContent(zip) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "wrong-key"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Upload_Returns400_WithInvalidVersionFormat() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = CreateUploadRequest("upload-test", "not-a-version", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid version format", json); + Assert.Contains("Must match pattern", json); + } + + [Fact] + public async Task Upload_Returns404_ForUnregisteredProduct() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = CreateUploadRequest("unregistered-product", "v1", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("not registered", json); + } + + [Fact] + public async Task Upload_Returns400_WithCorruptZip() + { + var content = new ByteArrayContent("this is not a zip"u8.ToArray()); + content.Headers.ContentType = new("application/zip"); + var request = CreateUploadRequest("upload-test", "v13", content); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid", json); + } + + [Fact] + public async Task Upload_Returns400_WithoutIndexHtml() + { + using var zip = ZipHelper.Create(("readme.txt", "hello"), ("assets/style.css", "body{}")); + var request = CreateUploadRequest("upload-test", "v14", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("index.html", json); + } + + [Fact] + public async Task Upload_ReplacesExistingVersion() + { + using var zip1 = ZipHelper.Create(("index.html", "original")); + var r1 = CreateUploadRequest("upload-test", "v15", ZipHelper.ToContent(zip1)); + await _client.SendAsync(r1); + + using var zip2 = ZipHelper.Create(("index.html", "updated")); + var r2 = CreateUploadRequest("upload-test", "v15", ZipHelper.ToContent(zip2)); + var response = await _client.SendAsync(r2); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var content = File.ReadAllText(Path.Combine(_fixture.DocsRoot, "upload-test", "v15", "index.html")); + Assert.Equal("updated", content); + } + + [Fact] + public async Task Upload_AcceptsSemVerVersions() + { + var versions = new[] { "v1", "v5.2", "v5.2.0", "5.0.0", "v6.0.0-beta.1" }; + + foreach (var version in versions) + { + using var zip = ZipHelper.Create(("index.html", $"{version}")); + var request = CreateUploadRequest("upload-test", version, ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + } + + [Fact] + public async Task Upload_IsVisibleInVersionsList() + { + await _fixture.RegisterProductViaApi(_client, "upload-visible"); + using var zip = ZipHelper.Create(("index.html", "")); + var request = CreateUploadRequest("upload-visible", "v3", ZipHelper.ToContent(zip)); + await _client.SendAsync(request); + + var response = await _client.GetAsync("/_api/products/upload-visible/versions"); + var json = await response.Content.ReadAsStringAsync(); + var doc = System.Text.Json.JsonDocument.Parse(json).RootElement; + + Assert.Equal("v3", doc.GetProperty("latest").GetString()); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/UploadDisabledTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/UploadDisabledTests.cs new file mode 100644 index 0000000..49b13de --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/UploadDisabledTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http.Headers; +using Cocoar.Configuration.Reactive; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class UploadDisabledTests +{ + private readonly HttpClient _client; + + public UploadDisabledTests(ShelfFixture fixture) + { + fixture.RegisterProduct("disabled-test"); + + // Create a separate factory that overrides ApiKey to empty + var factory = fixture.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Replace the scoped ShelfOptions with one that has no API key + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ShelfOptions)); + if (descriptor != null) + services.Remove(descriptor); + + services.AddScoped(sp => + { + var config = sp.GetRequiredService>(); + var opts = config.CurrentValue; + return new ShelfOptions + { + DocsRoot = opts.DocsRoot, + ConfigRoot = opts.ConfigRoot, + ApiKey = "", + VersionPattern = opts.VersionPattern, + BasePlaceholder = opts.BasePlaceholder, + MaxUploadSizeBytes = opts.MaxUploadSizeBytes, + PathBase = opts.PathBase + }; + }); + }); + }); + + _client = factory.CreateClient(); + } + + [Fact] + public async Task Upload_Returns503_WhenApiKeyNotConfigured() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products/disabled-test/versions/v1") + { + Content = new ByteArrayContent(Array.Empty()) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "any-key"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("disabled", json); + } + + [Fact] + public async Task ReadEndpoints_StillWork_WhenUploadDisabled() + { + var response = await _client.GetAsync("/_api/products"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ZipHelper.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ZipHelper.cs new file mode 100644 index 0000000..1d1484a --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ZipHelper.cs @@ -0,0 +1,28 @@ +using System.IO.Compression; + +namespace Cocoar.Shelf.Tests.Integration; + +internal static class ZipHelper +{ + public static MemoryStream Create(params (string name, string content)[] entries) + { + var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write(content); + } + } + ms.Position = 0; + return ms; + } + + public static ByteArrayContent ToContent(MemoryStream zip) + { + var content = new ByteArrayContent(zip.ToArray()); + content.Headers.ContentType = new("application/zip"); + return content; + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs b/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs index aace5da..5b6805e 100644 --- a/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs @@ -1,6 +1,5 @@ using Cocoar.Shelf.Services; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Tests; @@ -14,8 +13,8 @@ public ManifestServiceTests() _tempDir = Path.Combine(Path.GetTempPath(), $"shelf-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); - var options = Options.Create(new ShelfOptions { DocsRoot = _tempDir }); - _sut = new ManifestService(options, NullLogger.Instance); + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _tempDir }); + _sut = new ManifestService(config, NullLogger.Instance); } [Fact] @@ -180,8 +179,8 @@ public void GetProducts_ReturnsProductDirectories() [Fact] public void GetProducts_ReturnsEmpty_WhenNoProducts() { - var options = Options.Create(new ShelfOptions { DocsRoot = Path.Combine(_tempDir, "nonexistent") }); - using var sut = new ManifestService(options, NullLogger.Instance); + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = Path.Combine(_tempDir, "nonexistent") }); + using var sut = new ManifestService(config, NullLogger.Instance); var products = sut.GetProducts(); diff --git a/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs index 30ba2dd..fed5519 100644 --- a/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Cocoar.Shelf.Services; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Tests; @@ -19,8 +18,8 @@ public ProductConfigServiceTests() private ProductConfigService CreateService(string? configRoot = null) { - var options = Options.Create(new ShelfOptions { ConfigRoot = configRoot ?? _tempDir }); - return new ProductConfigService(options, NullLogger.Instance); + var config = new TestReactiveConfig(new ShelfOptions { ConfigRoot = configRoot ?? _tempDir }); + return new ProductConfigService(config, NullLogger.Instance); } [Fact] diff --git a/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceWriteTests.cs b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceWriteTests.cs new file mode 100644 index 0000000..f18df15 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceWriteTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using Cocoar.Shelf.Models; +using Cocoar.Shelf.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests; + +public sealed class ProductConfigServiceWriteTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _productsDir; + + public ProductConfigServiceWriteTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"shelf-write-tests-{Guid.NewGuid():N}"); + _productsDir = Path.Combine(_tempDir, "products"); + Directory.CreateDirectory(_productsDir); + } + + private ProductConfigService CreateService() + { + var config = new TestReactiveConfig(new ShelfOptions { ConfigRoot = _tempDir }); + return new ProductConfigService(config, NullLogger.Instance); + } + + [Fact] + public async Task CreateAsync_WritesJsonFile() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "new-product", DisplayName = "New", Description = "Desc", Source = "upload" }; + + await sut.CreateAsync(product); + + Assert.True(File.Exists(Path.Combine(_productsDir, "new-product.json"))); + var result = sut.GetConfig("new-product"); + Assert.NotNull(result); + Assert.Equal("New", result.DisplayName); + } + + [Fact] + public async Task CreateAsync_ThrowsWhenAlreadyExists() + { + File.WriteAllText(Path.Combine(_productsDir, "existing.json"), + JsonSerializer.Serialize(new { name = "existing", source = "upload" })); + using var sut = CreateService(); + + var product = new ProductConfig { Name = "existing" }; + + await Assert.ThrowsAsync(() => sut.CreateAsync(product)); + } + + [Fact] + public async Task CreateAsync_ThrowsWithEmptyName() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "" }; + + await Assert.ThrowsAsync(() => sut.CreateAsync(product)); + } + + [Fact] + public async Task CreateAsync_IsImmediatelyReadable() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "immediate-read", DisplayName = "Test" }; + + await sut.CreateAsync(product); + + // Should be in cache immediately, not waiting for FileSystemWatcher + var result = sut.GetConfig("immediate-read"); + Assert.NotNull(result); + Assert.Equal("Test", result.DisplayName); + } + + [Fact] + public async Task UpdateAsync_OverwritesExisting() + { + File.WriteAllText(Path.Combine(_productsDir, "to-update.json"), + JsonSerializer.Serialize(new { name = "to-update", displayName = "Old", source = "upload" })); + using var sut = CreateService(); + + var updated = new ProductConfig { Name = "to-update", DisplayName = "New", Description = "Added", Source = "upload" }; + await sut.UpdateAsync(updated); + + var result = sut.GetConfig("to-update"); + Assert.NotNull(result); + Assert.Equal("New", result.DisplayName); + Assert.Equal("Added", result.Description); + } + + [Fact] + public async Task UpdateAsync_ThrowsWhenNotFound() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "nonexistent" }; + + await Assert.ThrowsAsync(() => sut.UpdateAsync(product)); + } + + [Fact] + public async Task DeleteAsync_RemovesFileAndReturnsTrue() + { + File.WriteAllText(Path.Combine(_productsDir, "to-delete.json"), + JsonSerializer.Serialize(new { name = "to-delete", source = "upload" })); + using var sut = CreateService(); + + Assert.NotNull(sut.GetConfig("to-delete")); + + var result = await sut.DeleteAsync("to-delete"); + + Assert.True(result); + Assert.False(File.Exists(Path.Combine(_productsDir, "to-delete.json"))); + Assert.Null(sut.GetConfig("to-delete")); + } + + [Fact] + public async Task DeleteAsync_ReturnsFalseWhenNotFound() + { + using var sut = CreateService(); + + var result = await sut.DeleteAsync("nonexistent"); + + Assert.False(result); + } + + [Fact] + public async Task CreateAsync_CreatesDirectoryIfMissing() + { + var emptyDir = Path.Combine(_tempDir, "empty-config"); + var config = new TestReactiveConfig(new ShelfOptions { ConfigRoot = emptyDir }); + using var sut = new ProductConfigService(config, NullLogger.Instance); + + var product = new ProductConfig { Name = "auto-dir" }; + await sut.CreateAsync(product); + + Assert.True(File.Exists(Path.Combine(emptyDir, "products", "auto-dir.json"))); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort */ } + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/TestReactiveConfig.cs b/src/tests/Cocoar.Shelf.Tests/TestReactiveConfig.cs new file mode 100644 index 0000000..4b27306 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/TestReactiveConfig.cs @@ -0,0 +1,19 @@ +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Shelf.Tests; + +internal sealed class TestReactiveConfig(T value) : IReactiveConfig +{ + public T CurrentValue => value; + + public IDisposable Subscribe(IObserver observer) + { + observer.OnNext(value); + return new NoopDisposable(); + } + + private sealed class NoopDisposable : IDisposable + { + public void Dispose() { } + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs b/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs new file mode 100644 index 0000000..41a356b --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs @@ -0,0 +1,74 @@ +using Cocoar.Shelf.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests; + +public sealed class UploadServiceDeleteTests : IDisposable +{ + private readonly string _docsRoot; + private readonly UploadService _sut; + + public UploadServiceDeleteTests() + { + _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-delete-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_docsRoot); + + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); + _sut = new UploadService(config, NullLogger.Instance, new BasePathDetector()); + } + + [Fact] + public async Task DeleteVersion_RemovesDirectory_ReturnsTrue() + { + var versionDir = Path.Combine(_docsRoot, "myproduct", "v1"); + Directory.CreateDirectory(versionDir); + File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); + + var result = await _sut.DeleteVersionAsync("myproduct", "v1"); + + Assert.True(result); + Assert.False(Directory.Exists(versionDir)); + } + + [Fact] + public async Task DeleteVersion_ReturnsFalse_WhenNotFound() + { + var result = await _sut.DeleteVersionAsync("myproduct", "v99"); + + Assert.False(result); + } + + [Fact] + public async Task DeleteVersion_PreservesOtherVersions() + { + Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v1")); + Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v2")); + + await _sut.DeleteVersionAsync("myproduct", "v1"); + + Assert.False(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v1"))); + Assert.True(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v2"))); + } + + [Fact] + public async Task DeleteVersion_RemovesAllContents() + { + var versionDir = Path.Combine(_docsRoot, "myproduct", "v3"); + Directory.CreateDirectory(Path.Combine(versionDir, "assets")); + Directory.CreateDirectory(Path.Combine(versionDir, "guide")); + File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); + File.WriteAllText(Path.Combine(versionDir, "assets", "style.css"), "body{}"); + File.WriteAllText(Path.Combine(versionDir, "guide", "page.html"), ""); + + var result = await _sut.DeleteVersionAsync("myproduct", "v3"); + + Assert.True(result); + Assert.False(Directory.Exists(versionDir)); + } + + public void Dispose() + { + try { Directory.Delete(_docsRoot, recursive: true); } + catch { /* best-effort */ } + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs b/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs index 328567c..a8fdc68 100644 --- a/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs @@ -1,139 +1,138 @@ -using System.IO.Compression; -using Cocoar.Shelf.Services; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace Cocoar.Shelf.Tests; - -public sealed class UploadServiceTests : IDisposable -{ - private readonly string _docsRoot; - private readonly UploadService _sut; - - public UploadServiceTests() - { - _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-upload-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_docsRoot); - - var options = Options.Create(new ShelfOptions { DocsRoot = _docsRoot }); - _sut = new UploadService(options, NullLogger.Instance); - } - - [Fact] - public async Task UploadVersion_ExtractsZipToCorrectLocation() - { - using var zip = CreateZip(("index.html", ""), ("assets/style.css", "body{}")); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); - - Assert.Equal(UploadStatus.Success, result.Status); - Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "index.html"))); - Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "assets", "style.css"))); - } - - [Fact] - public async Task UploadVersion_ReturnsMissingIndexHtml_WhenNoIndexHtml() - { - using var zip = CreateZip(("readme.txt", "hello")); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); - - Assert.Equal(UploadStatus.MissingIndexHtml, result.Status); - } - - [Fact] - public async Task UploadVersion_DetectsZipSlipAttack() - { - using var zip = CreateZipWithEntry("../../../etc/passwd", "pwned"); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); - - Assert.Equal(UploadStatus.InvalidArchive, result.Status); - Assert.Contains("path traversal", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task UploadVersion_ReturnsInvalidArchive_WhenNotZip() - { - using var stream = new MemoryStream("not a zip file"u8.ToArray()); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", stream); - - Assert.Equal(UploadStatus.InvalidArchive, result.Status); - } - - [Fact] - public async Task UploadVersion_CleansUpTempDirOnFailure() - { - using var stream = new MemoryStream("not a zip file"u8.ToArray()); - - await _sut.UploadVersionAsync("myproduct", "v1", stream); - - var tempDir = Path.Combine(_docsRoot, ".shelf-tmp"); - if (Directory.Exists(tempDir)) - { - var remaining = Directory.GetDirectories(tempDir); - Assert.Empty(remaining); - } - } - - [Fact] - public async Task UploadVersion_ReplacesExistingVersion() - { - using var zip1 = CreateZip(("index.html", "v1")); - await _sut.UploadVersionAsync("myproduct", "v1", zip1); - - using var zip2 = CreateZip(("index.html", "v1-updated")); - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip2); - - Assert.Equal(UploadStatus.Success, result.Status); - var content = File.ReadAllText(Path.Combine(_docsRoot, "myproduct", "v1", "index.html")); - Assert.Equal("v1-updated", content); - } - - [Fact] - public async Task UploadVersion_CreatesProductDirectoryIfMissing() - { - using var zip = CreateZip(("index.html", "")); - - var result = await _sut.UploadVersionAsync("newproduct", "v1", zip); - - Assert.Equal(UploadStatus.Success, result.Status); - Assert.True(Directory.Exists(Path.Combine(_docsRoot, "newproduct", "v1"))); - } - - private static MemoryStream CreateZip(params (string name, string content)[] entries) - { - var ms = new MemoryStream(); - using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) - { - foreach (var (name, content) in entries) - { - var entry = archive.CreateEntry(name); - using var writer = new StreamWriter(entry.Open()); - writer.Write(content); - } - } - ms.Position = 0; - return ms; - } - - private static MemoryStream CreateZipWithEntry(string entryPath, string content) - { - var ms = new MemoryStream(); - using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) - { - var entry = archive.CreateEntry(entryPath); - using var writer = new StreamWriter(entry.Open()); - writer.Write(content); - } - ms.Position = 0; - return ms; - } - - public void Dispose() - { - try { Directory.Delete(_docsRoot, recursive: true); } - catch { /* cleanup best-effort */ } - } -} +using System.IO.Compression; +using Cocoar.Shelf.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests; + +public sealed class UploadServiceTests : IDisposable +{ + private readonly string _docsRoot; + private readonly UploadService _sut; + + public UploadServiceTests() + { + _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-upload-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_docsRoot); + + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); + _sut = new UploadService(config, NullLogger.Instance, new BasePathDetector()); + } + + [Fact] + public async Task UploadVersion_ExtractsZipToCorrectLocation() + { + using var zip = CreateZip(("index.html", ""), ("assets/style.css", "body{}")); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); + + Assert.Equal(UploadStatus.Success, result.Status); + Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "index.html"))); + Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "assets", "style.css"))); + } + + [Fact] + public async Task UploadVersion_ReturnsMissingIndexHtml_WhenNoIndexHtml() + { + using var zip = CreateZip(("readme.txt", "hello")); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); + + Assert.Equal(UploadStatus.MissingIndexHtml, result.Status); + } + + [Fact] + public async Task UploadVersion_DetectsZipSlipAttack() + { + using var zip = CreateZipWithEntry("../../../etc/passwd", "pwned"); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); + + Assert.Equal(UploadStatus.InvalidArchive, result.Status); + Assert.Contains("path traversal", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task UploadVersion_ReturnsInvalidArchive_WhenNotZip() + { + using var stream = new MemoryStream("not a zip file"u8.ToArray()); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", stream); + + Assert.Equal(UploadStatus.InvalidArchive, result.Status); + } + + [Fact] + public async Task UploadVersion_CleansUpTempDirOnFailure() + { + using var stream = new MemoryStream("not a zip file"u8.ToArray()); + + await _sut.UploadVersionAsync("myproduct", "v1", stream); + + var tempDir = Path.Combine(_docsRoot, ".shelf-tmp"); + if (Directory.Exists(tempDir)) + { + var remaining = Directory.GetDirectories(tempDir); + Assert.Empty(remaining); + } + } + + [Fact] + public async Task UploadVersion_ReplacesExistingVersion() + { + using var zip1 = CreateZip(("index.html", "v1")); + await _sut.UploadVersionAsync("myproduct", "v1", zip1); + + using var zip2 = CreateZip(("index.html", "v1-updated")); + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip2); + + Assert.Equal(UploadStatus.Success, result.Status); + var content = File.ReadAllText(Path.Combine(_docsRoot, "myproduct", "v1", "index.html")); + Assert.Equal("v1-updated", content); + } + + [Fact] + public async Task UploadVersion_CreatesProductDirectoryIfMissing() + { + using var zip = CreateZip(("index.html", "")); + + var result = await _sut.UploadVersionAsync("newproduct", "v1", zip); + + Assert.Equal(UploadStatus.Success, result.Status); + Assert.True(Directory.Exists(Path.Combine(_docsRoot, "newproduct", "v1"))); + } + + private static MemoryStream CreateZip(params (string name, string content)[] entries) + { + var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + } + ms.Position = 0; + return ms; + } + + private static MemoryStream CreateZipWithEntry(string entryPath, string content) + { + var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(entryPath); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + ms.Position = 0; + return ms; + } + + public void Dispose() + { + try { Directory.Delete(_docsRoot, recursive: true); } + catch { /* cleanup best-effort */ } + } +} diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 78c8c81..60d59ed 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -45,6 +45,7 @@ export default defineConfig({ items: [ { text: 'Docker', link: '/guide/docker' }, { text: 'Configuration', link: '/guide/configuration' }, + { text: 'Authentication', link: '/guide/authentication' }, ], }, { @@ -52,6 +53,7 @@ export default defineConfig({ items: [ { text: 'Product Registration', link: '/guide/product-registration' }, { text: 'Upload API', link: '/guide/upload-api' }, + { text: 'Admin UI', link: '/guide/admin-ui' }, ], }, { diff --git a/website/guide/admin-ui.md b/website/guide/admin-ui.md new file mode 100644 index 0000000..6d4ed80 --- /dev/null +++ b/website/guide/admin-ui.md @@ -0,0 +1,103 @@ +# Admin UI + +Shelf includes a built-in web interface for managing products and documentation versions. The Admin UI is a Vue 3 single-page application served at `/admin/`. + +## Accessing the Admin UI + +Navigate to `/admin/` (or `{PathBase}/admin/` if a [PathBase](./configuration.md#pathbase) is configured). You'll be prompted to log in with your API key. + +::: tip +The Admin UI requires an API key to be [configured](./configuration.md). If no API key is set, authentication is disabled and the Admin UI cannot be used. +::: + +## Login + +Enter your API key on the login screen. The Admin UI authenticates via a cookie session -- it sends your API key to `/_api/auth/login`, and the server sets a `shelf.auth` cookie. This means you stay logged in across page refreshes until you log out or the session expires. + +See [Authentication](./authentication.md) for details on how cookie auth works. + +## Dashboard + +After logging in, the dashboard shows an overview of your Shelf instance: + +- Total number of registered products +- Total number of deployed versions +- Quick links to product management + +## Managing Products + +### Creating a Product + +1. Click "Create Product" on the dashboard or products page +2. Fill in the product details: + - **Name** — Product identifier used in URLs (e.g., `configuration`). Cannot be changed after creation. + - **Display Name** — Human-readable name (e.g., `Cocoar.Configuration`) + - **Description** — Short description of the product + - **Source** — Deployment source type (default: `upload`) + - **Visibility** — `public` or `preview` + - **Show when empty** — Display on the landing page even before any version is deployed + - **Tags** — Free-form labels (e.g. `C#`, `UI`, `.NET`). Type a tag and press Enter or click Add. +3. Save the product + +### Editing a Product + +Click on a product name, then click Edit. You can update the display name, description, source, visibility, tags, and the "show when empty" setting. The product name (URL identifier) cannot be changed after creation. + +### Deleting a Product + +Delete a product from the product detail view. By default this removes only the product registration (config file). Deployed documentation files on disk are preserved unless you pass `?deleteData=true` to the API. + +### Visibility + +Products can be set to `public` or `preview`: + +| Visibility | Landing Page | Direct URL | API | +|------------|-------------|------------|-----| +| `public` | Shown by default | Accessible | Listed | +| `preview` | Hidden (shown with "Show preview" toggle) | Accessible | Listed | + +Preview products are useful for documentation that is in development or not yet ready for general discovery. + +### Tags + +Tags are free-form labels that appear on product cards and power the tag filter on the landing page. Add as many tags as you like (e.g. `C#`, `.NET`, `UI`, `CLI`). Use the tag editor in the product form to add and remove tags. + +### Show When Empty + +Enable **Show when empty** to make a product appear on the landing page before any documentation version has been deployed. The card is shown in a teaser style with a "Coming soon" indicator. Useful for testing registration and for announcing upcoming docs. + +## Managing Versions + +### Uploading a Version + +1. Navigate to a product's detail page +2. Click "Upload Version" +3. Enter the version identifier (e.g., `v5.2.0`) +4. Select the ZIP file containing your VitePress build output +5. Upload + +The ZIP must contain `index.html` at the root. See [Upload API](./upload-api.md) for details on the ZIP format. + +### Deleting a Version + +From the product detail page, click the delete button next to a version. This removes the version's files from disk. The ManifestService detects the change automatically. + +### Version List + +The product detail page shows all deployed versions with: + +- Version identifier +- Whether it's the "latest" (highest stable version) +- Direct link to the documentation + +## When to Use the Admin UI vs API + +| Task | Admin UI | API / CLI | +|------|----------|-----------| +| Initial setup / exploration | Recommended | -- | +| One-off product management | Recommended | -- | +| CI/CD deployment | -- | Recommended | +| Automated workflows | -- | Recommended | +| Bulk operations | -- | Recommended | + +The Admin UI and the API are backed by the same endpoints. Anything you do in the Admin UI can also be done via `curl` or any HTTP client. diff --git a/website/guide/authentication.md b/website/guide/authentication.md new file mode 100644 index 0000000..5a42f01 --- /dev/null +++ b/website/guide/authentication.md @@ -0,0 +1,99 @@ +# Authentication + +Shelf uses a single API key for authentication. Protected endpoints (product CRUD, version upload/delete) require one of two authentication methods: cookie session or Bearer token. + +## API Key Configuration + +Set the API key via environment variable or `data/configuration.json`: + +```yaml +environment: + - Shelf__ApiKey=your-secret-key-here +``` + +If no API key is configured, all protected endpoints return `503 Service Unavailable`. Public endpoints (`GET /_api/products`, `GET /_api/products/{product}/versions`) work without authentication. + +## Authentication Methods + +Both methods authenticate against the same configured API key. Use whichever fits your workflow. + +### Cookie Session (Browser / Admin UI) + +The cookie method is designed for browser-based usage, primarily the [Admin UI](./admin-ui.md). + +**Login:** + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -d '{"apiKey": "your-secret-key-here"}' \ + https://docs.cocoar.dev/_api/auth/login +``` + +On success, the server sets a `shelf.auth` cookie. Subsequent requests with this cookie are authenticated automatically. + +**Check auth status:** + +```bash +curl https://docs.cocoar.dev/_api/auth/me +``` + +Returns the current authentication state (authenticated or not). + +**Logout:** + +```bash +curl -X POST https://docs.cocoar.dev/_api/auth/logout +``` + +Clears the `shelf.auth` cookie. + +### Bearer Token (CI/CD / API) + +The Bearer method is designed for programmatic access from CI/CD pipelines and scripts. + +```bash +curl -X POST \ + -H "Authorization: Bearer your-secret-key-here" \ + -H "Content-Type: application/zip" \ + --data-binary @docs.zip \ + https://docs.cocoar.dev/_api/products/configuration/versions/v6 +``` + +The API key is sent in the `Authorization` header with every request. No session state is maintained. + +## Which Method to Use + +| Scenario | Method | Why | +|----------|--------|-----| +| Admin UI | Cookie | Browser handles cookies automatically | +| CI/CD pipelines | Bearer | Stateless, no cookie management needed | +| Scripts / automation | Bearer | Simpler, one header per request | +| Interactive API testing | Either | Both work with tools like curl or Postman | + +## Auth Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/_api/auth/login` | Authenticate with API key, receive session cookie | +| `POST` | `/_api/auth/logout` | Clear session cookie | +| `GET` | `/_api/auth/me` | Check current authentication status | + +## Protected Endpoints + +These endpoints require authentication (cookie or Bearer): + +| Method | Path | +|--------|------| +| `POST` | `/_api/products` | +| `PUT` | `/_api/products/{product}` | +| `DELETE` | `/_api/products/{product}` | +| `POST` | `/_api/products/{product}/versions/{version}` | +| `DELETE` | `/_api/products/{product}/versions/{version}` | + +## Error Responses + +| Status | When | +|--------|------| +| `401` | Missing or invalid API key (wrong Bearer token or no valid cookie) | +| `503` | No API key configured on the server | diff --git a/website/guide/configuration.md b/website/guide/configuration.md index 6066599..4aebb0c 100644 --- a/website/guide/configuration.md +++ b/website/guide/configuration.md @@ -1,93 +1,153 @@ -# Configuration - -Shelf requires minimal configuration. Most setups work with just Docker volume mounts and a few environment variables. - -## Options - -Configuration is done via environment variables using the ASP.NET Core configuration pattern (`Shelf__PropertyName`) or via `appsettings.json`: - -| Option | Env Variable | Default | Description | -|---|---|---|---| -| `DocsRoot` | `Shelf__DocsRoot` | `/data/docs` | Root directory for documentation files | -| `ConfigRoot` | `Shelf__ConfigRoot` | `/data/config` | Root directory for [product config](./product-registration.md) files | -| `PathBase` | `Shelf__PathBase` | _(empty)_ | Global URL prefix for running under a sub-path | -| `VersionPattern` | `Shelf__VersionPattern` | `^v?\d+(\.\d+(\.\d+(-[\w.]+)?)?)?$` | Regex pattern to identify version directories | -| `EnableLandingPage` | `Shelf__EnableLandingPage` | `false` | Show a product overview page at the root URL | -| `ApiKey` | `Shelf__ApiKey` | _(empty)_ | API key for [upload endpoint](./upload-api.md). Empty = upload disabled | -| `MaxUploadSizeBytes` | `Shelf__MaxUploadSizeBytes` | `104857600` | Maximum upload size in bytes (100 MB) | - -## PathBase - -When Shelf runs behind a reverse proxy under a sub-path (e.g., `example.com/docs/` instead of `docs.example.com/`), set `PathBase` to the prefix: - -```yaml -environment: - - Shelf__PathBase=/docs -``` - -All routes, redirects, and base path rewriting automatically include the prefix: - -| PathBase | Docs URL | API URL | -|----------|----------|---------| -| _(empty)_ | `/configuration/v5/` | `/api/products` | -| `/docs` | `/docs/configuration/v5/` | `/docs/api/products` | - -## Landing Page - -When `EnableLandingPage` is `true`, Shelf renders a product overview page at the root URL (`/` or `{PathBase}/`). The page shows cards for all [registered products](./product-registration.md) that have at least one deployed version. Clicking a product opens its documentation in a new tab. - -```yaml -environment: - - Shelf__EnableLandingPage=true -``` - -Only products with a config file and deployed versions appear on the landing page. Products deployed manually (without registration) are still accessible via direct URL but won't be listed. - -## Version Pattern - -By default, Shelf recognizes a wide range of version formats: - -| Format | Examples | -|--------|----------| -| Major only | `v1`, `v5`, `v10` | -| Major.Minor | `v5.1`, `v5.2`, `5.2` | -| Full SemVer | `v5.2.0`, `5.2.0` | -| Pre-release | `v6.0.0-beta.1`, `v6.0.0-rc.1` | - -The `v` prefix is optional. Directories that don't match the pattern are ignored: - -``` -/data/docs/configuration/ -├── v5.1.0/ ← recognized -├── v5.2.0/ ← recognized -├── v6.0.0-beta.1/ ← recognized (pre-release) -├── assets/ ← ignored -└── .hidden/ ← ignored -``` - -### Version Sorting - -Versions are sorted numerically by Major, Minor, and Patch. The **latest** version is determined as follows: - -1. **Stable versions** (without pre-release label) are always preferred -2. Among stable versions, the highest Major.Minor.Patch wins -3. If only pre-release versions exist, the highest one is used as latest - -Example: With `v5.2.0`, `v6.0.0-beta.1`, and `v5.1.0`, the latest is `v5.2.0` — because `v6.0.0-beta.1` is a pre-release. - -::: tip GitVersion / SemVer -Shelf works well with [GitVersion](https://gitversion.net/) or any SemVer-based versioning. Use the version output from your CI pipeline directly as the version directory name. -::: - -### Customizing the Version Pattern - -If you need a stricter or different versioning scheme, override the pattern: - -```yaml -environment: - # Only allow full SemVer with v prefix - - Shelf__VersionPattern=^v\d+\.\d+\.\d+$ - - # Only major versions - - Shelf__VersionPattern=^v\d+$ -``` +# Configuration + +Shelf requires minimal configuration. Most setups work with just Docker volume mounts and a few environment variables. + +## Configuration File + +Shelf uses its own configuration file at `data/configuration.json` (relative to the application root, typically `/data/configuration.json` in Docker). This is powered by [Cocoar.Configuration](https://github.com/cocoar-dev/Cocoar.Configuration) and replaces the standard ASP.NET Core `appsettings.json` pattern. + +```json +{ + "AppUrl": "http://0.0.0.0:8080", + "DocsRoot": "/data/docs", + "ConfigRoot": "/data/config", + "PathBase": "", + "ApiKey": "", + "MaxUploadSizeBytes": 104857600, + "VersionPattern": "^v?\\d+(\\.\\d+(\\.\\d+(-[\\w.-]+)?)?)?$", + "Logging": { + "LogLevels": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} +``` + +## Options + +Configuration can be set via `data/configuration.json` or overridden with environment variables using the `Shelf__` prefix: + +| Option | Env Variable | Default | Description | +|---|---|---|---| +| `AppUrl` | `Shelf__AppUrl` | `http://0.0.0.0:8080` | Server binding URL and port | +| `DocsRoot` | `Shelf__DocsRoot` | `/data/docs` | Root directory for documentation files | +| `ConfigRoot` | `Shelf__ConfigRoot` | `/data/config` | Root directory for [product config](./product-registration.md) files | +| `PathBase` | `Shelf__PathBase` | _(empty)_ | Global URL prefix for running under a sub-path | +| `VersionPattern` | `Shelf__VersionPattern` | `^v?\d+(\.\d+(\.\d+(-[\w.-]+)?)?)?$` | Regex pattern to identify version directories | +| `ApiKey` | `Shelf__ApiKey` | _(empty)_ | API key for protected endpoints. Empty = upload and admin disabled | +| `MaxUploadSizeBytes` | `Shelf__MaxUploadSizeBytes` | `104857600` | Maximum upload size in bytes (100 MB) | + +## PathBase + +When Shelf runs behind a reverse proxy under a sub-path (e.g., `example.com/docs/` instead of `docs.example.com/`), set `PathBase` to the prefix: + +```yaml +environment: + - Shelf__PathBase=/docs +``` + +All routes, redirects, and base path rewriting automatically include the prefix: + +| PathBase | Docs URL | API URL | +|----------|----------|---------| +| _(empty)_ | `/configuration/v5/` | `/_api/products` | +| `/docs` | `/docs/configuration/v5/` | `/docs/_api/products` | + +## Landing Page + +Shelf serves a Vue SPA as the landing page at the root URL (`/` or `{PathBase}/`). The page shows cards for all [registered products](./product-registration.md) that have at least one deployed stable version, or that have the `showWhenEmpty` flag enabled. + +### Tag Filter + +All tags used across any registered product are automatically collected and displayed as clickable filter chips in a toolbar above the product grid. Clicking a tag narrows the visible products to those that carry that tag. Multiple tags can be active simultaneously (AND filter). The active selection is persisted in `localStorage` so visitors get the same view on their next visit. + +### Preview Toggle + +Products with `visibility: "preview"` and pre-release-only versions are hidden by default. A "Show preview" toggle reveals: + +- Products with `visibility: "preview"` +- Products that only have pre-release versions +- Pre-release versions on public products + +This setting is also persisted in `localStorage`. + +### Teaser Cards + +Products with `showWhenEmpty: true` appear in the grid even without any deployed versions. The card is rendered with a dashed border and a **Coming soon** indicator at the bottom to make the teaser state visually distinct from products with actual documentation. + +## Version Pattern + +By default, Shelf recognizes a wide range of version formats: + +| Format | Examples | +|--------|----------| +| Major only | `v1`, `v5`, `v10` | +| Major.Minor | `v5.1`, `v5.2`, `5.2` | +| Full SemVer | `v5.2.0`, `5.2.0` | +| Pre-release | `v6.0.0-beta.1`, `v6.0.0-rc.1`, `v1.0.0-vue-ui.10` | + +The `v` prefix is optional. Pre-release labels support hyphens (e.g., `v1.0.0-vue-ui.10`). Directories that don't match the pattern are ignored: + +``` +/data/docs/configuration/ +├── v5.1.0/ ← recognized +├── v5.2.0/ ← recognized +├── v6.0.0-beta.1/ ← recognized (pre-release) +├── v1.0.0-vue-ui.10/ ← recognized (pre-release with hyphens) +├── assets/ ← ignored +└── .hidden/ ← ignored +``` + +### Version Sorting + +Versions are sorted numerically by Major, Minor, and Patch. The **latest** version is determined as follows: + +1. **Stable versions** (without pre-release label) are always preferred +2. Among stable versions, the highest Major.Minor.Patch wins +3. If only pre-release versions exist, the highest one is used as latest + +Example: With `v5.2.0`, `v6.0.0-beta.1`, and `v5.1.0`, the latest is `v5.2.0` -- because `v6.0.0-beta.1` is a pre-release. + +::: tip GitVersion / SemVer +Shelf works well with [GitVersion](https://gitversion.net/) or any SemVer-based versioning. Use the version output from your CI pipeline directly as the version directory name. +::: + +### Customizing the Version Pattern + +If you need a stricter or different versioning scheme, override the pattern: + +```yaml +environment: + # Only allow full SemVer with v prefix + - Shelf__VersionPattern=^v\d+\.\d+\.\d+$ + + # Only major versions + - Shelf__VersionPattern=^v\d+$ +``` + +## Logging + +Shelf uses [Serilog](https://serilog.net/) for structured logging. Log levels are configured in the `Logging.LogLevels` section of `data/configuration.json`: + +```json +{ + "Logging": { + "LogLevels": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Cocoar.Shelf": "Debug" + } + } +} +``` + +Log levels can also be set via environment variables: + +```yaml +environment: + - Shelf__Logging__LogLevels__Default=Information + - Shelf__Logging__LogLevels__Microsoft.AspNetCore=Warning +``` + +Available levels: `Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`. diff --git a/website/guide/docker.md b/website/guide/docker.md index 114f076..4999850 100644 --- a/website/guide/docker.md +++ b/website/guide/docker.md @@ -13,17 +13,22 @@ services: - "80:8080" volumes: - docs-data:/data/docs - - config-data:/data/config:ro + - config-data:/data/config environment: - Shelf__ApiKey=${SHELF_API_KEY:-} restart: unless-stopped ``` Two volumes: -- **Docs volume** — writable, so the [Upload API](./upload-api.md) can deploy versions -- **Config volume** — read-only, contains [product registration](./product-registration.md) files +- **Docs volume** -- writable, so the [Upload API](./upload-api.md) can deploy versions +- **Config volume** -- contains [product registration](./product-registration.md) files and the application configuration -If you don't use the Upload API and only deploy manually, you can mount the docs volume as `:ro` (read-only). +Shelf stores its configuration in `data/configuration.json`. If you need to customize settings beyond environment variables, mount a config file: + +```yaml +volumes: + - ./configuration.json:/data/configuration.json:ro +``` ## Volume Mounting @@ -34,7 +39,7 @@ The simplest approach. All products live in one volume: ```yaml volumes: - docs-data:/data/docs - - config-data:/data/config:ro + - config-data:/data/config ``` ### One Volume Per Product @@ -54,7 +59,7 @@ For development or simple setups, mount host directories directly: ```yaml volumes: - /srv/docs:/data/docs - - /srv/config:/data/config:ro + - /srv/config:/data/config ``` ## Environment Variables @@ -63,11 +68,14 @@ See [Configuration](./configuration.md) for all options. The most important ones | Variable | Default | Description | |---|---|---| +| `Shelf__AppUrl` | `http://0.0.0.0:8080` | Server binding URL and port | | `Shelf__DocsRoot` | `/data/docs` | Root directory for documentation files | | `Shelf__ConfigRoot` | `/data/config` | Root directory for product config files | -| `Shelf__ApiKey` | _(empty)_ | API key for upload endpoint. Empty = upload disabled | +| `Shelf__ApiKey` | _(empty)_ | API key for protected endpoints. Empty = upload and admin disabled | | `Shelf__PathBase` | _(empty)_ | Global URL prefix (e.g. `/docs`) | +Environment variables override values from `data/configuration.json`. + ## Building From Source ```bash @@ -79,3 +87,7 @@ Or build the image directly: ```bash docker build -t cocoar/shelf . ``` + +::: tip Node.js Required +The build process requires Node.js for the Vue client (Admin UI and landing page). The Dockerfile handles this automatically, but if building outside Docker, ensure Node.js is installed. +::: diff --git a/website/guide/getting-started.md b/website/guide/getting-started.md index 5219ceb..b51f566 100644 --- a/website/guide/getting-started.md +++ b/website/guide/getting-started.md @@ -8,6 +8,7 @@ Shelf is a static documentation hosting platform for Cocoar products. It serves - Routes requests to the correct product and version - Automatically determines the "latest" version per product - Provides an [Upload API](./upload-api.md) for deploying docs from CI/CD pipelines +- Includes a built-in [Admin UI](./admin-ui.md) for managing products and versions - Caches version information and invalidates on filesystem changes ## Quick Start @@ -23,7 +24,7 @@ services: - "80:8080" volumes: - docs-data:/data/docs - - config-data:/data/config:ro + - config-data:/data/config environment: - Shelf__ApiKey=${SHELF_API_KEY:-} restart: unless-stopped @@ -35,25 +36,35 @@ docker compose up -d ### 2. Add Documentation -**Option A: Upload API** (recommended for CI/CD) +**Option A: Admin UI** (recommended for getting started) + +Open `http://localhost/admin/`, log in with your API key, and create a product. Then upload a ZIP of your VitePress build output directly from the browser. + +See [Admin UI](./admin-ui.md) for details. + +**Option B: Upload API** (recommended for CI/CD) Register the product, then upload a ZIP: ```bash -# Create product config -echo '{"name": "configuration", "source": "upload"}' > /data/config/products/configuration.json +# Create product via API +curl -X POST \ + -H "Authorization: Bearer $SHELF_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name": "configuration", "displayName": "Cocoar.Configuration", "source": "upload"}' \ + http://localhost/_api/products # Upload docs curl -X POST \ -H "Authorization: Bearer $SHELF_API_KEY" \ -H "Content-Type: application/zip" \ --data-binary @docs.zip \ - http://localhost/api/products/configuration/versions/v5 + http://localhost/_api/products/configuration/versions/v5 ``` See [Product Registration](./product-registration.md) and [Upload API](./upload-api.md) for details. -**Option B: Manual** (copy files directly) +**Option C: Manual** (copy files directly) Place your VitePress build output in the volume: @@ -72,5 +83,6 @@ Place your VitePress build output in the volume: Your documentation is now available at: -- `http://localhost/configuration/` — latest version (redirects to v5) -- `http://localhost/configuration/v5/` — specific version +- `http://localhost/configuration/` -- latest version (redirects to v5) +- `http://localhost/configuration/v5/` -- specific version +- `http://localhost/admin/` -- Admin UI for management diff --git a/website/guide/llm-documentation.md b/website/guide/llm-documentation.md index 8aad92b..8cd658b 100644 --- a/website/guide/llm-documentation.md +++ b/website/guide/llm-documentation.md @@ -10,7 +10,7 @@ The emerging standard (see [llmstxt.org](https://llmstxt.org/)) is to serve two | File | Purpose | |------|---------| -| `llms.txt` | Short summary — product description, links, and table of contents | +| `llms.txt` | Short summary -- product description, links, and table of contents | | `llms-full.txt` | Complete documentation as plain Markdown | These files should be served at the root of your documentation: @@ -20,7 +20,19 @@ https://docs.example.com/myproduct/v5/llms.txt https://docs.example.com/myproduct/v5/llms-full.txt ``` -Shelf serves these files like any other static file — just include them in your deployment archive. +Shelf serves these files like any other static file -- just include them in your deployment archive. + +## Shelf's Product Index + +Shelf auto-generates a `/llms.txt` at the root URL that serves as a product index for LLMs. This file lists all public products that have at least one stable version, with links to each product's own `llms-full.txt`. + +``` +GET /llms.txt +``` + +The generated file only includes products where `visibility` is `"public"`. Preview products and products with only pre-release versions are excluded. + +This allows an AI assistant to discover all available documentation by fetching a single URL. The landing page also includes `` and a hidden element pointing to `/llms.txt` for programmatic discovery. ## Making the Files Discoverable diff --git a/website/guide/product-registration.md b/website/guide/product-registration.md index e352607..66e1570 100644 --- a/website/guide/product-registration.md +++ b/website/guide/product-registration.md @@ -1,71 +1,181 @@ -# Product Registration - -Before documentation can be deployed via the [Upload API](./upload-api.md), a product must be registered. Registration is done by placing a JSON config file in the config directory. - -## Config Directory - -Product config files live in `{ConfigRoot}/products/`, one file per product: - -``` -/data/config/ -└── products/ - ├── configuration.json - ├── capabilities.json - └── filesystem.json -``` - -## Config File Format - -Each JSON file describes one product: - -```json -{ - "name": "configuration", - "displayName": "Cocoar.Configuration", - "description": "Reactive configuration for .NET", - "source": "upload" -} -``` - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Product identifier, used in URLs and API calls | -| `displayName` | No | Human-readable name for API responses | -| `description` | No | Short description of the product | -| `source` | No | Deployment source type. Default: `"upload"` | - -::: tip -The `name` field determines the URL path, not the filename. By convention, keep both in sync (e.g., `configuration.json` with `"name": "configuration"`). -::: - -## Live Reload - -Config files are loaded at startup and monitored for changes. When you add, modify, or remove a config file, Shelf picks up the change automatically — no restart needed. - -## Why Registration? - -Product registration is required for the Upload API to prevent accidental creation of arbitrary products. Without registration, a typo in a CI pipeline (`configration` instead of `configuration`) would silently create a new product. - -Manual deployment (copying files directly into the docs volume) does **not** require registration — Shelf serves any product directory it finds, regardless of whether a config file exists. - -## API Integration - -Registered products are visible via the API: - -```bash -# List all registered products with their versions -curl https://docs.cocoar.dev/api/products -``` - -```json -[ - { - "name": "configuration", - "displayName": "Cocoar.Configuration", - "description": "Reactive configuration for .NET", - "source": "upload", - "latest": "v5", - "versions": ["v5", "v4"] - } -] -``` +# Product Registration + +Before documentation can be deployed via the [Upload API](./upload-api.md), a product must be registered. Products can be managed through the [Admin UI](./admin-ui.md), the API, or by placing JSON config files in the config directory. + +## Admin UI (Recommended) + +The easiest way to register products is through the [Admin UI](./admin-ui.md) at `/admin/`: + +1. Log in with your API key +2. Navigate to the product management section +3. Click "Create Product" +4. Fill in the product details (name, display name, description, visibility) +5. Save + +See [Admin UI](./admin-ui.md) for the full workflow. + +## API + +Products can be created, updated, and deleted via the API. All mutating endpoints require [authentication](./authentication.md). + +### Create a Product + +```bash +curl -X POST \ + -H "Authorization: Bearer $SHELF_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "configuration", + "displayName": "Cocoar.Configuration", + "description": "Reactive configuration for .NET", + "source": "upload", + "visibility": "public", + "tags": ["C#", ".NET"], + "showWhenEmpty": false + }' \ + https://docs.cocoar.dev/_api/products +``` + +### Update a Product + +```bash +curl -X PUT \ + -H "Authorization: Bearer $SHELF_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "displayName": "Cocoar.Configuration", + "description": "Updated description", + "visibility": "preview", + "tags": ["C#", ".NET", "Configuration"], + "showWhenEmpty": true + }' \ + https://docs.cocoar.dev/_api/products/configuration +``` + +### Delete a Product + +```bash +# Remove registration only (docs files kept on disk) +curl -X DELETE \ + -H "Authorization: Bearer $SHELF_API_KEY" \ + https://docs.cocoar.dev/_api/products/configuration + +# Remove registration AND all documentation files +curl -X DELETE \ + -H "Authorization: Bearer $SHELF_API_KEY" \ + "https://docs.cocoar.dev/_api/products/configuration?deleteData=true" +``` + +## JSON Config Files + +Products can also be registered by placing JSON config files in `{ConfigRoot}/products/`, one file per product: + +``` +/data/config/ +└── products/ + ├── configuration.json + ├── capabilities.json + └── filesystem.json +``` + +### Config File Format + +Each JSON file describes one product: + +```json +{ + "name": "configuration", + "displayName": "Cocoar.Configuration", + "description": "Reactive configuration for .NET", + "source": "upload", + "visibility": "public", + "tags": ["C#", ".NET"], + "showWhenEmpty": false +} +``` + +| Field | Required | Description | +|-------|----------|--------------| +| `name` | Yes | Product identifier, used in URLs and API calls | +| `displayName` | No | Human-readable name for API responses and landing page | +| `description` | No | Short description of the product | +| `source` | No | Deployment source type. Default: `"upload"` | +| `visibility` | No | `"public"` or `"preview"`. Default: `"public"` | +| `tags` | No | List of free-form labels (e.g. `["C#", "UI"]`). Used for filtering on the landing page | +| `showWhenEmpty` | No | Show on landing page even without any deployed versions. Default: `false` | + +::: tip +The `name` field determines the URL path, not the filename. By convention, keep both in sync (e.g., `configuration.json` with `"name": "configuration"`). +::: + +### Visibility + +The `visibility` field controls how a product appears on the landing page: + +| Value | Behavior | +|-------|----------| +| `"public"` | Shown on the landing page by default (if it has stable versions, or `showWhenEmpty` is set) | +| `"preview"` | Hidden by default, shown when "Show preview" is toggled on | + +Preview visibility is useful for products that are in development or not yet ready for general use. The documentation is still accessible via direct URL regardless of visibility. + +### Tags + +Tags are free-form labels you can attach to a product (e.g. `"C#"`, `"UI"`, `".NET"`, `"CLI"`). They appear as chips on product cards, and the landing page provides a tag filter bar so visitors can narrow down the list to products that interest them. + +Tags are stored as an array of strings. Shelf normalises them automatically: leading/trailing whitespace is trimmed, duplicates and empty values are removed, and the list is sorted alphabetically. + +```json +{ + "tags": ["C#", ".NET", "Configuration"] +} +``` + +### Show When Empty + +By default, a product only appears on the landing page once it has at least one deployed version. Set `showWhenEmpty: true` to display the product card immediately — even before any documentation is uploaded. The card is rendered in a distinct teaser style with a "Coming soon" indicator. + +This is useful for: +- **Testing** — verify the product is registered and visible before pushing the first version +- **Teasers** — announce upcoming documentation while it is still being written + +```json +{ + "showWhenEmpty": true +} +``` + +### Live Reload + +Config files are loaded at startup and monitored for changes. When you add, modify, or remove a config file, Shelf picks up the change automatically -- no restart needed. + +## Why Registration? + +Product registration is required for the Upload API to prevent accidental creation of arbitrary products. Without registration, a typo in a CI pipeline (`configration` instead of `configuration`) would silently create a new product. + +Manual deployment (copying files directly into the docs volume) does **not** require registration -- Shelf serves any product directory it finds, regardless of whether a config file exists. + +## API Integration + +Registered products are visible via the API: + +```bash +# List all registered products with their versions +curl https://docs.cocoar.dev/_api/products +``` + +```json +[ + { + "name": "configuration", + "displayName": "Cocoar.Configuration", + "description": "Reactive configuration for .NET", + "source": "upload", + "visibility": "public", + "tags": ["C#", ".NET"], + "showWhenEmpty": false, + "latest": "v5", + "versions": ["v5", "v4"] + } +] +``` diff --git a/website/guide/troubleshooting.md b/website/guide/troubleshooting.md index 7e77347..cc70de4 100644 --- a/website/guide/troubleshooting.md +++ b/website/guide/troubleshooting.md @@ -21,6 +21,12 @@ Verify which versions Shelf sees: docker exec shelf ls /data/docs/configuration/ ``` +Or check via the API: + +```bash +curl http://localhost/_api/products/configuration/versions +``` + ## Page Loads But Navigation Is Broken This is most likely a **base path rewriting** issue. Shelf rewrites the VitePress base path in HTML, CSS, and JavaScript files. If the rewriting misses something, the page may render initially but client-side navigation (clicking sidebar links) fails. @@ -49,6 +55,20 @@ This can happen if the CSS file's content type is not detected as `text/css`. Ch **Check path traversal protection.** Shelf blocks any resolved path that falls outside the docs root directory. If your symlinks or relative paths point outside the volume, they will be blocked. +## Admin UI Not Loading + +**Check that an API key is configured.** The Admin UI requires `Shelf__ApiKey` to be set. Without it, authentication is disabled and protected endpoints return `503`. + +**Check the URL.** The Admin UI is served at `/admin/` (with trailing slash). If you have a `PathBase` configured, the URL is `{PathBase}/admin/`. + +**Check browser console.** Open DevTools and look for JavaScript errors or failed network requests to `/_api/` endpoints. + +**Cookie issues.** If login succeeds but you're immediately logged out, check that cookies are not being blocked. The Admin UI uses a `shelf.auth` cookie for session management. + +## API Returns 503 + +This means no API key is configured. Set `Shelf__ApiKey` in your environment or `data/configuration.json`. All protected endpoints (product CRUD, upload, delete) require an API key to be configured. + ## Overlapping Volume Mounts Docker supports mounting multiple volumes at nested paths. If you have both a general mount and a product-specific mount: @@ -67,8 +87,8 @@ If a product's documentation is unexpectedly empty or outdated, check whether so Shelf caches two things in memory: -1. **Version lists** per product — invalidated automatically via `FileSystemWatcher` -2. **Detected base paths** per product/version — cached indefinitely +1. **Version lists** per product -- invalidated automatically via `FileSystemWatcher` +2. **Detected base paths** per product/version -- cached indefinitely If you suspect stale data after a problematic deployment, restarting the container clears all caches: diff --git a/website/guide/upload-api.md b/website/guide/upload-api.md index b15664f..2e54793 100644 --- a/website/guide/upload-api.md +++ b/website/guide/upload-api.md @@ -1,195 +1,246 @@ -# Upload API - -Shelf provides an HTTP API for deploying documentation versions. This is designed for CI/CD pipelines — analogous to `nuget push` or `docker push`. - -## Prerequisites - -1. The product must be [registered](./product-registration.md) via a config file -2. An API key must be [configured](./configuration.md) (`Shelf__ApiKey`) - -If no API key is configured, the upload endpoint returns `503 Service Unavailable`. - -## Uploading a Version - -```bash -curl -X POST \ - -H "Authorization: Bearer $SHELF_API_KEY" \ - -H "Content-Type: application/zip" \ - --data-binary @docs.zip \ - https://docs.cocoar.dev/api/products/configuration/versions/v6 -``` - -The ZIP file should contain the VitePress build output with `index.html` at the root: - -``` -docs.zip -├── index.html -├── assets/ -│ ├── style.a1b2c3.css -│ └── app.d4e5f6.js -├── guide/ -│ └── getting-started.html -├── llms.txt -└── llms-full.txt -``` - -### What Happens - -1. API key is validated -2. Product registration is checked (must exist in config) -3. Version format is validated against the [version pattern](./configuration.md#version-pattern) (supports SemVer: `v5`, `v5.2`, `v5.2.0`, `v5.2.0-beta.1`) -4. ZIP is extracted to a temporary directory -5. Validation: `index.html` must exist at the root -6. Atomic move to `/data/docs/{product}/{version}/` -7. ManifestService detects the new version automatically -8. Response: `201 Created` - -Re-uploading an existing version replaces it atomically. - -## CI/CD Integration - -### Setup - -1. Add `SHELF_API_KEY` as a repository secret in GitHub -2. Ensure the product is [registered](./product-registration.md) on the server -3. Add a deploy job to your workflow - -### GitHub Actions with GitVersion - -```yaml -name: Deploy Docs - -on: - push: - branches: [main] - -jobs: - deploy-docs: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: GitVersion - id: version - uses: gittools/actions/gitversion/execute@v3 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Build - working-directory: website - run: npm ci && npx vitepress build - - - name: Package - run: cd website/.vitepress/dist && zip -r $GITHUB_WORKSPACE/docs.zip . - - - name: Deploy to Shelf - env: # [!code highlight] - KEY: ${{ secrets.SHELF_API_KEY }} # [!code highlight] - VER: ${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }} # [!code highlight] - run: | - curl -f -X POST \ - -H "Authorization: Bearer $KEY" \ - -H "Content-Type: application/zip" \ - --data-binary @$GITHUB_WORKSPACE/docs.zip \ - https://docs.cocoar.dev/api/products/configuration/versions/v$VER -``` - -::: tip Version Format -Choose what makes sense for your docs. Use the GitVersion output variables in the upload URL: -- **Major only** → `v5` (use `outputs.major`) -- **Major.Minor** → `v5.2` (recommended, use `outputs.major` + `outputs.minor`) -- **Full SemVer** → `v5.2.0` (use `outputs.majorMinorPatch`) -::: - -### Manual Trigger Variant - -Replace the `on:` section to allow manual deploys with a version input: - -```yaml -on: - workflow_dispatch: - inputs: - version: - description: 'Version (e.g. v5.2)' - required: true -``` - -Then use the `inputs.version` variable instead of the GitVersion output in the `env` section. - -### Key Points - -- **ZIP from inside the dist directory** — `cd .vitepress/dist && zip -r ... .` so `index.html` is at the root -- **Use `curl -f`** — fails the step on HTTP errors (4xx/5xx) -- **Re-upload replaces atomically** — safe to re-run the pipeline -- **Pre-release versions are never "latest"** — visitors are redirected to the highest stable version - -## API Endpoints - -### List Products - -``` -GET /api/products -``` - -Returns all registered products with their version information. No authentication required. - -```json -[ - { - "name": "configuration", - "displayName": "Cocoar.Configuration", - "description": "Reactive configuration for .NET", - "source": "upload", - "latest": "v5.2.0", - "versions": ["v5.2.0", "v5.1.0", "v5.0.0"] - } -] -``` - -### List Versions - -``` -GET /api/products/{product}/versions -``` - -Returns version information for a specific product. No authentication required. - -```json -{ - "name": "configuration", - "latest": "v5.2.0", - "versions": ["v5.2.0", "v5.1.0", "v5.0.0"] -} -``` - -### Upload Version - -``` -POST /api/products/{product}/versions/{version} -``` - -Uploads a ZIP file as a new documentation version. Requires `Authorization: Bearer {key}` header. - -## Error Responses - -| Status | When | -|--------|------| -| `201` | Successfully deployed | -| `400` | Invalid version format, corrupt ZIP, or missing `index.html` | -| `401` | Missing or invalid API key | -| `404` | Product not registered | -| `409` | Concurrent upload for the same product/version | -| `413` | ZIP exceeds maximum upload size | -| `503` | No API key configured (upload disabled) | - -## Security - -- **Authentication**: Bearer token checked against the configured API key -- **ZIP-Slip protection**: All extracted paths are validated to stay within the target directory -- **Atomic deployment**: Files are extracted to a temp directory, validated, then moved — Shelf never serves a half-extracted state -- **Concurrent upload protection**: Simultaneous uploads for the same product/version return `409 Conflict` -- **Size limit**: Configurable via `MaxUploadSizeBytes` (default: 100 MB) +# Upload API + +Shelf provides an HTTP API for deploying documentation versions. This is designed for CI/CD pipelines -- analogous to `nuget push` or `docker push`. + +## Prerequisites + +1. The product must be [registered](./product-registration.md) (via Admin UI, API, or config file) +2. An API key must be [configured](./configuration.md) (`Shelf__ApiKey`) + +If no API key is configured, protected endpoints return `503 Service Unavailable`. + +## Uploading a Version + +```bash +curl -X POST \ + -H "Authorization: Bearer $SHELF_API_KEY" \ + -H "Content-Type: application/zip" \ + --data-binary @docs.zip \ + https://docs.cocoar.dev/_api/products/configuration/versions/v6 +``` + +The ZIP file should contain the VitePress build output with `index.html` at the root: + +``` +docs.zip +├── index.html +├── assets/ +│ ├── style.a1b2c3.css +│ └── app.d4e5f6.js +├── guide/ +│ └── getting-started.html +├── llms.txt +└── llms-full.txt +``` + +### What Happens + +1. API key is validated (Bearer token or cookie session) +2. Product registration is checked (must exist) +3. Version format is validated against the [version pattern](./configuration.md#version-pattern) (supports SemVer: `v5`, `v5.2`, `v5.2.0`, `v5.2.0-beta.1`, `v1.0.0-vue-ui.10`) +4. ZIP is extracted to a temporary directory +5. Validation: `index.html` must exist at the root +6. Atomic move to `/data/docs/{product}/{version}/` +7. ManifestService detects the new version automatically +8. Response: `201 Created` + +Re-uploading an existing version replaces it atomically. + +## Deleting a Version + +```bash +curl -X DELETE \ + -H "Authorization: Bearer $SHELF_API_KEY" \ + https://docs.cocoar.dev/_api/products/configuration/versions/v6 +``` + +The version directory is removed from disk. ManifestService detects the change automatically. + +## CI/CD Integration + +### Setup + +1. Add `SHELF_API_KEY` as a repository secret in GitHub +2. Ensure the product is [registered](./product-registration.md) on the server +3. Add a deploy job to your workflow + +### GitHub Actions with GitVersion + +```yaml +name: Deploy Docs + +on: + push: + branches: [main] + +jobs: + deploy-docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: GitVersion + id: version + uses: gittools/actions/gitversion/execute@v3 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build + working-directory: website + run: npm ci && npx vitepress build + + - name: Package + run: cd website/.vitepress/dist && zip -r $GITHUB_WORKSPACE/docs.zip . + + - name: Deploy to Shelf + env: # [!code highlight] + KEY: ${{ secrets.SHELF_API_KEY }} # [!code highlight] + VER: ${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }} # [!code highlight] + run: | + curl -f -X POST \ + -H "Authorization: Bearer $KEY" \ + -H "Content-Type: application/zip" \ + --data-binary @$GITHUB_WORKSPACE/docs.zip \ + https://docs.cocoar.dev/_api/products/configuration/versions/v$VER +``` + +::: tip Version Format +Choose what makes sense for your docs. Use the GitVersion output variables in the upload URL: +- **Major only** -- `v5` (use `outputs.major`) +- **Major.Minor** -- `v5.2` (recommended, use `outputs.major` + `outputs.minor`) +- **Full SemVer** -- `v5.2.0` (use `outputs.majorMinorPatch`) +- **Pre-release** -- `v5.2.0-beta.1` (use full version for pre-release builds) +::: + +### Manual Trigger Variant + +Replace the `on:` section to allow manual deploys with a version input: + +```yaml +on: + workflow_dispatch: + inputs: + version: + description: 'Version (e.g. v5.2)' + required: true +``` + +Then use the `inputs.version` variable instead of the GitVersion output in the `env` section. + +### Key Points + +- **ZIP from inside the dist directory** -- `cd .vitepress/dist && zip -r ... .` so `index.html` is at the root +- **Use `curl -f`** -- fails the step on HTTP errors (4xx/5xx) +- **Re-upload replaces atomically** -- safe to re-run the pipeline +- **Pre-release versions are never "latest"** -- visitors are redirected to the highest stable version +- **Node.js required** -- CI workflows need a Node.js setup step before building (for the Vue client) + +## API Reference + +All API routes use the `/_api/` prefix. See [Authentication](./authentication.md) for details on auth methods. + +### Products + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/_api/products` | No | List all registered products with version info | +| `GET` | `/_api/products/{product}` | No | Get a single product with version info | +| `POST` | `/_api/products` | Yes | Create a new product | +| `PUT` | `/_api/products/{product}` | Yes | Update a product | +| `DELETE` | `/_api/products/{product}` | Yes | Delete product config. Pass `?deleteData=true` to also delete docs from disk | + +### Versions + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/_api/products/{product}/versions` | No | List versions of a product | +| `POST` | `/_api/products/{product}/versions/{version}` | Yes | Upload a ZIP as a new version | +| `DELETE` | `/_api/products/{product}/versions/{version}` | Yes | Delete a version | + +### Authentication + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `POST` | `/_api/auth/login` | No | Cookie login with API key | +| `POST` | `/_api/auth/logout` | No | Clear session cookie | +| `GET` | `/_api/auth/me` | No | Get current auth status | + +### List Products + +``` +GET /_api/products +``` + +Returns all registered products with their version information. No authentication required. + +```json +[ + { + "name": "configuration", + "displayName": "Cocoar.Configuration", + "description": "Reactive configuration for .NET", + "source": "upload", + "visibility": "public", + "tags": ["C#", ".NET"], + "showWhenEmpty": false, + "latest": "v5.2.0", + "versions": ["v5.2.0", "v5.1.0", "v5.0.0"] + } +] +``` + +### List Versions + +``` +GET /_api/products/{product}/versions +``` + +Returns version information for a specific product. No authentication required. + +```json +{ + "name": "configuration", + "latest": "v5.2.0", + "versions": ["v5.2.0", "v5.1.0", "v5.0.0"] +} +``` + +### Upload Version + +``` +POST /_api/products/{product}/versions/{version} +``` + +Uploads a ZIP file as a new documentation version. Requires [authentication](./authentication.md). + +### Delete Version + +``` +DELETE /_api/products/{product}/versions/{version} +``` + +Deletes a documentation version. Requires [authentication](./authentication.md). + +## Error Responses + +| Status | When | +|--------|------| +| `201` | Successfully deployed | +| `400` | Invalid version format, corrupt ZIP, or missing `index.html` | +| `401` | Missing or invalid authentication | +| `404` | Product not registered | +| `409` | Concurrent upload for the same product/version | +| `413` | ZIP exceeds maximum upload size | +| `503` | No API key configured (upload disabled) | + +## Security + +- **Authentication**: Bearer token or cookie session checked against the configured API key. See [Authentication](./authentication.md) +- **ZIP-Slip protection**: All extracted paths are validated to stay within the target directory +- **Atomic deployment**: Files are extracted to a temp directory, validated, then moved -- Shelf never serves a half-extracted state +- **Concurrent upload protection**: Simultaneous uploads for the same product/version return `409 Conflict` +- **Size limit**: Configurable via `MaxUploadSizeBytes` (default: 100 MB) diff --git a/website/index.md b/website/index.md index 22064d2..ca2a820 100644 --- a/website/index.md +++ b/website/index.md @@ -20,6 +20,8 @@ features: details: Deploy new versions without touching old ones. All versions stay accessible side-by-side. - title: Upload API details: Deploy docs from CI/CD pipelines via HTTP. Just zip your VitePress output and POST it. + - title: Admin UI + details: Manage products and versions from a built-in web interface. Create, edit, delete products and upload docs from the browser. - title: Built for VitePress details: Optimized for serving VitePress static output including LLM documentation files (llms.txt). ---