diff --git a/.env.example b/.env.example index 8edde7b7..4d40c811 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,22 @@ PORT=3000 ADDRESS=0.0.0.0 -SECRET=app_secret -SECRET_FILE=app_secret_file APP_URL=http://localhost:3000 USERS=your_user_password_hash USERS_FILE=users_file -COOKIE_SECURE=false -GITHUB_CLIENT_ID=github_client_id -GITHUB_CLIENT_SECRET=github_client_secret -GITHUB_CLIENT_SECRET_FILE=github_client_secret_file -GOOGLE_CLIENT_ID=google_client_id -GOOGLE_CLIENT_SECRET=google_client_secret -GOOGLE_CLIENT_SECRET_FILE=google_client_secret_file -GENERIC_CLIENT_ID=generic_client_id -GENERIC_CLIENT_SECRET=generic_client_secret -GENERIC_CLIENT_SECRET_FILE=generic_client_secret_file -GENERIC_SCOPES=generic_scopes -GENERIC_AUTH_URL=generic_auth_url -GENERIC_TOKEN_URL=generic_token_url -GENERIC_USER_URL=generic_user_url -DISABLE_CONTINUE=false +SECURE_COOKIE=false OAUTH_WHITELIST= GENERIC_NAME=My OAuth SESSION_EXPIRY=7200 LOGIN_TIMEOUT=300 LOGIN_MAX_RETRIES=5 -LOG_LEVEL=0 +LOG_LEVEL=debug APP_TITLE=Tinyauth SSO FORGOT_PASSWORD_MESSAGE=Some message about resetting the password OAUTH_AUTO_REDIRECT=none BACKGROUND_IMAGE=some_image_url -GENERIC_SKIP_SSL=false \ No newline at end of file +GENERIC_SKIP_SSL=false +RESOURCES_DIR=/data/resources +DATABASE_PATH=/data/tinyauth.db +DISABLE_ANALYTICS=false +DISABLE_RESOURCES=false +TRUSTED_PROXIES= \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba634195..6c67bed9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,12 +23,17 @@ jobs: - name: Install frontend dependencies run: | cd frontend - bun install + bun install --frozen-lockfile - name: Set version run: | echo testing > internal/assets/version + - name: Lint frontend + run: | + cd frontend + bun run lint + - name: Build frontend run: | cd frontend diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9c46d895..ab4a9900 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -66,7 +66,7 @@ jobs: - name: Install frontend dependencies run: | cd frontend - bun install + bun install --frozen-lockfile - name: Install backend dependencies run: | @@ -80,7 +80,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 env: CGO_ENABLED: 0 @@ -112,7 +112,7 @@ jobs: - name: Install frontend dependencies run: | cd frontend - bun install + bun install --frozen-lockfile - name: Install backend dependencies run: | @@ -126,7 +126,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 env: CGO_ENABLED: 0 @@ -171,6 +171,9 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} @@ -190,6 +193,65 @@ jobs: if-no-files-found: error retention-days: 1 + image-build-distroless: + runs-on: ubuntu-latest + needs: + - create-release + - generate-metadata + - image-build + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: nightly + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/tinyauth + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + id: build + with: + platforms: linux/amd64 + labels: ${{ steps.meta.outputs.labels }} + tags: ghcr.io/${{ github.repository_owner }}/tinyauth + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + file: Dockerfile.distroless + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} + build-args: | + VERSION=${{ needs.generate-metadata.outputs.VERSION }} + COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} + BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-distroless-linux-amd64 + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + image-build-arm: runs-on: ubuntu-24.04-arm needs: @@ -217,9 +279,63 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Set version + - name: Build and push + uses: docker/build-push-action@v6 + id: build + with: + platforms: linux/arm64 + labels: ${{ steps.meta.outputs.labels }} + tags: ghcr.io/${{ github.repository_owner }}/tinyauth + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} + build-args: | + VERSION=${{ needs.generate-metadata.outputs.VERSION }} + COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} + BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} + + - name: Export digest run: | - echo nightly > internal/assets/version + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-linux-arm64 + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + image-build-arm-distroless: + runs-on: ubuntu-24.04-arm + needs: + - create-release + - generate-metadata + - image-build-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: nightly + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/tinyauth + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v6 @@ -229,6 +345,10 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true + file: Dockerfile.distroless + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} @@ -243,7 +363,7 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v4 with: - name: digests-linux-arm64 + name: digests-distroless-linux-arm64 path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 @@ -276,6 +396,8 @@ jobs: uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository_owner }}/tinyauth + flavor: | + latest=false tags: | type=raw,nightly @@ -285,6 +407,45 @@ jobs: docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *) + image-merge-distroless: + runs-on: ubuntu-latest + needs: + - image-build-distroless + - image-build-arm-distroless + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-distroless-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/tinyauth + flavor: | + latest=false + tags: | + type=raw,nightly-distroless + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *) + update-release: runs-on: ubuntu-latest needs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f6ae9b0..0cf3dde7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: - name: Install frontend dependencies run: | cd frontend - bun install + bun install --frozen-lockfile - name: Install backend dependencies run: | @@ -58,7 +58,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 env: CGO_ENABLED: 0 @@ -87,7 +87,7 @@ jobs: - name: Install frontend dependencies run: | cd frontend - bun install + bun install --frozen-lockfile - name: Install backend dependencies run: | @@ -101,7 +101,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 env: CGO_ENABLED: 0 @@ -143,6 +143,9 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} @@ -162,6 +165,62 @@ jobs: if-no-files-found: error retention-days: 1 + image-build-distroless: + runs-on: ubuntu-latest + needs: + - generate-metadata + - image-build + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/tinyauth + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + id: build + with: + platforms: linux/amd64 + labels: ${{ steps.meta.outputs.labels }} + tags: ghcr.io/${{ github.repository_owner }}/tinyauth + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + file: Dockerfile.distroless + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} + build-args: | + VERSION=${{ needs.generate-metadata.outputs.VERSION }} + COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} + BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-distroless-linux-amd64 + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + image-build-arm: runs-on: ubuntu-24.04-arm needs: @@ -194,6 +253,9 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: ghcr.io/${{ github.repository_owner }}/tinyauth outputs: type=image,push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} build-args: | VERSION=${{ needs.generate-metadata.outputs.VERSION }} COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} @@ -213,6 +275,62 @@ jobs: if-no-files-found: error retention-days: 1 + image-build-arm-distroless: + runs-on: ubuntu-24.04-arm + needs: + - generate-metadata + - image-build-arm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/tinyauth + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + id: build + with: + platforms: linux/arm64 + labels: ${{ steps.meta.outputs.labels }} + tags: ghcr.io/${{ github.repository_owner }}/tinyauth + outputs: type=image,push-by-digest=true,name-canonical=true,push=true + file: Dockerfile.distroless + cache-from: type=gha + cache-to: type=gha,mode=max + github-token: ${{ secrets.GITHUB_TOKEN }} + build-args: | + VERSION=${{ needs.generate-metadata.outputs.VERSION }} + COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }} + BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }} + + - name: Export digest + run: | + mkdir -p ${{ runner.temp }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-distroless-linux-arm64 + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + image-merge: runs-on: ubuntu-latest needs: @@ -241,10 +359,55 @@ jobs: uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository_owner }}/tinyauth + flavor: | + prefix=v,onlatest=false + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} + + - name: Create manifest list and push + working-directory: ${{ runner.temp }}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *) + + image-merge-distroless: + runs-on: ubuntu-latest + needs: + - image-build-distroless + - image-build-arm-distroless + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-distroless-* + merge-multiple: true + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/tinyauth + flavor: | + latest=false + prefix=v + suffix=-distroless tags: | - type=semver,pattern={{version}},prefix=v - type=semver,pattern={{major}},prefix=v - type=semver,pattern={{major}}.{{minor}},prefix=v + type=semver,pattern={{version}} + type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}} - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests diff --git a/.gitignore b/.gitignore index 0100a134..cb79b93b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ secret* tmp # version files -internal/assets/version \ No newline at end of file +internal/assets/version + +# data directory +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 29a68b96..5f9e64eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # Site builder -FROM oven/bun:1.2.20-alpine AS frontend-builder +FROM oven/bun:1.3.2-alpine AS frontend-builder WORKDIR /frontend COPY ./frontend/package.json ./ COPY ./frontend/bun.lock ./ -RUN bun install +RUN bun install --frozen-lockfile COPY ./frontend/public ./public COPY ./frontend/src ./src @@ -38,17 +38,25 @@ COPY ./cmd ./cmd COPY ./internal ./internal COPY --from=frontend-builder /frontend/dist ./internal/assets/dist -RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${VERSION} -X tinyauth/internal/constants.CommitHash=${COMMIT_HASH} -X tinyauth/internal/constants.BuildTimestamp=${BUILD_TIMESTAMP}" +RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" # Runner FROM alpine:3.22 AS runner WORKDIR /tinyauth -RUN apk add --no-cache curl - COPY --from=builder /tinyauth/tinyauth ./ +RUN mkdir -p /data + EXPOSE 3000 -ENTRYPOINT ["./tinyauth"] \ No newline at end of file +VOLUME ["/data"] + +ENV GIN_MODE=release + +ENV PATH=$PATH:/tinyauth + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"] + +ENTRYPOINT ["tinyauth"] \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev index d0889c96..a132dedf 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -7,13 +7,14 @@ COPY go.sum ./ RUN go mod download +RUN go install github.com/air-verse/air@v1.61.7 +RUN go install github.com/go-delve/delve/cmd/dlv@latest + COPY ./cmd ./cmd COPY ./internal ./internal COPY ./main.go ./ COPY ./air.toml ./ -RUN go install github.com/air-verse/air@v1.61.7 - EXPOSE 3000 ENTRYPOINT ["air", "-c", "air.toml"] \ No newline at end of file diff --git a/Dockerfile.distroless b/Dockerfile.distroless new file mode 100644 index 00000000..6118ca35 --- /dev/null +++ b/Dockerfile.distroless @@ -0,0 +1,65 @@ +# Site builder +FROM oven/bun:1.3.2-alpine AS frontend-builder + +WORKDIR /frontend + +COPY ./frontend/package.json ./ +COPY ./frontend/bun.lock ./ + +RUN bun install --frozen-lockfile + +COPY ./frontend/public ./public +COPY ./frontend/src ./src +COPY ./frontend/eslint.config.js ./ +COPY ./frontend/index.html ./ +COPY ./frontend/tsconfig.json ./ +COPY ./frontend/tsconfig.app.json ./ +COPY ./frontend/tsconfig.node.json ./ +COPY ./frontend/vite.config.ts ./ + +RUN bun run build + +# Builder +FROM golang:1.25-alpine3.21 AS builder + +ARG VERSION +ARG COMMIT_HASH +ARG BUILD_TIMESTAMP + +WORKDIR /tinyauth + +COPY go.mod ./ +COPY go.sum ./ + +RUN go mod download + +COPY ./main.go ./ +COPY ./cmd ./cmd +COPY ./internal ./internal +COPY --from=frontend-builder /frontend/dist ./internal/assets/dist + +RUN mkdir -p data + +RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" + +# Runner +FROM gcr.io/distroless/static-debian12:latest AS runner + +WORKDIR /tinyauth + +COPY --from=builder /tinyauth/tinyauth ./ + +# Since it's distroless, we need to copy the data directory from the builder stage +COPY --from=builder /tinyauth/data /data + +EXPOSE 3000 + +VOLUME ["/data"] + +ENV GIN_MODE=release + +ENV PATH=$PATH:/tinyauth + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"] + +ENTRYPOINT ["tinyauth"] \ No newline at end of file diff --git a/README.md b/README.md index 48f731a5..d56894c3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
Tinyauth

Tinyauth

-

The easiest way to secure your apps with a login screen.

+

The simplest way to protect your apps with a login screen.

@@ -14,7 +14,7 @@
-Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github and any provider to all of your docker apps. It supports all the popular proxies like Traefik, Nginx and Caddy. +Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github or any other provider to all of your apps. It supports all the popular proxies like Traefik, Nginx and Caddy. ![Screenshot](assets/screenshot.png) @@ -23,7 +23,7 @@ Tinyauth is a simple authentication middleware that adds a simple login screen o ## Getting Started -You can easily get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities. +You can easily get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities. ## Demo @@ -53,7 +53,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma A big thank you to the following people for providing me with more coffee: -User avatar: erwinkramer  User avatar: nicotsx  User avatar: SimpleHomelab  User avatar: jmadden91  User avatar: tribor  User avatar: eliasbenb  User avatar: afunworm   +User avatar: erwinkramer  User avatar: nicotsx  User avatar: SimpleHomelab  User avatar: jmadden91  User avatar: tribor  User avatar: eliasbenb  User avatar: afunworm  User avatar: chip-well  User avatar: Lancelot-Enguerrand  User avatar: allgoewer   ## Acknowledgements diff --git a/air.toml b/air.toml index 7505b79a..cddbc9d6 100644 --- a/air.toml +++ b/air.toml @@ -2,9 +2,10 @@ root = "/tinyauth" tmp_dir = "tmp" [build] -pre_cmd = ["mkdir -p internal/assets/dist", "echo 'backend running' > internal/assets/dist/index.html", "go install github.com/go-delve/delve/cmd/dlv@v1.25.0"] +pre_cmd = ["mkdir -p internal/assets/dist", "mkdir -p /data", "echo 'backend running' > internal/assets/dist/index.html"] cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ." -bin = "/go/bin/dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue" +bin = "tmp/tinyauth" +full_bin = "dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false" include_ext = ["go"] exclude_dir = ["internal/assets/dist"] exclude_regex = [".*_test\\.go"] diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 00000000..0abb3c7d --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/huh" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" +) + +type createUserCmd struct { + root *cobra.Command + cmd *cobra.Command + + interactive bool + docker bool + username string + password string +} + +func newCreateUserCmd(root *cobra.Command) *createUserCmd { + return &createUserCmd{ + root: root, + } +} + +func (c *createUserCmd) Register() { + c.cmd = &cobra.Command{ + Use: "create", + Short: "Create a user", + Long: `Create a user either interactively or by passing flags.`, + Run: c.run, + } + + c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Create a user interactively") + c.cmd.Flags().BoolVar(&c.docker, "docker", false, "Format output for docker") + c.cmd.Flags().StringVar(&c.username, "username", "", "Username") + c.cmd.Flags().StringVar(&c.password, "password", "", "Password") + + if c.root != nil { + c.root.AddCommand(c.cmd) + } +} + +func (c *createUserCmd) GetCmd() *cobra.Command { + return c.cmd +} + +func (c *createUserCmd) run(cmd *cobra.Command, args []string) { + log.Logger = log.Level(zerolog.InfoLevel) + + if c.interactive { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error { + if s == "" { + return errors.New("username cannot be empty") + } + return nil + })), + huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error { + if s == "" { + return errors.New("password cannot be empty") + } + return nil + })), + huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&c.docker), + ), + ) + var baseTheme *huh.Theme = huh.ThemeBase() + err := form.WithTheme(baseTheme).Run() + if err != nil { + log.Fatal().Err(err).Msg("Form failed") + } + } + + if c.username == "" || c.password == "" { + log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty") + } + + log.Info().Str("username", c.username).Msg("Creating user") + + passwd, err := bcrypt.GenerateFromPassword([]byte(c.password), bcrypt.DefaultCost) + if err != nil { + log.Fatal().Err(err).Msg("Failed to hash password") + } + + // If docker format is enabled, escape the dollar sign + passwdStr := string(passwd) + if c.docker { + passwdStr = strings.ReplaceAll(passwdStr, "$", "$$") + } + + log.Info().Str("user", fmt.Sprintf("%s:%s", c.username, passwdStr)).Msg("User created") +} diff --git a/cmd/generate.go b/cmd/generate.go new file mode 100644 index 00000000..005b4738 --- /dev/null +++ b/cmd/generate.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strings" + "tinyauth/internal/utils" + + "github.com/charmbracelet/huh" + "github.com/mdp/qrterminal/v3" + "github.com/pquerna/otp/totp" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +type generateTotpCmd struct { + root *cobra.Command + cmd *cobra.Command + + interactive bool + user string +} + +func newGenerateTotpCmd(root *cobra.Command) *generateTotpCmd { + return &generateTotpCmd{ + root: root, + } +} + +func (c *generateTotpCmd) Register() { + c.cmd = &cobra.Command{ + Use: "generate", + Short: "Generate a totp secret", + Long: `Generate a totp secret for a user either interactively or by passing flags.`, + Run: c.run, + } + + c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Run in interactive mode") + c.cmd.Flags().StringVar(&c.user, "user", "", "Your current user (username:hash)") + + if c.root != nil { + c.root.AddCommand(c.cmd) + } +} + +func (c *generateTotpCmd) GetCmd() *cobra.Command { + return c.cmd +} + +func (c *generateTotpCmd) run(cmd *cobra.Command, args []string) { + log.Logger = log.Level(zerolog.InfoLevel) + + if c.interactive { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Current user (username:hash)").Value(&c.user).Validate((func(s string) error { + if s == "" { + return errors.New("user cannot be empty") + } + return nil + })), + ), + ) + var baseTheme *huh.Theme = huh.ThemeBase() + err := form.WithTheme(baseTheme).Run() + if err != nil { + log.Fatal().Err(err).Msg("Form failed") + } + } + + user, err := utils.ParseUser(c.user) + if err != nil { + log.Fatal().Err(err).Msg("Failed to parse user") + } + + docker := false + if strings.Contains(c.user, "$$") { + docker = true + } + + if user.TotpSecret != "" { + log.Fatal().Msg("User already has a TOTP secret") + } + + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "Tinyauth", + AccountName: user.Username, + }) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to generate TOTP secret") + } + + secret := key.Secret() + + log.Info().Str("secret", secret).Msg("Generated TOTP secret") + + log.Info().Msg("Generated QR code") + + config := qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + QuietZone: 2, + } + + qrterminal.GenerateWithConfig(key.URL(), config) + + user.TotpSecret = secret + + // If using docker escape re-escape it + if docker { + user.Password = strings.ReplaceAll(user.Password, "$", "$$") + } + + log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.") +} diff --git a/cmd/healthcheck.go b/cmd/healthcheck.go new file mode 100644 index 00000000..ca2bd839 --- /dev/null +++ b/cmd/healthcheck.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type healthzResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +type healthcheckCmd struct { + root *cobra.Command + cmd *cobra.Command + + viper *viper.Viper +} + +func newHealthcheckCmd(root *cobra.Command) *healthcheckCmd { + return &healthcheckCmd{ + root: root, + viper: viper.New(), + } +} + +func (c *healthcheckCmd) Register() { + c.cmd = &cobra.Command{ + Use: "healthcheck [app-url]", + Short: "Perform a health check", + Long: `Use the health check endpoint to verify that Tinyauth is running and it's healthy.`, + Run: c.run, + } + + c.viper.AutomaticEnv() + + if c.root != nil { + c.root.AddCommand(c.cmd) + } +} + +func (c *healthcheckCmd) GetCmd() *cobra.Command { + return c.cmd +} + +func (c *healthcheckCmd) run(cmd *cobra.Command, args []string) { + log.Logger = log.Level(zerolog.InfoLevel) + + var appUrl string + + port := c.viper.GetString("PORT") + address := c.viper.GetString("ADDRESS") + + if port == "" { + port = "3000" + } + + if address == "" { + address = "127.0.0.1" + } + + appUrl = "http://" + address + ":" + port + + if len(args) > 0 { + appUrl = args[0] + } + + log.Info().Str("app_url", appUrl).Msg("Performing health check") + + client := http.Client{} + + req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to create request") + } + + resp, err := client.Do(req) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to perform request") + } + + if resp.StatusCode != http.StatusOK { + log.Fatal().Err(errors.New("service is not healthy")).Msgf("Service is not healthy. Status code: %d", resp.StatusCode) + } + + defer resp.Body.Close() + + var healthResp healthzResponse + + body, err := io.ReadAll(resp.Body) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to read response") + } + + err = json.Unmarshal(body, &healthResp) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to decode response") + } + + log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy") +} diff --git a/cmd/root.go b/cmd/root.go index f96ec6bc..4399ef05 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,20 +1,9 @@ package cmd import ( - "errors" - "fmt" "strings" - totpCmd "tinyauth/cmd/totp" - userCmd "tinyauth/cmd/user" - "tinyauth/internal/auth" - "tinyauth/internal/constants" - "tinyauth/internal/docker" - "tinyauth/internal/handlers" - "tinyauth/internal/hooks" - "tinyauth/internal/ldap" - "tinyauth/internal/providers" - "tinyauth/internal/server" - "tinyauth/internal/types" + "tinyauth/internal/bootstrap" + "tinyauth/internal/config" "tinyauth/internal/utils" "github.com/go-playground/validator/v10" @@ -24,240 +13,150 @@ import ( "github.com/spf13/viper" ) -var rootCmd = &cobra.Command{ - Use: "tinyauth", - Short: "The simplest way to protect your apps with a login screen.", - Long: `Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.`, - Run: func(cmd *cobra.Command, args []string) { - var config types.Config - err := viper.Unmarshal(&config) - HandleError(err, "Failed to parse config") +type rootCmd struct { + root *cobra.Command + cmd *cobra.Command - // Check if secrets have a file associated with them - config.Secret = utils.GetSecret(config.Secret, config.SecretFile) - config.GithubClientSecret = utils.GetSecret(config.GithubClientSecret, config.GithubClientSecretFile) - config.GoogleClientSecret = utils.GetSecret(config.GoogleClientSecret, config.GoogleClientSecretFile) - config.GenericClientSecret = utils.GetSecret(config.GenericClientSecret, config.GenericClientSecretFile) - - validator := validator.New() - err = validator.Struct(config) - HandleError(err, "Failed to validate config") - - log.Logger = log.Level(zerolog.Level(config.LogLevel)) - log.Info().Str("version", strings.TrimSpace(constants.Version)).Msg("Starting tinyauth") - - log.Info().Msg("Parsing users") - users, err := utils.GetUsers(config.Users, config.UsersFile) - HandleError(err, "Failed to parse users") - - log.Debug().Msg("Getting domain") - domain, err := utils.GetUpperDomain(config.AppURL) - HandleError(err, "Failed to get upper domain") - log.Info().Str("domain", domain).Msg("Using domain for cookie store") - - cookieId := utils.GenerateIdentifier(strings.Split(domain, ".")[0]) - sessionCookieName := fmt.Sprintf("%s-%s", constants.SessionCookieName, cookieId) - csrfCookieName := fmt.Sprintf("%s-%s", constants.CsrfCookieName, cookieId) - redirectCookieName := fmt.Sprintf("%s-%s", constants.RedirectCookieName, cookieId) + viper *viper.Viper +} - log.Debug().Msg("Deriving HMAC and encryption secrets") +func newRootCmd() *rootCmd { + return &rootCmd{ + viper: viper.New(), + } +} - hmacSecret, err := utils.DeriveKey(config.Secret, "hmac") - HandleError(err, "Failed to derive HMAC secret") +func (c *rootCmd) Register() { + c.cmd = &cobra.Command{ + Use: "tinyauth", + Short: "The simplest way to protect your apps with a login screen", + Long: `Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github or any other provider to all of your docker apps.`, + Run: c.run, + } - encryptionSecret, err := utils.DeriveKey(config.Secret, "encryption") - HandleError(err, "Failed to derive encryption secret") + // Ignore unknown flags to allow --providers-* + c.cmd.FParseErrWhitelist.UnknownFlags = true + + c.viper.AutomaticEnv() + + configOptions := []struct { + name string + defaultVal any + description string + }{ + {"port", 3000, "Port to run the server on."}, + {"address", "0.0.0.0", "Address to bind the server to."}, + {"app-url", "", "The Tinyauth URL."}, + {"users", "", "Comma separated list of users in the format username:hash."}, + {"users-file", "", "Path to a file containing users in the format username:hash."}, + {"secure-cookie", false, "Send cookie over secure connection only."}, + {"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."}, + {"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"}, + {"session-expiry", 86400, "Session (cookie) expiration time in seconds."}, + {"login-timeout", 300, "Login timeout in seconds after max retries reached (0 to disable)."}, + {"login-max-retries", 5, "Maximum login attempts before timeout (0 to disable)."}, + {"log-level", "info", "Log level."}, + {"app-title", "Tinyauth", "Title of the app."}, + {"forgot-password-message", "", "Message to show on the forgot password page."}, + {"background-image", "/background.jpg", "Background image URL for the login page."}, + {"ldap-address", "", "LDAP server address (e.g. ldap://localhost:389)."}, + {"ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com)."}, + {"ldap-bind-password", "", "LDAP bind password."}, + {"ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com)."}, + {"ldap-insecure", false, "Skip certificate verification for the LDAP server."}, + {"ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup."}, + {"resources-dir", "/data/resources", "Path to a directory containing custom resources (e.g. background image)."}, + {"database-path", "/data/tinyauth.db", "Path to the Sqlite database file."}, + {"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses or CIDRs) for correct client IP detection."}, + {"disable-analytics", false, "Disable anonymous version collection."}, + {"disable-resources", false, "Disable the resources server."}, + {"socket-path", "", "Path to the Unix socket to bind the server to."}, + {"disable-ui-warnings", false, "Disable UI warnings about insecure configurations."}, + } - // Split the config into service-specific sub-configs - oauthConfig := types.OAuthConfig{ - GithubClientId: config.GithubClientId, - GithubClientSecret: config.GithubClientSecret, - GoogleClientId: config.GoogleClientId, - GoogleClientSecret: config.GoogleClientSecret, - GenericClientId: config.GenericClientId, - GenericClientSecret: config.GenericClientSecret, - GenericScopes: strings.Split(config.GenericScopes, ","), - GenericAuthURL: config.GenericAuthURL, - GenericTokenURL: config.GenericTokenURL, - GenericUserURL: config.GenericUserURL, - GenericSkipSSL: config.GenericSkipSSL, - AppURL: config.AppURL, + for _, opt := range configOptions { + switch v := opt.defaultVal.(type) { + case bool: + c.cmd.Flags().Bool(opt.name, v, opt.description) + case int: + c.cmd.Flags().Int(opt.name, v, opt.description) + case string: + c.cmd.Flags().String(opt.name, v, opt.description) } - handlersConfig := types.HandlersConfig{ - AppURL: config.AppURL, - DisableContinue: config.DisableContinue, - Title: config.Title, - GenericName: config.GenericName, - CookieSecure: config.CookieSecure, - Domain: domain, - ForgotPasswordMessage: config.FogotPasswordMessage, - BackgroundImage: config.BackgroundImage, - OAuthAutoRedirect: config.OAuthAutoRedirect, - CsrfCookieName: csrfCookieName, - RedirectCookieName: redirectCookieName, - } + // Create uppercase env var name + envVar := strings.ReplaceAll(strings.ToUpper(opt.name), "-", "_") + c.viper.BindEnv(opt.name, envVar) + } - serverConfig := types.ServerConfig{ - Port: config.Port, - Address: config.Address, - } + c.viper.BindPFlags(c.cmd.Flags()) - authConfig := types.AuthConfig{ - Users: users, - OauthWhitelist: config.OAuthWhitelist, - CookieSecure: config.CookieSecure, - SessionExpiry: config.SessionExpiry, - Domain: domain, - LoginTimeout: config.LoginTimeout, - LoginMaxRetries: config.LoginMaxRetries, - SessionCookieName: sessionCookieName, - HMACSecret: hmacSecret, - EncryptionSecret: encryptionSecret, - } + if c.root != nil { + c.root.AddCommand(c.cmd) + } +} - hooksConfig := types.HooksConfig{ - Domain: domain, - } +func (c *rootCmd) GetCmd() *cobra.Command { + return c.cmd +} - var ldapService *ldap.LDAP +func (c *rootCmd) run(cmd *cobra.Command, args []string) { + var conf config.Config - if config.LdapAddress != "" { - log.Info().Msg("Using LDAP for authentication") - ldapConfig := types.LdapConfig{ - Address: config.LdapAddress, - BindDN: config.LdapBindDN, - BindPassword: config.LdapBindPassword, - BaseDN: config.LdapBaseDN, - Insecure: config.LdapInsecure, - SearchFilter: config.LdapSearchFilter, - } - ldapService, err = ldap.NewLDAP(ldapConfig) - if err != nil { - log.Error().Err(err).Msg("Failed to initialize LDAP service, disabling LDAP authentication") - ldapService = nil - } - } else { - log.Info().Msg("LDAP not configured, using local users or OAuth") - } + err := c.viper.Unmarshal(&conf) + if err != nil { + log.Fatal().Err(err).Msg("Failed to parse config") + } - // Check if we have a source of users - if len(users) == 0 && !utils.OAuthConfigured(config) && ldapService == nil { - HandleError(errors.New("err no users"), "Unable to find a source of users") - } + v := validator.New() + err = v.Struct(conf) + if err != nil { + log.Fatal().Err(err).Msg("Invalid config") + } - // Setup the services - docker, err := docker.NewDocker() - HandleError(err, "Failed to initialize docker") - auth := auth.NewAuth(authConfig, docker, ldapService) - providers := providers.NewProviders(oauthConfig) - hooks := hooks.NewHooks(hooksConfig, auth, providers) - handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker) - srv, err := server.NewServer(serverConfig, handlers) - HandleError(err, "Failed to create server") + log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel))) + log.Info().Str("version", strings.TrimSpace(config.Version)).Msg("Starting Tinyauth") - // Start up - err = srv.Start() - HandleError(err, "Failed to start server") - }, -} + if log.Logger.GetLevel() == zerolog.TraceLevel { + log.Warn().Msg("Log level set to trace, this will log sensitive information!") + } -func Execute() { - err := rootCmd.Execute() - HandleError(err, "Failed to execute root command") -} + app := bootstrap.NewBootstrapApp(conf) -func HandleError(err error, msg string) { + err = app.Setup() if err != nil { - log.Fatal().Err(err).Msg(msg) + log.Fatal().Err(err).Msg("Failed to setup app") } } -func init() { - rootCmd.AddCommand(userCmd.UserCmd()) - rootCmd.AddCommand(totpCmd.TotpCmd()) +func Run() { + rootCmd := newRootCmd() + rootCmd.Register() + root := rootCmd.GetCmd() + + userCmd := &cobra.Command{ + Use: "user", + Short: "User utilities", + Long: `Utilities for creating and verifying tinyauth compatible users.`, + } + totpCmd := &cobra.Command{ + Use: "totp", + Short: "Totp utilities", + Long: `Utilities for creating and verifying totp codes.`, + } - viper.AutomaticEnv() + newCreateUserCmd(userCmd).Register() + newVerifyUserCmd(userCmd).Register() + newGenerateTotpCmd(totpCmd).Register() + newVersionCmd(root).Register() + newHealthcheckCmd(root).Register() - rootCmd.Flags().Int("port", 3000, "Port to run the server on.") - rootCmd.Flags().String("address", "0.0.0.0", "Address to bind the server to.") - rootCmd.Flags().String("secret", "", "Secret to use for the cookie.") - rootCmd.Flags().String("secret-file", "", "Path to a file containing the secret.") - rootCmd.Flags().String("app-url", "", "The tinyauth URL.") - rootCmd.Flags().String("users", "", "Comma separated list of users in the format username:hash.") - rootCmd.Flags().String("users-file", "", "Path to a file containing users in the format username:hash.") - rootCmd.Flags().Bool("cookie-secure", false, "Send cookie over secure connection only.") - rootCmd.Flags().String("github-client-id", "", "Github OAuth client ID.") - rootCmd.Flags().String("github-client-secret", "", "Github OAuth client secret.") - rootCmd.Flags().String("github-client-secret-file", "", "Github OAuth client secret file.") - rootCmd.Flags().String("google-client-id", "", "Google OAuth client ID.") - rootCmd.Flags().String("google-client-secret", "", "Google OAuth client secret.") - rootCmd.Flags().String("google-client-secret-file", "", "Google OAuth client secret file.") - rootCmd.Flags().String("generic-client-id", "", "Generic OAuth client ID.") - rootCmd.Flags().String("generic-client-secret", "", "Generic OAuth client secret.") - rootCmd.Flags().String("generic-client-secret-file", "", "Generic OAuth client secret file.") - rootCmd.Flags().String("generic-scopes", "", "Generic OAuth scopes.") - rootCmd.Flags().String("generic-auth-url", "", "Generic OAuth auth URL.") - rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.") - rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.") - rootCmd.Flags().String("generic-name", "Generic", "Generic OAuth provider name.") - rootCmd.Flags().Bool("generic-skip-ssl", false, "Skip SSL verification for the generic OAuth provider.") - rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.") - rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.") - rootCmd.Flags().String("oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)") - rootCmd.Flags().Int("session-expiry", 86400, "Session (cookie) expiration time in seconds.") - rootCmd.Flags().Int("login-timeout", 300, "Login timeout in seconds after max retries reached (0 to disable).") - rootCmd.Flags().Int("login-max-retries", 5, "Maximum login attempts before timeout (0 to disable).") - rootCmd.Flags().Int("log-level", 1, "Log level.") - rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.") - rootCmd.Flags().String("forgot-password-message", "", "Message to show on the forgot password page.") - rootCmd.Flags().String("background-image", "/background.jpg", "Background image URL for the login page.") - rootCmd.Flags().String("ldap-address", "", "LDAP server address (e.g. ldap://localhost:389).") - rootCmd.Flags().String("ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com).") - rootCmd.Flags().String("ldap-bind-password", "", "LDAP bind password.") - rootCmd.Flags().String("ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com).") - rootCmd.Flags().Bool("ldap-insecure", false, "Skip certificate verification for the LDAP server.") - rootCmd.Flags().String("ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup.") + root.AddCommand(userCmd) + root.AddCommand(totpCmd) - viper.BindEnv("port", "PORT") - viper.BindEnv("address", "ADDRESS") - viper.BindEnv("secret", "SECRET") - viper.BindEnv("secret-file", "SECRET_FILE") - viper.BindEnv("app-url", "APP_URL") - viper.BindEnv("users", "USERS") - viper.BindEnv("users-file", "USERS_FILE") - viper.BindEnv("cookie-secure", "COOKIE_SECURE") - viper.BindEnv("github-client-id", "GITHUB_CLIENT_ID") - viper.BindEnv("github-client-secret", "GITHUB_CLIENT_SECRET") - viper.BindEnv("github-client-secret-file", "GITHUB_CLIENT_SECRET_FILE") - viper.BindEnv("google-client-id", "GOOGLE_CLIENT_ID") - viper.BindEnv("google-client-secret", "GOOGLE_CLIENT_SECRET") - viper.BindEnv("google-client-secret-file", "GOOGLE_CLIENT_SECRET_FILE") - viper.BindEnv("generic-client-id", "GENERIC_CLIENT_ID") - viper.BindEnv("generic-client-secret", "GENERIC_CLIENT_SECRET") - viper.BindEnv("generic-client-secret-file", "GENERIC_CLIENT_SECRET_FILE") - viper.BindEnv("generic-scopes", "GENERIC_SCOPES") - viper.BindEnv("generic-auth-url", "GENERIC_AUTH_URL") - viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL") - viper.BindEnv("generic-user-url", "GENERIC_USER_URL") - viper.BindEnv("generic-name", "GENERIC_NAME") - viper.BindEnv("generic-skip-ssl", "GENERIC_SKIP_SSL") - viper.BindEnv("disable-continue", "DISABLE_CONTINUE") - viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST") - viper.BindEnv("oauth-auto-redirect", "OAUTH_AUTO_REDIRECT") - viper.BindEnv("session-expiry", "SESSION_EXPIRY") - viper.BindEnv("log-level", "LOG_LEVEL") - viper.BindEnv("app-title", "APP_TITLE") - viper.BindEnv("login-timeout", "LOGIN_TIMEOUT") - viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES") - viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE") - viper.BindEnv("background-image", "BACKGROUND_IMAGE") - viper.BindEnv("ldap-address", "LDAP_ADDRESS") - viper.BindEnv("ldap-bind-dn", "LDAP_BIND_DN") - viper.BindEnv("ldap-bind-password", "LDAP_BIND_PASSWORD") - viper.BindEnv("ldap-base-dn", "LDAP_BASE_DN") - viper.BindEnv("ldap-insecure", "LDAP_INSECURE") - viper.BindEnv("ldap-search-filter", "LDAP_SEARCH_FILTER") + err := root.Execute() - viper.BindPFlags(rootCmd.Flags()) + if err != nil { + log.Fatal().Err(err).Msg("Failed to execute root command") + } } diff --git a/cmd/totp/generate/generate.go b/cmd/totp/generate/generate.go deleted file mode 100644 index 72f0c297..00000000 --- a/cmd/totp/generate/generate.go +++ /dev/null @@ -1,99 +0,0 @@ -package generate - -import ( - "errors" - "fmt" - "os" - "strings" - "tinyauth/internal/utils" - - "github.com/charmbracelet/huh" - "github.com/mdp/qrterminal/v3" - "github.com/pquerna/otp/totp" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" -) - -var interactive bool - -// Input user -var iUser string - -var GenerateCmd = &cobra.Command{ - Use: "generate", - Short: "Generate a totp secret", - Run: func(cmd *cobra.Command, args []string) { - log.Logger = log.Level(zerolog.InfoLevel) - - if interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Current username:hash").Value(&iUser).Validate((func(s string) error { - if s == "" { - return errors.New("user cannot be empty") - } - return nil - })), - ), - ) - var baseTheme *huh.Theme = huh.ThemeBase() - err := form.WithTheme(baseTheme).Run() - if err != nil { - log.Fatal().Err(err).Msg("Form failed") - } - } - - user, err := utils.ParseUser(iUser) - if err != nil { - log.Fatal().Err(err).Msg("Failed to parse user") - } - - dockerEscape := false - if strings.Contains(iUser, "$$") { - dockerEscape = true - } - - if user.TotpSecret != "" { - log.Fatal().Msg("User already has a totp secret") - } - - key, err := totp.Generate(totp.GenerateOpts{ - Issuer: "Tinyauth", - AccountName: user.Username, - }) - if err != nil { - log.Fatal().Err(err).Msg("Failed to generate totp secret") - } - - secret := key.Secret() - - log.Info().Str("secret", secret).Msg("Generated totp secret") - - log.Info().Msg("Generated QR code") - - config := qrterminal.Config{ - Level: qrterminal.L, - Writer: os.Stdout, - BlackChar: qrterminal.BLACK, - WhiteChar: qrterminal.WHITE, - QuietZone: 2, - } - - qrterminal.GenerateWithConfig(key.URL(), config) - - user.TotpSecret = secret - - // If using docker escape re-escape it - if dockerEscape { - user.Password = strings.ReplaceAll(user.Password, "$", "$$") - } - - log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.") - }, -} - -func init() { - GenerateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Run in interactive mode") - GenerateCmd.Flags().StringVar(&iUser, "user", "", "Your current username:hash") -} diff --git a/cmd/totp/totp.go b/cmd/totp/totp.go deleted file mode 100644 index bfe08aa9..00000000 --- a/cmd/totp/totp.go +++ /dev/null @@ -1,17 +0,0 @@ -package cmd - -import ( - "tinyauth/cmd/totp/generate" - - "github.com/spf13/cobra" -) - -func TotpCmd() *cobra.Command { - totpCmd := &cobra.Command{ - Use: "totp", - Short: "Totp utilities", - Long: `Utilities for creating and verifying totp codes.`, - } - totpCmd.AddCommand(generate.GenerateCmd) - return totpCmd -} diff --git a/cmd/user/create/create.go b/cmd/user/create/create.go deleted file mode 100644 index ca5f95e0..00000000 --- a/cmd/user/create/create.go +++ /dev/null @@ -1,80 +0,0 @@ -package create - -import ( - "errors" - "fmt" - "strings" - - "github.com/charmbracelet/huh" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - "golang.org/x/crypto/bcrypt" -) - -var interactive bool -var docker bool - -// i stands for input -var iUsername string -var iPassword string - -var CreateCmd = &cobra.Command{ - Use: "create", - Short: "Create a user", - Long: `Create a user either interactively or by passing flags.`, - Run: func(cmd *cobra.Command, args []string) { - log.Logger = log.Level(zerolog.InfoLevel) - - if interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error { - if s == "" { - return errors.New("username cannot be empty") - } - return nil - })), - huh.NewInput().Title("Password").Value(&iPassword).Validate((func(s string) error { - if s == "" { - return errors.New("password cannot be empty") - } - return nil - })), - huh.NewSelect[bool]().Title("Format the output for docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&docker), - ), - ) - var baseTheme *huh.Theme = huh.ThemeBase() - err := form.WithTheme(baseTheme).Run() - if err != nil { - log.Fatal().Err(err).Msg("Form failed") - } - } - - if iUsername == "" || iPassword == "" { - log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty") - } - - log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user") - - password, err := bcrypt.GenerateFromPassword([]byte(iPassword), bcrypt.DefaultCost) - if err != nil { - log.Fatal().Err(err).Msg("Failed to hash password") - } - - // If docker format is enabled, escape the dollar sign - passwordString := string(password) - if docker { - passwordString = strings.ReplaceAll(passwordString, "$", "$$") - } - - log.Info().Str("user", fmt.Sprintf("%s:%s", iUsername, passwordString)).Msg("User created") - }, -} - -func init() { - CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") - CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker") - CreateCmd.Flags().StringVar(&iUsername, "username", "", "Username") - CreateCmd.Flags().StringVar(&iPassword, "password", "", "Password") -} diff --git a/cmd/user/user.go b/cmd/user/user.go deleted file mode 100644 index ce7f4231..00000000 --- a/cmd/user/user.go +++ /dev/null @@ -1,19 +0,0 @@ -package cmd - -import ( - "tinyauth/cmd/user/create" - "tinyauth/cmd/user/verify" - - "github.com/spf13/cobra" -) - -func UserCmd() *cobra.Command { - userCmd := &cobra.Command{ - Use: "user", - Short: "User utilities", - Long: `Utilities for creating and verifying tinyauth compatible users.`, - } - userCmd.AddCommand(create.CreateCmd) - userCmd.AddCommand(verify.VerifyCmd) - return userCmd -} diff --git a/cmd/user/verify/verify.go b/cmd/user/verify/verify.go deleted file mode 100644 index b10ff705..00000000 --- a/cmd/user/verify/verify.go +++ /dev/null @@ -1,101 +0,0 @@ -package verify - -import ( - "errors" - "tinyauth/internal/utils" - - "github.com/charmbracelet/huh" - "github.com/pquerna/otp/totp" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - "golang.org/x/crypto/bcrypt" -) - -var interactive bool -var docker bool - -// i stands for input -var iUsername string -var iPassword string -var iTotp string -var iUser string - -var VerifyCmd = &cobra.Command{ - Use: "verify", - Short: "Verify a user is set up correctly", - Long: `Verify a user is set up correctly meaning that it has a correct username, password and totp code.`, - Run: func(cmd *cobra.Command, args []string) { - log.Logger = log.Level(zerolog.InfoLevel) - - if interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("User (username:hash:totp)").Value(&iUser).Validate((func(s string) error { - if s == "" { - return errors.New("user cannot be empty") - } - return nil - })), - huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error { - if s == "" { - return errors.New("username cannot be empty") - } - return nil - })), - huh.NewInput().Title("Password").Value(&iPassword).Validate((func(s string) error { - if s == "" { - return errors.New("password cannot be empty") - } - return nil - })), - huh.NewInput().Title("Totp Code (if setup)").Value(&iTotp), - ), - ) - var baseTheme *huh.Theme = huh.ThemeBase() - err := form.WithTheme(baseTheme).Run() - if err != nil { - log.Fatal().Err(err).Msg("Form failed") - } - } - - user, err := utils.ParseUser(iUser) - if err != nil { - log.Fatal().Err(err).Msg("Failed to parse user") - } - - if user.Username != iUsername { - log.Fatal().Msg("Username is incorrect") - } - - err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(iPassword)) - if err != nil { - log.Fatal().Msg("Ppassword is incorrect") - } - - if user.TotpSecret == "" { - if iTotp != "" { - log.Warn().Msg("User does not have 2fa secret") - } - log.Info().Msg("User verified") - return - } - - ok := totp.Validate(iTotp, user.TotpSecret) - if !ok { - log.Fatal().Msg("Totp code incorrect") - - } - - log.Info().Msg("User verified") - }, -} - -func init() { - VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively") - VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?") - VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username") - VerifyCmd.Flags().StringVar(&iPassword, "password", "", "Password") - VerifyCmd.Flags().StringVar(&iTotp, "totp", "", "Totp code") - VerifyCmd.Flags().StringVar(&iUser, "user", "", "Hash (username:hash:totp combination)") -} diff --git a/cmd/verify.go b/cmd/verify.go new file mode 100644 index 00000000..93b6a99e --- /dev/null +++ b/cmd/verify.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "errors" + "tinyauth/internal/utils" + + "github.com/charmbracelet/huh" + "github.com/pquerna/otp/totp" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" +) + +type verifyUserCmd struct { + root *cobra.Command + cmd *cobra.Command + + interactive bool + username string + password string + totp string + user string +} + +func newVerifyUserCmd(root *cobra.Command) *verifyUserCmd { + return &verifyUserCmd{ + root: root, + } +} + +func (c *verifyUserCmd) Register() { + c.cmd = &cobra.Command{ + Use: "verify", + Short: "Verify a user is set up correctly", + Long: `Verify a user is set up correctly meaning that it has a correct username, password and TOTP code.`, + Run: c.run, + } + + c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Validate a user interactively") + c.cmd.Flags().StringVar(&c.username, "username", "", "Username") + c.cmd.Flags().StringVar(&c.password, "password", "", "Password") + c.cmd.Flags().StringVar(&c.totp, "totp", "", "TOTP code") + c.cmd.Flags().StringVar(&c.user, "user", "", "Hash (username:hash:totp)") + + if c.root != nil { + c.root.AddCommand(c.cmd) + } +} + +func (c *verifyUserCmd) GetCmd() *cobra.Command { + return c.cmd +} + +func (c *verifyUserCmd) run(cmd *cobra.Command, args []string) { + log.Logger = log.Level(zerolog.InfoLevel) + + if c.interactive { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("User (username:hash:totp)").Value(&c.user).Validate((func(s string) error { + if s == "" { + return errors.New("user cannot be empty") + } + return nil + })), + huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error { + if s == "" { + return errors.New("username cannot be empty") + } + return nil + })), + huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error { + if s == "" { + return errors.New("password cannot be empty") + } + return nil + })), + huh.NewInput().Title("TOTP Code (optional)").Value(&c.totp), + ), + ) + var baseTheme *huh.Theme = huh.ThemeBase() + err := form.WithTheme(baseTheme).Run() + if err != nil { + log.Fatal().Err(err).Msg("Form failed") + } + } + + user, err := utils.ParseUser(c.user) + if err != nil { + log.Fatal().Err(err).Msg("Failed to parse user") + } + + if user.Username != c.username { + log.Fatal().Msg("Username is incorrect") + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(c.password)) + if err != nil { + log.Fatal().Msg("Password is incorrect") + } + + if user.TotpSecret == "" { + if c.totp != "" { + log.Warn().Msg("User does not have TOTP secret") + } + log.Info().Msg("User verified") + return + } + + ok := totp.Validate(c.totp, user.TotpSecret) + if !ok { + log.Fatal().Msg("TOTP code incorrect") + + } + + log.Info().Msg("User verified") +} diff --git a/cmd/version.go b/cmd/version.go index ffbd6fce..37eb14a6 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -2,22 +2,41 @@ package cmd import ( "fmt" - "tinyauth/internal/constants" + "tinyauth/internal/config" "github.com/spf13/cobra" ) -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print the version number of Tinyauth", - Long: `All software has versions. This is Tinyauth's`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("Version: %s\n", constants.Version) - fmt.Printf("Commit Hash: %s\n", constants.CommitHash) - fmt.Printf("Build Timestamp: %s\n", constants.BuildTimestamp) - }, +type versionCmd struct { + root *cobra.Command + cmd *cobra.Command } -func init() { - rootCmd.AddCommand(versionCmd) +func newVersionCmd(root *cobra.Command) *versionCmd { + return &versionCmd{ + root: root, + } +} + +func (c *versionCmd) Register() { + c.cmd = &cobra.Command{ + Use: "version", + Short: "Print the version number of Tinyauth", + Long: `All software has versions. This is Tinyauth's.`, + Run: c.run, + } + + if c.root != nil { + c.root.AddCommand(c.cmd) + } +} + +func (c *versionCmd) GetCmd() *cobra.Command { + return c.cmd +} + +func (c *versionCmd) run(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s\n", config.Version) + fmt.Printf("Commit Hash: %s\n", config.CommitHash) + fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp) } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d85d5e31..cc454f6a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -34,12 +34,17 @@ services: build: context: . dockerfile: Dockerfile.dev + args: + - VERSION=development + - COMMIT_HASH=development + - BUILD_TIMESTAMP=000-00-00T00:00:00Z env_file: .env volumes: - ./internal:/tinyauth/internal - ./cmd:/tinyauth/cmd - ./main.go:/tinyauth/main.go - /var/run/docker.sock:/var/run/docker.sock + - ./data:/data ports: - 3000:3000 - 4000:4000 diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 4b387070..9cec4a50 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -20,9 +20,10 @@ services: container_name: tinyauth image: ghcr.io/steveiliop56/tinyauth:v3 environment: - - SECRET=some-random-32-chars-string - APP_URL=https://tinyauth.example.com - USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password + volumes: + - ./data:/data labels: traefik.enable: true traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`) diff --git a/frontend/bun.lock b/frontend/bun.lock index 6b37b63b..4afba2d0 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -4,63 +4,61 @@ "": { "name": "tinyauth-shadcn", "dependencies": { - "@hookform/resolvers": "^5.2.1", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-select": "^2.2.5", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", - "@tailwindcss/vite": "^4.1.11", - "@tanstack/react-query": "^5.84.1", - "axios": "^1.11.0", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.10", + "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.2.6", - "i18next": "^25.3.2", + "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", "input-otp": "^1.4.2", - "lucide-react": "^0.539.0", + "lucide-react": "^0.554.0", "next-themes": "^0.4.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-hook-form": "^7.62.0", - "react-i18next": "^15.6.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.66.1", + "react-i18next": "^16.3.5", "react-markdown": "^10.1.0", - "react-router": "^7.8.0", + "react-router": "^7.9.6", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11", - "zod": "^4.0.15", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.17", + "zod": "^4.1.12", }, "devDependencies": { - "@eslint/js": "^9.32.0", - "@tanstack/eslint-plugin-query": "^5.83.1", - "@types/node": "^24.2.0", - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", - "@vitejs/plugin-react": "^5.0.0", - "eslint": "^9.32.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.3.0", + "@eslint/js": "^9.39.1", + "@tanstack/eslint-plugin-query": "^5.91.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.6", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", "prettier": "3.6.2", - "tw-animate-css": "^1.3.6", - "typescript": "~5.9.2", - "typescript-eslint": "^8.39.0", - "vite": "^7.1.1", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.47.0", + "vite": "^7.2.4", }, }, }, "packages": { - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.27.2", "", {}, "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ=="], - "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + "@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], - "@babel/generator": ["@babel/generator@7.28.0", "", { "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg=="], + "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], @@ -68,7 +66,7 @@ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], @@ -78,21 +76,21 @@ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="], + "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - "@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], + "@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="], + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], - "@babel/traverse": ["@babel/traverse@7.28.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/types": "^7.28.0", "debug": "^4.3.1" } }, "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg=="], + "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], - "@babel/types": ["@babel/types@7.28.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ=="], + "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], @@ -144,23 +142,23 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], - "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - "@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="], + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], - "@eslint/js": ["@eslint/js@9.32.0", "", {}, "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg=="], + "@eslint/js": ["@eslint/js@9.39.1", "", {}, "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.4", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="], @@ -170,7 +168,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], - "@hookform/resolvers": ["@hookform/resolvers@5.2.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ=="], + "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -180,17 +178,17 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -200,7 +198,7 @@ "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], @@ -212,27 +210,35 @@ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -254,7 +260,7 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.30", "", {}, "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.2", "", { "os": "android", "cpu": "arm" }, "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA=="], @@ -298,41 +304,41 @@ "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.17", "", { "dependencies": { "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "tailwindcss": "4.1.17" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA=="], - "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.83.1", "", { "dependencies": { "@typescript-eslint/utils": "^8.37.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-tdkpPFfzkTksN9BIlT/qjixSAtKrsW6PUVRwdKWaOcag7DrD1vpki3UzzdfMQGDRGeg1Ue1Dg+rcl5FJGembNg=="], + "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.91.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.83.1", "", {}, "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.10", "", {}, "sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ=="], - "@tanstack/react-query": ["@tanstack/react-query@5.84.1", "", { "dependencies": { "@tanstack/query-core": "5.83.1" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-zo7EUygcWJMQfFNWDSG7CBhy8irje/XY0RDVKKV4IQJAysb+ZJkkJPcnQi+KboyGUgT+SQebRFoTqLuTtfoDLw=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.10", "", { "dependencies": { "@tanstack/query-core": "5.90.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -344,7 +350,7 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], - "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -356,39 +362,37 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], - - "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], - "@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="], + "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/type-utils": "8.39.0", "@typescript-eslint/utils": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.47.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/type-utils": "8.47.0", "@typescript-eslint/utils": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.47.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.47.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.0", "@typescript-eslint/types": "^8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.47.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.47.0", "@typescript-eslint/types": "^8.47.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0" } }, "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1" } }, "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.47.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.47.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0", "@typescript-eslint/utils": "8.47.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.38.0", "", {}, "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.0", "@typescript-eslint/tsconfig-utils": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.47.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.47.0", "@typescript-eslint/tsconfig-utils": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/typescript-estree": "8.38.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.47.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.30", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -404,7 +408,7 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "axios": ["axios@1.11.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA=="], + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -434,8 +438,6 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -456,7 +458,7 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], @@ -474,13 +476,11 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "electron-to-chromium": ["electron-to-chromium@1.5.151", "", {}, "sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA=="], - "enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -496,11 +496,11 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.32.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.32.0", "@eslint/plugin-kit": "^0.3.4", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg=="], + "eslint": ["eslint@9.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.1", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="], - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="], + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -530,7 +530,7 @@ "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -560,7 +560,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="], + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -580,11 +580,15 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], - "i18next": ["i18next@25.3.2", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA=="], + "i18next": ["i18next@25.6.3", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg=="], "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="], @@ -638,27 +642,29 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -668,9 +674,9 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@0.539.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg=="], + "lucide-react": ["lucide-react@0.554.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA=="], - "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -742,12 +748,6 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], - - "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], - - "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -790,23 +790,23 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], - "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], - "react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], + "react-hook-form": ["react-hook-form@7.66.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA=="], - "react-i18next": ["react-i18next@15.6.1", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg=="], + "react-i18next": ["react-i18next@16.3.5", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww=="], "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-router": ["react-router@7.8.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg=="], + "react-router": ["react-router@7.9.6", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], @@ -822,7 +822,7 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -848,15 +848,13 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], - "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], + "tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="], "tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="], - "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], - - "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -868,15 +866,15 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tw-animate-css": ["tw-animate-css@1.3.6", "", {}, "sha512-9dy0R9UsYEGmgf26L8UcHiLmSFTHa9+D7+dAt/G/sF5dCnPePZbfgDYinc7/UzAM7g/baVrmS6m9yEpU46d+LA=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.39.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.0", "@typescript-eslint/parser": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q=="], + "typescript-eslint": ["typescript-eslint@8.47.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/parser": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0", "@typescript-eslint/utils": "8.47.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q=="], - "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -898,11 +896,13 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], - "vite": ["vite@7.1.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-yJ+Mp7OyV+4S+afWo+QyoL9jFWD11QFH0i5i7JypnfTcA1rmgxCbiA8WwAICDEtZ1Z1hzrVhN8R8rGTqkTY8ZQ=="], + "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], @@ -910,30 +910,38 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.0.15", "", {}, "sha512-2IVHb9h4Mt6+UXkyMs0XbfICUh1eUrlJJAOupBHUhLRnKkruawyDddYRCs0Eizt900ntIMk9/4RksYl+FgSpcQ=="], + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + "@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], - "@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@babel/generator/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], "@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.27.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/template": "^7.27.1", "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg=="], "@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], - "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.27.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA=="], + "@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="], + + "@babel/helpers/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], - "@babel/helpers/@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="], + "@babel/parser/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], "@babel/template/@babel/parser": ["@babel/parser@7.27.2", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw=="], "@babel/template/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], + "@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="], @@ -942,15 +950,33 @@ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.9.0" }, "bundled": true }, "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA=="], + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -966,51 +992,51 @@ "@types/babel__traverse/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], - "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="], + "@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + + "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.47.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0" } }, "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg=="], - "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.47.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="], - "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="], + "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.47.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0" } }, "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg=="], - "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], - "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], - "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="], + "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="], - "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], - "@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ=="], + "@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.47.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ=="], - "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], - "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.38.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/types": "8.38.0", "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.1", "@typescript-eslint/tsconfig-utils": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg=="], - "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], + + "eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + "i18next-browser-languagedetector/@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="], "i18next-resources-to-backend/@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="], - "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "tinyglobby/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], - - "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ=="], + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.47.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ=="], "@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="], @@ -1018,52 +1044,60 @@ "@babel/helper-module-imports/@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], - "@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="], - - "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], - "@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], - "@babel/helper-module-transforms/@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + "@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], "@eslint/eslintrc/espree/acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], - - "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], - - "@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="], + "@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.47.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0" } }, "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.38.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.38.0", "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.1", "@typescript-eslint/types": "^8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg=="], - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.38.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g=="], - "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="], + "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], - "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="], + "eslint-plugin-react-hooks/@babel/core/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.47.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0" } }, "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg=="], - "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.47.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/generator/@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="], + + "eslint-plugin-react-hooks/@babel/core/@babel/generator/@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], } } diff --git a/frontend/index.html b/frontend/index.html index 718fe608..04a8c04a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,10 +8,11 @@ + Tinyauth - +
diff --git a/frontend/package.json b/frontend/package.json index 3b9db37b..eee5688e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "tinyauth-shadcn", + "name": "tinyauth", "private": true, "version": "0.0.0", "type": "module", @@ -7,52 +7,53 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "tsc": "tsc -b" }, "dependencies": { - "@hookform/resolvers": "^5.2.1", - "@radix-ui/react-label": "^2.1.7", - "@radix-ui/react-select": "^2.2.5", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", - "@tailwindcss/vite": "^4.1.11", - "@tanstack/react-query": "^5.84.1", - "axios": "^1.11.0", + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.10", + "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.2.6", - "i18next": "^25.3.2", + "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", "input-otp": "^1.4.2", - "lucide-react": "^0.539.0", + "lucide-react": "^0.554.0", "next-themes": "^0.4.6", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-hook-form": "^7.62.0", - "react-i18next": "^15.6.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.66.1", + "react-i18next": "^16.3.5", "react-markdown": "^10.1.0", - "react-router": "^7.8.0", + "react-router": "^7.9.6", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11", - "zod": "^4.0.15" + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.17", + "zod": "^4.1.12" }, "devDependencies": { - "@eslint/js": "^9.32.0", - "@tanstack/eslint-plugin-query": "^5.83.1", - "@types/node": "^24.2.0", - "@types/react": "^19.1.9", - "@types/react-dom": "^19.1.7", - "@vitejs/plugin-react": "^5.0.0", - "eslint": "^9.32.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.3.0", + "@eslint/js": "^9.39.1", + "@tanstack/eslint-plugin-query": "^5.91.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.6", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", "prettier": "3.6.2", - "tw-animate-css": "^1.3.6", - "typescript": "~5.9.2", - "typescript-eslint": "^8.39.0", - "vite": "^7.1.1" + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.47.0", + "vite": "^7.2.4" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72b9238b..0559b26f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,8 +5,8 @@ export const App = () => { const { isLoggedIn } = useUserContext(); if (isLoggedIn) { - return ; + return ; } - return ; + return ; }; diff --git a/frontend/src/components/auth/totp-form.tsx b/frontend/src/components/auth/totp-form.tsx index 9a8729b7..60e5dbdc 100644 --- a/frontend/src/components/auth/totp-form.tsx +++ b/frontend/src/components/auth/totp-form.tsx @@ -44,6 +44,7 @@ export const TotpForm = (props: Props) => { disabled={loading} {...field} autoComplete="one-time-code" + autoFocus > diff --git a/frontend/src/components/domain-warning/domain-warning.tsx b/frontend/src/components/domain-warning/domain-warning.tsx new file mode 100644 index 00000000..4f83b231 --- /dev/null +++ b/frontend/src/components/domain-warning/domain-warning.tsx @@ -0,0 +1,56 @@ +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Button } from "../ui/button"; +import { Trans, useTranslation } from "react-i18next"; +import { useLocation } from "react-router"; + +interface Props { + onClick: () => void; + appUrl: string; + currentUrl: string; +} + +export const DomainWarning = (props: Props) => { + const { onClick, appUrl, currentUrl } = props; + const { t } = useTranslation(); + const { search } = useLocation(); + + const searchParams = new URLSearchParams(search); + const redirectUri = searchParams.get("redirect_uri"); + + return ( + + + {t("domainWarningTitle")} + + }} + /> + + + + + + + + ); +}; diff --git a/frontend/src/components/icons/microsoft.tsx b/frontend/src/components/icons/microsoft.tsx new file mode 100644 index 00000000..58d470c5 --- /dev/null +++ b/frontend/src/components/icons/microsoft.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from "react"; + +export function MicrosoftIcon(props: SVGProps) { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/icons/generic.tsx b/frontend/src/components/icons/oauth.tsx similarity index 91% rename from frontend/src/components/icons/generic.tsx rename to frontend/src/components/icons/oauth.tsx index 6be8289c..3ca531d6 100644 --- a/frontend/src/components/icons/generic.tsx +++ b/frontend/src/components/icons/oauth.tsx @@ -1,6 +1,6 @@ import type { SVGProps } from "react"; -export function GenericIcon(props: SVGProps) { +export function OAuthIcon(props: SVGProps) { return ( ) { + return ( + + + + + ); +} diff --git a/frontend/src/components/icons/tailscale.tsx b/frontend/src/components/icons/tailscale.tsx new file mode 100644 index 00000000..9381b5cd --- /dev/null +++ b/frontend/src/components/icons/tailscale.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from "react"; + +export function TailscaleIcon(props: SVGProps) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/language/language.tsx b/frontend/src/components/language/language.tsx index 875045cb..b38837ec 100644 --- a/frontend/src/components/language/language.tsx +++ b/frontend/src/components/language/language.tsx @@ -18,9 +18,10 @@ export const LanguageSelector = () => { setLanguage(option as SupportedLanguage); i18n.changeLanguage(option as SupportedLanguage); }; + return (
{ backgroundPosition: "center", }} > - - +
+ + +
+ {children}
); }; + +export const Layout = () => { + const { appUrl, disableUiWarnings } = useAppContext(); + const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => { + return window.sessionStorage.getItem("ignoreDomainWarning") === "true"; + }); + const currentUrl = window.location.origin; + + const handleIgnore = useCallback(() => { + window.sessionStorage.setItem("ignoreDomainWarning", "true"); + setIgnoreDomainWarning(true); + }, [setIgnoreDomainWarning]); + + if (!ignoreDomainWarning && !disableUiWarnings && appUrl !== currentUrl) { + return ( + + handleIgnore()} + /> + + ); + } + + return ( + + + + ); +}; diff --git a/frontend/src/components/providers/theme-provider.tsx b/frontend/src/components/providers/theme-provider.tsx new file mode 100644 index 00000000..e18440d7 --- /dev/null +++ b/frontend/src/components/providers/theme-provider.tsx @@ -0,0 +1,73 @@ +import { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "dark" | "light" | "system"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + ); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme); + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/frontend/src/components/theme-toggle/theme-toggle.tsx b/frontend/src/components/theme-toggle/theme-toggle.tsx new file mode 100644 index 00000000..c0791cfb --- /dev/null +++ b/frontend/src/components/theme-toggle/theme-toggle.tsx @@ -0,0 +1,40 @@ +import { Moon, Sun } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTheme } from "@/components/providers/theme-provider"; + +export function ThemeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index fbb5b27a..0ee404cf 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cn } from "@/lib/utils"; import { Loader2 } from "lucide-react"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:cursor-pointer", { variants: { variant: { @@ -22,7 +22,7 @@ const buttonVariants = cva( "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", warning: - "bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40 dark:bg-amber-600", + "bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..eaed9baf --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index c0b69e85..f96124b3 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -35,7 +35,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-card dark:hover:bg-card/90 flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "hover:cursor-pointer border-input data-[placeholder]:text-card-foreground [&_svg:not([class*='text-'])]:text-card-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 59f94479..8d1f4bf1 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -1,8 +1,8 @@ -import { useTheme } from "next-themes"; +import { useTheme } from "../providers/theme-provider"; import { Toaster as Sonner, ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme(); + const { theme } = useTheme(); return ( { const { isFetching, data, error } = useSuspenseQuery({ queryKey: ["app"], - queryFn: () => axios.get("/api/app").then((res) => res.data), + queryFn: () => axios.get("/api/context/app").then((res) => res.data), }); if (error && !isFetching) { diff --git a/frontend/src/context/user-context.tsx b/frontend/src/context/user-context.tsx index 43b3c005..a3cfeaa2 100644 --- a/frontend/src/context/user-context.tsx +++ b/frontend/src/context/user-context.tsx @@ -15,7 +15,7 @@ export const UserContextProvider = ({ }) => { const { isFetching, data, error } = useSuspenseQuery({ queryKey: ["user"], - queryFn: () => axios.get("/api/user").then((res) => res.data), + queryFn: () => axios.get("/api/context/user").then((res) => res.data), }); if (error && !isFetching) { diff --git a/frontend/src/index.css b/frontend/src/index.css index 0b1ee02c..97016361 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -156,7 +156,7 @@ ul { } code { - @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold; + @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all; } .lead { diff --git a/frontend/src/lib/hooks/use-is-mounted.ts b/frontend/src/lib/hooks/use-is-mounted.ts deleted file mode 100644 index 36498d38..00000000 --- a/frontend/src/lib/hooks/use-is-mounted.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; - -export function useIsMounted(): () => boolean { - const isMounted = useRef(false); - - useEffect(() => { - isMounted.current = true; - - return () => { - isMounted.current = false; - }; - }, []); - - return useCallback(() => isMounted.current, []); -} diff --git a/frontend/src/lib/i18n/locales.ts b/frontend/src/lib/i18n/locales.ts index ddb376f4..ab9bea6d 100644 --- a/frontend/src/lib/i18n/locales.ts +++ b/frontend/src/lib/i18n/locales.ts @@ -18,8 +18,8 @@ export const languages = { "nl-NL": "Nederlands", "no-NO": "Norsk", "pl-PL": "Polski", - "pt-BR": "Português", - "pt-PT": "Português", + "pt-BR": "Português (Brasil)", + "pt-PT": "Português (Portugal)", "ro-RO": "Română", "ru-RU": "Русский", "sr-SP": "Српски", @@ -28,7 +28,7 @@ export const languages = { "uk-UA": "Українська", "vi-VN": "Tiếng Việt", "zh-CN": "简体中文", - "zh-TW": "繁體中文(台灣)", + "zh-TW": "繁體中文", }; export type SupportedLanguage = keyof typeof languages; diff --git a/frontend/src/lib/i18n/locales/af-ZA.json b/frontend/src/lib/i18n/locales/af-ZA.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/af-ZA.json +++ b/frontend/src/lib/i18n/locales/af-ZA.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/ar-SA.json b/frontend/src/lib/i18n/locales/ar-SA.json index b5cc2109..98c620c5 100644 --- a/frontend/src/lib/i18n/locales/ar-SA.json +++ b/frontend/src/lib/i18n/locales/ar-SA.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "أخفق الحصول على رابط OAuth", "loginOauthSuccessTitle": "إعادة توجيه", "loginOauthSuccessSubtitle": "إعادة توجيه إلى مزود OAuth الخاص بك", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "متابعة", "continueRedirectingTitle": "إعادة توجيه...", "continueRedirectingSubtitle": "يجب إعادة توجيهك إلى التطبيق قريبا", - "continueInvalidRedirectTitle": "إعادة توجيه غير صالحة", - "continueInvalidRedirectSubtitle": "رابط إعادة التوجيه غير صالح", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "إعادة توجيه غير آمنة", "continueInsecureRedirectSubtitle": "أنت تحاول إعادة التوجيه من https إلى http، هل أنت متأكد أنك تريد المتابعة؟", - "continueTitle": "متابعة", - "continueSubtitle": "انقر الزر للمتابعة إلى التطبيق الخاص بك.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "فشل تسجيل الخروج", "logoutFailSubtitle": "يرجى إعادة المحاولة", "logoutSuccessTitle": "تم تسجيل الخروج", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "حاول مجددا", - "untrustedRedirectTitle": "إعادة توجيه غير موثوقة", - "untrustedRedirectSubtitle": "أنت تحاول إعادة التوجيه إلى نطاق لا يتطابق مع النطاق المكون الخاص بك ({{domain}}). هل أنت متأكد من أنك تريد المتابعة؟", "cancelTitle": "إلغاء", "forgotPasswordTitle": "نسيت كلمة المرور؟", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "تجاهل", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/ca-ES.json b/frontend/src/lib/i18n/locales/ca-ES.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/ca-ES.json +++ b/frontend/src/lib/i18n/locales/ca-ES.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/cs-CZ.json b/frontend/src/lib/i18n/locales/cs-CZ.json index 74e422f5..0308327c 100644 --- a/frontend/src/lib/i18n/locales/cs-CZ.json +++ b/frontend/src/lib/i18n/locales/cs-CZ.json @@ -1,57 +1,62 @@ { - "loginTitle": "Welcome back, login with", - "loginTitleSimple": "Welcome back, please login", - "loginDivider": "Or", - "loginUsername": "Username", - "loginPassword": "Password", - "loginSubmit": "Login", - "loginFailTitle": "Failed to log in", - "loginFailSubtitle": "Please check your username and password", - "loginFailRateLimit": "You failed to login too many times. Please try again later", - "loginSuccessTitle": "Logged in", - "loginSuccessSubtitle": "Welcome back!", - "loginOauthFailTitle": "An error occurred", - "loginOauthFailSubtitle": "Failed to get OAuth URL", - "loginOauthSuccessTitle": "Redirecting", - "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", - "continueRedirectingTitle": "Redirecting...", - "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", - "continueInsecureRedirectTitle": "Insecure redirect", - "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", - "logoutFailTitle": "Failed to log out", - "logoutFailSubtitle": "Please try again", - "logoutSuccessTitle": "Logged out", - "logoutSuccessSubtitle": "You have been logged out", - "logoutTitle": "Logout", - "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", - "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", - "notFoundTitle": "Page not found", - "notFoundSubtitle": "The page you are looking for does not exist.", - "notFoundButton": "Go home", - "totpFailTitle": "Failed to verify code", - "totpFailSubtitle": "Please check your code and try again", - "totpSuccessTitle": "Verified", - "totpSuccessSubtitle": "Redirecting to your app", - "totpTitle": "Enter your TOTP code", - "totpSubtitle": "Please enter the code from your authenticator app.", - "unauthorizedTitle": "Unauthorized", - "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", - "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", - "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", - "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", - "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", - "cancelTitle": "Cancel", - "forgotPasswordTitle": "Forgot your password?", - "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", - "errorTitle": "An error occurred", - "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "loginTitle": "Vítejte zpět, přihlaste se pomocí", + "loginTitleSimple": "Vítejte zpět, přihlaste se prosím", + "loginDivider": "Nebo", + "loginUsername": "Uživatelské jméno", + "loginPassword": "Heslo", + "loginSubmit": "Přihlásit", + "loginFailTitle": "Přihlášení se nezdařilo", + "loginFailSubtitle": "Zkontrolujte prosím své uživatelské jméno a heslo", + "loginFailRateLimit": "Přiliš mnoho neúspěšných pokusů přihlášení. Zkuste to prosím později", + "loginSuccessTitle": "Přihlášen", + "loginSuccessSubtitle": "Vítejte zpět!", + "loginOauthFailTitle": "Došlo k chybě", + "loginOauthFailSubtitle": "Nepodařilo se získat OAuth URL", + "loginOauthSuccessTitle": "Přesměrování", + "loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Pokračovat", + "continueRedirectingTitle": "Přesměrování...", + "continueRedirectingSubtitle": "Brzy budete přesměrováni do aplikace", + "continueRedirectManually": "Redirect me manually", + "continueInsecureRedirectTitle": "Nezabezpečené přesměrování", + "continueInsecureRedirectSubtitle": "Pokoušíte se přesměrovat z https na http, které není bezpečné. Opravdu chcete pokračovat?", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", + "logoutFailTitle": "Odhlášení se nezdařilo", + "logoutFailSubtitle": "Zkuste to prosím znovu", + "logoutSuccessTitle": "Odhlášen", + "logoutSuccessSubtitle": "Byl jste odhlášen", + "logoutTitle": "Odhlásit", + "logoutUsernameSubtitle": "Jste přihlášen jako {{username}}. Pro odhlášení klikněte na tlačítko níže.", + "logoutOauthSubtitle": "Jste přihlášen jako {{username}} pomocí {{provider}} poskytovatele OAuth. Pro odhlášení klikněte na tlačítko níže.", + "notFoundTitle": "Stránka nenalezena", + "notFoundSubtitle": "Stránka, kterou hledáte, neexistuje.", + "notFoundButton": "Jít domů", + "totpFailTitle": "Nepodařilo se ověřit kód", + "totpFailSubtitle": "Zkontrolujte prosím kód a zkuste to znovu", + "totpSuccessTitle": "Ověřeno", + "totpSuccessSubtitle": "Přesměrování do aplikace", + "totpTitle": "Zadejte TOTP kód", + "totpSubtitle": "Zadejte prosím kód z ověřovací aplikace.", + "unauthorizedTitle": "Nepovoleno", + "unauthorizedResourceSubtitle": "Uživatel s uživatelským jménem {{username}} není oprávněn k přístupu ke zdroji {{resource}}.", + "unauthorizedLoginSubtitle": "Uživatel s uživatelským jménem {{username}} není oprávněn k přihlášení.", + "unauthorizedGroupsSubtitle": "Uživatel s uživatelským jménem {{username}} není ve skupině potřebné k přístupu ke zdroji {{resource}}.", + "unauthorizedIpSubtitle": "Vaše IP adresa {{ip}} není oprávněna k přístupu ke zdroji {{resource}}.", + "unauthorizedButton": "Zkusit znovu", + "cancelTitle": "Zrušit", + "forgotPasswordTitle": "Zapomněli jste heslo?", + "failedToFetchProvidersTitle": "Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.", + "errorTitle": "Došlo k chybě", + "errorSubtitle": "Nastala chyba při pokusu o provedení této akce. Pro více informací prosím zkontrolujte konzolu.", + "forgotPasswordMessage": "Heslo můžete obnovit změnou proměnné `USERS`.", + "fieldRequired": "Toto pole je povinné", + "invalidInput": "Neplatný údaj", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/da-DK.json b/frontend/src/lib/i18n/locales/da-DK.json index 95f58178..c3801975 100644 --- a/frontend/src/lib/i18n/locales/da-DK.json +++ b/frontend/src/lib/i18n/locales/da-DK.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Kunne ikke hente OAuth-URL", "loginOauthSuccessTitle": "Omdirigerer", "loginOauthSuccessSubtitle": "Omdirigerer til din OAuth-udbyder", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Fortsæt", "continueRedirectingTitle": "Omdirigerer...", "continueRedirectingSubtitle": "Du bør blive omdirigeret til appen snart", - "continueInvalidRedirectTitle": "Ugyldig omdirigering", - "continueInvalidRedirectSubtitle": "Omdirigerings-URL'en er ugyldig", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Usikker omdirigering", "continueInsecureRedirectSubtitle": "Du forsøger at omdirigere fra https til http, som ikke er sikker. Er du sikker på, at du vil fortsætte?", - "continueTitle": "Fortsæt", - "continueSubtitle": "Klik på knappen for at fortsætte til din app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Log ud mislykkedes", "logoutFailSubtitle": "Prøv venligst igen", "logoutSuccessTitle": "Logget ud", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "Brugeren med brugernavnet {{username}} er ikke i de grupper, som ressourcen {{resource}} kræver.", "unauthorizedIpSubtitle": "Din IP adresse {{ip}} er ikke autoriseret til at tilgå ressourcen {{resource}}.", "unauthorizedButton": "Prøv igen", - "untrustedRedirectTitle": "Usikker omdirigering", - "untrustedRedirectSubtitle": "Du forsøger at omdirigere til et domæne, der ikke matcher dit konfigurerede domæne ({{domain}}). Er du sikker på, at du vil fortsætte?", "cancelTitle": "Annuller", "forgotPasswordTitle": "Glemt din adgangskode?", "failedToFetchProvidersTitle": "Kunne ikke indlæse godkendelsesudbydere. Tjek venligst din konfiguration.", @@ -53,5 +54,9 @@ "errorSubtitle": "Der opstod en fejl under forsøget på at udføre denne handling. Tjek venligst konsollen for mere information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/de-DE.json b/frontend/src/lib/i18n/locales/de-DE.json index 4c55082b..709767c4 100644 --- a/frontend/src/lib/i18n/locales/de-DE.json +++ b/frontend/src/lib/i18n/locales/de-DE.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Fehler beim Abrufen der OAuth-URL", "loginOauthSuccessTitle": "Leite weiter", "loginOauthSuccessSubtitle": "Weiterleitung zu Ihrem OAuth-Provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Weiter", "continueRedirectingTitle": "Leite weiter...", "continueRedirectingSubtitle": "Sie sollten in Kürze zur App weitergeleitet werden", - "continueInvalidRedirectTitle": "Ungültige Weiterleitung", - "continueInvalidRedirectSubtitle": "Die Weiterleitungs-URL ist ungültig", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Unsichere Weiterleitung", "continueInsecureRedirectSubtitle": "Sie versuchen von https auf http weiterzuleiten, was unsicher ist. Sind Sie sicher, dass Sie fortfahren möchten?", - "continueTitle": "Weiter", - "continueSubtitle": "Klicken Sie auf den Button, um zur App zu gelangen.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Abmelden fehlgeschlagen", "logoutFailSubtitle": "Bitte versuchen Sie es erneut", "logoutSuccessTitle": "Abgemeldet", @@ -31,7 +34,7 @@ "logoutOauthSubtitle": "Sie sind derzeit als {{username}} über den OAuth-Anbieter {{provider}} angemeldet. Klicken Sie auf den Button unten, um sich abzumelden.", "notFoundTitle": "Seite nicht gefunden", "notFoundSubtitle": "Die gesuchte Seite existiert nicht.", - "notFoundButton": "Nach Hause", + "notFoundButton": "Zurück", "totpFailTitle": "Fehler beim Verifizieren des Codes", "totpFailSubtitle": "Bitte überprüfen Sie Ihren Code und versuchen Sie es erneut", "totpSuccessTitle": "Verifiziert", @@ -44,14 +47,16 @@ "unauthorizedGroupsSubtitle": "Der Benutzer mit Benutzername {{username}} ist nicht in den Gruppen, die von der Ressource {{resource}} benötigt werden.", "unauthorizedIpSubtitle": "Ihre IP-Adresse {{ip}} ist nicht berechtigt, auf die Ressource {{resource}} zuzugreifen.", "unauthorizedButton": "Erneut versuchen", - "untrustedRedirectTitle": "Nicht vertrauenswürdige Weiterleitung", - "untrustedRedirectSubtitle": "Sie versuchen auf eine Domain umzuleiten, die nicht mit Ihrer konfigurierten Domain übereinstimmt ({{domain}}). Sind Sie sicher, dass Sie fortfahren möchten?", "cancelTitle": "Abbrechen", "forgotPasswordTitle": "Passwort vergessen?", "failedToFetchProvidersTitle": "Fehler beim Laden der Authentifizierungsanbieter. Bitte überprüfen Sie Ihre Konfiguration.", "errorTitle": "Ein Fehler ist aufgetreten", "errorSubtitle": "Beim Versuch, diese Aktion auszuführen, ist ein Fehler aufgetreten. Bitte überprüfen Sie die Konsole für weitere Informationen.", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "forgotPasswordMessage": "Das Passwort kann durch Änderung der 'USERS' Variable zurückgesetzt werden.", + "fieldRequired": "Dieses Feld ist notwendig", + "invalidInput": "Ungültige Eingabe", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/el-GR.json b/frontend/src/lib/i18n/locales/el-GR.json index 5cc5ceb4..ee401526 100644 --- a/frontend/src/lib/i18n/locales/el-GR.json +++ b/frontend/src/lib/i18n/locales/el-GR.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Αποτυχία λήψης OAuth URL", "loginOauthSuccessTitle": "Ανακατεύθυνση", "loginOauthSuccessSubtitle": "Ανακατεύθυνση στον πάροχο OAuth σας", + "loginOauthAutoRedirectTitle": "Αυτόματη Ανακατεύθυνση OAuth", + "loginOauthAutoRedirectSubtitle": "Θα ανακατευθυνθείτε αυτόματα στον πάροχο OAuth σας για να επαληθευτείτε.", + "loginOauthAutoRedirectButton": "Ανακατεύθυνση τώρα", + "continueTitle": "Συνέχεια", "continueRedirectingTitle": "Ανακατεύθυνση...", "continueRedirectingSubtitle": "Θα πρέπει να μεταφερθείτε σύντομα στην εφαρμογή σας", - "continueInvalidRedirectTitle": "Μη έγκυρη ανακατεύθυνση", - "continueInvalidRedirectSubtitle": "Το URL ανακατεύθυνσης δεν είναι έγκυρο", + "continueRedirectManually": "Χειροκίνητη ανακατεύθυνση", "continueInsecureRedirectTitle": "Μη ασφαλής ανακατεύθυνση", "continueInsecureRedirectSubtitle": "Προσπαθείτε να ανακατευθύνετε από https σε http το οποίο δεν είναι ασφαλές. Είστε σίγουροι ότι θέλετε να συνεχίσετε;", - "continueTitle": "Συνέχεια", - "continueSubtitle": "Κάντε κλικ στο κουμπί για να συνεχίσετε στην εφαρμογή σας.", + "continueUntrustedRedirectTitle": "Μη έμπιστη ανακατεύθυνση", + "continueUntrustedRedirectSubtitle": "Προσπαθείτε να ανακατευθύνετε σε ένα domain που δεν ταιριάζει με το ρυθμισμένο domain σας ({{cookieDomain}}). Είστε βέβαιοι ότι θέλετε να συνεχίσετε;", "logoutFailTitle": "Αποτυχία αποσύνδεσης", "logoutFailSubtitle": "Παρακαλώ δοκιμάστε ξανά", "logoutSuccessTitle": "Αποσυνδεδεμένος", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "Ο χρήστης με όνομα χρήστη {{username}} δεν είναι στις ομάδες που απαιτούνται από τον πόρο {{resource}}.", "unauthorizedIpSubtitle": "Η διεύθυνση IP σας {{ip}} δεν είναι εξουσιοδοτημένη να έχει πρόσβαση στον πόρο {{resource}}.", "unauthorizedButton": "Προσπαθήστε ξανά", - "untrustedRedirectTitle": "Μη έμπιστη ανακατεύθυνση", - "untrustedRedirectSubtitle": "Προσπαθείτε να ανακατευθύνετε σε ένα domain που δεν ταιριάζει με τον ρυθμισμένο domain σας ({{domain}}). Είστε βέβαιοι ότι θέλετε να συνεχίσετε;", "cancelTitle": "Ακύρωση", "forgotPasswordTitle": "Ξεχάσατε το συνθηματικό σας;", "failedToFetchProvidersTitle": "Αποτυχία φόρτωσης παρόχων πιστοποίησης. Παρακαλώ ελέγξτε τις ρυθμίσεις σας.", @@ -53,5 +54,9 @@ "errorSubtitle": "Παρουσιάστηκε σφάλμα κατά την προσπάθεια εκτέλεσης αυτής της ενέργειας. Ελέγξτε την κονσόλα για περισσότερες πληροφορίες.", "forgotPasswordMessage": "Μπορείτε να επαναφέρετε τον κωδικό πρόσβασής σας αλλάζοντας τη μεταβλητή περιβάλλοντος `USERS`.", "fieldRequired": "Αυτό το πεδίο είναι υποχρεωτικό", - "invalidInput": "Μη έγκυρη καταχώρηση" + "invalidInput": "Μη έγκυρη καταχώρηση", + "domainWarningTitle": "Μη έγκυρο domain", + "domainWarningSubtitle": "Αυτή η εφαρμογή έχει ρυθμιστεί για πρόσβαση από {{appUrl}}, αλλά {{currentUrl}} χρησιμοποιείται. Αν συνεχίσετε, μπορεί να αντιμετωπίσετε προβλήματα με την ταυτοποίηση.", + "ignoreTitle": "Παράβλεψη", + "goToCorrectDomainTitle": "Μεταβείτε στο σωστό domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/es-ES.json b/frontend/src/lib/i18n/locales/es-ES.json index 40b043ff..a4500b30 100644 --- a/frontend/src/lib/i18n/locales/es-ES.json +++ b/frontend/src/lib/i18n/locales/es-ES.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Error al obtener la URL de OAuth", "loginOauthSuccessTitle": "Redireccionando", "loginOauthSuccessSubtitle": "Redireccionando a tu proveedor de OAuth", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continuar", "continueRedirectingTitle": "Redireccionando...", "continueRedirectingSubtitle": "Pronto será redirigido a la aplicación", - "continueInvalidRedirectTitle": "Redirección inválida", - "continueInvalidRedirectSubtitle": "La URL de redirección es inválida", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Redirección insegura", "continueInsecureRedirectSubtitle": "Está intentando redirigir desde https a http lo cual no es seguro. ¿Está seguro que desea continuar?", - "continueTitle": "Continuar", - "continueSubtitle": "Haga clic en el botón para continuar hacia su aplicación.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Fallo al cerrar sesión", "logoutFailSubtitle": "Por favor intente nuevamente", "logoutSuccessTitle": "Sesión cerrada", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "El usuario con nombre de usuario {{username}} no está en los grupos requeridos por el recurso {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Inténtelo de nuevo", - "untrustedRedirectTitle": "Redirección no confiable", - "untrustedRedirectSubtitle": "Está intentando redirigir a un dominio que no coincide con su dominio configurado ({{domain}}). ¿Está seguro que desea continuar?", "cancelTitle": "Cancelar", "forgotPasswordTitle": "¿Olvidó su contraseña?", "failedToFetchProvidersTitle": "Error al cargar los proveedores de autenticación. Por favor revise su configuración.", @@ -53,5 +54,9 @@ "errorSubtitle": "Ocurrió un error mientras se trataba de realizar esta acción. Por favor, revise la consola para más información.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/fi-FI.json b/frontend/src/lib/i18n/locales/fi-FI.json index 74e422f5..d892dd84 100644 --- a/frontend/src/lib/i18n/locales/fi-FI.json +++ b/frontend/src/lib/i18n/locales/fi-FI.json @@ -1,57 +1,62 @@ { - "loginTitle": "Welcome back, login with", - "loginTitleSimple": "Welcome back, please login", - "loginDivider": "Or", - "loginUsername": "Username", - "loginPassword": "Password", - "loginSubmit": "Login", - "loginFailTitle": "Failed to log in", - "loginFailSubtitle": "Please check your username and password", - "loginFailRateLimit": "You failed to login too many times. Please try again later", - "loginSuccessTitle": "Logged in", - "loginSuccessSubtitle": "Welcome back!", - "loginOauthFailTitle": "An error occurred", - "loginOauthFailSubtitle": "Failed to get OAuth URL", - "loginOauthSuccessTitle": "Redirecting", - "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", - "continueRedirectingTitle": "Redirecting...", - "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", - "continueInsecureRedirectTitle": "Insecure redirect", - "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", - "logoutFailTitle": "Failed to log out", - "logoutFailSubtitle": "Please try again", - "logoutSuccessTitle": "Logged out", - "logoutSuccessSubtitle": "You have been logged out", - "logoutTitle": "Logout", - "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", - "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", - "notFoundTitle": "Page not found", - "notFoundSubtitle": "The page you are looking for does not exist.", - "notFoundButton": "Go home", - "totpFailTitle": "Failed to verify code", - "totpFailSubtitle": "Please check your code and try again", - "totpSuccessTitle": "Verified", - "totpSuccessSubtitle": "Redirecting to your app", - "totpTitle": "Enter your TOTP code", - "totpSubtitle": "Please enter the code from your authenticator app.", - "unauthorizedTitle": "Unauthorized", - "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", - "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", - "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", - "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", - "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", - "cancelTitle": "Cancel", - "forgotPasswordTitle": "Forgot your password?", - "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", - "errorTitle": "An error occurred", - "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "loginTitle": "Tervetuloa takaisin, kirjaudu sisään käyttäen", + "loginTitleSimple": "Tervetuloa takaisin, ole hyvä ja kirjaudu", + "loginDivider": "Tai", + "loginUsername": "Käyttäjätunnus", + "loginPassword": "Salasana", + "loginSubmit": "Kirjaudu", + "loginFailTitle": "Kirjautuminen epäonnistui", + "loginFailSubtitle": "Tarkista käyttäjätunnuksesi ja salasanasi", + "loginFailRateLimit": "Kirjautuminen epäonnistui liian monta kertaa. Yritä myöhemmin uudelleen", + "loginSuccessTitle": "Olet kirjautunut sisään", + "loginSuccessSubtitle": "Tervetuloa takaisin!", + "loginOauthFailTitle": "Tapahtui virhe", + "loginOauthFailSubtitle": "OAuthin URL-osoitteen haku epäonnistui", + "loginOauthSuccessTitle": "Uudelleenohjataan", + "loginOauthSuccessSubtitle": "Uudelleenohjaus OAuth -palveluntarjoajallesi", + "loginOauthAutoRedirectTitle": "Automaattinen OAuth -uudelleenohjaus", + "loginOauthAutoRedirectSubtitle": "Sinut ohjataan automaattisesti OAuth -palveluntarjoajallesi todentamista varten.", + "loginOauthAutoRedirectButton": "Siirry nyt", + "continueTitle": "Jatka", + "continueRedirectingTitle": "Uudelleenohjataan...", + "continueRedirectingSubtitle": "Sinun pitäisi ohjautua sovellukseen pian", + "continueRedirectManually": "Siirrä minut manuaalisesti", + "continueInsecureRedirectTitle": "Turvaton uudelleenohjaus", + "continueInsecureRedirectSubtitle": "Yrität siirtyä suojatusta https -sivusta suojaamattomalle http -sivulle. Oletko varma, että haluat jatkaa?", + "continueUntrustedRedirectTitle": "Ei-luotettu uudelleenohjaus", + "continueUntrustedRedirectSubtitle": "Yrität uudelleenohjata domainiin, joka ei vastaa määritettyä verkkotunnusta ({{cookieDomain}}). Oletko varma, että haluat jatkaa?", + "logoutFailTitle": "Uloskirjautuminen epäonnistui", + "logoutFailSubtitle": "Ole hyvä ja yritä uudelleen", + "logoutSuccessTitle": "Kirjauduttu ulos", + "logoutSuccessSubtitle": "Sinut on kirjattu ulos", + "logoutTitle": "Kirjaudu ulos", + "logoutUsernameSubtitle": "Olet kirjautuneena sisään tunnuksella {{username}}. Kirjaudu ulos alla olevasta painikkeesta.", + "logoutOauthSubtitle": "Olet kirjautuneena sisään tunnuksella {{username}} OAuth palvelun {{provider}} kautta. Kirjaudu ulos alla olevasta painikkeesta.", + "notFoundTitle": "Sivua ei löydy", + "notFoundSubtitle": "Sivua, jota etsit ei ole olemassa.", + "notFoundButton": "Palaa kotinäkymään", + "totpFailTitle": "Koodin vahvistus epäonnistui", + "totpFailSubtitle": "Tarkista koodisi ja yritä uudelleen", + "totpSuccessTitle": "Vahvistettu", + "totpSuccessSubtitle": "Uudelleenohjataan sovelluksellesi", + "totpTitle": "Syötä TOTP -koodisi", + "totpSubtitle": "Ole hyvä ja syötä koodi todennussovelluksestasi.", + "unauthorizedTitle": "Ei sallittu", + "unauthorizedResourceSubtitle": "Käyttäjällä {{username}} ei ole pääsyä kohteeseen {{resource}}.", + "unauthorizedLoginSubtitle": "Käyttäjällä {{username}} ei ole lupaa kirjautua.", + "unauthorizedGroupsSubtitle": "Käyttäjä {{username}} ei ole ryhmässä, joka vaaditaan pääsyyn kohteeseen {{resource}}.", + "unauthorizedIpSubtitle": "IP osoitteestasi {{ip}} ei ole pääsyä kohteeseen {{resource}}.", + "unauthorizedButton": "Yritä uudelleen", + "cancelTitle": "Peruuta", + "forgotPasswordTitle": "Unohditko salasanasi?", + "failedToFetchProvidersTitle": "Todennuspalvelujen tarjoajien lataaminen epäonnistui. Tarkista määrityksesi.", + "errorTitle": "Tapahtui virhe", + "errorSubtitle": "Tapahtui virhe yritettäessä suorittaa tämä toiminto. Ole hyvä ja tarkista konsoli saadaksesi lisätietoja.", + "forgotPasswordMessage": "Voit nollata salasanasi vaihtamalla ympäristömuuttujan `USERS`.", + "fieldRequired": "Tämä kenttä on pakollinen", + "invalidInput": "Virheellinen syöte", + "domainWarningTitle": "Virheellinen verkkotunnus", + "domainWarningSubtitle": "Tämä instanssi on määritelty käyttämään osoitetta {{appUrl}}, mutta nykyinen osoite on {{currentUrl}}. Jos jatkat, saatat törmätä ongelmiin autentikoinnissa.", + "ignoreTitle": "Jätä huomiotta", + "goToCorrectDomainTitle": "Siirry oikeaan verkkotunnukseen" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/fr-FR.json b/frontend/src/lib/i18n/locales/fr-FR.json index ffd2b925..69c32e74 100644 --- a/frontend/src/lib/i18n/locales/fr-FR.json +++ b/frontend/src/lib/i18n/locales/fr-FR.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Impossible d'obtenir l'URL OAuth", "loginOauthSuccessTitle": "Redirection", "loginOauthSuccessSubtitle": "Redirection vers votre fournisseur OAuth", + "loginOauthAutoRedirectTitle": "Redirection automatique OAuth", + "loginOauthAutoRedirectSubtitle": "Vous allez être automatiquement redirigé vers votre fournisseur OAuth pour vous authentifier.", + "loginOauthAutoRedirectButton": "Rediriger", + "continueTitle": "Continuer", "continueRedirectingTitle": "Redirection...", "continueRedirectingSubtitle": "Vous devriez être redirigé vers l'application bientôt", - "continueInvalidRedirectTitle": "Redirection invalide", - "continueInvalidRedirectSubtitle": "L'URL de redirection est invalide", + "continueRedirectManually": "Redirection manuelle", "continueInsecureRedirectTitle": "Redirection non sécurisée", "continueInsecureRedirectSubtitle": "Vous tentez de rediriger de https vers http, ce qui n'est pas sécurisé. Êtes-vous sûr de vouloir continuer ?", - "continueTitle": "Continuer", - "continueSubtitle": "Cliquez sur le bouton pour continuer vers votre application.", + "continueUntrustedRedirectTitle": "Redirection non sécurisée", + "continueUntrustedRedirectSubtitle": "Vous essayez de rediriger vers un domaine qui ne correspond pas à votre domaine configuré ({{cookieDomain}}). Êtes-vous sûr de vouloir continuer ?", "logoutFailTitle": "Échec de la déconnexion", "logoutFailSubtitle": "Veuillez réessayer", "logoutSuccessTitle": "Déconnecté", @@ -38,20 +41,22 @@ "totpSuccessSubtitle": "Redirection vers votre application", "totpTitle": "Saisissez votre code TOTP", "totpSubtitle": "Veuillez saisir le code de votre application d'authentification.", - "unauthorizedTitle": "Unauthorized", + "unauthorizedTitle": "Non autorisé", "unauthorizedResourceSubtitle": "L'utilisateur avec le nom d'utilisateur {{username}} n'est pas autorisé à accéder à la ressource {{resource}}.", "unauthorizedLoginSubtitle": "L'utilisateur avec le nom d'utilisateur {{username}} n'est pas autorisé à se connecter.", "unauthorizedGroupsSubtitle": "L'utilisateur avec le nom d'utilisateur {{username}} n'appartient pas aux groupes requis par la ressource {{resource}}.", "unauthorizedIpSubtitle": "Votre adresse IP {{ip}} n'est pas autorisée à accéder à la ressource {{resource}}.", "unauthorizedButton": "Réessayer", - "untrustedRedirectTitle": "Redirection non fiable", - "untrustedRedirectSubtitle": "Vous tentez de rediriger vers un domaine qui ne correspond pas à votre domaine configuré ({{domain}}). Êtes-vous sûr de vouloir continuer ?", "cancelTitle": "Annuler", "forgotPasswordTitle": "Mot de passe oublié ?", "failedToFetchProvidersTitle": "Échec du chargement des fournisseurs d'authentification. Veuillez vérifier votre configuration.", "errorTitle": "Une erreur est survenue", "errorSubtitle": "Une erreur est survenue lors de l'exécution de cette action. Veuillez consulter la console pour plus d'informations.", "forgotPasswordMessage": "Vous pouvez réinitialiser votre mot de passe en modifiant la variable d'environnement `USERS`.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "fieldRequired": "Ce champ est obligatoire", + "invalidInput": "Saisie non valide", + "domainWarningTitle": "Domaine invalide", + "domainWarningSubtitle": "Cette instance est configurée pour être accédée depuis {{appUrl}}, mais {{currentUrl}} est utilisé. Si vous continuez, vous pourriez rencontrer des problèmes d'authentification.", + "ignoreTitle": "Ignorer", + "goToCorrectDomainTitle": "Aller au bon domaine" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/he-IL.json b/frontend/src/lib/i18n/locales/he-IL.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/he-IL.json +++ b/frontend/src/lib/i18n/locales/he-IL.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/hu-HU.json b/frontend/src/lib/i18n/locales/hu-HU.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/hu-HU.json +++ b/frontend/src/lib/i18n/locales/hu-HU.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/it-IT.json b/frontend/src/lib/i18n/locales/it-IT.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/it-IT.json +++ b/frontend/src/lib/i18n/locales/it-IT.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/ja-JP.json b/frontend/src/lib/i18n/locales/ja-JP.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/ja-JP.json +++ b/frontend/src/lib/i18n/locales/ja-JP.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/ko-KR.json b/frontend/src/lib/i18n/locales/ko-KR.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/ko-KR.json +++ b/frontend/src/lib/i18n/locales/ko-KR.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/nl-NL.json b/frontend/src/lib/i18n/locales/nl-NL.json index 8a1bac34..3e890486 100644 --- a/frontend/src/lib/i18n/locales/nl-NL.json +++ b/frontend/src/lib/i18n/locales/nl-NL.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Fout bij het ophalen van OAuth URL", "loginOauthSuccessTitle": "Omleiden", "loginOauthSuccessSubtitle": "Omleiden naar je OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Ga verder", "continueRedirectingTitle": "Omleiden...", "continueRedirectingSubtitle": "Je wordt naar de app doorgestuurd", - "continueInvalidRedirectTitle": "Ongeldige omleiding", - "continueInvalidRedirectSubtitle": "De omleidings-URL is ongeldig", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Onveilige doorverwijzing", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Ga verder", - "continueSubtitle": "Klik op de knop om door te gaan naar de app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Afmelden mislukt", "logoutFailSubtitle": "Probeer het opnieuw", "logoutSuccessTitle": "Afgemeld", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Opnieuw proberen", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/no-NO.json b/frontend/src/lib/i18n/locales/no-NO.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/no-NO.json +++ b/frontend/src/lib/i18n/locales/no-NO.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/pl-PL.json b/frontend/src/lib/i18n/locales/pl-PL.json index 37b68168..abded9b4 100644 --- a/frontend/src/lib/i18n/locales/pl-PL.json +++ b/frontend/src/lib/i18n/locales/pl-PL.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Nie udało się uzyskać adresu URL OAuth", "loginOauthSuccessTitle": "Przekierowywanie", "loginOauthSuccessSubtitle": "Przekierowywanie do Twojego dostawcy OAuth", + "loginOauthAutoRedirectTitle": "Automatyczne przekierowanie OAuth", + "loginOauthAutoRedirectSubtitle": "Nastąpi automatyczne przekierowanie do dostawcy OAuth w celu uwierzytelnienia.", + "loginOauthAutoRedirectButton": "Przekieruj teraz", + "continueTitle": "Kontynuuj", "continueRedirectingTitle": "Przekierowywanie...", "continueRedirectingSubtitle": "Wkrótce powinieneś zostać przekierowany do aplikacji", - "continueInvalidRedirectTitle": "Nieprawidłowe przekierowanie", - "continueInvalidRedirectSubtitle": "Adres przekierowania jest nieprawidłowy", + "continueRedirectManually": "Przekieruj mnie ręcznie", "continueInsecureRedirectTitle": "Niezabezpieczone przekierowanie", "continueInsecureRedirectSubtitle": "Próbujesz przekierować z https do http, co nie jest bezpieczne. Czy na pewno chcesz kontynuować?", - "continueTitle": "Kontynuuj", - "continueSubtitle": "Kliknij przycisk, aby przejść do aplikacji.", + "continueUntrustedRedirectTitle": "Niezaufane przekierowanie", + "continueUntrustedRedirectSubtitle": "Próbujesz przekierować do domeny, która nie pasuje do skonfigurowanej domeny ({{cookieDomain}}). Czy na pewno chcesz kontynuować?", "logoutFailTitle": "Nie udało się wylogować", "logoutFailSubtitle": "Spróbuj ponownie", "logoutSuccessTitle": "Wylogowano", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "Użytkownik o nazwie {{username}} nie należy do grup wymaganych przez zasób {{resource}}.", "unauthorizedIpSubtitle": "Twój adres IP {{ip}} nie ma autoryzacji do dostępu do zasobu {{resource}}.", "unauthorizedButton": "Spróbuj ponownie", - "untrustedRedirectTitle": "Niezaufane przekierowanie", - "untrustedRedirectSubtitle": "Próbujesz przekierować do domeny, która nie pasuje do Twojej skonfigurowanej domeny ({{domain}}). Czy na pewno chcesz kontynuować?", "cancelTitle": "Anuluj", "forgotPasswordTitle": "Nie pamiętasz hasła?", "failedToFetchProvidersTitle": "Nie udało się załadować dostawców uwierzytelniania. Sprawdź swoją konfigurację.", @@ -53,5 +54,9 @@ "errorSubtitle": "Wystąpił błąd podczas próby wykonania tej czynności. Sprawdź konsolę, aby uzyskać więcej informacji.", "forgotPasswordMessage": "Możesz zresetować hasło, zmieniając zmienną środowiskową `USERS`.", "fieldRequired": "To pole jest wymagane", - "invalidInput": "Nieprawidłowe dane wejściowe" + "invalidInput": "Nieprawidłowe dane wejściowe", + "domainWarningTitle": "Nieprawidłowa domena", + "domainWarningSubtitle": "Ta instancja jest skonfigurowana do uzyskania dostępu z {{appUrl}}, ale {{currentUrl}} jest w użyciu. Jeśli będziesz kontynuować, mogą wystąpić problemy z uwierzytelnianiem.", + "ignoreTitle": "Zignoruj", + "goToCorrectDomainTitle": "Przejdź do prawidłowej domeny" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/pt-BR.json b/frontend/src/lib/i18n/locales/pt-BR.json index 1a2626cd..7869fbee 100644 --- a/frontend/src/lib/i18n/locales/pt-BR.json +++ b/frontend/src/lib/i18n/locales/pt-BR.json @@ -1,34 +1,37 @@ { "loginTitle": "Bem-vindo de volta, acesse com", - "loginTitleSimple": "Welcome back, please login", - "loginDivider": "Or", + "loginTitleSimple": "Bem-vindo de volta, faça o login", + "loginDivider": "Ou", "loginUsername": "Nome de usuário", "loginPassword": "Senha", "loginSubmit": "Entrar", "loginFailTitle": "Falha ao iniciar sessão", "loginFailSubtitle": "Por favor, verifique seu usuário e senha", - "loginFailRateLimit": "You failed to login too many times. Please try again later", + "loginFailRateLimit": "Você falhou em iniciar sessão muitas vezes, por favor tente novamente mais tarde", "loginSuccessTitle": "Sessão Iniciada", "loginSuccessSubtitle": "Bem-vindo de volta!", - "loginOauthFailTitle": "An error occurred", + "loginOauthFailTitle": "Ocorreu um erro", "loginOauthFailSubtitle": "Falha ao obter URL de OAuth", "loginOauthSuccessTitle": "Redirecionando", "loginOauthSuccessSubtitle": "Redirecionando para seu provedor OAuth", + "loginOauthAutoRedirectTitle": "Redirecionamento automático do OAuth", + "loginOauthAutoRedirectSubtitle": "Você será automaticamente redirecionado para seu provedor OAuth para autenticar.", + "loginOauthAutoRedirectButton": "Redirecionar agora", + "continueTitle": "Continuar", "continueRedirectingTitle": "Redirecionando...", "continueRedirectingSubtitle": "Você deve ser redirecionado para o aplicativo em breve", - "continueInvalidRedirectTitle": "Redirecionamento inválido", - "continueInvalidRedirectSubtitle": "O endereço de redirecionamento é inválido", + "continueRedirectManually": "Redirecionar-me manualmente", "continueInsecureRedirectTitle": "Redirecionamento inseguro", - "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continuar", - "continueSubtitle": "Clique no botão para continuar para o seu aplicativo.", + "continueInsecureRedirectSubtitle": "Você está tentando redirecionar de https para http, você tem certeza que deseja continuar?", + "continueUntrustedRedirectTitle": "Redirecionamento não confiável", + "continueUntrustedRedirectSubtitle": "Você está tentando redirecionar para um domínio que não corresponde ao seu domínio configurado ({{cookieDomain}}). Tem certeza que deseja continuar?", "logoutFailTitle": "Falha ao encerrar sessão", "logoutFailSubtitle": "Por favor, tente novamente", "logoutSuccessTitle": "Sessão encerrada", "logoutSuccessSubtitle": "Você foi desconectado", "logoutTitle": "Sair", - "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", - "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", + "logoutUsernameSubtitle": "Você está atualmente logado como {{username}}, clique no botão abaixo para sair.", + "logoutOauthSubtitle": "Você está atualmente logado como {{username}} usando o provedor {{provider}} OAuth, clique no botão abaixo para sair.", "notFoundTitle": "Página não encontrada", "notFoundSubtitle": "A página que você está procurando não existe.", "notFoundButton": "Voltar para a tela inicial", @@ -37,21 +40,23 @@ "totpSuccessTitle": "Verificado", "totpSuccessSubtitle": "Redirecionando para o seu aplicativo", "totpTitle": "Insira o seu código TOTP", - "totpSubtitle": "Please enter the code from your authenticator app.", + "totpSubtitle": "Por favor, insira o código do seu aplicativo de autenticação.", "unauthorizedTitle": "Não autorizado", - "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", - "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", - "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", - "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", + "unauthorizedResourceSubtitle": "O usuário com nome de usuário {{username}} não está autorizado a acessar o recurso {{resource}}.", + "unauthorizedLoginSubtitle": "O usuário com o nome {{username}} não está autorizado a acessar.", + "unauthorizedGroupsSubtitle": "O usuário {{username}} não está autorizado a acessar o recurso {{resource}}.", + "unauthorizedIpSubtitle": "Seu endereço IP {{ip}} não está autorizado a acessar o recurso {{resource}}.", "unauthorizedButton": "Tentar novamente", - "untrustedRedirectTitle": "Redirecionamento não confiável", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancelar", "forgotPasswordTitle": "Esqueceu sua senha?", - "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", - "errorTitle": "An error occurred", - "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "failedToFetchProvidersTitle": "Falha ao carregar provedores de autenticação. Verifique sua configuração.", + "errorTitle": "Ocorreu um erro", + "errorSubtitle": "Ocorreu um erro ao tentar executar esta ação. Por favor, verifique o console para mais informações.", + "forgotPasswordMessage": "Você pode redefinir sua senha alterando a variável de ambiente `USERS`.", + "fieldRequired": "Este campo é obrigatório", + "invalidInput": "Entrada Inválida", + "domainWarningTitle": "Domínio inválido", + "domainWarningSubtitle": "Esta instância está configurada para ser acessada de {{appUrl}}, mas {{currentUrl}} está sendo usado. Se você continuar, você pode encontrar problemas com a autenticação.", + "ignoreTitle": "Ignorar", + "goToCorrectDomainTitle": "Ir para o domínio correto" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/pt-PT.json b/frontend/src/lib/i18n/locales/pt-PT.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/pt-PT.json +++ b/frontend/src/lib/i18n/locales/pt-PT.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/ro-RO.json b/frontend/src/lib/i18n/locales/ro-RO.json index 74e422f5..43004285 100644 --- a/frontend/src/lib/i18n/locales/ro-RO.json +++ b/frontend/src/lib/i18n/locales/ro-RO.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/ru-RU.json b/frontend/src/lib/i18n/locales/ru-RU.json index f7b143fe..611c3d34 100644 --- a/frontend/src/lib/i18n/locales/ru-RU.json +++ b/frontend/src/lib/i18n/locales/ru-RU.json @@ -1,6 +1,6 @@ { "loginTitle": "С возвращением, войти с", - "loginTitleSimple": "Вход", + "loginTitleSimple": "С возвращением, пожалуйста войдите", "loginDivider": "Или", "loginUsername": "Имя пользователя", "loginPassword": "Пароль", @@ -8,24 +8,27 @@ "loginFailTitle": "Вход не удался", "loginFailSubtitle": "Проверьте имя пользователя и пароль", "loginFailRateLimit": "Слишком много ошибок входа. Попробуйте позже", - "loginSuccessTitle": "Вы вошли", + "loginSuccessTitle": "Вход выполнен", "loginSuccessSubtitle": "С возвращением!", "loginOauthFailTitle": "Произошла ошибка", - "loginOauthFailSubtitle": "Не удалось получить OAuth URL", + "loginOauthFailSubtitle": "Не удалось получить ссылку OAuth", "loginOauthSuccessTitle": "Перенаправление", "loginOauthSuccessSubtitle": "Перенаправление к поставщику OAuth", + "loginOauthAutoRedirectTitle": "OAuth автоматическое перенаправление", + "loginOauthAutoRedirectSubtitle": "Вы будете автоматически перенаправлены для авторизации у вашего поставщика OAuth.", + "loginOauthAutoRedirectButton": "Перенаправить сейчас", + "continueTitle": "Продолжить", "continueRedirectingTitle": "Перенаправление...", "continueRedirectingSubtitle": "Скоро вы будете перенаправлены в приложение", - "continueInvalidRedirectTitle": "Неверное перенаправление", - "continueInvalidRedirectSubtitle": "URL перенаправления недействителен", + "continueRedirectManually": "Перенаправить вручную", "continueInsecureRedirectTitle": "Небезопасное перенаправление", "continueInsecureRedirectSubtitle": "Попытка перенаправления с https на http, уверены, что хотите продолжить?", - "continueTitle": "Продолжить", - "continueSubtitle": "Нажмите на кнопку, чтобы перейти к приложению.", + "continueUntrustedRedirectTitle": "Недоверенное перенаправление", + "continueUntrustedRedirectSubtitle": "Вы пытаетесь перенаправить на домен, который не соответствует вашему настроенному домену ({{cookieDomain}}). Вы уверены, что хотите продолжить?", "logoutFailTitle": "Не удалось выйти", "logoutFailSubtitle": "Попробуйте ещё раз", "logoutSuccessTitle": "Выход", - "logoutSuccessSubtitle": "Вы вышли из системы", + "logoutSuccessSubtitle": "Вы вышли", "logoutTitle": "Выйти", "logoutUsernameSubtitle": "Вход выполнен как {{username}}, нажмите на кнопку ниже, чтобы выйти.", "logoutOauthSubtitle": "Вход выполнен как {{username}} с использованием {{provider}} OAuth, нажмите кнопку ниже, чтобы выйти.", @@ -37,21 +40,23 @@ "totpSuccessTitle": "Подтверждён", "totpSuccessSubtitle": "Перенаправление в приложение", "totpTitle": "Введите код TOTP", - "totpSubtitle": "Пожалуйста, введите код из вашего приложения — аутентификатора.", - "unauthorizedTitle": "Доступ запрещен", - "unauthorizedResourceSubtitle": "Пользователю {{username}} не разрешен доступ к {{resource}}.", - "unauthorizedLoginSubtitle": "Пользователю {{username}} не разрешен вход.", - "unauthorizedGroupsSubtitle": "Пользователь {{username}} не состоит в группах, которым разрешен доступ к {{resource}}.", - "unauthorizedIpSubtitle": "Ваш IP адрес {{ip}} не авторизован для доступа к ресурсу {{resource}}.", + "totpSubtitle": "Пожалуйста, введите код из вашего приложения авторизации.", + "unauthorizedTitle": "Доступ запрещён", + "unauthorizedResourceSubtitle": "Пользователю {{username}} не разрешён доступ к {{resource}}.", + "unauthorizedLoginSubtitle": "Пользователю {{username}} не разрешён вход.", + "unauthorizedGroupsSubtitle": "Пользователь {{username}} не состоит в группах, которым разрешён доступ к {{resource}}.", + "unauthorizedIpSubtitle": "Вашему IP-адресу {{ip}} не разрешён доступ к ресурсу {{resource}}.", "unauthorizedButton": "Повторить", - "untrustedRedirectTitle": "Ненадежное перенаправление", - "untrustedRedirectSubtitle": "Попытка перенаправить на домен, который не соответствует вашему заданному домену ({{domain}}). Уверены, что хотите продолжить?", "cancelTitle": "Отмена", "forgotPasswordTitle": "Забыли пароль?", - "failedToFetchProvidersTitle": "Не удалось загрузить провайдеров аутентификации. Пожалуйста, проверьте конфигурацию.", + "failedToFetchProvidersTitle": "Не удалось загрузить поставщика авторизации. Пожалуйста, проверьте конфигурацию.", "errorTitle": "Произошла ошибка", "errorSubtitle": "Произошла ошибка при попытке выполнить это действие. Проверьте консоль для дополнительной информации.", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "forgotPasswordMessage": "Вы можете сбросить свой пароль, изменив переменную окружения `USERS`.", + "fieldRequired": "Это поле является обязательным", + "invalidInput": "Недопустимый ввод", + "domainWarningTitle": "Неверный домен", + "domainWarningSubtitle": "Этот экземпляр настроен на доступ к нему из {{appUrl}}, но {{currentUrl}} в настоящее время используется. Если вы продолжите, то могут возникнуть проблемы с авторизацией.", + "ignoreTitle": "Игнорировать", + "goToCorrectDomainTitle": "Перейти к правильному домену" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/sr-SP.json b/frontend/src/lib/i18n/locales/sr-SP.json index 34df08d7..f7460e3b 100644 --- a/frontend/src/lib/i18n/locales/sr-SP.json +++ b/frontend/src/lib/i18n/locales/sr-SP.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Неуспело преузимање OAuth адресе", "loginOauthSuccessTitle": "Преусмеравање", "loginOauthSuccessSubtitle": "Преусмеравање на вашег OAuth провајдера", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Настави", "continueRedirectingTitle": "Преусмеравање...", "continueRedirectingSubtitle": "Требали би сте ускоро да будете преусмерени на апликацију", - "continueInvalidRedirectTitle": "Неисправно преусмеравање", - "continueInvalidRedirectSubtitle": "Адреса за преусмеравање није исправна", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Небезбедно преусмеравање", "continueInsecureRedirectSubtitle": "Покушавате да преусмерите са https на http што није безбедно. Да ли желите да наставите?", - "continueTitle": "Настави", - "continueSubtitle": "Кликните на дугме да би сте наставили на нашу апликацију.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Неуспешно одјављивање", "logoutFailSubtitle": "Молим вас покушајте поново", "logoutSuccessTitle": "Одјављени", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "Корисник са корисничким именом {{username}} није у групама које захтева ресурс {{resource}}.", "unauthorizedIpSubtitle": "Ваша IP адреса {{ip}} није ауторизована да приступи ресурсу {{resource}}.", "unauthorizedButton": "Покушајте поново", - "untrustedRedirectTitle": "Преусмерење без поверења", - "untrustedRedirectSubtitle": "Покушавате да преусмерите на домен који се не поклапа са подешеним доменом ({{domain}}). Да ли желите да наставите?", "cancelTitle": "Поништи", "forgotPasswordTitle": "Заборавили сте лозинку?", "failedToFetchProvidersTitle": "Није успело учитавање провајдера аутентификације. Молим вас проверите ваша подешавања.", @@ -53,5 +54,9 @@ "errorSubtitle": "Појавила се грешка при покушају извршавања ове радње. Молим вас проверите конзолу за додатне информације.", "forgotPasswordMessage": "Можете поништити вашу лозинку променом `USERS` променљиве окружења.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/sv-SE.json b/frontend/src/lib/i18n/locales/sv-SE.json index 74e422f5..be1a6288 100644 --- a/frontend/src/lib/i18n/locales/sv-SE.json +++ b/frontend/src/lib/i18n/locales/sv-SE.json @@ -1,32 +1,35 @@ { - "loginTitle": "Welcome back, login with", - "loginTitleSimple": "Welcome back, please login", - "loginDivider": "Or", - "loginUsername": "Username", - "loginPassword": "Password", - "loginSubmit": "Login", - "loginFailTitle": "Failed to log in", - "loginFailSubtitle": "Please check your username and password", - "loginFailRateLimit": "You failed to login too many times. Please try again later", - "loginSuccessTitle": "Logged in", - "loginSuccessSubtitle": "Welcome back!", - "loginOauthFailTitle": "An error occurred", - "loginOauthFailSubtitle": "Failed to get OAuth URL", - "loginOauthSuccessTitle": "Redirecting", - "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", - "continueRedirectingTitle": "Redirecting...", - "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", - "continueInsecureRedirectTitle": "Insecure redirect", - "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", - "logoutFailTitle": "Failed to log out", - "logoutFailSubtitle": "Please try again", - "logoutSuccessTitle": "Logged out", - "logoutSuccessSubtitle": "You have been logged out", - "logoutTitle": "Logout", + "loginTitle": "Välkommen tillbaka, logga in med", + "loginTitleSimple": "Välkommen tillbaka, logga in", + "loginDivider": "Eller", + "loginUsername": "Användarnamn", + "loginPassword": "Lösenord", + "loginSubmit": "Logga in", + "loginFailTitle": "Kunde inte logga in", + "loginFailSubtitle": "Kontrollera ditt användarnamn och lösenord", + "loginFailRateLimit": "Du misslyckades med att logga in för många gånger. Försök igen senare", + "loginSuccessTitle": "Inloggad", + "loginSuccessSubtitle": "Välkommen tillbaka!", + "loginOauthFailTitle": "Ett fel har uppstått", + "loginOauthFailSubtitle": "Kunde inte hämta OAuth URL", + "loginOauthSuccessTitle": "Omdirigerar", + "loginOauthSuccessSubtitle": "Omdirigera till din OAuth leverantör", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Fortsätt", + "continueRedirectingTitle": "Omdirigerar...", + "continueRedirectingSubtitle": "Du bör omdirigeras till appen snart", + "continueRedirectManually": "Redirect me manually", + "continueInsecureRedirectTitle": "Osäker omdirigering", + "continueInsecureRedirectSubtitle": "Du försöker omdirigera från https till http som inte är säker. Är du säker på att du vill fortsätta?", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", + "logoutFailTitle": "Kunde inte logga ut.", + "logoutFailSubtitle": "Vänligen försök igen", + "logoutSuccessTitle": "Utloggad", + "logoutSuccessSubtitle": "Du har blivit utloggad", + "logoutTitle": "Logga ut", "logoutUsernameSubtitle": "You are currently logged in as {{username}}. Click the button below to logout.", "logoutOauthSubtitle": "You are currently logged in as {{username}} using the {{provider}} OAuth provider. Click the button below to logout.", "notFoundTitle": "Page not found", @@ -38,14 +41,12 @@ "totpSuccessSubtitle": "Redirecting to your app", "totpTitle": "Enter your TOTP code", "totpSubtitle": "Please enter the code from your authenticator app.", - "unauthorizedTitle": "Unauthorized", + "unauthorizedTitle": "Obehörig", "unauthorizedResourceSubtitle": "The user with username {{username}} is not authorized to access the resource {{resource}}.", "unauthorizedLoginSubtitle": "The user with username {{username}} is not authorized to login.", "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/tr-TR.json b/frontend/src/lib/i18n/locales/tr-TR.json index df6c3c33..af2fdc02 100644 --- a/frontend/src/lib/i18n/locales/tr-TR.json +++ b/frontend/src/lib/i18n/locales/tr-TR.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Yönlendiriliyor", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Devam et", "continueRedirectingTitle": "Yönlendiriliyor...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Devam et", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Lütfen tekrar deneyin", "logoutSuccessTitle": "Çıkış yapıldı", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "İptal", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/uk-UA.json b/frontend/src/lib/i18n/locales/uk-UA.json index 74e422f5..60f8ad38 100644 --- a/frontend/src/lib/i18n/locales/uk-UA.json +++ b/frontend/src/lib/i18n/locales/uk-UA.json @@ -1,5 +1,5 @@ { - "loginTitle": "Welcome back, login with", + "loginTitle": "З поверненням, увійдіть через", "loginTitleSimple": "Welcome back, please login", "loginDivider": "Or", "loginUsername": "Username", @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,8 +47,6 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", "forgotPasswordTitle": "Forgot your password?", "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", @@ -53,5 +54,9 @@ "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/vi-VN.json b/frontend/src/lib/i18n/locales/vi-VN.json index 74e422f5..d2052f93 100644 --- a/frontend/src/lib/i18n/locales/vi-VN.json +++ b/frontend/src/lib/i18n/locales/vi-VN.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", + "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", - "continueInvalidRedirectTitle": "Invalid redirect", - "continueInvalidRedirectSubtitle": "The redirect URL is invalid", + "continueRedirectManually": "Redirect me manually", "continueInsecureRedirectTitle": "Insecure redirect", "continueInsecureRedirectSubtitle": "You are trying to redirect from https to http which is not secure. Are you sure you want to continue?", - "continueTitle": "Continue", - "continueSubtitle": "Click the button to continue to your app.", + "continueUntrustedRedirectTitle": "Untrusted redirect", + "continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{cookieDomain}}). Are you sure you want to continue?", "logoutFailTitle": "Failed to log out", "logoutFailSubtitle": "Please try again", "logoutSuccessTitle": "Logged out", @@ -44,14 +47,16 @@ "unauthorizedGroupsSubtitle": "The user with username {{username}} is not in the groups required by the resource {{resource}}.", "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", "unauthorizedButton": "Try again", - "untrustedRedirectTitle": "Untrusted redirect", - "untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain ({{domain}}). Are you sure you want to continue?", "cancelTitle": "Cancel", - "forgotPasswordTitle": "Forgot your password?", - "failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.", + "forgotPasswordTitle": "Bạn quên mật khẩu?", + "failedToFetchProvidersTitle": "Không tải được nhà cung cấp xác thực. Vui lòng kiểm tra cấu hình của bạn.", "errorTitle": "An error occurred", - "errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.", + "errorSubtitle": "Đã xảy ra lỗi khi thực hiện thao tác này. Vui lòng kiểm tra bảng điều khiển để biết thêm thông tin.", "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "invalidInput": "Invalid input", + "domainWarningTitle": "Invalid Domain", + "domainWarningSubtitle": "This instance is configured to be accessed from {{appUrl}}, but {{currentUrl}} is being used. If you proceed, you may encounter issues with authentication.", + "ignoreTitle": "Ignore", + "goToCorrectDomainTitle": "Go to correct domain" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/zh-CN.json b/frontend/src/lib/i18n/locales/zh-CN.json index e29a00f9..25b50ff2 100644 --- a/frontend/src/lib/i18n/locales/zh-CN.json +++ b/frontend/src/lib/i18n/locales/zh-CN.json @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "获取 OAuth URL 失败", "loginOauthSuccessTitle": "重定向中", "loginOauthSuccessSubtitle": "重定向到您的 OAuth 提供商", + "loginOauthAutoRedirectTitle": "OAuth自动重定向", + "loginOauthAutoRedirectSubtitle": "您将被自动重定向到您的 OAuth 提供商进行身份验证。", + "loginOauthAutoRedirectButton": "立即跳转", + "continueTitle": "继续", "continueRedirectingTitle": "正在重定向……", "continueRedirectingSubtitle": "您应该很快被重定向到应用", - "continueInvalidRedirectTitle": "无效的重定向", - "continueInvalidRedirectSubtitle": "重定向URL无效", + "continueRedirectManually": "请手动跳转", "continueInsecureRedirectTitle": "不安全的重定向", "continueInsecureRedirectSubtitle": "您正在尝试从https重定向到http可能存在风险。您确定要继续吗?", - "continueTitle": "继续", - "continueSubtitle": "点击按钮以继续您的应用。", + "continueUntrustedRedirectTitle": "不可信的重定向", + "continueUntrustedRedirectSubtitle": "您尝试跳转的域名与配置的域名({{cookieDomain}})不匹配。是否继续?", "logoutFailTitle": "注销失败", "logoutFailSubtitle": "请重试", "logoutSuccessTitle": "已登出", @@ -30,7 +33,7 @@ "logoutUsernameSubtitle": "您当前登录用户为{{username}}。点击下方按钮注销。", "logoutOauthSubtitle": "您当前以{{username}}登录,使用的是{{provider}} OAuth 提供商。点击下方按钮注销。", "notFoundTitle": "无法找到页面", - "notFoundSubtitle": "您正在查找的页面不存在。", + "notFoundSubtitle": "您访问的页面不存在。", "notFoundButton": "回到主页", "totpFailTitle": "无法验证代码", "totpFailSubtitle": "请检查您的代码并重试", @@ -42,16 +45,18 @@ "unauthorizedResourceSubtitle": "用户名为{{username}}的用户无权访问资源{{resource}}。", "unauthorizedLoginSubtitle": "用户名为{{username}}的用户无权登录。", "unauthorizedGroupsSubtitle": "用户名为{{username}}的用户不在资源{{resource}}所需的组中。", - "unauthorizedIpSubtitle": "Your IP address {{ip}} is not authorized to access the resource {{resource}}.", + "unauthorizedIpSubtitle": "用户 {{ip}} 无权访问资源 {{resource}}。", "unauthorizedButton": "重试", - "untrustedRedirectTitle": "不可信的重定向", - "untrustedRedirectSubtitle": "您正在尝试重定向到一个与您已配置的域名 ({{domain}}) 不匹配的域名。您确定要继续吗?", "cancelTitle": "取消", "forgotPasswordTitle": "忘记密码?", "failedToFetchProvidersTitle": "加载身份验证提供程序失败,请检查您的配置。", "errorTitle": "发生了错误", "errorSubtitle": "执行此操作时发生错误,请检查控制台以获取更多信息。", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "forgotPasswordMessage": "您可以通过更改 `USERS ` 环境变量重置您的密码。", + "fieldRequired": "必添字段", + "invalidInput": "无效的输入", + "domainWarningTitle": "无效域名", + "domainWarningSubtitle": "当前实例配置的访问地址为 {{appUrl}},但您正在使用 {{currentUrl}}。若继续操作,可能会遇到身份验证问题。", + "ignoreTitle": "忽略", + "goToCorrectDomainTitle": "转到正确的域名" } \ No newline at end of file diff --git a/frontend/src/lib/i18n/locales/zh-TW.json b/frontend/src/lib/i18n/locales/zh-TW.json index 8c324303..140a71c3 100644 --- a/frontend/src/lib/i18n/locales/zh-TW.json +++ b/frontend/src/lib/i18n/locales/zh-TW.json @@ -1,5 +1,5 @@ { - "loginTitle": "歡迎回來,請用以下方式登入", + "loginTitle": "歡迎回來,請使用以下方式登入", "loginTitleSimple": "歡迎回來,請登入", "loginDivider": "或", "loginUsername": "帳號", @@ -14,14 +14,17 @@ "loginOauthFailSubtitle": "無法取得 OAuth 網址", "loginOauthSuccessTitle": "重新導向中", "loginOauthSuccessSubtitle": "正在將您重新導向至 OAuth 供應商", + "loginOauthAutoRedirectTitle": "OAuth 自動跳轉", + "loginOauthAutoRedirectSubtitle": "自動跳轉到 OAuth 供應商進行身份驗證。", + "loginOauthAutoRedirectButton": "立即重新導向", + "continueTitle": "繼續", "continueRedirectingTitle": "重新導向中……", "continueRedirectingSubtitle": "您即將被重新導向至應用程式", - "continueInvalidRedirectTitle": "無效的重新導向", - "continueInvalidRedirectSubtitle": "重新導向的網址無效", + "continueRedirectManually": "手動重新導向", "continueInsecureRedirectTitle": "不安全的重新導向", "continueInsecureRedirectSubtitle": "您正嘗試從安全的 https 重新導向至不安全的 http。您確定要繼續嗎?", - "continueTitle": "繼續", - "continueSubtitle": "點擊按鈕以繼續前往您的應用程式。", + "continueUntrustedRedirectTitle": "不受信任的重新導向", + "continueUntrustedRedirectSubtitle": "你嘗試重新導向的域名與設定不符({{cookieDomain}})。你確定要繼續嗎?", "logoutFailTitle": "登出失敗", "logoutFailSubtitle": "請再試一次", "logoutSuccessTitle": "登出成功", @@ -44,14 +47,16 @@ "unauthorizedGroupsSubtitle": "使用者 {{username}} 不在存取資源 {{resource}} 所需的群組中。", "unauthorizedIpSubtitle": "您的 IP 位址 {{ip}} 未被授權存取資源 {{resource}}。", "unauthorizedButton": "再試一次", - "untrustedRedirectTitle": "不受信任的重新導向", - "untrustedRedirectSubtitle": "您正嘗試重新導向至的網域與您設定的網域 ({{domain}}) 不符。您確定要繼續嗎?", "cancelTitle": "取消", "forgotPasswordTitle": "忘記密碼?", "failedToFetchProvidersTitle": "載入驗證供應商失敗。請檢查您的設定。", "errorTitle": "發生錯誤", "errorSubtitle": "執行此操作時發生錯誤。請檢查主控台以獲取更多資訊。", - "forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.", - "fieldRequired": "This field is required", - "invalidInput": "Invalid input" + "forgotPasswordMessage": "透過修改 `USERS` 環境變數,你可以重設你的密碼。", + "fieldRequired": "此為必填欄位", + "invalidInput": "無效的輸入", + "domainWarningTitle": "無效的網域", + "domainWarningSubtitle": "此服務設定為透過 {{appUrl}} 存取,但目前使用的是 {{currentUrl}}。若繼續操作,可能會遇到驗證問題。", + "ignoreTitle": "忽略", + "goToCorrectDomainTitle": "前往正確域名" } \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 5172a7c8..a451ae67 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -9,7 +9,7 @@ export const isValidUrl = (url: string) => { try { new URL(url); return true; - } catch (e) { + } catch { return false; } }; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b79bbdb8..0d20de8f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -16,6 +16,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppContextProvider } from "./context/app-context.tsx"; import { UserContextProvider } from "./context/user-context.tsx"; import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "./components/providers/theme-provider.tsx"; const queryClient = new QueryClient(); @@ -24,25 +25,27 @@ createRoot(document.getElementById("root")!).render( - - - } errorElement={}> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - - - - + + + + } errorElement={}> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + + + + + diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index cc4d4326..b6c8b006 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -11,60 +11,105 @@ import { useUserContext } from "@/context/user-context"; import { isValidUrl } from "@/lib/utils"; import { Trans, useTranslation } from "react-i18next"; import { Navigate, useLocation, useNavigate } from "react-router"; -import DOMPurify from "dompurify"; -import { useState } from "react"; +import { useEffect, useState } from "react"; export const ContinuePage = () => { + const { cookieDomain, disableUiWarnings } = useAppContext(); const { isLoggedIn } = useUserContext(); - - if (!isLoggedIn) { - return ; - } - - const { domain, disableContinue } = useAppContext(); const { search } = useLocation(); + const { t } = useTranslation(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [showRedirectButton, setShowRedirectButton] = useState(false); const searchParams = new URLSearchParams(search); - const redirectURI = searchParams.get("redirect_uri"); + const redirectUri = searchParams.get("redirect_uri"); - if (!redirectURI) { - return ; - } - - if (!isValidUrl(DOMPurify.sanitize(redirectURI))) { - return ; - } + const isValidRedirectUri = + redirectUri !== null ? isValidUrl(redirectUri) : false; + const redirectUriObj = isValidRedirectUri + ? new URL(redirectUri as string) + : null; + const isTrustedRedirectUri = + redirectUriObj !== null + ? redirectUriObj.hostname === cookieDomain || + redirectUriObj.hostname.endsWith(`.${cookieDomain}`) + : false; + const isAllowedRedirectProto = + redirectUriObj !== null + ? redirectUriObj.protocol === "https:" || + redirectUriObj.protocol === "http:" + : false; + const isHttpsDowngrade = + redirectUriObj !== null + ? redirectUriObj.protocol === "http:" && + window.location.protocol === "https:" + : false; const handleRedirect = () => { setLoading(true); - window.location.href = DOMPurify.sanitize(redirectURI); - } + window.location.assign(redirectUriObj!.toString()); + }; - if (disableContinue) { - handleRedirect(); - } + useEffect(() => { + if (!isLoggedIn) { + return; + } - const { t } = useTranslation(); - const navigate = useNavigate(); + if ( + (!isValidRedirectUri || + !isAllowedRedirectProto || + !isTrustedRedirectUri || + isHttpsDowngrade) && + !disableUiWarnings + ) { + return; + } + + const auto = setTimeout(() => { + handleRedirect(); + }, 100); - const url = new URL(redirectURI); + const reveal = setTimeout(() => { + setLoading(false); + setShowRedirectButton(true); + }, 5000); - if (!(url.hostname == domain) && !url.hostname.endsWith(`.${domain}`)) { + return () => { + clearTimeout(auto); + clearTimeout(reveal); + }; + }, []); + + if (!isLoggedIn) { return ( - + + ); + } + + if (!isValidRedirectUri || !isAllowedRedirectProto) { + return ; + } + + if (!isTrustedRedirectUri && !disableUiWarnings) { + return ( + - {t("untrustedRedirectTitle")} + {t("continueUntrustedRedirectTitle")} , }} - values={{ domain }} + values={{ cookieDomain }} /> @@ -76,7 +121,11 @@ export const ContinuePage = () => { > {t("continueTitle")} - @@ -84,9 +133,9 @@ export const ContinuePage = () => { ); } - if (url.protocol === "http:" && window.location.protocol === "https:") { + if (isHttpsDowngrade && !disableUiWarnings) { return ( - + {t("continueInsecureRedirectTitle")} @@ -102,14 +151,14 @@ export const ContinuePage = () => { - - @@ -120,17 +169,18 @@ export const ContinuePage = () => { return ( - {t("continueTitle")} - {t("continueSubtitle")} + + {t("continueRedirectingTitle")} + + {t("continueRedirectingSubtitle")} - - - + {showRedirectButton && ( + + + + )} ); }; diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 4828b383..e2efdd67 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -1,46 +1,59 @@ import { LoginForm } from "@/components/auth/login-form"; -import { GenericIcon } from "@/components/icons/generic"; import { GithubIcon } from "@/components/icons/github"; import { GoogleIcon } from "@/components/icons/google"; +import { MicrosoftIcon } from "@/components/icons/microsoft"; +import { OAuthIcon } from "@/components/icons/oauth"; +import { PocketIDIcon } from "@/components/icons/pocket-id"; +import { TailscaleIcon } from "@/components/icons/tailscale"; +import { Button } from "@/components/ui/button"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, + CardFooter, } from "@/components/ui/card"; import { OAuthButton } from "@/components/ui/oauth-button"; import { SeperatorWithChildren } from "@/components/ui/separator"; import { useAppContext } from "@/context/app-context"; import { useUserContext } from "@/context/user-context"; -import { useIsMounted } from "@/lib/hooks/use-is-mounted"; import { LoginSchema } from "@/schemas/login-schema"; import { useMutation } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; +const iconMap: Record = { + google: , + github: , + tailscale: , + microsoft: , + pocketid: , +}; + export const LoginPage = () => { const { isLoggedIn } = useUserContext(); - - if (isLoggedIn) { - return ; - } - - const { configuredProviders, title, oauthAutoRedirect, genericName } = useAppContext(); + const { providers, title, oauthAutoRedirect } = useAppContext(); const { search } = useLocation(); const { t } = useTranslation(); - const isMounted = useIsMounted(); + const [oauthAutoRedirectHandover, setOauthAutoRedirectHandover] = + useState(false); + const [showRedirectButton, setShowRedirectButton] = useState(false); + + const redirectTimer = useRef(null); + const redirectButtonTimer = useRef(null); const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); - const oauthConfigured = - configuredProviders.filter((provider) => provider !== "username").length > - 0; - const userAuthConfigured = configuredProviders.includes("username"); + const oauthProviders = providers.filter( + (provider) => provider.id !== "username", + ); + const userAuthConfigured = + providers.find((provider) => provider.id === "username") !== undefined; const oauthMutation = useMutation({ mutationFn: (provider: string) => @@ -53,11 +66,12 @@ export const LoginPage = () => { description: t("loginOauthSuccessSubtitle"), }); - setTimeout(() => { - window.location.href = data.data.url; + redirectTimer.current = window.setTimeout(() => { + window.location.replace(data.data.url); }, 500); }, onError: () => { + setOauthAutoRedirectHandover(false); toast.error(t("loginOauthFailTitle"), { description: t("loginOauthFailSubtitle"), }); @@ -65,7 +79,7 @@ export const LoginPage = () => { }); const loginMutation = useMutation({ - mutationFn: (values: LoginSchema) => axios.post("/api/login", values), + mutationFn: (values: LoginSchema) => axios.post("/api/user/login", values), mutationKey: ["login"], onSuccess: (data) => { if (data.data.totpPending) { @@ -79,7 +93,7 @@ export const LoginPage = () => { description: t("loginSuccessSubtitle"), }); - setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); @@ -96,63 +110,100 @@ export const LoginPage = () => { }); useEffect(() => { - if (isMounted()) { - if ( - oauthConfigured && - configuredProviders.includes(oauthAutoRedirect) && - redirectUri - ) { - oauthMutation.mutate(oauthAutoRedirect); - } + if ( + providers.find((provider) => provider.id === oauthAutoRedirect) && + !isLoggedIn && + redirectUri + ) { + // Not sure of a better way to do this + // eslint-disable-next-line react-hooks/set-state-in-effect + setOauthAutoRedirectHandover(true); + oauthMutation.mutate(oauthAutoRedirect); + redirectButtonTimer.current = window.setTimeout(() => { + setShowRedirectButton(true); + }, 5000); } }, []); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + if (redirectButtonTimer.current) + clearTimeout(redirectButtonTimer.current); + }, + [], + ); + + if (isLoggedIn && redirectUri) { + return ( + + ); + } + + if (isLoggedIn) { + return ; + } + + if (oauthAutoRedirectHandover) { + return ( + + + + {t("loginOauthAutoRedirectTitle")} + + + {t("loginOauthAutoRedirectSubtitle")} + + + {showRedirectButton && ( + + + + )} + + ); + } return ( {title} - {configuredProviders.length > 0 && ( + {providers.length > 0 && ( - {oauthConfigured ? t("loginTitle") : t("loginTitleSimple")} + {oauthProviders.length !== 0 + ? t("loginTitle") + : t("loginTitleSimple")} )} - {oauthConfigured && ( + {oauthProviders.length !== 0 && (
- {configuredProviders.includes("google") && ( - } - className="w-full" - onClick={() => oauthMutation.mutate("google")} - loading={oauthMutation.isPending && oauthMutation.variables === "google"} - disabled={oauthMutation.isPending || loginMutation.isPending} - /> - )} - {configuredProviders.includes("github") && ( - } - className="w-full" - onClick={() => oauthMutation.mutate("github")} - loading={oauthMutation.isPending && oauthMutation.variables === "github"} - disabled={oauthMutation.isPending || loginMutation.isPending} - /> - )} - {configuredProviders.includes("generic") && ( + {oauthProviders.map((provider) => ( } + key={provider.id} + title={provider.name} + icon={iconMap[provider.id] ?? } className="w-full" - onClick={() => oauthMutation.mutate("generic")} - loading={oauthMutation.isPending && oauthMutation.variables === "generic"} + onClick={() => oauthMutation.mutate(provider.id)} + loading={ + oauthMutation.isPending && + oauthMutation.variables === provider.id + } disabled={oauthMutation.isPending || loginMutation.isPending} /> - )} + ))}
)} - {userAuthConfigured && oauthConfigured && ( + {userAuthConfigured && oauthProviders.length !== 0 && ( {t("loginDivider")} )} {userAuthConfigured && ( @@ -161,7 +212,7 @@ export const LoginPage = () => { loading={loginMutation.isPending || oauthMutation.isPending} /> )} - {configuredProviders.length == 0 && ( + {providers.length == 0 && (

{t("failedToFetchProvidersTitle")}

diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index 8c285002..480d8ae5 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -6,35 +6,30 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { useAppContext } from "@/context/app-context"; import { useUserContext } from "@/context/user-context"; -import { capitalize } from "@/lib/utils"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; +import { useEffect, useRef } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Navigate } from "react-router"; import { toast } from "sonner"; export const LogoutPage = () => { - const { provider, username, isLoggedIn, email } = useUserContext(); - - if (!isLoggedIn) { - return ; - } - - const { genericName } = useAppContext(); + const { provider, username, isLoggedIn, email, oauthName } = useUserContext(); const { t } = useTranslation(); + const redirectTimer = useRef(null); + const logoutMutation = useMutation({ - mutationFn: () => axios.post("/api/logout"), + mutationFn: () => axios.post("/api/user/logout"), mutationKey: ["logout"], onSuccess: () => { toast.success(t("logoutSuccessTitle"), { description: t("logoutSuccessSubtitle"), }); - setTimeout(async () => { - window.location.replace("/login"); + redirectTimer.current = window.setTimeout(() => { + window.location.assign("/login"); }, 500); }, onError: () => { @@ -44,6 +39,17 @@ export const LogoutPage = () => { }, }); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + + if (!isLoggedIn) { + return ; + } + return ( @@ -58,8 +64,7 @@ export const LogoutPage = () => { }} values={{ username: email, - provider: - provider === "generic" ? genericName : capitalize(provider), + provider: oauthName, }} /> ) : ( diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index e04fb2f4..ef055652 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -12,34 +12,31 @@ import { useUserContext } from "@/context/user-context"; import { TotpSchema } from "@/schemas/totp-schema"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import { useId } from "react"; +import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; export const TotpPage = () => { const { totpPending } = useUserContext(); - - if (!totpPending) { - return ; - } - const { t } = useTranslation(); const { search } = useLocation(); const formId = useId(); + const redirectTimer = useRef(null); + const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); const totpMutation = useMutation({ - mutationFn: (values: TotpSchema) => axios.post("/api/totp", values), + mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values), mutationKey: ["totp"], onSuccess: () => { toast.success(t("totpSuccessTitle"), { description: t("totpSuccessSubtitle"), }); - setTimeout(() => { + redirectTimer.current = window.setTimeout(() => { window.location.replace( `/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`, ); @@ -52,6 +49,17 @@ export const TotpPage = () => { }, }); + useEffect( + () => () => { + if (redirectTimer.current) clearTimeout(redirectTimer.current); + }, + [], + ); + + if (!totpPending) { + return ; + } + return ( diff --git a/frontend/src/pages/unauthorized-page.tsx b/frontend/src/pages/unauthorized-page.tsx index e0bd6cac..007e01c5 100644 --- a/frontend/src/pages/unauthorized-page.tsx +++ b/frontend/src/pages/unauthorized-page.tsx @@ -12,6 +12,10 @@ import { Navigate, useLocation, useNavigate } from "react-router"; export const UnauthorizedPage = () => { const { search } = useLocation(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [loading, setLoading] = useState(false); const searchParams = new URLSearchParams(search); const username = searchParams.get("username"); @@ -19,19 +23,15 @@ export const UnauthorizedPage = () => { const groupErr = searchParams.get("groupErr"); const ip = searchParams.get("ip"); - if (!username && !ip) { - return ; - } - - const { t } = useTranslation(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const handleRedirect = () => { setLoading(true); navigate("/login"); }; + if (!username && !ip) { + return ; + } + let i18nKey = "unauthorizedLoginSubtitle"; if (resource) { diff --git a/frontend/src/schemas/app-context-schema.ts b/frontend/src/schemas/app-context-schema.ts index 31ded496..6935ca4d 100644 --- a/frontend/src/schemas/app-context-schema.ts +++ b/frontend/src/schemas/app-context-schema.ts @@ -1,14 +1,20 @@ import { z } from "zod"; +export const providerSchema = z.object({ + id: z.string(), + name: z.string(), + oauth: z.boolean(), +}); + export const appContextSchema = z.object({ - configuredProviders: z.array(z.string()), - disableContinue: z.boolean(), + providers: z.array(providerSchema), title: z.string(), - genericName: z.string(), - domain: z.string(), + appUrl: z.string(), + cookieDomain: z.string(), forgotPasswordMessage: z.string(), - oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]), backgroundImage: z.string(), + oauthAutoRedirect: z.string(), + disableUiWarnings: z.boolean(), }); export type AppContextSchema = z.infer; diff --git a/frontend/src/schemas/user-context-schema.ts b/frontend/src/schemas/user-context-schema.ts index ee6682cf..e7e057ac 100644 --- a/frontend/src/schemas/user-context-schema.ts +++ b/frontend/src/schemas/user-context-schema.ts @@ -8,6 +8,7 @@ export const userContextSchema = z.object({ provider: z.string(), oauth: z.boolean(), totpPending: z.boolean(), + oauthName: z.string(), }); export type UserContextSchema = z.infer; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 07e6e7e6..f391a49d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,6 +19,11 @@ export default defineConfig({ changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ""), }, + "/resources": { + target: "http://tinyauth-backend:3000/resources", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/resources/, ""), + }, }, allowedHosts: true, }, diff --git a/go.mod b/go.mod index 0a6f8852..fac18abc 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,28 @@ module tinyauth -go 1.23.2 +go 1.24.0 + +toolchain go1.24.3 require ( github.com/cenkalti/backoff/v5 v5.0.3 - github.com/gin-gonic/gin v1.10.1 - github.com/go-playground/validator/v10 v10.27.0 + github.com/gin-gonic/gin v1.11.0 + github.com/glebarez/sqlite v1.11.0 + github.com/go-playground/validator/v10 v10.28.0 + github.com/golang-migrate/migrate/v4 v4.19.0 github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 github.com/mdp/qrterminal/v3 v3.2.1 github.com/rs/zerolog v1.34.0 - github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.20.1 + github.com/spf13/cobra v1.10.1 + github.com/spf13/viper v1.21.0 + github.com/stoewer/go-strcase v1.3.1 github.com/traefik/paerser v0.2.2 - golang.org/x/crypto v0.41.0 + github.com/weppos/publicsuffix-go v0.50.1 + golang.org/x/crypto v0.45.0 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b + gorm.io/gorm v1.31.1 + gotest.tools/v3 v3.5.2 ) require ( @@ -23,61 +32,75 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect - github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect - github.com/morikuni/aec v1.0.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect go.opentelemetry.io/otel/sdk v1.34.0 // indirect - golang.org/x/term v0.34.0 // indirect - gotest.tools/v3 v3.5.2 // indirect + go.uber.org/mock v0.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/tools v0.38.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.2 // indirect rsc.io/qr v0.2.0 // indirect ) require ( - github.com/Microsoft/go-winio v0.4.14 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/boombuler/barcode v1.0.2 // indirect - github.com/bytedance/sonic v1.12.7 // indirect - github.com/bytedance/sonic/loader v0.2.3 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect - github.com/charmbracelet/bubbletea v1.3.4 // indirect - github.com/charmbracelet/huh v0.7.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.3.3+incompatible + github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/gin-contrib/sse v1.0.0 // indirect - github.com/go-ldap/ldap/v3 v3.4.11 - github.com/go-logr/logr v1.4.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-ldap/ldap/v3 v3.4.12 + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.4 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/sessions v1.4.0 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.10 github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -91,30 +114,27 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pquerna/otp v1.5.0 github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.12.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/arch v0.13.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - google.golang.org/protobuf v1.36.3 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/go.sum b/go.sum index dabff47e..0c72e5b5 100644 --- a/go.sum +++ b/go.sum @@ -4,42 +4,41 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU= -github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= -github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q= -github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOEYuemj13I= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= -github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= -github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= @@ -56,9 +55,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -74,8 +72,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -88,21 +86,25 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= -github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= -github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -111,31 +113,34 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= +github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -152,25 +157,24 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -182,6 +186,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -207,19 +213,26 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= +github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -229,139 +242,141 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= -github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ= github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/weppos/publicsuffix-go v0.50.1 h1:elrBHeSkS/eIb169+DnLrknqmdP4AjT0Q0tEdytz1Og= +github.com/weppos/publicsuffix-go v0.50.1/go.mod h1:znn0JVXjcR5hpUl9pbEogwH6I710rA1AX0QQPT0bf+k= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA= -golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/internal/assets/assets.go b/internal/assets/assets.go index 69188674..412403c9 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -4,7 +4,12 @@ import ( "embed" ) -// UI assets +// Frontend // //go:embed dist -var Assets embed.FS +var FrontendAssets embed.FS + +// Migrations +// +//go:embed migrations/*.sql +var Migrations embed.FS diff --git a/internal/assets/migrations/000001_init_sqlite.down.sql b/internal/assets/migrations/000001_init_sqlite.down.sql new file mode 100644 index 00000000..9a8955bd --- /dev/null +++ b/internal/assets/migrations/000001_init_sqlite.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "sessions"; \ No newline at end of file diff --git a/internal/assets/migrations/000001_init_sqlite.up.sql b/internal/assets/migrations/000001_init_sqlite.up.sql new file mode 100644 index 00000000..4ffa992d --- /dev/null +++ b/internal/assets/migrations/000001_init_sqlite.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS "sessions" ( + "uuid" TEXT NOT NULL PRIMARY KEY UNIQUE, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "totp_pending" BOOLEAN NOT NULL, + "oauth_groups" TEXT NULL, + "expiry" INTEGER NOT NULL +); \ No newline at end of file diff --git a/internal/assets/migrations/000002_oauth_name.down.sql b/internal/assets/migrations/000002_oauth_name.down.sql new file mode 100644 index 00000000..75ce3b06 --- /dev/null +++ b/internal/assets/migrations/000002_oauth_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" DROP COLUMN "oauth_name"; \ No newline at end of file diff --git a/internal/assets/migrations/000002_oauth_name.up.sql b/internal/assets/migrations/000002_oauth_name.up.sql new file mode 100644 index 00000000..416bd295 --- /dev/null +++ b/internal/assets/migrations/000002_oauth_name.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE "sessions" ADD COLUMN "oauth_name" TEXT; + +UPDATE "sessions" +SET "oauth_name" = CASE + WHEN LOWER("provider") = 'github' THEN 'GitHub' + WHEN LOWER("provider") = 'google' THEN 'Google' + ELSE UPPER(SUBSTR("provider", 1, 1)) || SUBSTR("provider", 2) +END +WHERE "oauth_name" IS NULL AND "provider" IS NOT NULL; + diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9bb89126..e69de29b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,473 +0,0 @@ -package auth - -import ( - "fmt" - "regexp" - "strings" - "sync" - "time" - "tinyauth/internal/docker" - "tinyauth/internal/ldap" - "tinyauth/internal/types" - "tinyauth/internal/utils" - - "encoding/base64" - - "github.com/gin-gonic/gin" - "github.com/gorilla/sessions" - "github.com/rs/zerolog/log" - "golang.org/x/crypto/bcrypt" -) - -type Auth struct { - Config types.AuthConfig - Docker *docker.Docker - LoginAttempts map[string]*types.LoginAttempt - LoginMutex sync.RWMutex - Store *sessions.CookieStore - LDAP *ldap.LDAP -} - -func NewAuth(config types.AuthConfig, docker *docker.Docker, ldap *ldap.LDAP) *Auth { - // Setup cookie store and create the auth service - store := sessions.NewCookieStore([]byte(config.HMACSecret), []byte(config.EncryptionSecret)) - store.Options = &sessions.Options{ - Path: "/", - MaxAge: config.SessionExpiry, - Secure: config.CookieSecure, - HttpOnly: true, - Domain: fmt.Sprintf(".%s", config.Domain), - } - return &Auth{ - Config: config, - Docker: docker, - LoginAttempts: make(map[string]*types.LoginAttempt), - Store: store, - LDAP: ldap, - } -} - -func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) { - session, err := auth.Store.Get(c.Request, auth.Config.SessionCookieName) - - // If there was an error getting the session, it might be invalid so let's clear it and retry - if err != nil { - log.Error().Err(err).Msg("Invalid session, clearing cookie and retrying") - c.SetCookie(auth.Config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.Config.Domain), auth.Config.CookieSecure, true) - session, err = auth.Store.Get(c.Request, auth.Config.SessionCookieName) - if err != nil { - log.Error().Err(err).Msg("Failed to get session") - return nil, err - } - } - - return session, nil -} - -func (auth *Auth) SearchUser(username string) types.UserSearch { - log.Debug().Str("username", username).Msg("Searching for user") - - // Check local users first - if auth.GetLocalUser(username).Username != "" { - log.Debug().Str("username", username).Msg("Found local user") - return types.UserSearch{ - Username: username, - Type: "local", - } - } - - // If no user found, check LDAP - if auth.LDAP != nil { - log.Debug().Str("username", username).Msg("Checking LDAP for user") - userDN, err := auth.LDAP.Search(username) - if err != nil { - log.Warn().Err(err).Str("username", username).Msg("Failed to find user in LDAP") - return types.UserSearch{} - } - return types.UserSearch{ - Username: userDN, - Type: "ldap", - } - } - - return types.UserSearch{ - Type: "unknown", - } -} - -func (auth *Auth) VerifyUser(search types.UserSearch, password string) bool { - // Authenticate the user based on the type - switch search.Type { - case "local": - // If local user, get the user and check the password - user := auth.GetLocalUser(search.Username) - return auth.CheckPassword(user, password) - case "ldap": - // If LDAP is configured, bind to the LDAP server with the user DN and password - if auth.LDAP != nil { - log.Debug().Str("username", search.Username).Msg("Binding to LDAP for user authentication") - - err := auth.LDAP.Bind(search.Username, password) - if err != nil { - log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP") - return false - } - - // Rebind with the service account to reset the connection - err = auth.LDAP.Bind(auth.LDAP.Config.BindDN, auth.LDAP.Config.BindPassword) - if err != nil { - log.Error().Err(err).Msg("Failed to rebind with service account after user authentication") - return false - } - - log.Debug().Str("username", search.Username).Msg("LDAP authentication successful") - return true - } - default: - log.Warn().Str("type", search.Type).Msg("Unknown user type for authentication") - return false - } - - // If no user found or authentication failed, return false - log.Warn().Str("username", search.Username).Msg("User authentication failed") - return false -} - -func (auth *Auth) GetLocalUser(username string) types.User { - // Loop through users and return the user if the username matches - log.Debug().Str("username", username).Msg("Searching for local user") - - for _, user := range auth.Config.Users { - if user.Username == username { - return user - } - } - - // If no user found, return an empty user - log.Warn().Str("username", username).Msg("Local user not found") - return types.User{} -} - -func (auth *Auth) CheckPassword(user types.User, password string) bool { - return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil -} - -func (auth *Auth) IsAccountLocked(identifier string) (bool, int) { - auth.LoginMutex.RLock() - defer auth.LoginMutex.RUnlock() - - // Return false if rate limiting is not configured - if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 { - return false, 0 - } - - // Check if the identifier exists in the map - attempt, exists := auth.LoginAttempts[identifier] - if !exists { - return false, 0 - } - - // If account is locked, check if lock time has expired - if attempt.LockedUntil.After(time.Now()) { - // Calculate remaining lockout time in seconds - remaining := int(time.Until(attempt.LockedUntil).Seconds()) - return true, remaining - } - - // Lock has expired - return false, 0 -} - -func (auth *Auth) RecordLoginAttempt(identifier string, success bool) { - // Skip if rate limiting is not configured - if auth.Config.LoginMaxRetries <= 0 || auth.Config.LoginTimeout <= 0 { - return - } - - auth.LoginMutex.Lock() - defer auth.LoginMutex.Unlock() - - // Get current attempt record or create a new one - attempt, exists := auth.LoginAttempts[identifier] - if !exists { - attempt = &types.LoginAttempt{} - auth.LoginAttempts[identifier] = attempt - } - - // Update last attempt time - attempt.LastAttempt = time.Now() - - // If successful login, reset failed attempts - if success { - attempt.FailedAttempts = 0 - attempt.LockedUntil = time.Time{} // Reset lock time - return - } - - // Increment failed attempts - attempt.FailedAttempts++ - - // If max retries reached, lock the account - if attempt.FailedAttempts >= auth.Config.LoginMaxRetries { - attempt.LockedUntil = time.Now().Add(time.Duration(auth.Config.LoginTimeout) * time.Second) - log.Warn().Str("identifier", identifier).Int("timeout", auth.Config.LoginTimeout).Msg("Account locked due to too many failed login attempts") - } -} - -func (auth *Auth) EmailWhitelisted(email string) bool { - return utils.CheckFilter(auth.Config.OauthWhitelist, email) -} - -func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie) error { - log.Debug().Msg("Creating session cookie") - - session, err := auth.GetSession(c) - if err != nil { - log.Error().Err(err).Msg("Failed to get session") - return err - } - - log.Debug().Msg("Setting session cookie") - - var sessionExpiry int - - if data.TotpPending { - sessionExpiry = 3600 - } else { - sessionExpiry = auth.Config.SessionExpiry - } - - session.Values["username"] = data.Username - session.Values["name"] = data.Name - session.Values["email"] = data.Email - session.Values["provider"] = data.Provider - session.Values["expiry"] = time.Now().Add(time.Duration(sessionExpiry) * time.Second).Unix() - session.Values["totpPending"] = data.TotpPending - session.Values["oauthGroups"] = data.OAuthGroups - - err = session.Save(c.Request, c.Writer) - if err != nil { - log.Error().Err(err).Msg("Failed to save session") - return err - } - - return nil -} - -func (auth *Auth) DeleteSessionCookie(c *gin.Context) error { - log.Debug().Msg("Deleting session cookie") - - session, err := auth.GetSession(c) - if err != nil { - log.Error().Err(err).Msg("Failed to get session") - return err - } - - // Delete all values in the session - for key := range session.Values { - delete(session.Values, key) - } - - err = session.Save(c.Request, c.Writer) - if err != nil { - log.Error().Err(err).Msg("Failed to save session") - return err - } - - return nil -} - -func (auth *Auth) GetSessionCookie(c *gin.Context) (types.SessionCookie, error) { - log.Debug().Msg("Getting session cookie") - - session, err := auth.GetSession(c) - if err != nil { - log.Error().Err(err).Msg("Failed to get session") - return types.SessionCookie{}, err - } - - log.Debug().Msg("Got session") - - username, usernameOk := session.Values["username"].(string) - email, emailOk := session.Values["email"].(string) - name, nameOk := session.Values["name"].(string) - provider, providerOK := session.Values["provider"].(string) - expiry, expiryOk := session.Values["expiry"].(int64) - totpPending, totpPendingOk := session.Values["totpPending"].(bool) - oauthGroups, oauthGroupsOk := session.Values["oauthGroups"].(string) - - // If any data is missing, delete the session cookie - if !usernameOk || !providerOK || !expiryOk || !totpPendingOk || !emailOk || !nameOk || !oauthGroupsOk { - log.Warn().Msg("Session cookie is invalid") - auth.DeleteSessionCookie(c) - return types.SessionCookie{}, nil - } - - // If the session cookie has expired, delete it - if time.Now().Unix() > expiry { - log.Warn().Msg("Session cookie expired") - auth.DeleteSessionCookie(c) - return types.SessionCookie{}, nil - } - - log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Str("name", name).Str("email", email).Str("oauthGroups", oauthGroups).Msg("Parsed cookie") - return types.SessionCookie{ - Username: username, - Name: name, - Email: email, - Provider: provider, - TotpPending: totpPending, - OAuthGroups: oauthGroups, - }, nil -} - -func (auth *Auth) UserAuthConfigured() bool { - // If there are users or LDAP is configured, return true - return len(auth.Config.Users) > 0 || auth.LDAP != nil -} - -func (auth *Auth) ResourceAllowed(c *gin.Context, context types.UserContext, labels types.Labels) bool { - if context.OAuth { - log.Debug().Msg("Checking OAuth whitelist") - return utils.CheckFilter(labels.OAuth.Whitelist, context.Email) - } - - log.Debug().Msg("Checking users") - return utils.CheckFilter(labels.Users, context.Username) -} - -func (auth *Auth) OAuthGroup(c *gin.Context, context types.UserContext, labels types.Labels) bool { - if labels.OAuth.Groups == "" { - return true - } - - // Check if we are using the generic oauth provider - if context.Provider != "generic" { - log.Debug().Msg("Not using generic provider, skipping group check") - return true - } - - // Split the groups by comma (no need to parse since they are from the API response) - oauthGroups := strings.Split(context.OAuthGroups, ",") - - // For every group check if it is in the required groups - for _, group := range oauthGroups { - if utils.CheckFilter(labels.OAuth.Groups, group) { - log.Debug().Str("group", group).Msg("Group is in required groups") - return true - } - } - - // No groups matched - log.Debug().Msg("No groups matched") - return false -} - -func (auth *Auth) AuthEnabled(uri string, labels types.Labels) (bool, error) { - // If the label is empty, auth is enabled - if labels.Allowed == "" { - return true, nil - } - - // Compile regex - regex, err := regexp.Compile(labels.Allowed) - - // If there is an error, invalid regex, auth enabled - if err != nil { - log.Error().Err(err).Msg("Invalid regex") - return true, err - } - - // If the regex matches the URI, auth is not enabled - if regex.MatchString(uri) { - return false, nil - } - - // Auth enabled - return true, nil -} - -func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User { - // Get the X-TinyAuth-Authorization header - authHeader := c.Request.Header.Get("X-Api-Key") - if authHeader == "" { - return nil - } - - // Parse Basic Auth from header - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Basic" { - return nil - } - - // Decode base64 - payload, err := base64.StdEncoding.DecodeString(parts[1]) - if err != nil { - return nil - } - - // Split username and password - pair := strings.SplitN(string(payload), ":", 2) - if len(pair) != 2 { - return nil - } - return &types.User{ - Username: pair[0], - Password: pair[1], - } -} - -func (auth *Auth) CheckIP(labels types.Labels, ip string) bool { - // Check if the IP is in block list - for _, blocked := range labels.IP.Block { - res, err := utils.FilterIP(blocked, ip) - if err != nil { - log.Error().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") - continue - } - if res { - log.Warn().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access") - return false - } - } - - // For every IP in the allow list, check if the IP matches - for _, allowed := range labels.IP.Allow { - res, err := utils.FilterIP(allowed, ip) - if err != nil { - log.Error().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") - continue - } - if res { - log.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access") - return true - } - } - - // If not in allowed range and allowed range is not empty, deny access - if len(labels.IP.Allow) > 0 { - log.Warn().Str("ip", ip).Msg("IP not in allow list, denying access") - return false - } - - log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default") - return true -} - -func (auth *Auth) BypassedIP(labels types.Labels, ip string) bool { - // For every IP in the bypass list, check if the IP matches - for _, bypassed := range labels.IP.Bypass { - res, err := utils.FilterIP(bypassed, ip) - if err != nil { - log.Error().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") - continue - } - if res { - log.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access") - return true - } - } - - log.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication") - return false -} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go deleted file mode 100644 index 1ab73294..00000000 --- a/internal/auth/auth_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package auth_test - -import ( - "testing" - "time" - "tinyauth/internal/auth" - "tinyauth/internal/types" -) - -var config = types.AuthConfig{ - Users: types.Users{}, - OauthWhitelist: "", - SessionExpiry: 3600, -} - -func TestLoginRateLimiting(t *testing.T) { - // Initialize a new auth service with 3 max retries and 5 seconds timeout - config.LoginMaxRetries = 3 - config.LoginTimeout = 5 - authService := auth.NewAuth(config, nil, nil) - - // Test identifier - identifier := "test_user" - - // Test successful login - should not lock account - t.Log("Testing successful login") - - authService.RecordLoginAttempt(identifier, true) - locked, _ := authService.IsAccountLocked(identifier) - - if locked { - t.Fatalf("Account should not be locked after successful login") - } - - // Test 2 failed attempts - should not lock account yet - t.Log("Testing 2 failed login attempts") - - authService.RecordLoginAttempt(identifier, false) - authService.RecordLoginAttempt(identifier, false) - locked, _ = authService.IsAccountLocked(identifier) - - if locked { - t.Fatalf("Account should not be locked after only 2 failed attempts") - } - - // Add one more failed attempt (total 3) - should lock account with maxRetries=3 - t.Log("Testing 3 failed login attempts") - authService.RecordLoginAttempt(identifier, false) - locked, remainingTime := authService.IsAccountLocked(identifier) - - if !locked { - t.Fatalf("Account should be locked after reaching max retries") - } - if remainingTime <= 0 || remainingTime > 5 { - t.Fatalf("Expected remaining time between 1-5 seconds, got %d", remainingTime) - } - - // Test reset after waiting for timeout - use 1 second timeout for fast testing - t.Log("Testing unlocking after timeout") - - // Reinitialize auth service with a shorter timeout for testing - config.LoginTimeout = 1 - config.LoginMaxRetries = 3 - authService = auth.NewAuth(config, nil, nil) - - // Add enough failed attempts to lock the account - for i := 0; i < 3; i++ { - authService.RecordLoginAttempt(identifier, false) - } - - // Verify it's locked - locked, _ = authService.IsAccountLocked(identifier) - if !locked { - t.Fatalf("Account should be locked initially") - } - - // Wait a bit and verify it gets unlocked after timeout - time.Sleep(1500 * time.Millisecond) // Wait longer than the timeout - locked, _ = authService.IsAccountLocked(identifier) - - if locked { - t.Fatalf("Account should be unlocked after timeout period") - } - - // Test disabled rate limiting - t.Log("Testing disabled rate limiting") - config.LoginMaxRetries = 0 - config.LoginTimeout = 0 - authService = auth.NewAuth(config, nil, nil) - - for i := 0; i < 10; i++ { - authService.RecordLoginAttempt(identifier, false) - } - - locked, _ = authService.IsAccountLocked(identifier) - if locked { - t.Fatalf("Account should not be locked when rate limiting is disabled") - } -} - -func TestConcurrentLoginAttempts(t *testing.T) { - // Initialize a new auth service with 2 max retries and 5 seconds timeout - config.LoginMaxRetries = 2 - config.LoginTimeout = 5 - authService := auth.NewAuth(config, nil, nil) - - // Test multiple identifiers - identifiers := []string{"user1", "user2", "user3"} - - // Test that locking one identifier doesn't affect others - t.Log("Testing multiple identifiers") - - // Add enough failed attempts to lock first user (2 attempts with maxRetries=2) - authService.RecordLoginAttempt(identifiers[0], false) - authService.RecordLoginAttempt(identifiers[0], false) - - // Check if first user is locked - locked, _ := authService.IsAccountLocked(identifiers[0]) - if !locked { - t.Fatalf("User1 should be locked after reaching max retries") - } - - // Check that other users are not affected - for i := 1; i < len(identifiers); i++ { - locked, _ := authService.IsAccountLocked(identifiers[i]) - if locked { - t.Fatalf("User%d should not be locked", i+1) - } - } - - // Test successful login after failed attempts (but before lock) - t.Log("Testing successful login after failed attempts but before lock") - - // One failed attempt for user2 - authService.RecordLoginAttempt(identifiers[1], false) - - // Successful login should reset the counter - authService.RecordLoginAttempt(identifiers[1], true) - - // Now try a failed login again - should not be locked as counter was reset - authService.RecordLoginAttempt(identifiers[1], false) - locked, _ = authService.IsAccountLocked(identifiers[1]) - if locked { - t.Fatalf("User2 should not be locked after successful login reset") - } -} diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go new file mode 100644 index 00000000..01b60f6d --- /dev/null +++ b/internal/bootstrap/app_bootstrap.go @@ -0,0 +1,384 @@ +package bootstrap + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" + "tinyauth/internal/config" + "tinyauth/internal/controller" + "tinyauth/internal/middleware" + "tinyauth/internal/model" + "tinyauth/internal/service" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +type Controller interface { + SetupRoutes() +} + +type Middleware interface { + Middleware() gin.HandlerFunc + Init() error +} + +type Service interface { + Init() error +} + +type BootstrapApp struct { + config config.Config + uuid string +} + +func NewBootstrapApp(config config.Config) *BootstrapApp { + return &BootstrapApp{ + config: config, + } +} + +func (app *BootstrapApp) Setup() error { + // Parse users + users, err := utils.GetUsers(app.config.Users, app.config.UsersFile) + + if err != nil { + return err + } + + // Get OAuth configs + oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.config.AppURL) + + if err != nil { + return err + } + + // Get cookie domain + cookieDomain, err := utils.GetCookieDomain(app.config.AppURL) + + if err != nil { + return err + } + + // Cookie names + appUrl, _ := url.Parse(app.config.AppURL) // Already validated + uuid := utils.GenerateUUID(appUrl.Hostname()) + app.uuid = uuid + cookieId := strings.Split(uuid, "-")[0] + sessionCookieName := fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId) + csrfCookieName := fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId) + redirectCookieName := fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId) + + // Dumps + log.Trace().Interface("config", app.config).Msg("Config dump") + log.Trace().Interface("users", users).Msg("Users dump") + log.Trace().Interface("oauthProviders", oauthProviders).Msg("OAuth providers dump") + log.Trace().Str("cookieDomain", cookieDomain).Msg("Cookie domain") + log.Trace().Str("sessionCookieName", sessionCookieName).Msg("Session cookie name") + log.Trace().Str("csrfCookieName", csrfCookieName).Msg("CSRF cookie name") + log.Trace().Str("redirectCookieName", redirectCookieName).Msg("Redirect cookie name") + + // Create configs + authConfig := service.AuthServiceConfig{ + Users: users, + OauthWhitelist: app.config.OAuthWhitelist, + SessionExpiry: app.config.SessionExpiry, + SecureCookie: app.config.SecureCookie, + CookieDomain: cookieDomain, + LoginTimeout: app.config.LoginTimeout, + LoginMaxRetries: app.config.LoginMaxRetries, + SessionCookieName: sessionCookieName, + } + + // Setup services + var ldapService *service.LdapService + + if app.config.LdapAddress != "" { + ldapConfig := service.LdapServiceConfig{ + Address: app.config.LdapAddress, + BindDN: app.config.LdapBindDN, + BindPassword: app.config.LdapBindPassword, + BaseDN: app.config.LdapBaseDN, + Insecure: app.config.LdapInsecure, + SearchFilter: app.config.LdapSearchFilter, + } + + ldapService = service.NewLdapService(ldapConfig) + + err := ldapService.Init() + + if err != nil { + log.Warn().Err(err).Msg("Failed to initialize LDAP service, continuing without LDAP") + ldapService = nil + } + } + + // Bootstrap database + databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{ + DatabasePath: app.config.DatabasePath, + }) + + log.Debug().Str("service", fmt.Sprintf("%T", databaseService)).Msg("Initializing service") + + err = databaseService.Init() + + if err != nil { + return fmt.Errorf("failed to initialize database service: %w", err) + } + + database := databaseService.GetDatabase() + + // Create services + dockerService := service.NewDockerService() + aclsService := service.NewAccessControlsService(dockerService) + authService := service.NewAuthService(authConfig, dockerService, ldapService, database) + oauthBrokerService := service.NewOAuthBrokerService(oauthProviders) + + // Initialize services (order matters) + services := []Service{ + dockerService, + aclsService, + authService, + oauthBrokerService, + } + + for _, svc := range services { + if svc != nil { + log.Debug().Str("service", fmt.Sprintf("%T", svc)).Msg("Initializing service") + err := svc.Init() + if err != nil { + return err + } + } + } + + // Configured providers + configuredProviders := make([]controller.Provider, 0) + + for id, provider := range oauthProviders { + configuredProviders = append(configuredProviders, controller.Provider{ + Name: provider.Name, + ID: id, + OAuth: true, + }) + } + + sort.Slice(configuredProviders, func(i, j int) bool { + return configuredProviders[i].Name < configuredProviders[j].Name + }) + + if authService.UserAuthConfigured() || ldapService != nil { + configuredProviders = append(configuredProviders, controller.Provider{ + Name: "Username", + ID: "username", + OAuth: false, + }) + } + + log.Debug().Interface("providers", configuredProviders).Msg("Authentication providers") + + if len(configuredProviders) == 0 { + return fmt.Errorf("no authentication providers configured") + } + + // Create engine + engine := gin.New() + engine.Use(gin.Recovery()) + + if len(app.config.TrustedProxies) > 0 { + err := engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ",")) + + if err != nil { + return fmt.Errorf("failed to set trusted proxies: %w", err) + } + } + + // Create middlewares + var middlewares []Middleware + + contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{ + CookieDomain: cookieDomain, + }, authService, oauthBrokerService) + + uiMiddleware := middleware.NewUIMiddleware() + zerologMiddleware := middleware.NewZerologMiddleware() + + middlewares = append(middlewares, contextMiddleware, uiMiddleware, zerologMiddleware) + + for _, middleware := range middlewares { + log.Debug().Str("middleware", fmt.Sprintf("%T", middleware)).Msg("Initializing middleware") + err := middleware.Init() + if err != nil { + return fmt.Errorf("failed to initialize middleware %T: %w", middleware, err) + } + engine.Use(middleware.Middleware()) + } + + // Create routers + mainRouter := engine.Group("") + apiRouter := engine.Group("/api") + + // Create controllers + contextController := controller.NewContextController(controller.ContextControllerConfig{ + Providers: configuredProviders, + Title: app.config.Title, + AppURL: app.config.AppURL, + CookieDomain: cookieDomain, + ForgotPasswordMessage: app.config.ForgotPasswordMessage, + BackgroundImage: app.config.BackgroundImage, + OAuthAutoRedirect: app.config.OAuthAutoRedirect, + DisableUIWarnings: app.config.DisableUIWarnings, + }, apiRouter) + + oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{ + AppURL: app.config.AppURL, + SecureCookie: app.config.SecureCookie, + CSRFCookieName: csrfCookieName, + RedirectCookieName: redirectCookieName, + CookieDomain: cookieDomain, + }, apiRouter, authService, oauthBrokerService) + + proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ + AppURL: app.config.AppURL, + }, apiRouter, aclsService, authService) + + userController := controller.NewUserController(controller.UserControllerConfig{ + CookieDomain: cookieDomain, + }, apiRouter, authService) + + resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{ + ResourcesDir: app.config.ResourcesDir, + ResourcesDisabled: app.config.DisableResources, + }, mainRouter) + + healthController := controller.NewHealthController(apiRouter) + + // Setup routes + controller := []Controller{ + contextController, + oauthController, + proxyController, + userController, + healthController, + resourcesController, + } + + for _, ctrl := range controller { + log.Debug().Msgf("Setting up %T controller", ctrl) + ctrl.SetupRoutes() + } + + // If analytics are not disabled, start heartbeat + if !app.config.DisableAnalytics { + log.Debug().Msg("Starting heartbeat routine") + go app.heartbeat() + } + + // Start DB cleanup routine + log.Debug().Msg("Starting database cleanup routine") + go app.dbCleanup(database) + + // If we have an socket path, bind to it + if app.config.SocketPath != "" { + // Remove existing socket file + if _, err := os.Stat(app.config.SocketPath); err == nil { + log.Info().Msgf("Removing existing socket file %s", app.config.SocketPath) + err := os.Remove(app.config.SocketPath) + if err != nil { + return fmt.Errorf("failed to remove existing socket file: %w", err) + } + } + + // Start server with unix socket + log.Info().Msgf("Starting server on unix socket %s", app.config.SocketPath) + if err := engine.RunUnix(app.config.SocketPath); err != nil { + log.Fatal().Err(err).Msg("Failed to start server") + } + + return nil + } + + // Start server + address := fmt.Sprintf("%s:%d", app.config.Address, app.config.Port) + log.Info().Msgf("Starting server on %s", address) + if err := engine.Run(address); err != nil { + log.Fatal().Err(err).Msg("Failed to start server") + } + + return nil +} + +func (app *BootstrapApp) heartbeat() { + ticker := time.NewTicker(time.Duration(12) * time.Hour) + defer ticker.Stop() + + type heartbeat struct { + UUID string `json:"uuid"` + Version string `json:"version"` + } + + var body heartbeat + + body.UUID = app.uuid + body.Version = config.Version + + bodyJson, err := json.Marshal(body) + + if err != nil { + log.Error().Err(err).Msg("Failed to marshal heartbeat body") + return + } + + client := &http.Client{} + + heartbeatURL := config.ApiServer + "/v1/instances/heartbeat" + + for ; true; <-ticker.C { + log.Debug().Msg("Sending heartbeat") + + req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson)) + + if err != nil { + log.Error().Err(err).Msg("Failed to create heartbeat request") + continue + } + + req.Header.Add("Content-Type", "application/json") + + res, err := client.Do(req) + + if err != nil { + log.Error().Err(err).Msg("Failed to send heartbeat") + continue + } + + res.Body.Close() + + if res.StatusCode != 200 && res.StatusCode != 201 { + log.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status") + } + } +} + +func (app *BootstrapApp) dbCleanup(db *gorm.DB) { + ticker := time.NewTicker(time.Duration(30) * time.Minute) + defer ticker.Stop() + ctx := context.Background() + + for ; true; <-ticker.C { + log.Debug().Msg("Cleaning up old database sessions") + _, err := gorm.G[model.Session](db).Where("expiry < ?", time.Now().Unix()).Delete(ctx) + if err != nil { + log.Error().Err(err).Msg("Failed to cleanup old sessions") + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..f13d97e4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,184 @@ +package config + +// Version information, set at build time + +var Version = "development" +var CommitHash = "development" +var BuildTimestamp = "0000-00-00T00:00:00Z" + +// Cookie name templates + +var SessionCookieName = "tinyauth-session" +var CSRFCookieName = "tinyauth-csrf" +var RedirectCookieName = "tinyauth-redirect" + +// Main app config + +type Config struct { + Port int `mapstructure:"port" validate:"required"` + Address string `validate:"required,ip4_addr" mapstructure:"address"` + AppURL string `validate:"required,url" mapstructure:"app-url"` + Users string `mapstructure:"users"` + UsersFile string `mapstructure:"users-file"` + SecureCookie bool `mapstructure:"secure-cookie"` + OAuthWhitelist string `mapstructure:"oauth-whitelist"` + OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect"` + SessionExpiry int `mapstructure:"session-expiry"` + LogLevel string `mapstructure:"log-level" validate:"oneof=trace debug info warn error fatal panic"` + Title string `mapstructure:"app-title"` + LoginTimeout int `mapstructure:"login-timeout"` + LoginMaxRetries int `mapstructure:"login-max-retries"` + ForgotPasswordMessage string `mapstructure:"forgot-password-message"` + BackgroundImage string `mapstructure:"background-image" validate:"required"` + LdapAddress string `mapstructure:"ldap-address"` + LdapBindDN string `mapstructure:"ldap-bind-dn"` + LdapBindPassword string `mapstructure:"ldap-bind-password"` + LdapBaseDN string `mapstructure:"ldap-base-dn"` + LdapInsecure bool `mapstructure:"ldap-insecure"` + LdapSearchFilter string `mapstructure:"ldap-search-filter"` + ResourcesDir string `mapstructure:"resources-dir"` + DatabasePath string `mapstructure:"database-path" validate:"required"` + TrustedProxies string `mapstructure:"trusted-proxies"` + DisableAnalytics bool `mapstructure:"disable-analytics"` + DisableResources bool `mapstructure:"disable-resources"` + DisableUIWarnings bool `mapstructure:"disable-ui-warnings"` + SocketPath string `mapstructure:"socket-path"` +} + +// OAuth/OIDC config + +type Claims struct { + Name string `json:"name"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Groups any `json:"groups"` +} + +type OAuthServiceConfig struct { + ClientID string `field:"client-id"` + ClientSecret string + ClientSecretFile string + Scopes []string + RedirectURL string `field:"redirect-url"` + AuthURL string `field:"auth-url"` + TokenURL string `field:"token-url"` + UserinfoURL string `field:"user-info-url"` + InsecureSkipVerify bool + Name string +} + +var OverrideProviders = map[string]string{ + "google": "Google", + "github": "GitHub", +} + +// User/session related stuff + +type User struct { + Username string + Password string + TotpSecret string +} + +type UserSearch struct { + Username string + Type string // local, ldap or unknown +} + +type SessionCookie struct { + UUID string + Username string + Name string + Email string + Provider string + TotpPending bool + OAuthGroups string + OAuthName string +} + +type UserContext struct { + Username string + Name string + Email string + IsLoggedIn bool + OAuth bool + Provider string + TotpPending bool + OAuthGroups string + TotpEnabled bool + OAuthName string +} + +// API responses and queries + +type UnauthorizedQuery struct { + Username string `url:"username"` + Resource string `url:"resource"` + GroupErr bool `url:"groupErr"` + IP string `url:"ip"` +} + +type RedirectQuery struct { + RedirectURI string `url:"redirect_uri"` +} + +// Labels + +type Apps struct { + Apps map[string]App +} + +type App struct { + Config AppConfig + Users AppUsers + OAuth AppOAuth + IP AppIP + Response AppResponse + Path AppPath +} + +type AppConfig struct { + Domain string +} + +type AppUsers struct { + Allow string + Block string +} + +type AppOAuth struct { + Whitelist string + Groups string +} + +type AppIP struct { + Allow []string + Block []string + Bypass []string +} + +type AppResponse struct { + Headers []string + BasicAuth AppBasicAuth +} + +type AppBasicAuth struct { + Username string + Password string + PasswordFile string +} + +type AppPath struct { + Allow string + Block string +} + +// Flags + +type Providers struct { + Providers map[string]OAuthServiceConfig +} + +// API server + +var ApiServer = "https://api.tinyauth.app" diff --git a/internal/constants/constants.go b/internal/constants/constants.go deleted file mode 100644 index d6f64fab..00000000 --- a/internal/constants/constants.go +++ /dev/null @@ -1,19 +0,0 @@ -package constants - -// Claims are the OIDC supported claims (prefered username is included for convinience) -type Claims struct { - Name string `json:"name"` - Email string `json:"email"` - PreferredUsername string `json:"preferred_username"` - Groups any `json:"groups"` -} - -// Version information -var Version = "development" -var CommitHash = "n/a" -var BuildTimestamp = "n/a" - -// Base cookie names -var SessionCookieName = "tinyauth-session" -var CsrfCookieName = "tinyauth-csrf" -var RedirectCookieName = "tinyauth-redirect" diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go new file mode 100644 index 00000000..5b3b30dc --- /dev/null +++ b/internal/controller/context_controller.go @@ -0,0 +1,128 @@ +package controller + +import ( + "fmt" + "net/url" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" +) + +type UserContextResponse struct { + Status int `json:"status"` + Message string `json:"message"` + IsLoggedIn bool `json:"isLoggedIn"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Provider string `json:"provider"` + OAuth bool `json:"oauth"` + TotpPending bool `json:"totpPending"` + OAuthName string `json:"oauthName"` +} + +type AppContextResponse struct { + Status int `json:"status"` + Message string `json:"message"` + Providers []Provider `json:"providers"` + Title string `json:"title"` + AppURL string `json:"appUrl"` + CookieDomain string `json:"cookieDomain"` + ForgotPasswordMessage string `json:"forgotPasswordMessage"` + BackgroundImage string `json:"backgroundImage"` + OAuthAutoRedirect string `json:"oauthAutoRedirect"` + DisableUIWarnings bool `json:"disableUiWarnings"` +} + +type Provider struct { + Name string `json:"name"` + ID string `json:"id"` + OAuth bool `json:"oauth"` +} + +type ContextControllerConfig struct { + Providers []Provider + Title string + AppURL string + CookieDomain string + ForgotPasswordMessage string + BackgroundImage string + OAuthAutoRedirect string + DisableUIWarnings bool +} + +type ContextController struct { + config ContextControllerConfig + router *gin.RouterGroup +} + +func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController { + if config.DisableUIWarnings { + log.Warn().Msg("UI warnings are disabled. This may expose users to security risks. Proceed with caution.") + } + + return &ContextController{ + config: config, + router: router, + } +} + +func (controller *ContextController) SetupRoutes() { + contextGroup := controller.router.Group("/context") + contextGroup.GET("/user", controller.userContextHandler) + contextGroup.GET("/app", controller.appContextHandler) +} + +func (controller *ContextController) userContextHandler(c *gin.Context) { + context, err := utils.GetContext(c) + + userContext := UserContextResponse{ + Status: 200, + Message: "Success", + IsLoggedIn: context.IsLoggedIn, + Username: context.Username, + Name: context.Name, + Email: context.Email, + Provider: context.Provider, + OAuth: context.OAuth, + TotpPending: context.TotpPending, + OAuthName: context.OAuthName, + } + + if err != nil { + log.Debug().Err(err).Msg("No user context found in request") + userContext.Status = 401 + userContext.Message = "Unauthorized" + userContext.IsLoggedIn = false + c.JSON(200, userContext) + return + } + + c.JSON(200, userContext) +} + +func (controller *ContextController) appContextHandler(c *gin.Context) { + appUrl, err := url.Parse(controller.config.AppURL) + if err != nil { + log.Error().Err(err).Msg("Failed to parse app URL") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + c.JSON(200, AppContextResponse{ + Status: 200, + Message: "Success", + Providers: controller.config.Providers, + Title: controller.config.Title, + AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host), + CookieDomain: controller.config.CookieDomain, + ForgotPasswordMessage: controller.config.ForgotPasswordMessage, + BackgroundImage: controller.config.BackgroundImage, + OAuthAutoRedirect: controller.config.OAuthAutoRedirect, + DisableUIWarnings: controller.config.DisableUIWarnings, + }) +} diff --git a/internal/controller/context_controller_test.go b/internal/controller/context_controller_test.go new file mode 100644 index 00000000..bfefd133 --- /dev/null +++ b/internal/controller/context_controller_test.go @@ -0,0 +1,147 @@ +package controller_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "tinyauth/internal/config" + "tinyauth/internal/controller" + + "github.com/gin-gonic/gin" + "gotest.tools/v3/assert" +) + +var controllerCfg = controller.ContextControllerConfig{ + Providers: []controller.Provider{ + { + Name: "Username", + ID: "username", + OAuth: false, + }, + { + Name: "Google", + ID: "google", + OAuth: true, + }, + }, + Title: "Test App", + AppURL: "http://localhost:8080", + CookieDomain: "localhost", + ForgotPasswordMessage: "Contact admin to reset your password.", + BackgroundImage: "/assets/bg.jpg", + OAuthAutoRedirect: "google", + DisableUIWarnings: false, +} + +var userContext = config.UserContext{ + Username: "testuser", + Name: "testuser", + Email: "test@example.com", + IsLoggedIn: true, + OAuth: false, + Provider: "username", + TotpPending: false, + OAuthGroups: "", + TotpEnabled: false, +} + +func setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { + // Setup + gin.SetMode(gin.TestMode) + router := gin.Default() + recorder := httptest.NewRecorder() + + if middlewares != nil { + for _, m := range *middlewares { + router.Use(m) + } + } + + group := router.Group("/api") + + ctrl := controller.NewContextController(controllerCfg, group) + ctrl.SetupRoutes() + + return router, recorder +} + +func TestAppContextHandler(t *testing.T) { + expectedRes := controller.AppContextResponse{ + Status: 200, + Message: "Success", + Providers: controllerCfg.Providers, + Title: controllerCfg.Title, + AppURL: controllerCfg.AppURL, + CookieDomain: controllerCfg.CookieDomain, + ForgotPasswordMessage: controllerCfg.ForgotPasswordMessage, + BackgroundImage: controllerCfg.BackgroundImage, + OAuthAutoRedirect: controllerCfg.OAuthAutoRedirect, + DisableUIWarnings: controllerCfg.DisableUIWarnings, + } + + router, recorder := setupContextController(nil) + req := httptest.NewRequest("GET", "/api/context/app", nil) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + var ctrlRes controller.AppContextResponse + + err := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes) + + assert.NilError(t, err) + assert.DeepEqual(t, expectedRes, ctrlRes) +} + +func TestUserContextHandler(t *testing.T) { + expectedRes := controller.UserContextResponse{ + Status: 200, + Message: "Success", + IsLoggedIn: userContext.IsLoggedIn, + Username: userContext.Username, + Name: userContext.Name, + Email: userContext.Email, + Provider: userContext.Provider, + OAuth: userContext.OAuth, + TotpPending: userContext.TotpPending, + OAuthName: userContext.OAuthName, + } + + // Test with context + router, recorder := setupContextController(&[]gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &userContext) + c.Next() + }, + }) + + req := httptest.NewRequest("GET", "/api/context/user", nil) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + var ctrlRes controller.UserContextResponse + + err := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes) + + assert.NilError(t, err) + assert.DeepEqual(t, expectedRes, ctrlRes) + + // Test no context + expectedRes = controller.UserContextResponse{ + Status: 401, + Message: "Unauthorized", + IsLoggedIn: false, + } + + router, recorder = setupContextController(nil) + req = httptest.NewRequest("GET", "/api/context/user", nil) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + err = json.Unmarshal(recorder.Body.Bytes(), &ctrlRes) + + assert.NilError(t, err) + assert.DeepEqual(t, expectedRes, ctrlRes) +} diff --git a/internal/controller/health_controller.go b/internal/controller/health_controller.go new file mode 100644 index 00000000..8ad67b50 --- /dev/null +++ b/internal/controller/health_controller.go @@ -0,0 +1,25 @@ +package controller + +import "github.com/gin-gonic/gin" + +type HealthController struct { + router *gin.RouterGroup +} + +func NewHealthController(router *gin.RouterGroup) *HealthController { + return &HealthController{ + router: router, + } +} + +func (controller *HealthController) SetupRoutes() { + controller.router.GET("/healthz", controller.healthHandler) + controller.router.HEAD("/healthz", controller.healthHandler) +} + +func (controller *HealthController) healthHandler(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "message": "Healthy", + }) +} diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go new file mode 100644 index 00000000..c58c6c41 --- /dev/null +++ b/internal/controller/oauth_controller.go @@ -0,0 +1,231 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + "time" + "tinyauth/internal/config" + "tinyauth/internal/service" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/google/go-querystring/query" + "github.com/rs/zerolog/log" +) + +type OAuthRequest struct { + Provider string `uri:"provider" binding:"required"` +} + +type OAuthControllerConfig struct { + CSRFCookieName string + RedirectCookieName string + SecureCookie bool + AppURL string + CookieDomain string +} + +type OAuthController struct { + config OAuthControllerConfig + router *gin.RouterGroup + auth *service.AuthService + broker *service.OAuthBrokerService +} + +func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService, broker *service.OAuthBrokerService) *OAuthController { + return &OAuthController{ + config: config, + router: router, + auth: auth, + broker: broker, + } +} + +func (controller *OAuthController) SetupRoutes() { + oauthGroup := controller.router.Group("/oauth") + oauthGroup.GET("/url/:provider", controller.oauthURLHandler) + oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler) +} + +func (controller *OAuthController) oauthURLHandler(c *gin.Context) { + var req OAuthRequest + + err := c.BindUri(&req) + if err != nil { + log.Error().Err(err).Msg("Failed to bind URI") + c.JSON(400, gin.H{ + "status": 400, + "message": "Bad Request", + }) + return + } + + service, exists := controller.broker.GetService(req.Provider) + + if !exists { + log.Warn().Msgf("OAuth provider not found: %s", req.Provider) + c.JSON(404, gin.H{ + "status": 404, + "message": "Not Found", + }) + return + } + + service.GenerateVerifier() + state := service.GenerateState() + authURL := service.GetAuthURL(state) + c.SetCookie(controller.config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + + redirectURI := c.Query("redirect_uri") + isRedirectSafe := utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) + + if !isRedirectSafe { + log.Warn().Str("redirect_uri", redirectURI).Msg("Unsafe redirect URI detected, ignoring") + redirectURI = "" + } + + if redirectURI != "" && isRedirectSafe { + log.Debug().Msg("Setting redirect URI cookie") + c.SetCookie(controller.config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + } + + c.JSON(200, gin.H{ + "status": 200, + "message": "OK", + "url": authURL, + }) +} + +func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { + var req OAuthRequest + + err := c.BindUri(&req) + if err != nil { + log.Error().Err(err).Msg("Failed to bind URI") + c.JSON(400, gin.H{ + "status": 400, + "message": "Bad Request", + }) + return + } + + state := c.Query("state") + csrfCookie, err := c.Cookie(controller.config.CSRFCookieName) + + if err != nil || state != csrfCookie { + log.Warn().Err(err).Msg("CSRF token mismatch or cookie missing") + c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + + code := c.Query("code") + service, exists := controller.broker.GetService(req.Provider) + + if !exists { + log.Warn().Msgf("OAuth provider not found: %s", req.Provider) + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + err = service.VerifyCode(code) + if err != nil { + log.Error().Err(err).Msg("Failed to verify OAuth code") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + user, err := controller.broker.GetUser(req.Provider) + + if err != nil { + log.Error().Err(err).Msg("Failed to get user from OAuth provider") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + if user.Email == "" { + log.Error().Msg("OAuth provider did not return an email") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + if !controller.auth.IsEmailWhitelisted(user.Email) { + log.Warn().Str("email", user.Email).Msg("Email not whitelisted") + + queries, err := query.Values(config.UnauthorizedQuery{ + Username: user.Email, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to encode unauthorized query") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) + return + } + + var name string + + if strings.TrimSpace(user.Name) != "" { + log.Debug().Msg("Using name from OAuth provider") + name = user.Name + } else { + log.Debug().Msg("No name from OAuth provider, using pseudo name") + name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1]) + } + + var username string + + if strings.TrimSpace(user.PreferredUsername) != "" { + log.Debug().Msg("Using preferred username from OAuth provider") + username = user.PreferredUsername + } else { + log.Debug().Msg("No preferred username from OAuth provider, using pseudo username") + username = strings.Replace(user.Email, "@", "_", -1) + } + + sessionCookie := config.SessionCookie{ + Username: username, + Name: name, + Email: user.Email, + Provider: req.Provider, + OAuthGroups: utils.CoalesceToString(user.Groups), + OAuthName: service.GetName(), + } + + log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") + + err = controller.auth.CreateSessionCookie(c, &sessionCookie) + + if err != nil { + log.Error().Err(err).Msg("Failed to create session cookie") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + redirectURI, err := c.Cookie(controller.config.RedirectCookieName) + + if err != nil || !utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) { + log.Debug().Msg("No redirect URI cookie found, redirecting to app root") + c.Redirect(http.StatusTemporaryRedirect, controller.config.AppURL) + return + } + + queries, err := query.Values(config.RedirectQuery{ + RedirectURI: redirectURI, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to encode redirect URI query") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + c.SetCookie(controller.config.RedirectCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", controller.config.AppURL, queries.Encode())) +} diff --git a/internal/controller/proxy_controller.go b/internal/controller/proxy_controller.go new file mode 100644 index 00000000..2b6738ac --- /dev/null +++ b/internal/controller/proxy_controller.go @@ -0,0 +1,296 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/service" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/google/go-querystring/query" + "github.com/rs/zerolog/log" +) + +type Proxy struct { + Proxy string `uri:"proxy" binding:"required"` +} + +type ProxyControllerConfig struct { + AppURL string +} + +type ProxyController struct { + config ProxyControllerConfig + router *gin.RouterGroup + acls *service.AccessControlsService + auth *service.AuthService +} + +func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController { + return &ProxyController{ + config: config, + router: router, + acls: acls, + auth: auth, + } +} + +func (controller *ProxyController) SetupRoutes() { + proxyGroup := controller.router.Group("/auth") + proxyGroup.GET("/:proxy", controller.proxyHandler) +} + +func (controller *ProxyController) proxyHandler(c *gin.Context) { + var req Proxy + + err := c.BindUri(&req) + if err != nil { + log.Error().Err(err).Msg("Failed to bind URI") + c.JSON(400, gin.H{ + "status": 400, + "message": "Bad Request", + }) + return + } + + if req.Proxy != "nginx" && req.Proxy != "traefik" && req.Proxy != "caddy" { + log.Warn().Str("proxy", req.Proxy).Msg("Invalid proxy") + c.JSON(400, gin.H{ + "status": 400, + "message": "Bad Request", + }) + return + } + + isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html") + + if isBrowser { + log.Debug().Msg("Request identified as (most likely) coming from a browser") + } else { + log.Debug().Msg("Request identified as (most likely) coming from a non-browser client") + } + + uri := c.Request.Header.Get("X-Forwarded-Uri") + proto := c.Request.Header.Get("X-Forwarded-Proto") + host := c.Request.Header.Get("X-Forwarded-Host") + + // Get acls + acls, err := controller.acls.GetAccessControls(host) + + if err != nil { + log.Error().Err(err).Msg("Failed to get access controls for resource") + controller.handleError(c, req, isBrowser) + return + } + + log.Trace().Interface("acls", acls).Msg("ACLs for resource") + + clientIP := c.ClientIP() + + if controller.auth.IsBypassedIP(acls.IP, clientIP) { + controller.setHeaders(c, acls) + c.JSON(200, gin.H{ + "status": 200, + "message": "Authenticated", + }) + return + } + + authEnabled, err := controller.auth.IsAuthEnabled(uri, acls.Path) + + if err != nil { + log.Error().Err(err).Msg("Failed to check if auth is enabled for resource") + controller.handleError(c, req, isBrowser) + return + } + + if !authEnabled { + log.Debug().Msg("Authentication disabled for resource, allowing access") + controller.setHeaders(c, acls) + c.JSON(200, gin.H{ + "status": 200, + "message": "Authenticated", + }) + return + } + + if !controller.auth.CheckIP(acls.IP, clientIP) { + if req.Proxy == "nginx" || !isBrowser { + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + queries, err := query.Values(config.UnauthorizedQuery{ + Resource: strings.Split(host, ".")[0], + IP: clientIP, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to encode unauthorized query") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) + return + } + + var userContext config.UserContext + + context, err := utils.GetContext(c) + + if err != nil { + log.Debug().Msg("No user context found in request, treating as not logged in") + userContext = config.UserContext{ + IsLoggedIn: false, + } + } else { + userContext = context + } + + log.Trace().Interface("context", userContext).Msg("User context from request") + + if userContext.Provider == "basic" && userContext.TotpEnabled { + log.Debug().Msg("User has TOTP enabled, denying basic auth access") + userContext.IsLoggedIn = false + } + + if userContext.IsLoggedIn { + appAllowed := controller.auth.IsResourceAllowed(c, userContext, acls) + + if !appAllowed { + log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User not allowed to access resource") + + if req.Proxy == "nginx" || !isBrowser { + c.JSON(403, gin.H{ + "status": 403, + "message": "Forbidden", + }) + return + } + + queries, err := query.Values(config.UnauthorizedQuery{ + Resource: strings.Split(host, ".")[0], + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to encode unauthorized query") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + if userContext.OAuth { + queries.Set("username", userContext.Email) + } else { + queries.Set("username", userContext.Username) + } + + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) + return + } + + if userContext.OAuth { + groupOK := controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups) + + if !groupOK { + log.Warn().Str("user", userContext.Username).Str("resource", strings.Split(host, ".")[0]).Msg("User OAuth groups do not match resource requirements") + + if req.Proxy == "nginx" || !isBrowser { + c.JSON(403, gin.H{ + "status": 403, + "message": "Forbidden", + }) + return + } + + queries, err := query.Values(config.UnauthorizedQuery{ + Resource: strings.Split(host, ".")[0], + GroupErr: true, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to encode unauthorized query") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + if userContext.OAuth { + queries.Set("username", userContext.Email) + } else { + queries.Set("username", userContext.Username) + } + + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", controller.config.AppURL, queries.Encode())) + return + } + } + + c.Header("Remote-User", utils.SanitizeHeader(userContext.Username)) + c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name)) + c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) + c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) + + controller.setHeaders(c, acls) + + c.JSON(200, gin.H{ + "status": 200, + "message": "Authenticated", + }) + return + } + + if req.Proxy == "nginx" || !isBrowser { + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + queries, err := query.Values(config.RedirectQuery{ + RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri), + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to encode redirect URI query") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", controller.config.AppURL, queries.Encode())) +} + +func (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) { + c.Header("Authorization", c.Request.Header.Get("Authorization")) + + headers := utils.ParseHeaders(acls.Response.Headers) + + for key, value := range headers { + log.Debug().Str("header", key).Msg("Setting header") + c.Header(key, value) + } + + basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile) + + if acls.Response.BasicAuth.Username != "" && basicPassword != "" { + log.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header") + c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword))) + } +} + +func (controller *ProxyController) handleError(c *gin.Context, req Proxy, isBrowser bool) { + if req.Proxy == "nginx" || !isBrowser { + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) +} diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go new file mode 100644 index 00000000..e7e27cf0 --- /dev/null +++ b/internal/controller/proxy_controller_test.go @@ -0,0 +1,169 @@ +package controller_test + +import ( + "net/http/httptest" + "testing" + "tinyauth/internal/config" + "tinyauth/internal/controller" + "tinyauth/internal/service" + + "github.com/gin-gonic/gin" + "gotest.tools/v3/assert" +) + +func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder, *service.AuthService) { + // Setup + gin.SetMode(gin.TestMode) + router := gin.Default() + + if middlewares != nil { + for _, m := range *middlewares { + router.Use(m) + } + } + + group := router.Group("/api") + recorder := httptest.NewRecorder() + + // Database + databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{ + DatabasePath: "/tmp/tinyauth_test.db", + }) + + assert.NilError(t, databaseService.Init()) + + database := databaseService.GetDatabase() + + // Docker + dockerService := service.NewDockerService() + + assert.NilError(t, dockerService.Init()) + + // Access controls + accessControlsService := service.NewAccessControlsService(dockerService) + + assert.NilError(t, accessControlsService.Init()) + + // Auth service + authService := service.NewAuthService(service.AuthServiceConfig{ + Users: []config.User{ + { + Username: "testuser", + Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test + }, + }, + OauthWhitelist: "", + SessionExpiry: 3600, + SecureCookie: false, + CookieDomain: "localhost", + LoginTimeout: 300, + LoginMaxRetries: 3, + SessionCookieName: "tinyauth-session", + }, dockerService, nil, database) + + // Controller + ctrl := controller.NewProxyController(controller.ProxyControllerConfig{ + AppURL: "http://localhost:8080", + }, group, accessControlsService, authService) + ctrl.SetupRoutes() + + return router, recorder, authService +} + +func TestProxyHandler(t *testing.T) { + // Setup + router, recorder, authService := setupProxyController(t, nil) + + // Test invalid proxy + req := httptest.NewRequest("GET", "/api/auth/invalidproxy", nil) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 400, recorder.Code) + + // Test logged out user (traefik/caddy) + recorder = httptest.NewRecorder() + req = httptest.NewRequest("GET", "/api/auth/traefik", nil) + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "example.com") + req.Header.Set("X-Forwarded-Uri", "/somepath") + req.Header.Set("Accept", "text/html") + router.ServeHTTP(recorder, req) + + assert.Equal(t, 307, recorder.Code) + assert.Equal(t, "http://localhost:8080/login?redirect_uri=https%3A%2F%2Fexample.com%2Fsomepath", recorder.Header().Get("Location")) + + // Test logged out user (nginx) + recorder = httptest.NewRecorder() + req = httptest.NewRequest("GET", "/api/auth/nginx", nil) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 401, recorder.Code) + + // Test logged in user + c := gin.CreateTestContextOnly(recorder, router) + + err := authService.CreateSessionCookie(c, &config.SessionCookie{ + Username: "testuser", + Name: "testuser", + Email: "testuser@example.com", + Provider: "username", + TotpPending: false, + OAuthGroups: "", + }) + + assert.NilError(t, err) + + cookie := c.Writer.Header().Get("Set-Cookie") + + router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &config.UserContext{ + Username: "testuser", + Name: "testuser", + Email: "testuser@example.com", + IsLoggedIn: true, + OAuth: false, + Provider: "username", + TotpPending: false, + OAuthGroups: "", + TotpEnabled: false, + }) + c.Next() + }, + }) + + req = httptest.NewRequest("GET", "/api/auth/traefik", nil) + req.Header.Set("Cookie", cookie) + req.Header.Set("Accept", "text/html") + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + assert.Equal(t, "testuser", recorder.Header().Get("Remote-User")) + assert.Equal(t, "testuser", recorder.Header().Get("Remote-Name")) + assert.Equal(t, "testuser@example.com", recorder.Header().Get("Remote-Email")) + + // Ensure basic auth is disabled for TOTP enabled users + router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &config.UserContext{ + Username: "testuser", + Name: "testuser", + Email: "testuser@example.com", + IsLoggedIn: true, + OAuth: false, + Provider: "basic", + TotpPending: false, + OAuthGroups: "", + TotpEnabled: true, + }) + c.Next() + }, + }) + + req = httptest.NewRequest("GET", "/api/auth/traefik", nil) + req.SetBasicAuth("testuser", "test") + router.ServeHTTP(recorder, req) + + assert.Equal(t, 401, recorder.Code) +} diff --git a/internal/controller/resources_controller.go b/internal/controller/resources_controller.go new file mode 100644 index 00000000..bed4fcc7 --- /dev/null +++ b/internal/controller/resources_controller.go @@ -0,0 +1,50 @@ +package controller + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type ResourcesControllerConfig struct { + ResourcesDir string + ResourcesDisabled bool +} + +type ResourcesController struct { + config ResourcesControllerConfig + router *gin.RouterGroup + fileServer http.Handler +} + +func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController { + fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.ResourcesDir))) + + return &ResourcesController{ + config: config, + router: router, + fileServer: fileServer, + } +} + +func (controller *ResourcesController) SetupRoutes() { + controller.router.GET("/resources/*resource", controller.resourcesHandler) +} + +func (controller *ResourcesController) resourcesHandler(c *gin.Context) { + if controller.config.ResourcesDir == "" { + c.JSON(404, gin.H{ + "status": 404, + "message": "Resources not found", + }) + return + } + if controller.config.ResourcesDisabled { + c.JSON(403, gin.H{ + "status": 403, + "message": "Resources are disabled", + }) + return + } + controller.fileServer.ServeHTTP(c.Writer, c.Request) +} diff --git a/internal/controller/resources_controller_test.go b/internal/controller/resources_controller_test.go new file mode 100644 index 00000000..8e4f8434 --- /dev/null +++ b/internal/controller/resources_controller_test.go @@ -0,0 +1,57 @@ +package controller_test + +import ( + "net/http/httptest" + "os" + "testing" + "tinyauth/internal/controller" + + "github.com/gin-gonic/gin" + "gotest.tools/v3/assert" +) + +func TestResourcesHandler(t *testing.T) { + // Setup + gin.SetMode(gin.TestMode) + router := gin.New() + group := router.Group("/") + + ctrl := controller.NewResourcesController(controller.ResourcesControllerConfig{ + ResourcesDir: "/tmp/tinyauth", + }, group) + ctrl.SetupRoutes() + + // Create test data + err := os.Mkdir("/tmp/tinyauth", 0755) + assert.NilError(t, err) + defer os.RemoveAll("/tmp/tinyauth") + + file, err := os.Create("/tmp/tinyauth/test.txt") + assert.NilError(t, err) + + _, err = file.WriteString("This is a test file.") + assert.NilError(t, err) + file.Close() + + // Test existing file + req := httptest.NewRequest("GET", "/resources/test.txt", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + assert.Equal(t, "This is a test file.", recorder.Body.String()) + + // Test non-existing file + req = httptest.NewRequest("GET", "/resources/nonexistent.txt", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + assert.Equal(t, 404, recorder.Code) + + // Test directory traversal attack + req = httptest.NewRequest("GET", "/resources/../etc/passwd", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + assert.Equal(t, 404, recorder.Code) +} diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go new file mode 100644 index 00000000..ff26de17 --- /dev/null +++ b/internal/controller/user_controller.go @@ -0,0 +1,274 @@ +package controller + +import ( + "fmt" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/service" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" + "github.com/rs/zerolog/log" +) + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type TotpRequest struct { + Code string `json:"code"` +} + +type UserControllerConfig struct { + CookieDomain string +} + +type UserController struct { + config UserControllerConfig + router *gin.RouterGroup + auth *service.AuthService +} + +func NewUserController(config UserControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *UserController { + return &UserController{ + config: config, + router: router, + auth: auth, + } +} + +func (controller *UserController) SetupRoutes() { + userGroup := controller.router.Group("/user") + userGroup.POST("/login", controller.loginHandler) + userGroup.POST("/logout", controller.logoutHandler) + userGroup.POST("/totp", controller.totpHandler) +} + +func (controller *UserController) loginHandler(c *gin.Context) { + var req LoginRequest + + err := c.ShouldBindJSON(&req) + if err != nil { + log.Error().Err(err).Msg("Failed to bind JSON") + c.JSON(400, gin.H{ + "status": 400, + "message": "Bad Request", + }) + return + } + + clientIP := c.ClientIP() + + rateIdentifier := req.Username + + if rateIdentifier == "" { + rateIdentifier = clientIP + } + + log.Debug().Str("username", req.Username).Str("ip", clientIP).Msg("Login attempt") + + isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier) + + if isLocked { + log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed login attempts") + c.JSON(429, gin.H{ + "status": 429, + "message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime), + }) + return + } + + userSearch := controller.auth.SearchUser(req.Username) + + if userSearch.Type == "unknown" { + log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("User not found") + controller.auth.RecordLoginAttempt(rateIdentifier, false) + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + if !controller.auth.VerifyUser(userSearch, req.Password) { + log.Warn().Str("username", req.Username).Str("ip", clientIP).Msg("Invalid password") + controller.auth.RecordLoginAttempt(rateIdentifier, false) + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + log.Info().Str("username", req.Username).Str("ip", clientIP).Msg("Login successful") + + controller.auth.RecordLoginAttempt(rateIdentifier, true) + + if userSearch.Type == "local" { + user := controller.auth.GetLocalUser(userSearch.Username) + + if user.TotpSecret != "" { + log.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification") + + err := controller.auth.CreateSessionCookie(c, &config.SessionCookie{ + Username: user.Username, + Name: utils.Capitalize(req.Username), + Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), + Provider: "username", + TotpPending: true, + }) + + if err != nil { + log.Error().Err(err).Msg("Failed to create session cookie") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + c.JSON(200, gin.H{ + "status": 200, + "message": "TOTP required", + "totpPending": true, + }) + return + } + } + + sessionCookie := config.SessionCookie{ + Username: req.Username, + Name: utils.Capitalize(req.Username), + Email: fmt.Sprintf("%s@%s", strings.ToLower(req.Username), controller.config.CookieDomain), + Provider: "username", + } + + log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") + + err = controller.auth.CreateSessionCookie(c, &sessionCookie) + + if err != nil { + log.Error().Err(err).Msg("Failed to create session cookie") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + c.JSON(200, gin.H{ + "status": 200, + "message": "Login successful", + }) +} + +func (controller *UserController) logoutHandler(c *gin.Context) { + log.Debug().Msg("Logout request received") + + controller.auth.DeleteSessionCookie(c) + + c.JSON(200, gin.H{ + "status": 200, + "message": "Logout successful", + }) +} + +func (controller *UserController) totpHandler(c *gin.Context) { + var req TotpRequest + + err := c.ShouldBindJSON(&req) + if err != nil { + log.Error().Err(err).Msg("Failed to bind JSON") + c.JSON(400, gin.H{ + "status": 400, + "message": "Bad Request", + }) + return + } + + context, err := utils.GetContext(c) + + if err != nil { + log.Error().Err(err).Msg("Failed to get user context") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + if !context.TotpPending { + log.Warn().Msg("TOTP attempt without a pending TOTP session") + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + clientIP := c.ClientIP() + + rateIdentifier := context.Username + + if rateIdentifier == "" { + rateIdentifier = clientIP + } + + log.Debug().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification attempt") + + isLocked, remainingTime := controller.auth.IsAccountLocked(rateIdentifier) + + if isLocked { + log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Account is locked due to too many failed TOTP attempts") + c.JSON(429, gin.H{ + "status": 429, + "message": fmt.Sprintf("Too many failed TOTP attempts. Try again in %d seconds", remainingTime), + }) + return + } + + user := controller.auth.GetLocalUser(context.Username) + + ok := totp.Validate(req.Code, user.TotpSecret) + + if !ok { + log.Warn().Str("username", context.Username).Str("ip", clientIP).Msg("Invalid TOTP code") + controller.auth.RecordLoginAttempt(rateIdentifier, false) + c.JSON(401, gin.H{ + "status": 401, + "message": "Unauthorized", + }) + return + } + + log.Info().Str("username", context.Username).Str("ip", clientIP).Msg("TOTP verification successful") + + controller.auth.RecordLoginAttempt(rateIdentifier, true) + + sessionCookie := config.SessionCookie{ + Username: user.Username, + Name: utils.Capitalize(user.Username), + Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), controller.config.CookieDomain), + Provider: "username", + } + + log.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie") + + err = controller.auth.CreateSessionCookie(c, &sessionCookie) + + if err != nil { + log.Error().Err(err).Msg("Failed to create session cookie") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + c.JSON(200, gin.H{ + "status": 200, + "message": "Login successful", + }) +} diff --git a/internal/controller/user_controller_test.go b/internal/controller/user_controller_test.go new file mode 100644 index 00000000..60655218 --- /dev/null +++ b/internal/controller/user_controller_test.go @@ -0,0 +1,297 @@ +package controller_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "tinyauth/internal/config" + "tinyauth/internal/controller" + "tinyauth/internal/service" + + "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" + "gotest.tools/v3/assert" +) + +var cookieValue string +var totpSecret = "6WFZXPEZRK5MZHHYAFW4DAOUYQMCASBJ" + +func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) { + // Setup + gin.SetMode(gin.TestMode) + router := gin.Default() + + if middlewares != nil { + for _, m := range *middlewares { + router.Use(m) + } + } + + group := router.Group("/api") + recorder := httptest.NewRecorder() + + // Database + databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{ + DatabasePath: "/tmp/tinyauth_test.db", + }) + + assert.NilError(t, databaseService.Init()) + + database := databaseService.GetDatabase() + + // Auth service + authService := service.NewAuthService(service.AuthServiceConfig{ + Users: []config.User{ + { + Username: "testuser", + Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test + }, + { + Username: "totpuser", + Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test + TotpSecret: totpSecret, + }, + }, + OauthWhitelist: "", + SessionExpiry: 3600, + SecureCookie: false, + CookieDomain: "localhost", + LoginTimeout: 300, + LoginMaxRetries: 3, + SessionCookieName: "tinyauth-session", + }, nil, nil, database) + + // Controller + ctrl := controller.NewUserController(controller.UserControllerConfig{ + CookieDomain: "localhost", + }, group, authService) + ctrl.SetupRoutes() + + return router, recorder +} + +func TestLoginHandler(t *testing.T) { + // Setup + router, recorder := setupUserController(t, nil) + + loginReq := controller.LoginRequest{ + Username: "testuser", + Password: "test", + } + + loginReqJson, err := json.Marshal(loginReq) + assert.NilError(t, err) + + // Test + req := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + cookie := recorder.Result().Cookies()[0] + + assert.Equal(t, "tinyauth-session", cookie.Name) + assert.Assert(t, cookie.Value != "") + + cookieValue = cookie.Value + + // Test invalid credentials + loginReq = controller.LoginRequest{ + Username: "testuser", + Password: "invalid", + } + + loginReqJson, err = json.Marshal(loginReq) + assert.NilError(t, err) + + recorder = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 401, recorder.Code) + + // Test totp required + loginReq = controller.LoginRequest{ + Username: "totpuser", + Password: "test", + } + + loginReqJson, err = json.Marshal(loginReq) + assert.NilError(t, err) + + recorder = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + loginResJson, err := json.Marshal(map[string]any{ + "message": "TOTP required", + "status": 200, + "totpPending": true, + }) + + assert.NilError(t, err) + assert.Equal(t, string(loginResJson), recorder.Body.String()) + + // Test invalid json + recorder = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader("{invalid json}")) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 400, recorder.Code) + + // Test rate limiting + loginReq = controller.LoginRequest{ + Username: "testuser", + Password: "invalid", + } + + loginReqJson, err = json.Marshal(loginReq) + assert.NilError(t, err) + + for range 5 { + recorder = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqJson))) + router.ServeHTTP(recorder, req) + } + + assert.Equal(t, 429, recorder.Code) +} + +func TestLogoutHandler(t *testing.T) { + // Setup + router, recorder := setupUserController(t, nil) + + // Test + req := httptest.NewRequest("POST", "/api/user/logout", nil) + + req.AddCookie(&http.Cookie{ + Name: "tinyauth-session", + Value: cookieValue, + }) + + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + cookie := recorder.Result().Cookies()[0] + + assert.Equal(t, "tinyauth-session", cookie.Name) + assert.Equal(t, "", cookie.Value) + assert.Equal(t, -1, cookie.MaxAge) +} + +func TestTotpHandler(t *testing.T) { + // Setup + router, recorder := setupUserController(t, &[]gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &config.UserContext{ + Username: "totpuser", + Name: "totpuser", + Email: "totpuser@example.com", + IsLoggedIn: false, + OAuth: false, + Provider: "username", + TotpPending: true, + OAuthGroups: "", + TotpEnabled: true, + }) + c.Next() + }, + }) + + // Test + code, err := totp.GenerateCode(totpSecret, time.Now()) + + assert.NilError(t, err) + + totpReq := controller.TotpRequest{ + Code: code, + } + + totpReqJson, err := json.Marshal(totpReq) + assert.NilError(t, err) + + req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 200, recorder.Code) + + cookie := recorder.Result().Cookies()[0] + + assert.Equal(t, "tinyauth-session", cookie.Name) + assert.Assert(t, cookie.Value != "") + + // Test invalid json + recorder = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader("{invalid json}")) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 400, recorder.Code) + + // Test rate limiting + totpReq = controller.TotpRequest{ + Code: "000000", + } + + totpReqJson, err = json.Marshal(totpReq) + assert.NilError(t, err) + + for range 5 { + recorder = httptest.NewRecorder() + req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) + router.ServeHTTP(recorder, req) + } + + assert.Equal(t, 429, recorder.Code) + + // Test invalid code + router, recorder = setupUserController(t, &[]gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &config.UserContext{ + Username: "totpuser", + Name: "totpuser", + Email: "totpuser@example.com", + IsLoggedIn: false, + OAuth: false, + Provider: "username", + TotpPending: true, + OAuthGroups: "", + TotpEnabled: true, + }) + c.Next() + }, + }) + + req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 401, recorder.Code) + + // Test no totp pending + router, recorder = setupUserController(t, &[]gin.HandlerFunc{ + func(c *gin.Context) { + c.Set("context", &config.UserContext{ + Username: "totpuser", + Name: "totpuser", + Email: "totpuser@example.com", + IsLoggedIn: false, + OAuth: false, + Provider: "username", + TotpPending: false, + OAuthGroups: "", + TotpEnabled: false, + }) + c.Next() + }, + }) + + req = httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqJson))) + router.ServeHTTP(recorder, req) + + assert.Equal(t, 401, recorder.Code) +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go deleted file mode 100644 index f5a04681..00000000 --- a/internal/docker/docker.go +++ /dev/null @@ -1,102 +0,0 @@ -package docker - -import ( - "context" - "strings" - "tinyauth/internal/types" - "tinyauth/internal/utils" - - container "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" - "github.com/rs/zerolog/log" -) - -type Docker struct { - Client *client.Client - Context context.Context -} - -func NewDocker() (*Docker, error) { - client, err := client.NewClientWithOpts(client.FromEnv) - if err != nil { - return nil, err - } - - ctx := context.Background() - client.NegotiateAPIVersion(ctx) - - return &Docker{ - Client: client, - Context: ctx, - }, nil -} - -func (docker *Docker) GetContainers() ([]container.Summary, error) { - containers, err := docker.Client.ContainerList(docker.Context, container.ListOptions{}) - if err != nil { - return nil, err - } - return containers, nil -} - -func (docker *Docker) InspectContainer(containerId string) (container.InspectResponse, error) { - inspect, err := docker.Client.ContainerInspect(docker.Context, containerId) - if err != nil { - return container.InspectResponse{}, err - } - return inspect, nil -} - -func (docker *Docker) DockerConnected() bool { - _, err := docker.Client.Ping(docker.Context) - return err == nil -} - -func (docker *Docker) GetLabels(app string, domain string) (types.Labels, error) { - isConnected := docker.DockerConnected() - - if !isConnected { - log.Debug().Msg("Docker not connected, returning empty labels") - return types.Labels{}, nil - } - - log.Debug().Msg("Getting containers") - - containers, err := docker.GetContainers() - if err != nil { - log.Error().Err(err).Msg("Error getting containers") - return types.Labels{}, err - } - - for _, container := range containers { - inspect, err := docker.InspectContainer(container.ID) - if err != nil { - log.Warn().Str("id", container.ID).Err(err).Msg("Error inspecting container, skipping") - continue - } - - log.Debug().Str("id", inspect.ID).Msg("Getting labels for container") - - labels, err := utils.GetLabels(inspect.Config.Labels) - if err != nil { - log.Warn().Str("id", container.ID).Err(err).Msg("Error getting container labels, skipping") - continue - } - - // Check if the container matches the ID or domain - for _, lDomain := range labels.Domain { - if lDomain == domain { - log.Debug().Str("id", inspect.ID).Msg("Found matching container by domain") - return labels, nil - } - } - - if strings.TrimPrefix(inspect.Name, "/") == app { - log.Debug().Str("id", inspect.ID).Msg("Found matching container by name") - return labels, nil - } - } - - log.Debug().Msg("No matching container found, returning empty labels") - return types.Labels{}, nil -} diff --git a/internal/handlers/context.go b/internal/handlers/context.go deleted file mode 100644 index d0fff5e5..00000000 --- a/internal/handlers/context.go +++ /dev/null @@ -1,64 +0,0 @@ -package handlers - -import ( - "tinyauth/internal/types" - - "github.com/gin-gonic/gin" - "github.com/rs/zerolog/log" -) - -func (h *Handlers) AppContextHandler(c *gin.Context) { - log.Debug().Msg("Getting app context") - - // Get configured providers - configuredProviders := h.Providers.GetConfiguredProviders() - - // We have username/password configured so add it to our providers - if h.Auth.UserAuthConfigured() { - configuredProviders = append(configuredProviders, "username") - } - - // Return app context - appContext := types.AppContext{ - Status: 200, - Message: "OK", - ConfiguredProviders: configuredProviders, - DisableContinue: h.Config.DisableContinue, - Title: h.Config.Title, - GenericName: h.Config.GenericName, - Domain: h.Config.Domain, - ForgotPasswordMessage: h.Config.ForgotPasswordMessage, - BackgroundImage: h.Config.BackgroundImage, - OAuthAutoRedirect: h.Config.OAuthAutoRedirect, - } - c.JSON(200, appContext) -} - -func (h *Handlers) UserContextHandler(c *gin.Context) { - log.Debug().Msg("Getting user context") - - // Create user context using hooks - userContext := h.Hooks.UseUserContext(c) - - userContextResponse := types.UserContextResponse{ - Status: 200, - IsLoggedIn: userContext.IsLoggedIn, - Username: userContext.Username, - Name: userContext.Name, - Email: userContext.Email, - Provider: userContext.Provider, - Oauth: userContext.OAuth, - TotpPending: userContext.TotpPending, - } - - // If we are not logged in we set the status to 401 else we set it to 200 - if !userContext.IsLoggedIn { - log.Debug().Msg("Unauthorized") - userContextResponse.Message = "Unauthorized" - } else { - log.Debug().Interface("userContext", userContext).Msg("Authenticated") - userContextResponse.Message = "Authenticated" - } - - c.JSON(200, userContextResponse) -} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go deleted file mode 100644 index 0e8ebe22..00000000 --- a/internal/handlers/handlers.go +++ /dev/null @@ -1,36 +0,0 @@ -package handlers - -import ( - "tinyauth/internal/auth" - "tinyauth/internal/docker" - "tinyauth/internal/hooks" - "tinyauth/internal/providers" - "tinyauth/internal/types" - - "github.com/gin-gonic/gin" -) - -type Handlers struct { - Config types.HandlersConfig - Auth *auth.Auth - Hooks *hooks.Hooks - Providers *providers.Providers - Docker *docker.Docker -} - -func NewHandlers(config types.HandlersConfig, auth *auth.Auth, hooks *hooks.Hooks, providers *providers.Providers, docker *docker.Docker) *Handlers { - return &Handlers{ - Config: config, - Auth: auth, - Hooks: hooks, - Providers: providers, - Docker: docker, - } -} - -func (h *Handlers) HealthcheckHandler(c *gin.Context) { - c.JSON(200, gin.H{ - "status": 200, - "message": "OK", - }) -} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go deleted file mode 100644 index 279534d6..00000000 --- a/internal/handlers/handlers_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package handlers_test - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "reflect" - "strings" - "testing" - "time" - "tinyauth/internal/auth" - "tinyauth/internal/docker" - "tinyauth/internal/handlers" - "tinyauth/internal/hooks" - "tinyauth/internal/providers" - "tinyauth/internal/server" - "tinyauth/internal/types" - - "github.com/magiconair/properties/assert" - "github.com/pquerna/otp/totp" -) - -// Simple server config -var serverConfig = types.ServerConfig{ - Port: 8080, - Address: "0.0.0.0", -} - -// Simple handlers config -var handlersConfig = types.HandlersConfig{ - AppURL: "http://localhost:8080", - Domain: "localhost", - DisableContinue: false, - CookieSecure: false, - Title: "Tinyauth", - GenericName: "Generic", - ForgotPasswordMessage: "Message", - CsrfCookieName: "tinyauth-csrf", - RedirectCookieName: "tinyauth-redirect", - BackgroundImage: "https://example.com/image.png", - OAuthAutoRedirect: "none", -} - -// Simple auth config -var authConfig = types.AuthConfig{ - Users: types.Users{}, - OauthWhitelist: "", - HMACSecret: "4bZ9K.*:;zH=,9zG!meUxu.B5-S[7.V.", // Complex on purpose - EncryptionSecret: "\\:!R(u[Sbv6ZLm.7es)H|OqH4y}0u\\rj", - CookieSecure: false, - SessionExpiry: 3600, - LoginTimeout: 0, - LoginMaxRetries: 0, - SessionCookieName: "tinyauth-session", - Domain: "localhost", -} - -// Simple hooks config -var hooksConfig = types.HooksConfig{ - Domain: "localhost", -} - -// Cookie -var cookie string - -// User -var user = types.User{ - Username: "user", - Password: "$2a$10$AvGHLTYv3xiRJ0xV9xs3XeVIlkGTygI9nqIamFYB5Xu.5.0UWF7B6", // pass -} - -// Initialize the server for tests -func getServer(t *testing.T) *server.Server { - // Create services - authConfig.Users = types.Users{ - { - Username: user.Username, - Password: user.Password, - TotpSecret: user.TotpSecret, - }, - } - docker, err := docker.NewDocker() - if err != nil { - t.Fatalf("Failed to create docker client: %v", err) - } - auth := auth.NewAuth(authConfig, nil, nil) - providers := providers.NewProviders(types.OAuthConfig{}) - hooks := hooks.NewHooks(hooksConfig, auth, providers) - handlers := handlers.NewHandlers(handlersConfig, auth, hooks, providers, docker) - - // Create server - srv, err := server.NewServer(serverConfig, handlers) - if err != nil { - t.Fatalf("Failed to create server: %v", err) - } - - return srv -} - -func TestLogin(t *testing.T) { - t.Log("Testing login") - - srv := getServer(t) - - recorder := httptest.NewRecorder() - - user := types.LoginRequest{ - Username: "user", - Password: "pass", - } - - json, err := json.Marshal(user) - if err != nil { - t.Fatalf("Error marshalling json: %v", err) - } - - req, err := http.NewRequest("POST", "/api/login", strings.NewReader(string(json))) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) - - cookies := recorder.Result().Cookies() - - if len(cookies) == 0 { - t.Fatalf("Cookie not set") - } - - // Set the cookie for further tests - cookie = cookies[0].Value -} - -func TestAppContext(t *testing.T) { - // Refresh the cookie - TestLogin(t) - - t.Log("Testing app context") - - srv := getServer(t) - - recorder := httptest.NewRecorder() - - req, err := http.NewRequest("GET", "/api/app", nil) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - // Set the cookie from the previous test - req.AddCookie(&http.Cookie{ - Name: "tinyauth", - Value: cookie, - }) - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) - - body, err := io.ReadAll(recorder.Body) - if err != nil { - t.Fatalf("Error getting body: %v", err) - } - - var app types.AppContext - - err = json.Unmarshal(body, &app) - if err != nil { - t.Fatalf("Error unmarshalling body: %v", err) - } - - expected := types.AppContext{ - Status: 200, - Message: "OK", - ConfiguredProviders: []string{"username"}, - DisableContinue: false, - Title: "Tinyauth", - GenericName: "Generic", - ForgotPasswordMessage: "Message", - BackgroundImage: "https://example.com/image.png", - OAuthAutoRedirect: "none", - Domain: "localhost", - } - - // We should get the username back - if !reflect.DeepEqual(app, expected) { - t.Fatalf("Expected %v, got %v", expected, app) - } -} - -func TestUserContext(t *testing.T) { - // Refresh the cookie - TestLogin(t) - - t.Log("Testing user context") - - srv := getServer(t) - - recorder := httptest.NewRecorder() - - req, err := http.NewRequest("GET", "/api/user", nil) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - req.AddCookie(&http.Cookie{ - Name: "tinyauth-session", - Value: cookie, - }) - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) - - body, err := io.ReadAll(recorder.Body) - if err != nil { - t.Fatalf("Error getting body: %v", err) - } - - type User struct { - Username string `json:"username"` - } - - var user User - - err = json.Unmarshal(body, &user) - if err != nil { - t.Fatalf("Error unmarshalling body: %v", err) - } - - // We should get the user back - if user.Username != "user" { - t.Fatalf("Expected user, got %s", user.Username) - } -} - -func TestLogout(t *testing.T) { - // Refresh the cookie - TestLogin(t) - - t.Log("Testing logout") - - srv := getServer(t) - - recorder := httptest.NewRecorder() - - req, err := http.NewRequest("POST", "/api/logout", nil) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - req.AddCookie(&http.Cookie{ - Name: "tinyauth-session", - Value: cookie, - }) - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) - - // Check if the cookie is different (means the cookie is gone) - if recorder.Result().Cookies()[0].Value == cookie { - t.Fatalf("Cookie not flushed") - } -} - -func TestAuth(t *testing.T) { - // Refresh the cookie - TestLogin(t) - - t.Log("Testing auth endpoint") - - srv := getServer(t) - - recorder := httptest.NewRecorder() - - req, err := http.NewRequest("GET", "/api/auth/traefik", nil) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - req.Header.Set("Accept", "text/html") - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusTemporaryRedirect) - - recorder = httptest.NewRecorder() - - req, err = http.NewRequest("GET", "/api/auth/traefik", nil) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - req.AddCookie(&http.Cookie{ - Name: "tinyauth-session", - Value: cookie, - }) - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) - - recorder = httptest.NewRecorder() - - req, err = http.NewRequest("GET", "/api/auth/nginx", nil) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusUnauthorized) - - recorder = httptest.NewRecorder() - - req, err = http.NewRequest("GET", "/api/auth/nginx", nil) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - req.AddCookie(&http.Cookie{ - Name: "tinyauth-session", - Value: cookie, - }) - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) -} - -func TestTOTP(t *testing.T) { - t.Log("Testing TOTP") - - key, err := totp.Generate(totp.GenerateOpts{ - Issuer: "Tinyauth", - AccountName: user.Username, - }) - if err != nil { - t.Fatalf("Failed to generate TOTP secret: %v", err) - } - - secret := key.Secret() - - user.TotpSecret = secret - - srv := getServer(t) - - user := types.LoginRequest{ - Username: "user", - Password: "pass", - } - - loginJson, err := json.Marshal(user) - if err != nil { - t.Fatalf("Error marshalling json: %v", err) - } - - recorder := httptest.NewRecorder() - - req, err := http.NewRequest("POST", "/api/login", strings.NewReader(string(loginJson))) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) - - // Set the cookie for next test - cookie = recorder.Result().Cookies()[0].Value - - code, err := totp.GenerateCode(secret, time.Now()) - if err != nil { - t.Fatalf("Failed to generate TOTP code: %v", err) - } - - totpRequest := types.TotpRequest{ - Code: code, - } - - totpJson, err := json.Marshal(totpRequest) - if err != nil { - t.Fatalf("Error marshalling TOTP request: %v", err) - } - - recorder = httptest.NewRecorder() - - req, err = http.NewRequest("POST", "/api/totp", strings.NewReader(string(totpJson))) - if err != nil { - t.Fatalf("Error creating request: %v", err) - } - - req.AddCookie(&http.Cookie{ - Name: "tinyauth-session", - Value: cookie, - }) - - srv.Router.ServeHTTP(recorder, req) - assert.Equal(t, recorder.Code, http.StatusOK) -} diff --git a/internal/handlers/oauth.go b/internal/handlers/oauth.go deleted file mode 100644 index 13c3a474..00000000 --- a/internal/handlers/oauth.go +++ /dev/null @@ -1,223 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - "strings" - "time" - "tinyauth/internal/types" - "tinyauth/internal/utils" - - "github.com/gin-gonic/gin" - "github.com/google/go-querystring/query" - "github.com/rs/zerolog/log" -) - -func (h *Handlers) OAuthURLHandler(c *gin.Context) { - var request types.OAuthRequest - - err := c.BindUri(&request) - if err != nil { - log.Error().Err(err).Msg("Failed to bind URI") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return - } - - log.Debug().Msg("Got OAuth request") - - // Check if provider exists - provider := h.Providers.GetProvider(request.Provider) - - if provider == nil { - c.JSON(404, gin.H{ - "status": 404, - "message": "Not Found", - }) - return - } - - log.Debug().Str("provider", request.Provider).Msg("Got provider") - - // Create state - state := provider.GenerateState() - - // Get auth URL - authURL := provider.GetAuthURL(state) - - log.Debug().Msg("Got auth URL") - - // Set CSRF cookie - c.SetCookie(h.Config.CsrfCookieName, state, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true) - - // Get redirect URI - redirectURI := c.Query("redirect_uri") - - // Set redirect cookie if redirect URI is provided - if redirectURI != "" { - log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie") - c.SetCookie(h.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true) - } - - // Return auth URL - c.JSON(200, gin.H{ - "status": 200, - "message": "OK", - "url": authURL, - }) -} - -func (h *Handlers) OAuthCallbackHandler(c *gin.Context) { - var providerName types.OAuthRequest - - err := c.BindUri(&providerName) - if err != nil { - log.Error().Err(err).Msg("Failed to bind URI") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - log.Debug().Interface("provider", providerName.Provider).Msg("Got provider name") - - // Get state - state := c.Query("state") - - // Get CSRF cookie - csrfCookie, err := c.Cookie(h.Config.CsrfCookieName) - - if err != nil { - log.Debug().Msg("No CSRF cookie") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - log.Debug().Str("csrfCookie", csrfCookie).Msg("Got CSRF cookie") - - // Check if CSRF cookie is valid - if csrfCookie != state { - log.Warn().Msg("Invalid CSRF cookie or CSRF cookie does not match with the state") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - // Clean up CSRF cookie - c.SetCookie(h.Config.CsrfCookieName, "", -1, "/", "", h.Config.CookieSecure, true) - - // Get code - code := c.Query("code") - - log.Debug().Msg("Got code") - - // Get provider - provider := h.Providers.GetProvider(providerName.Provider) - - if provider == nil { - c.Redirect(http.StatusTemporaryRedirect, "/not-found") - return - } - - log.Debug().Str("provider", providerName.Provider).Msg("Got provider") - - // Exchange token (authenticates user) - _, err = provider.ExchangeToken(code) - if err != nil { - log.Error().Err(err).Msg("Failed to exchange token") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - log.Debug().Msg("Got token") - - // Get user - user, err := h.Providers.GetUser(providerName.Provider) - if err != nil { - log.Error().Err(err).Msg("Failed to get user") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - log.Debug().Interface("user", user).Msg("Got user") - - // Check that email is not empty - if user.Email == "" { - log.Error().Msg("Email is empty") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - // Email is not whitelisted - if !h.Auth.EmailWhitelisted(user.Email) { - log.Warn().Str("email", user.Email).Msg("Email not whitelisted") - queries, err := query.Values(types.UnauthorizedQuery{ - Username: user.Email, - }) - - if err != nil { - log.Error().Err(err).Msg("Failed to build queries") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode())) - } - - log.Debug().Msg("Email whitelisted") - - // Get username - var username string - - if user.PreferredUsername != "" { - username = user.PreferredUsername - } else { - username = fmt.Sprintf("%s_%s", strings.Split(user.Email, "@")[0], strings.Split(user.Email, "@")[1]) - } - - // Get name - var name string - - if user.Name != "" { - name = user.Name - } else { - name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1]) - } - - // Create session cookie - h.Auth.CreateSessionCookie(c, &types.SessionCookie{ - Username: username, - Name: name, - Email: user.Email, - Provider: providerName.Provider, - OAuthGroups: utils.CoalesceToString(user.Groups), - }) - - // Check if we have a redirect URI - redirectCookie, err := c.Cookie(h.Config.RedirectCookieName) - - if err != nil { - log.Debug().Msg("No redirect cookie") - c.Redirect(http.StatusTemporaryRedirect, h.Config.AppURL) - return - } - - log.Debug().Str("redirectURI", redirectCookie).Msg("Got redirect URI") - - queries, err := query.Values(types.LoginQuery{ - RedirectURI: redirectCookie, - }) - - if err != nil { - log.Error().Err(err).Msg("Failed to build queries") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - log.Debug().Msg("Got redirect query") - - // Clean up redirect cookie - c.SetCookie(h.Config.RedirectCookieName, "", -1, "/", "", h.Config.CookieSecure, true) - - // Redirect to continue with the redirect URI - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/continue?%s", h.Config.AppURL, queries.Encode())) -} diff --git a/internal/handlers/proxy.go b/internal/handlers/proxy.go deleted file mode 100644 index fd87fd16..00000000 --- a/internal/handlers/proxy.go +++ /dev/null @@ -1,282 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - "strings" - "tinyauth/internal/types" - "tinyauth/internal/utils" - - "github.com/gin-gonic/gin" - "github.com/google/go-querystring/query" - "github.com/rs/zerolog/log" -) - -func (h *Handlers) ProxyHandler(c *gin.Context) { - var proxy types.Proxy - - err := c.BindUri(&proxy) - if err != nil { - log.Error().Err(err).Msg("Failed to bind URI") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return - } - - // Check if the request is coming from a browser (tools like curl/bruno use */* and they don't include the text/html) - isBrowser := strings.Contains(c.Request.Header.Get("Accept"), "text/html") - - if isBrowser { - log.Debug().Msg("Request is most likely coming from a browser") - } else { - log.Debug().Msg("Request is most likely not coming from a browser") - } - - log.Debug().Interface("proxy", proxy.Proxy).Msg("Got proxy") - - uri := c.Request.Header.Get("X-Forwarded-Uri") - proto := c.Request.Header.Get("X-Forwarded-Proto") - host := c.Request.Header.Get("X-Forwarded-Host") - - hostPortless := strings.Split(host, ":")[0] // *lol* - id := strings.Split(hostPortless, ".")[0] - - labels, err := h.Docker.GetLabels(id, hostPortless) - if err != nil { - log.Error().Err(err).Msg("Failed to get container labels") - - if proxy.Proxy == "nginx" || !isBrowser { - c.JSON(500, gin.H{ - "status": 500, - "message": "Internal Server Error", - }) - return - } - - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - log.Debug().Interface("labels", labels).Msg("Got labels") - - ip := c.ClientIP() - - if h.Auth.BypassedIP(labels, ip) { - c.Header("Authorization", c.Request.Header.Get("Authorization")) - - headersParsed := utils.ParseHeaders(labels.Headers) - for key, value := range headersParsed { - log.Debug().Str("key", key).Msg("Setting header") - c.Header(key, value) - } - - if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" { - log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth headers") - c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File)))) - } - - c.JSON(200, gin.H{ - "status": 200, - "message": "Authenticated", - }) - return - } - - if !h.Auth.CheckIP(labels, ip) { - if proxy.Proxy == "nginx" || !isBrowser { - c.JSON(403, gin.H{ - "status": 403, - "message": "Forbidden", - }) - return - } - - values := types.UnauthorizedQuery{ - Resource: strings.Split(host, ".")[0], - IP: ip, - } - - queries, err := query.Values(values) - if err != nil { - log.Error().Err(err).Msg("Failed to build queries") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode())) - return - } - - authEnabled, err := h.Auth.AuthEnabled(uri, labels) - if err != nil { - log.Error().Err(err).Msg("Failed to check if app is allowed") - if proxy.Proxy == "nginx" || !isBrowser { - c.JSON(500, gin.H{ - "status": 500, - "message": "Internal Server Error", - }) - return - } - - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - if !authEnabled { - c.Header("Authorization", c.Request.Header.Get("Authorization")) - - headersParsed := utils.ParseHeaders(labels.Headers) - for key, value := range headersParsed { - log.Debug().Str("key", key).Msg("Setting header") - c.Header(key, value) - } - - if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" { - log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth headers") - c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File)))) - } - - c.JSON(200, gin.H{ - "status": 200, - "message": "Authenticated", - }) - - return - } - - userContext := h.Hooks.UseUserContext(c) - - // If we are using basic auth, we need to check if the user has totp and if it does then disable basic auth - if userContext.Provider == "basic" && userContext.TotpEnabled { - log.Warn().Str("username", userContext.Username).Msg("User has totp enabled, disabling basic auth") - userContext.IsLoggedIn = false - } - - if userContext.IsLoggedIn { - log.Debug().Msg("Authenticated") - - // Check if user is allowed to access subdomain, if request is nginx.example.com the subdomain (resource) is nginx - appAllowed := h.Auth.ResourceAllowed(c, userContext, labels) - - log.Debug().Bool("appAllowed", appAllowed).Msg("Checking if app is allowed") - - if !appAllowed { - log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed") - - if proxy.Proxy == "nginx" || !isBrowser { - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } - - values := types.UnauthorizedQuery{ - Resource: strings.Split(host, ".")[0], - } - - if userContext.OAuth { - values.Username = userContext.Email - } else { - values.Username = userContext.Username - } - - queries, err := query.Values(values) - if err != nil { - log.Error().Err(err).Msg("Failed to build queries") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode())) - return - } - - if userContext.OAuth { - groupOk := h.Auth.OAuthGroup(c, userContext, labels) - - log.Debug().Bool("groupOk", groupOk).Msg("Checking if user is in required groups") - - if !groupOk { - log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User is not in required groups") - if proxy.Proxy == "nginx" || !isBrowser { - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } - - values := types.UnauthorizedQuery{ - Resource: strings.Split(host, ".")[0], - GroupErr: true, - } - - if userContext.OAuth { - values.Username = userContext.Email - } else { - values.Username = userContext.Username - } - - queries, err := query.Values(values) - if err != nil { - log.Error().Err(err).Msg("Failed to build queries") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode())) - return - } - } - - c.Header("Authorization", c.Request.Header.Get("Authorization")) - c.Header("Remote-User", utils.SanitizeHeader(userContext.Username)) - c.Header("Remote-Name", utils.SanitizeHeader(userContext.Name)) - c.Header("Remote-Email", utils.SanitizeHeader(userContext.Email)) - c.Header("Remote-Groups", utils.SanitizeHeader(userContext.OAuthGroups)) - - parsedHeaders := utils.ParseHeaders(labels.Headers) - for key, value := range parsedHeaders { - log.Debug().Str("key", key).Msg("Setting header") - c.Header(key, value) - } - - if labels.Basic.Username != "" && utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File) != "" { - log.Debug().Str("username", labels.Basic.Username).Msg("Setting basic auth headers") - c.Header("Authorization", fmt.Sprintf("Basic %s", utils.GetBasicAuth(labels.Basic.Username, utils.GetSecret(labels.Basic.Password.Plain, labels.Basic.Password.File)))) - } - - c.JSON(200, gin.H{ - "status": 200, - "message": "Authenticated", - }) - return - } - - // The user is not logged in - log.Debug().Msg("Unauthorized") - - if proxy.Proxy == "nginx" || !isBrowser { - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } - - queries, err := query.Values(types.LoginQuery{ - RedirectURI: fmt.Sprintf("%s://%s%s", proto, host, uri), - }) - - if err != nil { - log.Error().Err(err).Msg("Failed to build queries") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL)) - return - } - - log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", h.Config.AppURL, queries.Encode())) -} diff --git a/internal/handlers/user.go b/internal/handlers/user.go deleted file mode 100644 index 91d0fef5..00000000 --- a/internal/handlers/user.go +++ /dev/null @@ -1,197 +0,0 @@ -package handlers - -import ( - "fmt" - "strings" - "tinyauth/internal/types" - "tinyauth/internal/utils" - - "github.com/gin-gonic/gin" - "github.com/pquerna/otp/totp" - "github.com/rs/zerolog/log" -) - -func (h *Handlers) LoginHandler(c *gin.Context) { - var login types.LoginRequest - - err := c.BindJSON(&login) - if err != nil { - log.Error().Err(err).Msg("Failed to bind JSON") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return - } - - log.Debug().Msg("Got login request") - - clientIP := c.ClientIP() - - // Create an identifier for rate limiting (username or IP if username doesn't exist yet) - rateIdentifier := login.Username - if rateIdentifier == "" { - rateIdentifier = clientIP - } - - // Check if the account is locked due to too many failed attempts - locked, remainingTime := h.Auth.IsAccountLocked(rateIdentifier) - if locked { - log.Warn().Str("identifier", rateIdentifier).Int("remaining_seconds", remainingTime).Msg("Account is locked due to too many failed login attempts") - c.JSON(429, gin.H{ - "status": 429, - "message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remainingTime), - }) - return - } - - // Search for a user based on username - log.Debug().Interface("username", login.Username).Msg("Searching for user") - - userSearch := h.Auth.SearchUser(login.Username) - - // User does not exist - if userSearch.Type == "" { - log.Debug().Str("username", login.Username).Msg("User not found") - // Record failed login attempt - h.Auth.RecordLoginAttempt(rateIdentifier, false) - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } - - log.Debug().Msg("Got user") - - // Check if password is correct - if !h.Auth.VerifyUser(userSearch, login.Password) { - log.Debug().Str("username", login.Username).Msg("Password incorrect") - // Record failed login attempt - h.Auth.RecordLoginAttempt(rateIdentifier, false) - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } - - log.Debug().Msg("Password correct, checking totp") - - // Record successful login attempt (will reset failed attempt counter) - h.Auth.RecordLoginAttempt(rateIdentifier, true) - - // Check if user is using TOTP - if userSearch.Type == "local" { - // Get local user - localUser := h.Auth.GetLocalUser(login.Username) - - // Check if TOTP is enabled - if localUser.TotpSecret != "" { - log.Debug().Msg("Totp enabled") - - // Set totp pending cookie - h.Auth.CreateSessionCookie(c, &types.SessionCookie{ - Username: login.Username, - Name: utils.Capitalize(login.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain), - Provider: "username", - TotpPending: true, - }) - - // Return totp required - c.JSON(200, gin.H{ - "status": 200, - "message": "Waiting for totp", - "totpPending": true, - }) - return - } - } - - // Create session cookie with username as provider - h.Auth.CreateSessionCookie(c, &types.SessionCookie{ - Username: login.Username, - Name: utils.Capitalize(login.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(login.Username), h.Config.Domain), - Provider: "username", - }) - - // Return logged in - c.JSON(200, gin.H{ - "status": 200, - "message": "Logged in", - "totpPending": false, - }) -} - -func (h *Handlers) TOTPHandler(c *gin.Context) { - var totpReq types.TotpRequest - - err := c.BindJSON(&totpReq) - if err != nil { - log.Error().Err(err).Msg("Failed to bind JSON") - c.JSON(400, gin.H{ - "status": 400, - "message": "Bad Request", - }) - return - } - - log.Debug().Msg("Checking totp") - - // Get user context - userContext := h.Hooks.UseUserContext(c) - - // Check if we have a user - if userContext.Username == "" { - log.Debug().Msg("No user context") - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } - - // Get user - user := h.Auth.GetLocalUser(userContext.Username) - - // Check if totp is correct - ok := totp.Validate(totpReq.Code, user.TotpSecret) - - if !ok { - log.Debug().Msg("Totp incorrect") - c.JSON(401, gin.H{ - "status": 401, - "message": "Unauthorized", - }) - return - } - - log.Debug().Msg("Totp correct") - - // Create session cookie with username as provider - h.Auth.CreateSessionCookie(c, &types.SessionCookie{ - Username: user.Username, - Name: utils.Capitalize(user.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), h.Config.Domain), - Provider: "username", - }) - - // Return logged in - c.JSON(200, gin.H{ - "status": 200, - "message": "Logged in", - }) -} - -func (h *Handlers) LogoutHandler(c *gin.Context) { - log.Debug().Msg("Cleaning up redirect cookie") - - h.Auth.DeleteSessionCookie(c) - - c.JSON(200, gin.H{ - "status": 200, - "message": "Logged out", - }) -} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go deleted file mode 100644 index 3083b98e..00000000 --- a/internal/hooks/hooks.go +++ /dev/null @@ -1,144 +0,0 @@ -package hooks - -import ( - "fmt" - "strings" - "tinyauth/internal/auth" - "tinyauth/internal/oauth" - "tinyauth/internal/providers" - "tinyauth/internal/types" - "tinyauth/internal/utils" - - "github.com/gin-gonic/gin" - "github.com/rs/zerolog/log" -) - -type Hooks struct { - Config types.HooksConfig - Auth *auth.Auth - Providers *providers.Providers -} - -func NewHooks(config types.HooksConfig, auth *auth.Auth, providers *providers.Providers) *Hooks { - return &Hooks{ - Config: config, - Auth: auth, - Providers: providers, - } -} - -func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext { - cookie, err := hooks.Auth.GetSessionCookie(c) - var provider *oauth.OAuth - - if err != nil { - log.Error().Err(err).Msg("Failed to get session cookie") - goto basic - } - - if cookie.TotpPending { - log.Debug().Msg("Totp pending") - return types.UserContext{ - Username: cookie.Username, - Name: cookie.Name, - Email: cookie.Email, - Provider: cookie.Provider, - TotpPending: true, - } - } - - if cookie.Provider == "username" { - log.Debug().Msg("Provider is username") - - userSearch := hooks.Auth.SearchUser(cookie.Username) - - if userSearch.Type == "unknown" { - log.Warn().Str("username", cookie.Username).Msg("User does not exist") - goto basic - } - - log.Debug().Str("type", userSearch.Type).Msg("User exists") - - return types.UserContext{ - Username: cookie.Username, - Name: cookie.Name, - Email: cookie.Email, - IsLoggedIn: true, - Provider: "username", - } - } - - log.Debug().Msg("Provider is not username") - - provider = hooks.Providers.GetProvider(cookie.Provider) - - if provider != nil { - log.Debug().Msg("Provider exists") - - if !hooks.Auth.EmailWhitelisted(cookie.Email) { - log.Warn().Str("email", cookie.Email).Msg("Email is not whitelisted") - hooks.Auth.DeleteSessionCookie(c) - goto basic - } - - log.Debug().Msg("Email is whitelisted") - - return types.UserContext{ - Username: cookie.Username, - Name: cookie.Name, - Email: cookie.Email, - IsLoggedIn: true, - OAuth: true, - Provider: cookie.Provider, - OAuthGroups: cookie.OAuthGroups, - } - } - -basic: - log.Debug().Msg("Trying basic auth") - - basic := hooks.Auth.GetBasicAuth(c) - - if basic != nil { - log.Debug().Msg("Got basic auth") - - userSearch := hooks.Auth.SearchUser(basic.Username) - - if userSearch.Type == "unkown" { - log.Error().Str("username", basic.Username).Msg("Basic auth user does not exist") - return types.UserContext{} - } - - if !hooks.Auth.VerifyUser(userSearch, basic.Password) { - log.Error().Str("username", basic.Username).Msg("Basic auth user password incorrect") - return types.UserContext{} - } - - if userSearch.Type == "ldap" { - log.Debug().Msg("User is LDAP") - - return types.UserContext{ - Username: basic.Username, - Name: utils.Capitalize(basic.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), hooks.Config.Domain), - IsLoggedIn: true, - Provider: "basic", - TotpEnabled: false, - } - } - - user := hooks.Auth.GetLocalUser(basic.Username) - - return types.UserContext{ - Username: basic.Username, - Name: utils.Capitalize(basic.Username), - Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), hooks.Config.Domain), - IsLoggedIn: true, - Provider: "basic", - TotpEnabled: user.TotpSecret != "", - } - - } - - return types.UserContext{} -} diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go new file mode 100644 index 00000000..2c903be3 --- /dev/null +++ b/internal/middleware/context_middleware.go @@ -0,0 +1,160 @@ +package middleware + +import ( + "fmt" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/service" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" +) + +type ContextMiddlewareConfig struct { + CookieDomain string +} + +type ContextMiddleware struct { + config ContextMiddlewareConfig + auth *service.AuthService + broker *service.OAuthBrokerService +} + +func NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware { + return &ContextMiddleware{ + config: config, + auth: auth, + broker: broker, + } +} + +func (m *ContextMiddleware) Init() error { + return nil +} + +func (m *ContextMiddleware) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + cookie, err := m.auth.GetSessionCookie(c) + + if err != nil { + log.Debug().Err(err).Msg("No valid session cookie found") + goto basic + } + + if cookie.TotpPending { + c.Set("context", &config.UserContext{ + Username: cookie.Username, + Name: cookie.Name, + Email: cookie.Email, + Provider: "username", + TotpPending: true, + TotpEnabled: true, + }) + c.Next() + return + } + + switch cookie.Provider { + case "username": + userSearch := m.auth.SearchUser(cookie.Username) + + if userSearch.Type == "unknown" || userSearch.Type == "error" { + log.Debug().Msg("User from session cookie not found") + m.auth.DeleteSessionCookie(c) + goto basic + } + + c.Set("context", &config.UserContext{ + Username: cookie.Username, + Name: cookie.Name, + Email: cookie.Email, + Provider: "username", + IsLoggedIn: true, + }) + c.Next() + return + default: + _, exists := m.broker.GetService(cookie.Provider) + + if !exists { + log.Debug().Msg("OAuth provider from session cookie not found") + m.auth.DeleteSessionCookie(c) + goto basic + } + + if !m.auth.IsEmailWhitelisted(cookie.Email) { + log.Debug().Msg("Email from session cookie not whitelisted") + m.auth.DeleteSessionCookie(c) + goto basic + } + + c.Set("context", &config.UserContext{ + Username: cookie.Username, + Name: cookie.Name, + Email: cookie.Email, + Provider: cookie.Provider, + OAuthGroups: cookie.OAuthGroups, + OAuthName: cookie.OAuthName, + IsLoggedIn: true, + OAuth: true, + }) + c.Next() + return + } + + basic: + basic := m.auth.GetBasicAuth(c) + + if basic == nil { + log.Debug().Msg("No basic auth provided") + c.Next() + return + } + + userSearch := m.auth.SearchUser(basic.Username) + + if userSearch.Type == "unknown" || userSearch.Type == "error" { + log.Debug().Msg("User from basic auth not found") + c.Next() + return + } + + if !m.auth.VerifyUser(userSearch, basic.Password) { + log.Debug().Msg("Invalid password for basic auth user") + c.Next() + return + } + + switch userSearch.Type { + case "local": + log.Debug().Msg("Basic auth user is local") + + user := m.auth.GetLocalUser(basic.Username) + + c.Set("context", &config.UserContext{ + Username: user.Username, + Name: utils.Capitalize(user.Username), + Email: fmt.Sprintf("%s@%s", strings.ToLower(user.Username), m.config.CookieDomain), + Provider: "basic", + IsLoggedIn: true, + TotpEnabled: user.TotpSecret != "", + }) + c.Next() + return + case "ldap": + log.Debug().Msg("Basic auth user is LDAP") + c.Set("context", &config.UserContext{ + Username: basic.Username, + Name: utils.Capitalize(basic.Username), + Email: fmt.Sprintf("%s@%s", strings.ToLower(basic.Username), m.config.CookieDomain), + Provider: "basic", + IsLoggedIn: true, + }) + c.Next() + return + } + + c.Next() + } +} diff --git a/internal/middleware/ui_middleware.go b/internal/middleware/ui_middleware.go new file mode 100644 index 00000000..adcb784a --- /dev/null +++ b/internal/middleware/ui_middleware.go @@ -0,0 +1,67 @@ +package middleware + +import ( + "fmt" + "io/fs" + "net/http" + "os" + "strings" + "time" + "tinyauth/internal/assets" + + "github.com/gin-gonic/gin" +) + +type UIMiddleware struct { + uiFs fs.FS + uiFileServer http.Handler +} + +func NewUIMiddleware() *UIMiddleware { + return &UIMiddleware{} +} + +func (m *UIMiddleware) Init() error { + ui, err := fs.Sub(assets.FrontendAssets, "dist") + + if err != nil { + return err + } + + m.uiFs = ui + m.uiFileServer = http.FileServerFS(ui) + + return nil +} + +func (m *UIMiddleware) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + path := strings.TrimPrefix(c.Request.URL.Path, "/") + + switch strings.SplitN(path, "/", 2)[0] { + case "api": + c.Next() + return + case "resources": + c.Next() + return + default: + _, err := fs.Stat(m.uiFs, path) + + // Enough for one authentication flow + maxAge := 15 * time.Minute + + if os.IsNotExist(err) { + c.Request.URL.Path = "/" + } else if strings.HasPrefix(path, "assets/") { + // assets are named with a hash and can be cached for a long time + maxAge = 30 * 24 * time.Hour + } + + c.Writer.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(maxAge.Seconds()))) + m.uiFileServer.ServeHTTP(c.Writer, c.Request) + c.Abort() + return + } + } +} diff --git a/internal/middleware/zerolog_middleware.go b/internal/middleware/zerolog_middleware.go new file mode 100644 index 00000000..f3ca4855 --- /dev/null +++ b/internal/middleware/zerolog_middleware.go @@ -0,0 +1,72 @@ +package middleware + +import ( + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" +) + +var ( + loggerSkipPathsPrefix = []string{ + "GET /api/health", + "HEAD /api/health", + "GET /favicon.ico", + } +) + +type ZerologMiddleware struct{} + +func NewZerologMiddleware() *ZerologMiddleware { + return &ZerologMiddleware{} +} + +func (m *ZerologMiddleware) Init() error { + return nil +} + +func (m *ZerologMiddleware) logPath(path string) bool { + for _, prefix := range loggerSkipPathsPrefix { + if strings.HasPrefix(path, prefix) { + return false + } + } + return true +} + +func (m *ZerologMiddleware) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + tStart := time.Now() + + c.Next() + + code := c.Writer.Status() + address := c.Request.RemoteAddr + clientIP := c.ClientIP() + method := c.Request.Method + path := c.Request.URL.Path + + latency := time.Since(tStart).String() + + subLogger := log.With().Str("method", method). + Str("path", path). + Str("address", address). + Str("client_ip", clientIP). + Int("status", code). + Str("latency", latency).Logger() + + if m.logPath(method + " " + path) { + switch { + case code >= 400 && code < 500: + subLogger.Warn().Msg("Client Error") + case code >= 500: + subLogger.Error().Msg("Server Error") + default: + subLogger.Info().Msg("Request") + } + } else { + subLogger.Debug().Msg("Request") + } + } +} diff --git a/internal/model/session_model.go b/internal/model/session_model.go new file mode 100644 index 00000000..0fdb6c37 --- /dev/null +++ b/internal/model/session_model.go @@ -0,0 +1,13 @@ +package model + +type Session struct { + UUID string `gorm:"column:uuid;primaryKey"` + Username string `gorm:"column:username"` + Email string `gorm:"column:email"` + Name string `gorm:"column:name"` + Provider string `gorm:"column:provider"` + TOTPPending bool `gorm:"column:totp_pending"` + OAuthGroups string `gorm:"column:oauth_groups"` + Expiry int64 `gorm:"column:expiry"` + OAuthName string `gorm:"column:oauth_name"` +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go deleted file mode 100644 index 9529fce5..00000000 --- a/internal/oauth/oauth.go +++ /dev/null @@ -1,71 +0,0 @@ -package oauth - -import ( - "context" - "crypto/rand" - "crypto/tls" - "encoding/base64" - "net/http" - - "golang.org/x/oauth2" -) - -type OAuth struct { - Config oauth2.Config - Context context.Context - Token *oauth2.Token - Verifier string -} - -func NewOAuth(config oauth2.Config, insecureSkipVerify bool) *OAuth { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, - } - - httpClient := &http.Client{ - Transport: transport, - } - - ctx := context.Background() - - // Set the HTTP client in the context - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - - verifier := oauth2.GenerateVerifier() - - return &OAuth{ - Config: config, - Context: ctx, - Verifier: verifier, - } -} - -func (oauth *OAuth) GetAuthURL(state string) string { - return oauth.Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier)) -} - -func (oauth *OAuth) ExchangeToken(code string) (string, error) { - token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier)) - - if err != nil { - return "", err - } - - // Set and return the token - oauth.Token = token - return oauth.Token.AccessToken, nil -} - -func (oauth *OAuth) GetClient() *http.Client { - return oauth.Config.Client(oauth.Context, oauth.Token) -} - -func (oauth *OAuth) GenerateState() string { - b := make([]byte, 128) - rand.Read(b) - state := base64.URLEncoding.EncodeToString(b) - return state -} diff --git a/internal/providers/generic.go b/internal/providers/generic.go deleted file mode 100644 index 200f7c4b..00000000 --- a/internal/providers/generic.go +++ /dev/null @@ -1,37 +0,0 @@ -package providers - -import ( - "encoding/json" - "io" - "net/http" - "tinyauth/internal/constants" - - "github.com/rs/zerolog/log" -) - -func GetGenericUser(client *http.Client, url string) (constants.Claims, error) { - var user constants.Claims - - res, err := client.Get(url) - if err != nil { - return user, err - } - defer res.Body.Close() - - log.Debug().Msg("Got response from generic provider") - - body, err := io.ReadAll(res.Body) - if err != nil { - return user, err - } - - log.Debug().Msg("Read body from generic provider") - - err = json.Unmarshal(body, &user) - if err != nil { - return user, err - } - - log.Debug().Msg("Parsed user from generic provider") - return user, nil -} diff --git a/internal/providers/github.go b/internal/providers/github.go deleted file mode 100644 index 67f85104..00000000 --- a/internal/providers/github.go +++ /dev/null @@ -1,102 +0,0 @@ -package providers - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "tinyauth/internal/constants" - - "github.com/rs/zerolog/log" -) - -// Response for the github email endpoint -type GithubEmailResponse []struct { - Email string `json:"email"` - Primary bool `json:"primary"` -} - -// Response for the github user endpoint -type GithubUserInfoResponse struct { - Login string `json:"login"` - Name string `json:"name"` -} - -// The scopes required for the github provider -func GithubScopes() []string { - return []string{"user:email", "read:user"} -} - -func GetGithubUser(client *http.Client) (constants.Claims, error) { - var user constants.Claims - - res, err := client.Get("https://api.github.com/user") - if err != nil { - return user, err - } - defer res.Body.Close() - - log.Debug().Msg("Got user response from github") - - body, err := io.ReadAll(res.Body) - if err != nil { - return user, err - } - - log.Debug().Msg("Read user body from github") - - var userInfo GithubUserInfoResponse - - err = json.Unmarshal(body, &userInfo) - if err != nil { - return user, err - } - - res, err = client.Get("https://api.github.com/user/emails") - if err != nil { - return user, err - } - defer res.Body.Close() - - log.Debug().Msg("Got email response from github") - - body, err = io.ReadAll(res.Body) - if err != nil { - return user, err - } - - log.Debug().Msg("Read email body from github") - - var emails GithubEmailResponse - - err = json.Unmarshal(body, &emails) - if err != nil { - return user, err - } - - log.Debug().Msg("Parsed emails from github") - - // Find and return the primary email - for _, email := range emails { - if email.Primary { - log.Debug().Str("email", email.Email).Msg("Found primary email") - user.Email = email.Email - break - } - } - - if len(emails) == 0 { - return user, errors.New("no emails found") - } - - // Use first available email if no primary email was found - if user.Email == "" { - log.Warn().Str("email", emails[0].Email).Msg("No primary email found, using first email") - user.Email = emails[0].Email - } - - user.PreferredUsername = userInfo.Login - user.Name = userInfo.Name - - return user, nil -} diff --git a/internal/providers/google.go b/internal/providers/google.go deleted file mode 100644 index e794beec..00000000 --- a/internal/providers/google.go +++ /dev/null @@ -1,56 +0,0 @@ -package providers - -import ( - "encoding/json" - "io" - "net/http" - "strings" - "tinyauth/internal/constants" - - "github.com/rs/zerolog/log" -) - -// Response for the google user endpoint -type GoogleUserInfoResponse struct { - Email string `json:"email"` - Name string `json:"name"` -} - -// The scopes required for the google provider -func GoogleScopes() []string { - return []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"} -} - -func GetGoogleUser(client *http.Client) (constants.Claims, error) { - var user constants.Claims - - res, err := client.Get("https://www.googleapis.com/userinfo/v2/me") - if err != nil { - return user, err - } - defer res.Body.Close() - - log.Debug().Msg("Got response from google") - - body, err := io.ReadAll(res.Body) - if err != nil { - return user, err - } - - log.Debug().Msg("Read body from google") - - var userInfo GoogleUserInfoResponse - - err = json.Unmarshal(body, &userInfo) - if err != nil { - return user, err - } - - log.Debug().Msg("Parsed user from google") - - user.PreferredUsername = strings.Split(userInfo.Email, "@")[0] - user.Name = userInfo.Name - user.Email = userInfo.Email - - return user, nil -} diff --git a/internal/providers/providers.go b/internal/providers/providers.go deleted file mode 100644 index 7af127ea..00000000 --- a/internal/providers/providers.go +++ /dev/null @@ -1,154 +0,0 @@ -package providers - -import ( - "fmt" - "tinyauth/internal/constants" - "tinyauth/internal/oauth" - "tinyauth/internal/types" - - "github.com/rs/zerolog/log" - "golang.org/x/oauth2" - "golang.org/x/oauth2/endpoints" -) - -type Providers struct { - Config types.OAuthConfig - Github *oauth.OAuth - Google *oauth.OAuth - Generic *oauth.OAuth -} - -func NewProviders(config types.OAuthConfig) *Providers { - providers := &Providers{ - Config: config, - } - - if config.GithubClientId != "" && config.GithubClientSecret != "" { - log.Info().Msg("Initializing Github OAuth") - providers.Github = oauth.NewOAuth(oauth2.Config{ - ClientID: config.GithubClientId, - ClientSecret: config.GithubClientSecret, - RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", config.AppURL), - Scopes: GithubScopes(), - Endpoint: endpoints.GitHub, - }, false) - } - - if config.GoogleClientId != "" && config.GoogleClientSecret != "" { - log.Info().Msg("Initializing Google OAuth") - providers.Google = oauth.NewOAuth(oauth2.Config{ - ClientID: config.GoogleClientId, - ClientSecret: config.GoogleClientSecret, - RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", config.AppURL), - Scopes: GoogleScopes(), - Endpoint: endpoints.Google, - }, false) - } - - if config.GenericClientId != "" && config.GenericClientSecret != "" { - log.Info().Msg("Initializing Generic OAuth") - providers.Generic = oauth.NewOAuth(oauth2.Config{ - ClientID: config.GenericClientId, - ClientSecret: config.GenericClientSecret, - RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", config.AppURL), - Scopes: config.GenericScopes, - Endpoint: oauth2.Endpoint{ - AuthURL: config.GenericAuthURL, - TokenURL: config.GenericTokenURL, - }, - }, config.GenericSkipSSL) - } - - return providers -} - -func (providers *Providers) GetProvider(provider string) *oauth.OAuth { - switch provider { - case "github": - return providers.Github - case "google": - return providers.Google - case "generic": - return providers.Generic - default: - return nil - } -} - -func (providers *Providers) GetUser(provider string) (constants.Claims, error) { - var user constants.Claims - - // Get the user from the provider - switch provider { - case "github": - if providers.Github == nil { - log.Debug().Msg("Github provider not configured") - return user, nil - } - - client := providers.Github.GetClient() - - log.Debug().Msg("Got client from github") - - user, err := GetGithubUser(client) - if err != nil { - return user, err - } - - log.Debug().Msg("Got user from github") - - return user, nil - case "google": - if providers.Google == nil { - log.Debug().Msg("Google provider not configured") - return user, nil - } - - client := providers.Google.GetClient() - - log.Debug().Msg("Got client from google") - - user, err := GetGoogleUser(client) - if err != nil { - return user, err - } - - log.Debug().Msg("Got user from google") - - return user, nil - case "generic": - if providers.Generic == nil { - log.Debug().Msg("Generic provider not configured") - return user, nil - } - - client := providers.Generic.GetClient() - - log.Debug().Msg("Got client from generic") - - user, err := GetGenericUser(client, providers.Config.GenericUserURL) - if err != nil { - return user, err - } - - log.Debug().Msg("Got user from generic") - - return user, nil - default: - return user, nil - } -} - -func (provider *Providers) GetConfiguredProviders() []string { - providers := []string{} - if provider.Github != nil { - providers = append(providers, "github") - } - if provider.Google != nil { - providers = append(providers, "google") - } - if provider.Generic != nil { - providers = append(providers, "generic") - } - return providers -} diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index 88260322..00000000 --- a/internal/server/server.go +++ /dev/null @@ -1,130 +0,0 @@ -package server - -import ( - "fmt" - "io/fs" - "net/http" - "os" - "strings" - "time" - "tinyauth/internal/assets" - "tinyauth/internal/handlers" - "tinyauth/internal/types" - - "github.com/gin-gonic/gin" - "github.com/rs/zerolog/log" -) - -type Server struct { - Config types.ServerConfig - Handlers *handlers.Handlers - Router *gin.Engine -} - -var ( - loggerSkipPathsPrefix = []string{ - "GET /api/healthcheck", - "HEAD /api/healthcheck", - "GET /favicon.ico", - } -) - -func logPath(path string) bool { - for _, prefix := range loggerSkipPathsPrefix { - if strings.HasPrefix(path, prefix) { - return false - } - } - return true -} - -func NewServer(config types.ServerConfig, handlers *handlers.Handlers) (*Server, error) { - gin.SetMode(gin.ReleaseMode) - - log.Debug().Msg("Setting up router") - router := gin.New() - router.Use(zerolog()) - - log.Debug().Msg("Setting up assets") - dist, err := fs.Sub(assets.Assets, "dist") - if err != nil { - return nil, err - } - - log.Debug().Msg("Setting up file server") - fileServer := http.FileServer(http.FS(dist)) - - // UI middleware - router.Use(func(c *gin.Context) { - // If not an API request, serve the UI - if !strings.HasPrefix(c.Request.URL.Path, "/api") { - _, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/")) - if os.IsNotExist(err) { - c.Request.URL.Path = "/" - } - fileServer.ServeHTTP(c.Writer, c.Request) - c.Abort() - } - }) - - // Proxy routes - router.GET("/api/auth/:proxy", handlers.ProxyHandler) - - // Auth routes - router.POST("/api/login", handlers.LoginHandler) - router.POST("/api/totp", handlers.TOTPHandler) - router.POST("/api/logout", handlers.LogoutHandler) - - // Context routes - router.GET("/api/app", handlers.AppContextHandler) - router.GET("/api/user", handlers.UserContextHandler) - - // OAuth routes - router.GET("/api/oauth/url/:provider", handlers.OAuthURLHandler) - router.GET("/api/oauth/callback/:provider", handlers.OAuthCallbackHandler) - - // App routes - router.GET("/api/healthcheck", handlers.HealthcheckHandler) - router.HEAD("/api/healthcheck", handlers.HealthcheckHandler) - - return &Server{ - Config: config, - Handlers: handlers, - Router: router, - }, nil -} - -func (s *Server) Start() error { - log.Info().Str("address", s.Config.Address).Int("port", s.Config.Port).Msg("Starting server") - return s.Router.Run(fmt.Sprintf("%s:%d", s.Config.Address, s.Config.Port)) -} - -// zerolog is a middleware for gin that logs requests using zerolog -func zerolog() gin.HandlerFunc { - return func(c *gin.Context) { - tStart := time.Now() - - c.Next() - - code := c.Writer.Status() - address := c.Request.RemoteAddr - method := c.Request.Method - path := c.Request.URL.Path - - latency := time.Since(tStart).String() - - // logPath check if the path should be logged normally or with debug - if logPath(method + " " + path) { - switch { - case code >= 200 && code < 300: - log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") - case code >= 300 && code < 400: - log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") - case code >= 400: - log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") - } - } else { - log.Debug().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request") - } - } -} diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go new file mode 100644 index 00000000..f6e4b567 --- /dev/null +++ b/internal/service/access_controls_service.go @@ -0,0 +1,122 @@ +package service + +import ( + "tinyauth/internal/config" +) + +/* +Environment variable/flag based ACLs are disabled until v5 due to a technical challenge +with the current parsing logic. + +The current parser works for simple OAuth provider configs like: +- PROVIDERS_MY_AMAZING_PROVIDER_CLIENT_ID + +However, it breaks down when handling nested structs required for ACLs. The custom parsing +solution that worked for v4 OAuth providers is incompatible with the ACL parsing logic, +making the codebase unmaintainable and fragile. + +A solution is being considered for v5 that would standardize the format to something like: +- TINYAUTH_PROVIDERS_GOOGLE_CLIENTSECRET +- TINYAUTH_APPS_MYAPP_CONFIG_DOMAIN + +This would allow the Traefik parser to handle everything consistently, but requires a +config migration. Until this is resolved, environment-based ACLs are disabled and only +Docker label-based ACLs are supported. + +See: https://discord.com/channels/1337450123600465984/1337459086270271538/1434986689935179838 for more information +*/ + +type AccessControlsService struct { + docker *DockerService + // envACLs config.Apps +} + +func NewAccessControlsService(docker *DockerService) *AccessControlsService { + return &AccessControlsService{ + docker: docker, + } +} + +func (acls *AccessControlsService) Init() error { + // acls.envACLs = config.Apps{} + // env := os.Environ() + // appEnvVars := []string{} + + // for _, e := range env { + // if strings.HasPrefix(e, "TINYAUTH_APPS_") { + // appEnvVars = append(appEnvVars, e) + // } + // } + + // err := acls.loadEnvACLs(appEnvVars) + + // if err != nil { + // return err + // } + + // return nil + + return nil + +} + +// func (acls *AccessControlsService) loadEnvACLs(appEnvVars []string) error { +// if len(appEnvVars) == 0 { +// return nil +// } + +// envAcls := map[string]string{} + +// for _, e := range appEnvVars { +// parts := strings.SplitN(e, "=", 2) +// if len(parts) != 2 { +// continue +// } + +// key := parts[0] +// key = strings.ToLower(key) +// key = strings.ReplaceAll(key, "_", ".") +// value := parts[1] +// envAcls[key] = value +// } + +// apps, err := decoders.DecodeLabels(envAcls) + +// if err != nil { +// return err +// } + +// acls.envACLs = apps +// return nil +// } + +// func (acls *AccessControlsService) lookupEnvACLs(appDomain string) *config.App { +// if len(acls.envACLs.Apps) == 0 { +// return nil +// } + +// for appName, appACLs := range acls.envACLs.Apps { +// if appACLs.Config.Domain == appDomain { +// return &appACLs +// } + +// if strings.SplitN(appDomain, ".", 2)[0] == appName { +// return &appACLs +// } +// } + +// return nil +// } + +func (acls *AccessControlsService) GetAccessControls(appDomain string) (config.App, error) { + // First check environment variables + // envACLs := acls.lookupEnvACLs(appDomain) + + // if envACLs != nil { + // log.Debug().Str("domain", appDomain).Msg("Found matching access controls in environment variables") + // return *envACLs, nil + // } + + // Fallback to Docker labels + return acls.docker.GetLabels(appDomain) +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 00000000..bcba4811 --- /dev/null +++ b/internal/service/auth_service.go @@ -0,0 +1,423 @@ +package service + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "sync" + "time" + "tinyauth/internal/config" + "tinyauth/internal/model" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type LoginAttempt struct { + FailedAttempts int + LastAttempt time.Time + LockedUntil time.Time +} + +type AuthServiceConfig struct { + Users []config.User + OauthWhitelist string + SessionExpiry int + SecureCookie bool + CookieDomain string + LoginTimeout int + LoginMaxRetries int + SessionCookieName string +} + +type AuthService struct { + config AuthServiceConfig + docker *DockerService + loginAttempts map[string]*LoginAttempt + loginMutex sync.RWMutex + ldap *LdapService + database *gorm.DB + ctx context.Context +} + +func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService { + return &AuthService{ + config: config, + docker: docker, + loginAttempts: make(map[string]*LoginAttempt), + ldap: ldap, + database: database, + } +} + +func (auth *AuthService) Init() error { + auth.ctx = context.Background() + return nil +} + +func (auth *AuthService) SearchUser(username string) config.UserSearch { + if auth.GetLocalUser(username).Username != "" { + return config.UserSearch{ + Username: username, + Type: "local", + } + } + + if auth.ldap != nil { + userDN, err := auth.ldap.Search(username) + + if err != nil { + log.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP") + return config.UserSearch{ + Type: "error", + } + } + + return config.UserSearch{ + Username: userDN, + Type: "ldap", + } + } + + return config.UserSearch{ + Type: "unknown", + } +} + +func (auth *AuthService) VerifyUser(search config.UserSearch, password string) bool { + switch search.Type { + case "local": + user := auth.GetLocalUser(search.Username) + return auth.CheckPassword(user, password) + case "ldap": + if auth.ldap != nil { + err := auth.ldap.Bind(search.Username, password) + if err != nil { + log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP") + return false + } + + err = auth.ldap.Bind(auth.ldap.Config.BindDN, auth.ldap.Config.BindPassword) + if err != nil { + log.Error().Err(err).Msg("Failed to rebind with service account after user authentication") + return false + } + + return true + } + default: + log.Debug().Str("type", search.Type).Msg("Unknown user type for authentication") + return false + } + + log.Warn().Str("username", search.Username).Msg("User authentication failed") + return false +} + +func (auth *AuthService) GetLocalUser(username string) config.User { + for _, user := range auth.config.Users { + if user.Username == username { + return user + } + } + + log.Warn().Str("username", username).Msg("Local user not found") + return config.User{} +} + +func (auth *AuthService) CheckPassword(user config.User, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil +} + +func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) { + auth.loginMutex.RLock() + defer auth.loginMutex.RUnlock() + + if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 { + return false, 0 + } + + attempt, exists := auth.loginAttempts[identifier] + if !exists { + return false, 0 + } + + if attempt.LockedUntil.After(time.Now()) { + remaining := int(time.Until(attempt.LockedUntil).Seconds()) + return true, remaining + } + + return false, 0 +} + +func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) { + if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 { + return + } + + auth.loginMutex.Lock() + defer auth.loginMutex.Unlock() + + attempt, exists := auth.loginAttempts[identifier] + if !exists { + attempt = &LoginAttempt{} + auth.loginAttempts[identifier] = attempt + } + + attempt.LastAttempt = time.Now() + + if success { + attempt.FailedAttempts = 0 + attempt.LockedUntil = time.Time{} // Reset lock time + return + } + + attempt.FailedAttempts++ + + if attempt.FailedAttempts >= auth.config.LoginMaxRetries { + attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second) + log.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts") + } +} + +func (auth *AuthService) IsEmailWhitelisted(email string) bool { + return utils.CheckFilter(auth.config.OauthWhitelist, email) +} + +func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.SessionCookie) error { + uuid, err := uuid.NewRandom() + + if err != nil { + return err + } + + var expiry int + + if data.TotpPending { + expiry = 3600 + } else { + expiry = auth.config.SessionExpiry + } + + session := model.Session{ + UUID: uuid.String(), + Username: data.Username, + Email: data.Email, + Name: data.Name, + Provider: data.Provider, + TOTPPending: data.TotpPending, + OAuthGroups: data.OAuthGroups, + Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(), + OAuthName: data.OAuthName, + } + + err = gorm.G[model.Session](auth.database).Create(auth.ctx, &session) + + if err != nil { + return err + } + + c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) + + return nil +} + +func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error { + cookie, err := c.Cookie(auth.config.SessionCookieName) + + if err != nil { + return err + } + + _, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(auth.ctx) + + if err != nil { + return err + } + + c.SetCookie(auth.config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true) + + return nil +} + +func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, error) { + cookie, err := c.Cookie(auth.config.SessionCookieName) + + if err != nil { + return config.SessionCookie{}, err + } + + session, err := gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).First(auth.ctx) + + if err != nil { + return config.SessionCookie{}, err + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + return config.SessionCookie{}, fmt.Errorf("session not found") + } + + currentTime := time.Now().Unix() + + if currentTime > session.Expiry { + _, err = gorm.G[model.Session](auth.database).Where("uuid = ?", cookie).Delete(auth.ctx) + if err != nil { + log.Error().Err(err).Msg("Failed to delete expired session") + } + return config.SessionCookie{}, fmt.Errorf("session expired") + } + + return config.SessionCookie{ + UUID: session.UUID, + Username: session.Username, + Email: session.Email, + Name: session.Name, + Provider: session.Provider, + TotpPending: session.TOTPPending, + OAuthGroups: session.OAuthGroups, + OAuthName: session.OAuthName, + }, nil +} + +func (auth *AuthService) UserAuthConfigured() bool { + return len(auth.config.Users) > 0 || auth.ldap != nil +} + +func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, acls config.App) bool { + if context.OAuth { + log.Debug().Msg("Checking OAuth whitelist") + return utils.CheckFilter(acls.OAuth.Whitelist, context.Email) + } + + if acls.Users.Block != "" { + log.Debug().Msg("Checking blocked users") + if utils.CheckFilter(acls.Users.Block, context.Username) { + return false + } + } + + log.Debug().Msg("Checking users") + return utils.CheckFilter(acls.Users.Allow, context.Username) +} + +func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool { + if requiredGroups == "" { + return true + } + + for id := range config.OverrideProviders { + if context.Provider == id { + log.Info().Str("provider", id).Msg("OAuth groups not supported for this provider") + return true + } + } + + for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") { + if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) { + log.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched") + return true + } + } + + log.Debug().Msg("No groups matched") + return false +} + +func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) { + // Check for block list + if path.Block != "" { + regex, err := regexp.Compile(path.Block) + + if err != nil { + return true, err + } + + if !regex.MatchString(uri) { + return false, nil + } + } + + // Check for allow list + if path.Allow != "" { + regex, err := regexp.Compile(path.Allow) + + if err != nil { + return true, err + } + + if regex.MatchString(uri) { + return false, nil + } + } + + return true, nil +} + +func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User { + username, password, ok := c.Request.BasicAuth() + if !ok { + log.Debug().Msg("No basic auth provided") + return nil + } + return &config.User{ + Username: username, + Password: password, + } +} + +func (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool { + for _, blocked := range acls.Block { + res, err := utils.FilterIP(blocked, ip) + if err != nil { + log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list") + continue + } + if res { + log.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access") + return false + } + } + + for _, allowed := range acls.Allow { + res, err := utils.FilterIP(allowed, ip) + if err != nil { + log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list") + continue + } + if res { + log.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access") + return true + } + } + + if len(acls.Allow) > 0 { + log.Debug().Str("ip", ip).Msg("IP not in allow list, denying access") + return false + } + + log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default") + return true +} + +func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool { + for _, bypassed := range acls.Bypass { + res, err := utils.FilterIP(bypassed, ip) + if err != nil { + log.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list") + continue + } + if res { + log.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access") + return true + } + } + + log.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication") + return false +} diff --git a/internal/service/database_service.go b/internal/service/database_service.go new file mode 100644 index 00000000..eb75b9fc --- /dev/null +++ b/internal/service/database_service.go @@ -0,0 +1,78 @@ +package service + +import ( + "database/sql" + "tinyauth/internal/assets" + + "github.com/glebarez/sqlite" + "github.com/golang-migrate/migrate/v4" + sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" + "github.com/golang-migrate/migrate/v4/source/iofs" + "gorm.io/gorm" +) + +type DatabaseServiceConfig struct { + DatabasePath string +} + +type DatabaseService struct { + config DatabaseServiceConfig + database *gorm.DB +} + +func NewDatabaseService(config DatabaseServiceConfig) *DatabaseService { + return &DatabaseService{ + config: config, + } +} + +func (ds *DatabaseService) Init() error { + gormDB, err := gorm.Open(sqlite.Open(ds.config.DatabasePath), &gorm.Config{}) + + if err != nil { + return err + } + + sqlDB, err := gormDB.DB() + + if err != nil { + return err + } + + sqlDB.SetMaxOpenConns(1) + + err = ds.migrateDatabase(sqlDB) + + if err != nil && err != migrate.ErrNoChange { + return err + } + + ds.database = gormDB + return nil +} + +func (ds *DatabaseService) migrateDatabase(sqlDB *sql.DB) error { + data, err := iofs.New(assets.Migrations, "migrations") + + if err != nil { + return err + } + + target, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{}) + + if err != nil { + return err + } + + migrator, err := migrate.NewWithInstance("iofs", data, "tinyauth", target) + + if err != nil { + return err + } + + return migrator.Up() +} + +func (ds *DatabaseService) GetDatabase() *gorm.DB { + return ds.database +} diff --git a/internal/service/docker_service.go b/internal/service/docker_service.go new file mode 100644 index 00000000..b0f977d3 --- /dev/null +++ b/internal/service/docker_service.go @@ -0,0 +1,105 @@ +package service + +import ( + "context" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + container "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/rs/zerolog/log" +) + +type DockerService struct { + client *client.Client + context context.Context + isConnected bool +} + +func NewDockerService() *DockerService { + return &DockerService{} +} + +func (docker *DockerService) Init() error { + client, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return err + } + + ctx := context.Background() + client.NegotiateAPIVersion(ctx) + + docker.client = client + docker.context = ctx + + _, err = docker.client.Ping(docker.context) + + if err != nil { + log.Debug().Err(err).Msg("Docker not connected") + docker.isConnected = false + docker.client = nil + docker.context = nil + return nil + } + + docker.isConnected = true + log.Debug().Msg("Docker connected") + + return nil +} + +func (docker *DockerService) getContainers() ([]container.Summary, error) { + containers, err := docker.client.ContainerList(docker.context, container.ListOptions{}) + if err != nil { + return nil, err + } + return containers, nil +} + +func (docker *DockerService) inspectContainer(containerId string) (container.InspectResponse, error) { + inspect, err := docker.client.ContainerInspect(docker.context, containerId) + if err != nil { + return container.InspectResponse{}, err + } + return inspect, nil +} + +func (docker *DockerService) GetLabels(appDomain string) (config.App, error) { + if !docker.isConnected { + log.Debug().Msg("Docker not connected, returning empty labels") + return config.App{}, nil + } + + containers, err := docker.getContainers() + if err != nil { + return config.App{}, err + } + + for _, ctr := range containers { + inspect, err := docker.inspectContainer(ctr.ID) + if err != nil { + return config.App{}, err + } + + labels, err := decoders.DecodeLabels(inspect.Config.Labels) + if err != nil { + return config.App{}, err + } + + for appName, appLabels := range labels.Apps { + if appLabels.Config.Domain == appDomain { + log.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain") + return appLabels, nil + } + + if strings.SplitN(appDomain, ".", 2)[0] == appName { + log.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name") + return appLabels, nil + } + } + } + + log.Debug().Msg("No matching container found, returning empty labels") + return config.App{}, nil +} diff --git a/internal/service/generic_oauth_service.go b/internal/service/generic_oauth_service.go new file mode 100644 index 00000000..49fa9bd3 --- /dev/null +++ b/internal/service/generic_oauth_service.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + "tinyauth/internal/config" + + "github.com/rs/zerolog/log" + "golang.org/x/oauth2" +) + +type GenericOAuthService struct { + config oauth2.Config + context context.Context + token *oauth2.Token + verifier string + insecureSkipVerify bool + userinfoUrl string + name string +} + +func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService { + return &GenericOAuthService{ + config: oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURL: config.RedirectURL, + Scopes: config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: config.AuthURL, + TokenURL: config.TokenURL, + }, + }, + insecureSkipVerify: config.InsecureSkipVerify, + userinfoUrl: config.UserinfoURL, + name: config.Name, + } +} + +func (generic *GenericOAuthService) Init() error { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: generic.insecureSkipVerify, + MinVersion: tls.VersionTLS12, + }, + } + + httpClient := &http.Client{ + Transport: transport, + } + + ctx := context.Background() + + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + generic.context = ctx + return nil +} + +func (generic *GenericOAuthService) GenerateState() string { + b := make([]byte, 128) + _, err := rand.Read(b) + if err != nil { + return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano())) + } + state := base64.RawURLEncoding.EncodeToString(b) + return state +} + +func (generic *GenericOAuthService) GenerateVerifier() string { + verifier := oauth2.GenerateVerifier() + generic.verifier = verifier + return verifier +} + +func (generic *GenericOAuthService) GetAuthURL(state string) string { + return generic.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(generic.verifier)) +} + +func (generic *GenericOAuthService) VerifyCode(code string) error { + token, err := generic.config.Exchange(generic.context, code, oauth2.VerifierOption(generic.verifier)) + + if err != nil { + return err + } + + generic.token = token + return nil +} + +func (generic *GenericOAuthService) Userinfo() (config.Claims, error) { + var user config.Claims + + client := generic.config.Client(generic.context, generic.token) + + res, err := client.Get(generic.userinfoUrl) + if err != nil { + return user, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return user, fmt.Errorf("request failed with status: %s", res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return user, err + } + + log.Trace().Str("body", string(body)).Msg("Userinfo response body") + + err = json.Unmarshal(body, &user) + if err != nil { + return user, err + } + + return user, nil +} + +func (generic *GenericOAuthService) GetName() string { + return generic.name +} diff --git a/internal/service/github_oauth_service.go b/internal/service/github_oauth_service.go new file mode 100644 index 00000000..0d3d76fa --- /dev/null +++ b/internal/service/github_oauth_service.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + "tinyauth/internal/config" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" +) + +var GithubOAuthScopes = []string{"user:email", "read:user"} + +type GithubEmailResponse []struct { + Email string `json:"email"` + Primary bool `json:"primary"` +} + +type GithubUserInfoResponse struct { + Login string `json:"login"` + Name string `json:"name"` +} + +type GithubOAuthService struct { + config oauth2.Config + context context.Context + token *oauth2.Token + verifier string + name string +} + +func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService { + return &GithubOAuthService{ + config: oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURL: config.RedirectURL, + Scopes: GithubOAuthScopes, + Endpoint: endpoints.GitHub, + }, + name: config.Name, + } +} + +func (github *GithubOAuthService) Init() error { + httpClient := &http.Client{} + ctx := context.Background() + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + github.context = ctx + return nil +} + +func (github *GithubOAuthService) GenerateState() string { + b := make([]byte, 128) + _, err := rand.Read(b) + if err != nil { + return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano())) + } + state := base64.RawURLEncoding.EncodeToString(b) + return state +} + +func (github *GithubOAuthService) GenerateVerifier() string { + verifier := oauth2.GenerateVerifier() + github.verifier = verifier + return verifier +} + +func (github *GithubOAuthService) GetAuthURL(state string) string { + return github.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.verifier)) +} + +func (github *GithubOAuthService) VerifyCode(code string) error { + token, err := github.config.Exchange(github.context, code, oauth2.VerifierOption(github.verifier)) + + if err != nil { + return err + } + + github.token = token + return nil +} + +func (github *GithubOAuthService) Userinfo() (config.Claims, error) { + var user config.Claims + + client := github.config.Client(github.context, github.token) + + req, err := http.NewRequest("GET", "https://api.github.com/user", nil) + if err != nil { + return user, err + } + + req.Header.Set("Accept", "application/vnd.github+json") + + res, err := client.Do(req) + if err != nil { + return user, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return user, fmt.Errorf("request failed with status: %s", res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return user, err + } + + var userInfo GithubUserInfoResponse + + err = json.Unmarshal(body, &userInfo) + if err != nil { + return user, err + } + + req, err = http.NewRequest("GET", "https://api.github.com/user/emails", nil) + if err != nil { + return user, err + } + + req.Header.Set("Accept", "application/vnd.github+json") + + res, err = client.Do(req) + if err != nil { + return user, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return user, fmt.Errorf("request failed with status: %s", res.Status) + } + + body, err = io.ReadAll(res.Body) + if err != nil { + return user, err + } + + var emails GithubEmailResponse + + err = json.Unmarshal(body, &emails) + if err != nil { + return user, err + } + + for _, email := range emails { + if email.Primary { + user.Email = email.Email + break + } + } + + if len(emails) == 0 { + return user, errors.New("no emails found") + } + + // Use first available email if no primary email was found + if user.Email == "" { + user.Email = emails[0].Email + } + + user.PreferredUsername = userInfo.Login + user.Name = userInfo.Name + + return user, nil +} + +func (github *GithubOAuthService) GetName() string { + return github.name +} diff --git a/internal/service/google_oauth_service.go b/internal/service/google_oauth_service.go new file mode 100644 index 00000000..474c2851 --- /dev/null +++ b/internal/service/google_oauth_service.go @@ -0,0 +1,122 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + "tinyauth/internal/config" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" +) + +var GoogleOAuthScopes = []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"} + +type GoogleUserInfoResponse struct { + Email string `json:"email"` + Name string `json:"name"` +} + +type GoogleOAuthService struct { + config oauth2.Config + context context.Context + token *oauth2.Token + verifier string + name string +} + +func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService { + return &GoogleOAuthService{ + config: oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURL: config.RedirectURL, + Scopes: GoogleOAuthScopes, + Endpoint: endpoints.Google, + }, + name: config.Name, + } +} + +func (google *GoogleOAuthService) Init() error { + httpClient := &http.Client{} + ctx := context.Background() + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + google.context = ctx + return nil +} + +func (oauth *GoogleOAuthService) GenerateState() string { + b := make([]byte, 128) + _, err := rand.Read(b) + if err != nil { + return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano())) + } + state := base64.RawURLEncoding.EncodeToString(b) + return state +} + +func (google *GoogleOAuthService) GenerateVerifier() string { + verifier := oauth2.GenerateVerifier() + google.verifier = verifier + return verifier +} + +func (google *GoogleOAuthService) GetAuthURL(state string) string { + return google.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(google.verifier)) +} + +func (google *GoogleOAuthService) VerifyCode(code string) error { + token, err := google.config.Exchange(google.context, code, oauth2.VerifierOption(google.verifier)) + + if err != nil { + return err + } + + google.token = token + return nil +} + +func (google *GoogleOAuthService) Userinfo() (config.Claims, error) { + var user config.Claims + + client := google.config.Client(google.context, google.token) + + res, err := client.Get("https://www.googleapis.com/userinfo/v2/me") + if err != nil { + return config.Claims{}, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return user, fmt.Errorf("request failed with status: %s", res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return config.Claims{}, err + } + + var userInfo GoogleUserInfoResponse + + err = json.Unmarshal(body, &userInfo) + if err != nil { + return config.Claims{}, err + } + + user.PreferredUsername = strings.Split(userInfo.Email, "@")[0] + user.Name = userInfo.Name + user.Email = userInfo.Email + + return user, nil +} + +func (google *GoogleOAuthService) GetName() string { + return google.name +} diff --git a/internal/ldap/ldap.go b/internal/service/ldap_service.go similarity index 56% rename from internal/ldap/ldap.go rename to internal/service/ldap_service.go index 61578d76..5734c639 100644 --- a/internal/ldap/ldap.go +++ b/internal/service/ldap_service.go @@ -1,30 +1,42 @@ -package ldap +package service import ( "context" "crypto/tls" "fmt" + "sync" "time" - "tinyauth/internal/types" "github.com/cenkalti/backoff/v5" ldapgo "github.com/go-ldap/ldap/v3" "github.com/rs/zerolog/log" ) -type LDAP struct { - Config types.LdapConfig - Conn *ldapgo.Conn +type LdapServiceConfig struct { + Address string + BindDN string + BindPassword string + BaseDN string + Insecure bool + SearchFilter string } -func NewLDAP(config types.LdapConfig) (*LDAP, error) { - ldap := &LDAP{ +type LdapService struct { + Config LdapServiceConfig // exported so as the auth service can use it + conn *ldapgo.Conn + mutex sync.RWMutex +} + +func NewLdapService(config LdapServiceConfig) *LdapService { + return &LdapService{ Config: config, } +} +func (ldap *LdapService) Init() error { _, err := ldap.connect() if err != nil { - return nil, fmt.Errorf("failed to connect to LDAP server: %w", err) + return fmt.Errorf("failed to connect to LDAP server: %w", err) } go func() { @@ -41,65 +53,71 @@ func NewLDAP(config types.LdapConfig) (*LDAP, error) { } }() - return ldap, nil + return nil } -func (l *LDAP) connect() (*ldapgo.Conn, error) { - log.Debug().Msg("Connecting to LDAP server") - conn, err := ldapgo.DialURL(l.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{ - InsecureSkipVerify: l.Config.Insecure, +func (ldap *LdapService) connect() (*ldapgo.Conn, error) { + ldap.mutex.Lock() + defer ldap.mutex.Unlock() + + conn, err := ldapgo.DialURL(ldap.Config.Address, ldapgo.DialWithTLSConfig(&tls.Config{ + InsecureSkipVerify: ldap.Config.Insecure, MinVersion: tls.VersionTLS12, })) if err != nil { return nil, err } - log.Debug().Msg("Binding to LDAP server") - err = conn.Bind(l.Config.BindDN, l.Config.BindPassword) + err = conn.Bind(ldap.Config.BindDN, ldap.Config.BindPassword) if err != nil { return nil, err } // Set and return the connection - l.Conn = conn + ldap.conn = conn return conn, nil } -func (l *LDAP) Search(username string) (string, error) { +func (ldap *LdapService) Search(username string) (string, error) { // Escape the username to prevent LDAP injection escapedUsername := ldapgo.EscapeFilter(username) - filter := fmt.Sprintf(l.Config.SearchFilter, escapedUsername) + filter := fmt.Sprintf(ldap.Config.SearchFilter, escapedUsername) searchRequest := ldapgo.NewSearchRequest( - l.Config.BaseDN, + ldap.Config.BaseDN, ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false, filter, []string{"dn"}, nil, ) - searchResult, err := l.Conn.Search(searchRequest) + ldap.mutex.Lock() + defer ldap.mutex.Unlock() + + searchResult, err := ldap.conn.Search(searchRequest) if err != nil { return "", err } if len(searchResult.Entries) != 1 { - return "", fmt.Errorf("err multiple or no entries found for user %s", username) + return "", fmt.Errorf("multiple or no entries found for user %s", username) } userDN := searchResult.Entries[0].DN return userDN, nil } -func (l *LDAP) Bind(userDN string, password string) error { - err := l.Conn.Bind(userDN, password) +func (ldap *LdapService) Bind(userDN string, password string) error { + ldap.mutex.Lock() + defer ldap.mutex.Unlock() + err := ldap.conn.Bind(userDN, password) if err != nil { return err } return nil } -func (l *LDAP) heartbeat() error { +func (ldap *LdapService) heartbeat() error { log.Debug().Msg("Performing LDAP connection heartbeat") searchRequest := ldapgo.NewSearchRequest( @@ -110,7 +128,9 @@ func (l *LDAP) heartbeat() error { nil, ) - _, err := l.Conn.Search(searchRequest) + ldap.mutex.Lock() + defer ldap.mutex.Unlock() + _, err := ldap.conn.Search(searchRequest) if err != nil { return err } @@ -119,7 +139,7 @@ func (l *LDAP) heartbeat() error { return nil } -func (l *LDAP) reconnect() error { +func (ldap *LdapService) reconnect() error { log.Info().Msg("Reconnecting to LDAP server") exp := backoff.NewExponentialBackOff() @@ -129,10 +149,10 @@ func (l *LDAP) reconnect() error { exp.Reset() operation := func() (*ldapgo.Conn, error) { - l.Conn.Close() - conn, err := l.connect() + ldap.conn.Close() + conn, err := ldap.connect() if err != nil { - return nil, nil + return nil, err } return conn, nil } diff --git a/internal/service/oauth_broker_service.go b/internal/service/oauth_broker_service.go new file mode 100644 index 00000000..e15d9c69 --- /dev/null +++ b/internal/service/oauth_broker_service.go @@ -0,0 +1,80 @@ +package service + +import ( + "errors" + "tinyauth/internal/config" + + "github.com/rs/zerolog/log" + "golang.org/x/exp/slices" +) + +type OAuthService interface { + Init() error + GenerateState() string + GenerateVerifier() string + GetAuthURL(state string) string + VerifyCode(code string) error + Userinfo() (config.Claims, error) + GetName() string +} + +type OAuthBrokerService struct { + services map[string]OAuthService + configs map[string]config.OAuthServiceConfig +} + +func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService { + return &OAuthBrokerService{ + services: make(map[string]OAuthService), + configs: configs, + } +} + +func (broker *OAuthBrokerService) Init() error { + for name, cfg := range broker.configs { + switch name { + case "github": + service := NewGithubOAuthService(cfg) + broker.services[name] = service + case "google": + service := NewGoogleOAuthService(cfg) + broker.services[name] = service + default: + service := NewGenericOAuthService(cfg) + broker.services[name] = service + } + } + + for name, service := range broker.services { + err := service.Init() + if err != nil { + log.Error().Err(err).Msgf("Failed to initialize OAuth service: %T", name) + return err + } + log.Info().Str("service", name).Msg("Initialized OAuth service") + } + + return nil +} + +func (broker *OAuthBrokerService) GetConfiguredServices() []string { + services := make([]string, 0, len(broker.services)) + for name := range broker.services { + services = append(services, name) + } + slices.Sort(services) + return services +} + +func (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) { + service, exists := broker.services[name] + return service, exists +} + +func (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) { + oauthService, exists := broker.services[service] + if !exists { + return config.Claims{}, errors.New("oauth service not found") + } + return oauthService.Userinfo() +} diff --git a/internal/types/api.go b/internal/types/api.go deleted file mode 100644 index fbf8bf77..00000000 --- a/internal/types/api.go +++ /dev/null @@ -1,62 +0,0 @@ -package types - -// LoginQuery is the query parameters for the login endpoint -type LoginQuery struct { - RedirectURI string `url:"redirect_uri"` -} - -// LoginRequest is the request body for the login endpoint -type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// OAuthRequest is the request for the OAuth endpoint -type OAuthRequest struct { - Provider string `uri:"provider" binding:"required"` -} - -// UnauthorizedQuery is the query parameters for the unauthorized endpoint -type UnauthorizedQuery struct { - Username string `url:"username"` - Resource string `url:"resource"` - GroupErr bool `url:"groupErr"` - IP string `url:"ip"` -} - -// Proxy is the uri parameters for the proxy endpoint -type Proxy struct { - Proxy string `uri:"proxy" binding:"required"` -} - -// User Context response is the response for the user context endpoint -type UserContextResponse struct { - Status int `json:"status"` - Message string `json:"message"` - IsLoggedIn bool `json:"isLoggedIn"` - Username string `json:"username"` - Name string `json:"name"` - Email string `json:"email"` - Provider string `json:"provider"` - Oauth bool `json:"oauth"` - TotpPending bool `json:"totpPending"` -} - -// App Context is the response for the app context endpoint -type AppContext struct { - Status int `json:"status"` - Message string `json:"message"` - ConfiguredProviders []string `json:"configuredProviders"` - DisableContinue bool `json:"disableContinue"` - Title string `json:"title"` - GenericName string `json:"genericName"` - Domain string `json:"domain"` - ForgotPasswordMessage string `json:"forgotPasswordMessage"` - BackgroundImage string `json:"backgroundImage"` - OAuthAutoRedirect string `json:"oauthAutoRedirect"` -} - -// Totp request is the request for the totp endpoint -type TotpRequest struct { - Code string `json:"code"` -} diff --git a/internal/types/config.go b/internal/types/config.go deleted file mode 100644 index b53e0536..00000000 --- a/internal/types/config.go +++ /dev/null @@ -1,147 +0,0 @@ -package types - -// Config is the configuration for the tinyauth server -type Config struct { - Port int `mapstructure:"port" validate:"required"` - Address string `validate:"required,ip4_addr" mapstructure:"address"` - Secret string `validate:"required,len=32" mapstructure:"secret"` - SecretFile string `mapstructure:"secret-file"` - AppURL string `validate:"required,url" mapstructure:"app-url"` - Users string `mapstructure:"users"` - UsersFile string `mapstructure:"users-file"` - CookieSecure bool `mapstructure:"cookie-secure"` - GithubClientId string `mapstructure:"github-client-id"` - GithubClientSecret string `mapstructure:"github-client-secret"` - GithubClientSecretFile string `mapstructure:"github-client-secret-file"` - GoogleClientId string `mapstructure:"google-client-id"` - GoogleClientSecret string `mapstructure:"google-client-secret"` - GoogleClientSecretFile string `mapstructure:"google-client-secret-file"` - GenericClientId string `mapstructure:"generic-client-id"` - GenericClientSecret string `mapstructure:"generic-client-secret"` - GenericClientSecretFile string `mapstructure:"generic-client-secret-file"` - GenericScopes string `mapstructure:"generic-scopes"` - GenericAuthURL string `mapstructure:"generic-auth-url"` - GenericTokenURL string `mapstructure:"generic-token-url"` - GenericUserURL string `mapstructure:"generic-user-url"` - GenericName string `mapstructure:"generic-name"` - GenericSkipSSL bool `mapstructure:"generic-skip-ssl"` - DisableContinue bool `mapstructure:"disable-continue"` - OAuthWhitelist string `mapstructure:"oauth-whitelist"` - OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"` - SessionExpiry int `mapstructure:"session-expiry"` - LogLevel int8 `mapstructure:"log-level" validate:"min=-1,max=5"` - Title string `mapstructure:"app-title"` - EnvFile string `mapstructure:"env-file"` - LoginTimeout int `mapstructure:"login-timeout"` - LoginMaxRetries int `mapstructure:"login-max-retries"` - FogotPasswordMessage string `mapstructure:"forgot-password-message"` - BackgroundImage string `mapstructure:"background-image" validate:"required"` - LdapAddress string `mapstructure:"ldap-address"` - LdapBindDN string `mapstructure:"ldap-bind-dn"` - LdapBindPassword string `mapstructure:"ldap-bind-password"` - LdapBaseDN string `mapstructure:"ldap-base-dn"` - LdapInsecure bool `mapstructure:"ldap-insecure"` - LdapSearchFilter string `mapstructure:"ldap-search-filter"` -} - -// Server configuration -type HandlersConfig struct { - AppURL string - Domain string - CookieSecure bool - DisableContinue bool - GenericName string - Title string - ForgotPasswordMessage string - BackgroundImage string - OAuthAutoRedirect string - CsrfCookieName string - RedirectCookieName string -} - -// OAuthConfig is the configuration for the providers -type OAuthConfig struct { - GithubClientId string - GithubClientSecret string - GoogleClientId string - GoogleClientSecret string - GenericClientId string - GenericClientSecret string - GenericScopes []string - GenericAuthURL string - GenericTokenURL string - GenericUserURL string - GenericSkipSSL bool - AppURL string -} - -// ServerConfig is the configuration for the server -type ServerConfig struct { - Port int - Address string -} - -// AuthConfig is the configuration for the auth service -type AuthConfig struct { - Users Users - OauthWhitelist string - SessionExpiry int - CookieSecure bool - Domain string - LoginTimeout int - LoginMaxRetries int - SessionCookieName string - HMACSecret string - EncryptionSecret string -} - -// HooksConfig is the configuration for the hooks service -type HooksConfig struct { - Domain string -} - -// OAuthLabels is a list of labels that can be used in a tinyauth protected container -type OAuthLabels struct { - Whitelist string - Groups string -} - -// Basic auth labels for a tinyauth protected container -type BasicLabels struct { - Username string - Password PassowrdLabels -} - -// PassowrdLabels is a struct that contains the password labels for a tinyauth protected container -type PassowrdLabels struct { - Plain string - File string -} - -// IP labels for a tinyauth protected container -type IPLabels struct { - Allow []string - Block []string - Bypass []string -} - -// Labels is a struct that contains the labels for a tinyauth protected container -type Labels struct { - Users string - Allowed string - Headers []string - Domain []string - Basic BasicLabels - OAuth OAuthLabels - IP IPLabels -} - -// Ldap config is a struct that contains the configuration for the LDAP service -type LdapConfig struct { - Address string - BindDN string - BindPassword string - BaseDN string - Insecure bool - SearchFilter string -} diff --git a/internal/types/types.go b/internal/types/types.go deleted file mode 100644 index 2c40ae55..00000000 --- a/internal/types/types.go +++ /dev/null @@ -1,59 +0,0 @@ -package types - -import ( - "time" - "tinyauth/internal/oauth" -) - -// User is the struct for a user -type User struct { - Username string - Password string - TotpSecret string -} - -// UserSearch is the response of the get user -type UserSearch struct { - Username string - Type string // "local", "ldap" or empty -} - -// Users is a list of users -type Users []User - -// OAuthProviders is the struct for the OAuth providers -type OAuthProviders struct { - Github *oauth.OAuth - Google *oauth.OAuth - Microsoft *oauth.OAuth -} - -// SessionCookie is the cookie for the session (exculding the expiry) -type SessionCookie struct { - Username string - Name string - Email string - Provider string - TotpPending bool - OAuthGroups string -} - -// UserContext is the context for the user -type UserContext struct { - Username string - Name string - Email string - IsLoggedIn bool - OAuth bool - Provider string - TotpPending bool - OAuthGroups string - TotpEnabled bool -} - -// LoginAttempt tracks information about login attempts for rate limiting -type LoginAttempt struct { - FailedAttempts int - LastAttempt time.Time - LockedUntil time.Time -} diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go new file mode 100644 index 00000000..40f48e8b --- /dev/null +++ b/internal/utils/app_utils.go @@ -0,0 +1,222 @@ +package utils + +import ( + "errors" + "net" + "net/url" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "maps" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/weppos/publicsuffix-go/publicsuffix" +) + +// Get cookie domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) +func GetCookieDomain(u string) (string, error) { + parsed, err := url.Parse(u) + if err != nil { + return "", err + } + + host := parsed.Hostname() + + if netIP := net.ParseIP(host); netIP != nil { + return "", errors.New("IP addresses not allowed") + } + + parts := strings.Split(host, ".") + + if len(parts) < 3 { + return "", errors.New("invalid app url, must be at least second level domain") + } + + domain := strings.Join(parts[1:], ".") + + _, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil) + + if err != nil { + return "", errors.New("domain in public suffix list, cannot set cookies") + } + + return domain, nil +} + +func ParseFileToLine(content string) string { + lines := strings.Split(content, "\n") + users := make([]string, 0) + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + users = append(users, strings.TrimSpace(line)) + } + + return strings.Join(users, ",") +} + +func Filter[T any](slice []T, test func(T) bool) (res []T) { + res = make([]T, 0) + for _, value := range slice { + if test(value) { + res = append(res, value) + } + } + return res +} + +func GetContext(c *gin.Context) (config.UserContext, error) { + userContextValue, exists := c.Get("context") + + if !exists { + return config.UserContext{}, errors.New("no user context in request") + } + + userContext, ok := userContextValue.(*config.UserContext) + + if !ok { + return config.UserContext{}, errors.New("invalid user context in request") + } + + return *userContext, nil +} + +func IsRedirectSafe(redirectURL string, domain string) bool { + if redirectURL == "" { + return false + } + + parsed, err := url.Parse(redirectURL) + + if err != nil { + return false + } + + hostname := parsed.Hostname() + + if strings.HasSuffix(hostname, domain) { + return true + } + + return hostname == domain +} + +func GetLogLevel(level string) zerolog.Level { + switch strings.ToLower(level) { + case "trace": + return zerolog.TraceLevel + case "debug": + return zerolog.DebugLevel + case "info": + return zerolog.InfoLevel + case "warn": + return zerolog.WarnLevel + case "error": + return zerolog.ErrorLevel + case "fatal": + return zerolog.FatalLevel + case "panic": + return zerolog.PanicLevel + default: + return zerolog.InfoLevel + } +} + +func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) { + providers := make(map[string]config.OAuthServiceConfig) + + // Get from environment variables + envMap := make(map[string]string) + + for _, e := range env { + pair := strings.SplitN(e, "=", 2) + if len(pair) == 2 { + envMap[pair[0]] = pair[1] + } + } + + envProviders, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](envMap, "providers") + + if err != nil { + return nil, err + } + + maps.Copy(providers, envProviders.Providers) + + // Get from flags + flagsMap := make(map[string]string) + + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "--") { + pair := strings.SplitN(arg[2:], "=", 2) + if len(pair) == 2 { + flagsMap[pair[0]] = pair[1] + } + } + } + + flagProviders, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flagsMap, "providers") + + if err != nil { + return nil, err + } + + maps.Copy(providers, flagProviders.Providers) + + // For every provider get correct secret from file if set + for name, provider := range providers { + secret := GetSecret(provider.ClientSecret, provider.ClientSecretFile) + provider.ClientSecret = secret + provider.ClientSecretFile = "" + providers[name] = provider + } + + // If we have google/github providers and no redirect URL then set a default + for id := range config.OverrideProviders { + if provider, exists := providers[id]; exists { + if provider.RedirectURL == "" { + provider.RedirectURL = appUrl + "/api/oauth/callback/" + id + providers[id] = provider + } + } + } + + // Set names + for id, provider := range providers { + if provider.Name == "" { + if name, ok := config.OverrideProviders[id]; ok { + provider.Name = name + } else { + provider.Name = Capitalize(id) + } + } + providers[id] = provider + } + + // Return combined providers + return providers, nil +} + +func ShoudLogJSON(environ []string, args []string) bool { + for _, e := range environ { + pair := strings.SplitN(e, "=", 2) + if len(pair) == 2 && pair[0] == "LOG_JSON" && strings.ToLower(pair[1]) == "true" { + return true + } + } + + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "--log-json=") { + value := strings.SplitN(arg, "=", 2)[1] + if strings.ToLower(value) == "true" { + return true + } + } + } + + return false +} diff --git a/internal/utils/app_utils_test.go b/internal/utils/app_utils_test.go new file mode 100644 index 00000000..71c1aa0b --- /dev/null +++ b/internal/utils/app_utils_test.go @@ -0,0 +1,298 @@ +package utils_test + +import ( + "os" + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils" + + "github.com/gin-gonic/gin" + "gotest.tools/v3/assert" +) + +func TestGetRootDomain(t *testing.T) { + // Normal case + domain := "http://sub.tinyauth.app" + expected := "tinyauth.app" + result, err := utils.GetCookieDomain(domain) + assert.NilError(t, err) + assert.Equal(t, expected, result) + + // Domain with multiple subdomains + domain = "http://b.c.tinyauth.app" + expected = "c.tinyauth.app" + result, err = utils.GetCookieDomain(domain) + assert.NilError(t, err) + assert.Equal(t, expected, result) + + // Domain with no subdomain + domain = "http://tinyauth.app" + expected = "tinyauth.app" + _, err = utils.GetCookieDomain(domain) + assert.Error(t, err, "invalid app url, must be at least second level domain") + + // Invalid domain (only TLD) + domain = "com" + _, err = utils.GetCookieDomain(domain) + assert.ErrorContains(t, err, "invalid app url, must be at least second level domain") + + // IP address + domain = "http://10.10.10.10" + _, err = utils.GetCookieDomain(domain) + assert.ErrorContains(t, err, "IP addresses not allowed") + + // Invalid URL + domain = "http://[::1]:namedport" + _, err = utils.GetCookieDomain(domain) + assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host") + + // URL with scheme and path + domain = "https://sub.tinyauth.app/path" + expected = "tinyauth.app" + result, err = utils.GetCookieDomain(domain) + assert.NilError(t, err) + assert.Equal(t, expected, result) + + // URL with port + domain = "http://sub.tinyauth.app:8080" + expected = "tinyauth.app" + result, err = utils.GetCookieDomain(domain) + assert.NilError(t, err) + assert.Equal(t, expected, result) + + // Domain managed by ICANN + domain = "http://example.co.uk" + _, err = utils.GetCookieDomain(domain) + assert.Error(t, err, "domain in public suffix list, cannot set cookies") +} + +func TestParseFileToLine(t *testing.T) { + // Normal case + content := "user1\nuser2\nuser3" + expected := "user1,user2,user3" + result := utils.ParseFileToLine(content) + assert.Equal(t, expected, result) + + // Case with empty lines and spaces + content = " user1 \n\n user2 \n user3 \n" + expected = "user1,user2,user3" + result = utils.ParseFileToLine(content) + assert.Equal(t, expected, result) + + // Case with only empty lines + content = "\n\n\n" + expected = "" + result = utils.ParseFileToLine(content) + assert.Equal(t, expected, result) + + // Case with single user + content = "singleuser" + expected = "singleuser" + result = utils.ParseFileToLine(content) + assert.Equal(t, expected, result) + + // Case with trailing newline + content = "user1\nuser2\n" + expected = "user1,user2" + result = utils.ParseFileToLine(content) + assert.Equal(t, expected, result) +} + +func TestFilter(t *testing.T) { + // Normal case + slice := []int{1, 2, 3, 4, 5} + testFunc := func(n int) bool { return n%2 == 0 } + expected := []int{2, 4} + result := utils.Filter(slice, testFunc) + assert.DeepEqual(t, expected, result) + + // Case with no matches + slice = []int{1, 3, 5} + testFunc = func(n int) bool { return n%2 == 0 } + expected = []int{} + result = utils.Filter(slice, testFunc) + assert.DeepEqual(t, expected, result) + + // Case with all matches + slice = []int{2, 4, 6} + testFunc = func(n int) bool { return n%2 == 0 } + expected = []int{2, 4, 6} + result = utils.Filter(slice, testFunc) + assert.DeepEqual(t, expected, result) + + // Case with empty slice + slice = []int{} + testFunc = func(n int) bool { return n%2 == 0 } + expected = []int{} + result = utils.Filter(slice, testFunc) + assert.DeepEqual(t, expected, result) + + // Case with different type (string) + sliceStr := []string{"apple", "banana", "cherry"} + testFuncStr := func(s string) bool { return len(s) > 5 } + expectedStr := []string{"banana", "cherry"} + resultStr := utils.Filter(sliceStr, testFuncStr) + assert.DeepEqual(t, expectedStr, resultStr) +} + +func TestGetContext(t *testing.T) { + // Setup + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(nil) + + // Normal case + c.Set("context", &config.UserContext{Username: "testuser"}) + result, err := utils.GetContext(c) + assert.NilError(t, err) + assert.Equal(t, "testuser", result.Username) + + // Case with no context + c.Set("context", nil) + _, err = utils.GetContext(c) + assert.Error(t, err, "invalid user context in request") + + // Case with invalid context type + c.Set("context", "invalid type") + _, err = utils.GetContext(c) + assert.Error(t, err, "invalid user context in request") +} + +func TestIsRedirectSafe(t *testing.T) { + // Setup + domain := "example.com" + + // Case with no subdomain + redirectURL := "http://example.com/welcome" + result := utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, true, result) + + // Case with different domain + redirectURL = "http://malicious.com/phishing" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, false, result) + + // Case with subdomain + redirectURL = "http://sub.example.com/page" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, true, result) + + // Case with sub-subdomain + redirectURL = "http://a.b.example.com/home" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, true, result) + + // Case with empty redirect URL + redirectURL = "" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, false, result) + + // Case with invalid URL + redirectURL = "http://[::1]:namedport" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, false, result) + + // Case with URL having port + redirectURL = "http://sub.example.com:8080/page" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, true, result) + + // Case with URL having different subdomain + redirectURL = "http://another.example.com/page" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, true, result) + + // Case with URL having different TLD + redirectURL = "http://example.org/page" + result = utils.IsRedirectSafe(redirectURL, domain) + assert.Equal(t, false, result) +} + +func TestGetOAuthProvidersConfig(t *testing.T) { + env := []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET=client1-secret"} + args := []string{"/tinyauth/tinyauth", "--providers-client2-client-id=client2-id", "--providers-client2-client-secret=client2-secret"} + + expected := map[string]config.OAuthServiceConfig{ + "client1": { + ClientID: "client1-id", + ClientSecret: "client1-secret", + Name: "Client1", + }, + "client2": { + ClientID: "client2-id", + ClientSecret: "client2-secret", + Name: "Client2", + }, + } + + result, err := utils.GetOAuthProvidersConfig(env, args, "") + assert.NilError(t, err) + assert.DeepEqual(t, expected, result) + + // Case with no providers + env = []string{} + args = []string{"/tinyauth/tinyauth"} + expected = map[string]config.OAuthServiceConfig{} + + result, err = utils.GetOAuthProvidersConfig(env, args, "") + assert.NilError(t, err) + assert.DeepEqual(t, expected, result) + + // Case with secret from file + file, err := os.Create("/tmp/tinyauth_test_file") + assert.NilError(t, err) + + _, err = file.WriteString("file content\n") + assert.NilError(t, err) + + err = file.Close() + assert.NilError(t, err) + defer os.Remove("/tmp/tinyauth_test_file") + + env = []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET_FILE=/tmp/tinyauth_test_file"} + args = []string{"/tinyauth/tinyauth"} + expected = map[string]config.OAuthServiceConfig{ + "client1": { + ClientID: "client1-id", + ClientSecret: "file content", + Name: "Client1", + }, + } + + result, err = utils.GetOAuthProvidersConfig(env, args, "") + assert.NilError(t, err) + assert.DeepEqual(t, expected, result) + + // Case with google provider and no redirect URL + env = []string{"PROVIDERS_GOOGLE_CLIENT_ID=google-id", "PROVIDERS_GOOGLE_CLIENT_SECRET=google-secret"} + args = []string{"/tinyauth/tinyauth"} + expected = map[string]config.OAuthServiceConfig{ + "google": { + ClientID: "google-id", + ClientSecret: "google-secret", + RedirectURL: "http://app.url/api/oauth/callback/google", + Name: "Google", + }, + } + + result, err = utils.GetOAuthProvidersConfig(env, args, "http://app.url") + assert.NilError(t, err) + assert.DeepEqual(t, expected, result) +} + +func TestShoudLogJSON(t *testing.T) { + // Test with no env or args + result := utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--foo-bar=baz"}) + assert.Equal(t, false, result) + + // Test with env variable set + result = utils.ShoudLogJSON([]string{"LOG_JSON=true"}, []string{"tinyauth", "--foo-bar=baz"}) + assert.Equal(t, true, result) + + // Test with flag set + result = utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--log-json=true"}) + assert.Equal(t, true, result) + + // Test with both env and flag set to false + result = utils.ShoudLogJSON([]string{"LOG_JSON=false"}, []string{"tinyauth", "--log-json=false"}) + assert.Equal(t, false, result) +} diff --git a/internal/utils/decoders/decoders.go b/internal/utils/decoders/decoders.go new file mode 100644 index 00000000..0c3d22d5 --- /dev/null +++ b/internal/utils/decoders/decoders.go @@ -0,0 +1,80 @@ +package decoders + +import ( + "reflect" + "strings" + + "github.com/stoewer/go-strcase" +) + +func normalizeKeys[T any](input map[string]string, root string, sep string) map[string]string { + knownKeys := getKnownKeys[T]() + normalized := make(map[string]string) + + for k, v := range input { + parts := []string{"tinyauth"} + + key := strings.ToLower(k) + key = strings.ReplaceAll(key, sep, "-") + + if !strings.HasPrefix(key, root+"-") { + continue + } + + suffix := "" + + for _, known := range knownKeys { + if strings.HasSuffix(key, known) { + suffix = known + break + } + } + + if suffix == "" { + continue + } + + parts = append(parts, root) + + id := strings.TrimPrefix(key, root+"-") + id = strings.TrimSuffix(id, "-"+suffix) + + if id == "" { + continue + } + + parts = append(parts, id) + parts = append(parts, suffix) + + final := "" + + for i, part := range parts { + if i > 0 { + final += "." + } + final += strcase.LowerCamelCase(part) + } + + normalized[final] = v + } + + return normalized +} + +func getKnownKeys[T any]() []string { + var keys []string + var t T + + v := reflect.ValueOf(t) + typeOfT := v.Type() + + for field := range typeOfT.NumField() { + if typeOfT.Field(field).Tag.Get("field") != "" { + keys = append(keys, typeOfT.Field(field).Tag.Get("field")) + continue + } + keys = append(keys, strcase.KebabCase(typeOfT.Field(field).Name)) + } + + return keys +} diff --git a/internal/utils/decoders/env_decoder.go b/internal/utils/decoders/env_decoder.go new file mode 100644 index 00000000..532ec648 --- /dev/null +++ b/internal/utils/decoders/env_decoder.go @@ -0,0 +1,19 @@ +package decoders + +import ( + "github.com/traefik/paerser/parser" +) + +func DecodeEnv[T any, C any](env map[string]string, subName string) (T, error) { + var result T + + normalized := normalizeKeys[C](env, subName, "_") + + err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName) + + if err != nil { + return result, err + } + + return result, nil +} diff --git a/internal/utils/decoders/env_decoder_test.go b/internal/utils/decoders/env_decoder_test.go new file mode 100644 index 00000000..da679f0e --- /dev/null +++ b/internal/utils/decoders/env_decoder_test.go @@ -0,0 +1,37 @@ +package decoders_test + +import ( + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" +) + +func TestDecodeEnv(t *testing.T) { + // Setup + env := map[string]string{ + "PROVIDERS_GOOGLE_CLIENT_ID": "google-client-id", + "PROVIDERS_GOOGLE_CLIENT_SECRET": "google-client-secret", + "PROVIDERS_MY_GITHUB_CLIENT_ID": "github-client-id", + "PROVIDERS_MY_GITHUB_CLIENT_SECRET": "github-client-secret", + } + + expected := config.Providers{ + Providers: map[string]config.OAuthServiceConfig{ + "google": { + ClientID: "google-client-id", + ClientSecret: "google-client-secret", + }, + "myGithub": { + ClientID: "github-client-id", + ClientSecret: "github-client-secret", + }, + }, + } + + // Execute + result, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](env, "providers") + assert.NilError(t, err) + assert.DeepEqual(t, result, expected) +} diff --git a/internal/utils/decoders/flags_decoder.go b/internal/utils/decoders/flags_decoder.go new file mode 100644 index 00000000..0aae2341 --- /dev/null +++ b/internal/utils/decoders/flags_decoder.go @@ -0,0 +1,30 @@ +package decoders + +import ( + "strings" + + "github.com/traefik/paerser/parser" +) + +func DecodeFlags[T any, C any](flags map[string]string, subName string) (T, error) { + var result T + + filtered := filterFlags(flags) + normalized := normalizeKeys[C](filtered, subName, "_") + + err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName) + + if err != nil { + return result, err + } + + return result, nil +} + +func filterFlags(flags map[string]string) map[string]string { + filtered := make(map[string]string) + for k, v := range flags { + filtered[strings.TrimPrefix(k, "--")] = v + } + return filtered +} diff --git a/internal/utils/decoders/flags_decoder_test.go b/internal/utils/decoders/flags_decoder_test.go new file mode 100644 index 00000000..935dea0f --- /dev/null +++ b/internal/utils/decoders/flags_decoder_test.go @@ -0,0 +1,37 @@ +package decoders_test + +import ( + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" +) + +func TestDecodeFlags(t *testing.T) { + // Setup + flags := map[string]string{ + "--providers-google-client-id": "google-client-id", + "--providers-google-client-secret": "google-client-secret", + "--providers-my-github-client-id": "github-client-id", + "--providers-my-github-client-secret": "github-client-secret", + } + + expected := config.Providers{ + Providers: map[string]config.OAuthServiceConfig{ + "google": { + ClientID: "google-client-id", + ClientSecret: "google-client-secret", + }, + "myGithub": { + ClientID: "github-client-id", + ClientSecret: "github-client-secret", + }, + }, + } + + // Execute + result, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flags, "providers") + assert.NilError(t, err) + assert.DeepEqual(t, result, expected) +} diff --git a/internal/utils/decoders/label_decoder.go b/internal/utils/decoders/label_decoder.go new file mode 100644 index 00000000..e83e275e --- /dev/null +++ b/internal/utils/decoders/label_decoder.go @@ -0,0 +1,19 @@ +package decoders + +import ( + "tinyauth/internal/config" + + "github.com/traefik/paerser/parser" +) + +func DecodeLabels(labels map[string]string) (config.Apps, error) { + var appLabels config.Apps + + err := parser.Decode(labels, &appLabels, "tinyauth", "tinyauth.apps") + + if err != nil { + return config.Apps{}, err + } + + return appLabels, nil +} diff --git a/internal/utils/decoders/label_decoder_test.go b/internal/utils/decoders/label_decoder_test.go new file mode 100644 index 00000000..63189d19 --- /dev/null +++ b/internal/utils/decoders/label_decoder_test.go @@ -0,0 +1,68 @@ +package decoders_test + +import ( + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" +) + +func TestDecodeLabels(t *testing.T) { + // Variables + expected := config.Apps{ + Apps: map[string]config.App{ + "foo": { + Config: config.AppConfig{ + Domain: "example.com", + }, + Users: config.AppUsers{ + Allow: "user1,user2", + Block: "user3", + }, + OAuth: config.AppOAuth{ + Whitelist: "somebody@example.com", + Groups: "group3", + }, + IP: config.AppIP{ + Allow: []string{"10.71.0.1/24", "10.71.0.2"}, + Block: []string{"10.10.10.10", "10.0.0.0/24"}, + Bypass: []string{"192.168.1.1"}, + }, + Response: config.AppResponse{ + Headers: []string{"X-Foo=Bar", "X-Baz=Qux"}, + BasicAuth: config.AppBasicAuth{ + Username: "admin", + Password: "password", + PasswordFile: "/path/to/passwordfile", + }, + }, + Path: config.AppPath{ + Allow: "/public", + Block: "/private", + }, + }, + }, + } + test := map[string]string{ + "tinyauth.apps.foo.config.domain": "example.com", + "tinyauth.apps.foo.users.allow": "user1,user2", + "tinyauth.apps.foo.users.block": "user3", + "tinyauth.apps.foo.oauth.whitelist": "somebody@example.com", + "tinyauth.apps.foo.oauth.groups": "group3", + "tinyauth.apps.foo.ip.allow": "10.71.0.1/24,10.71.0.2", + "tinyauth.apps.foo.ip.block": "10.10.10.10,10.0.0.0/24", + "tinyauth.apps.foo.ip.bypass": "192.168.1.1", + "tinyauth.apps.foo.response.headers": "X-Foo=Bar,X-Baz=Qux", + "tinyauth.apps.foo.response.basicauth.username": "admin", + "tinyauth.apps.foo.response.basicauth.password": "password", + "tinyauth.apps.foo.response.basicauth.passwordfile": "/path/to/passwordfile", + "tinyauth.apps.foo.path.allow": "/public", + "tinyauth.apps.foo.path.block": "/private", + } + + // Test + result, err := decoders.DecodeLabels(test) + assert.NilError(t, err) + assert.DeepEqual(t, expected, result) +} diff --git a/internal/utils/fs_utils.go b/internal/utils/fs_utils.go new file mode 100644 index 00000000..8b9f28bf --- /dev/null +++ b/internal/utils/fs_utils.go @@ -0,0 +1,17 @@ +package utils + +import "os" + +func ReadFile(file string) (string, error) { + _, err := os.Stat(file) + if err != nil { + return "", err + } + + data, err := os.ReadFile(file) + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/internal/utils/fs_utils_test.go b/internal/utils/fs_utils_test.go new file mode 100644 index 00000000..54033ba5 --- /dev/null +++ b/internal/utils/fs_utils_test.go @@ -0,0 +1,31 @@ +package utils + +import ( + "os" + "testing" + + "gotest.tools/v3/assert" +) + +func TestReadFile(t *testing.T) { + // Setup + file, err := os.Create("/tmp/tinyauth_test_file") + assert.NilError(t, err) + + _, err = file.WriteString("file content\n") + assert.NilError(t, err) + + err = file.Close() + assert.NilError(t, err) + defer os.Remove("/tmp/tinyauth_test_file") + + // Normal case + content, err := ReadFile("/tmp/tinyauth_test_file") + assert.NilError(t, err) + assert.Equal(t, "file content\n", content) + + // Non-existing file + content, err = ReadFile("/tmp/non_existing_file") + assert.ErrorContains(t, err, "no such file or directory") + assert.Equal(t, "", content) +} diff --git a/internal/utils/label_utils.go b/internal/utils/label_utils.go new file mode 100644 index 00000000..2ef9a70b --- /dev/null +++ b/internal/utils/label_utils.go @@ -0,0 +1,34 @@ +package utils + +import ( + "net/http" + "strings" +) + +func ParseHeaders(headers []string) map[string]string { + headerMap := make(map[string]string) + for _, header := range headers { + split := strings.SplitN(header, "=", 2) + if len(split) != 2 || strings.TrimSpace(split[0]) == "" || strings.TrimSpace(split[1]) == "" { + continue + } + key := SanitizeHeader(strings.TrimSpace(split[0])) + if strings.ContainsAny(key, " \t") { + continue + } + key = http.CanonicalHeaderKey(key) + value := SanitizeHeader(strings.TrimSpace(split[1])) + headerMap[key] = value + } + return headerMap +} + +func SanitizeHeader(header string) string { + return strings.Map(func(r rune) rune { + // Allow only printable ASCII characters (32-126) and safe whitespace (space, tab) + if r == ' ' || r == '\t' || (r >= 32 && r <= 126) { + return r + } + return -1 + }, header) +} diff --git a/internal/utils/label_utils_test.go b/internal/utils/label_utils_test.go new file mode 100644 index 00000000..f38302d1 --- /dev/null +++ b/internal/utils/label_utils_test.go @@ -0,0 +1,87 @@ +package utils_test + +import ( + "testing" + "tinyauth/internal/utils" + + "gotest.tools/v3/assert" +) + +func TestParseHeaders(t *testing.T) { + // Normal case + headers := []string{ + "X-Custom-Header=Value", + "Another-Header=AnotherValue", + } + expected := map[string]string{ + "X-Custom-Header": "Value", + "Another-Header": "AnotherValue", + } + assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) + + // Case insensitivity and trimming + headers = []string{ + " x-custom-header = Value ", + "ANOTHER-HEADER=AnotherValue", + } + expected = map[string]string{ + "X-Custom-Header": "Value", + "Another-Header": "AnotherValue", + } + assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) + + // Invalid headers (missing '=', empty key/value) + headers = []string{ + "InvalidHeader", + "=NoKey", + "NoValue=", + " = ", + } + expected = map[string]string{} + assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) + + // Headers with unsafe characters + headers = []string{ + "X-Custom-Header=Val\x00ue", // Null byte + "Another-Header=Anoth\x7FerValue", // DEL character + "Good-Header=GoodValue", + } + expected = map[string]string{ + "X-Custom-Header": "Value", + "Another-Header": "AnotherValue", + "Good-Header": "GoodValue", + } + assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) + + // Header with spaces in key (should be ignored) + headers = []string{ + "X Custom Header=Value", + "Valid-Header=ValidValue", + } + expected = map[string]string{ + "Valid-Header": "ValidValue", + } + assert.DeepEqual(t, expected, utils.ParseHeaders(headers)) +} + +func TestSanitizeHeader(t *testing.T) { + // Normal case + header := "X-Custom-Header" + expected := "X-Custom-Header" + assert.Equal(t, expected, utils.SanitizeHeader(header)) + + // Header with unsafe characters + header = "X-Cust\x00om-Hea\x7Fder" // Null byte and DEL character + expected = "X-Custom-Header" + assert.Equal(t, expected, utils.SanitizeHeader(header)) + + // Header with only unsafe characters + header = "\x00\x01\x02\x7F" + expected = "" + assert.Equal(t, expected, utils.SanitizeHeader(header)) + + // Header with spaces and tabs (should be preserved) + header = "X Custom\tHeader" + expected = "X Custom\tHeader" + assert.Equal(t, expected, utils.SanitizeHeader(header)) +} diff --git a/internal/utils/security_utils.go b/internal/utils/security_utils.go new file mode 100644 index 00000000..40fe7130 --- /dev/null +++ b/internal/utils/security_utils.go @@ -0,0 +1,107 @@ +package utils + +import ( + "encoding/base64" + "errors" + "net" + "regexp" + "strings" + + "github.com/google/uuid" +) + +func GetSecret(conf string, file string) string { + if conf == "" && file == "" { + return "" + } + + if conf != "" { + return conf + } + + contents, err := ReadFile(file) + if err != nil { + return "" + } + + return ParseSecretFile(contents) +} + +func ParseSecretFile(contents string) string { + lines := strings.Split(contents, "\n") + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + return strings.TrimSpace(line) + } + + return "" +} + +func GetBasicAuth(username string, password string) string { + auth := username + ":" + password + return base64.StdEncoding.EncodeToString([]byte(auth)) +} + +func FilterIP(filter string, ip string) (bool, error) { + ipAddr := net.ParseIP(ip) + + if ipAddr == nil { + return false, errors.New("invalid IP address") + } + + filter = strings.Replace(filter, "-", "/", -1) + + if strings.Contains(filter, "/") { + _, cidr, err := net.ParseCIDR(filter) + if err != nil { + return false, err + } + return cidr.Contains(ipAddr), nil + } + + ipFilter := net.ParseIP(filter) + if ipFilter == nil { + return false, errors.New("invalid IP address in filter") + } + + if ipFilter.Equal(ipAddr) { + return true, nil + } + + return false, nil +} + +func CheckFilter(filter string, str string) bool { + if len(strings.TrimSpace(filter)) == 0 { + return true + } + + if strings.HasPrefix(filter, "/") && strings.HasSuffix(filter, "/") { + re, err := regexp.Compile(filter[1 : len(filter)-1]) + if err != nil { + return false + } + + if re.MatchString(strings.TrimSpace(str)) { + return true + } + } + + filterSplit := strings.Split(filter, ",") + + for _, item := range filterSplit { + if strings.TrimSpace(item) == strings.TrimSpace(str) { + return true + } + } + + return false +} + +func GenerateUUID(str string) string { + uuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str)) + return uuid.String() +} diff --git a/internal/utils/security_utils_test.go b/internal/utils/security_utils_test.go new file mode 100644 index 00000000..9adcd7ca --- /dev/null +++ b/internal/utils/security_utils_test.go @@ -0,0 +1,148 @@ +package utils_test + +import ( + "os" + "testing" + "tinyauth/internal/utils" + + "gotest.tools/v3/assert" +) + +func TestGetSecret(t *testing.T) { + // Setup + file, err := os.Create("/tmp/tinyauth_test_secret") + assert.NilError(t, err) + + _, err = file.WriteString(" secret \n") + assert.NilError(t, err) + + err = file.Close() + assert.NilError(t, err) + defer os.Remove("/tmp/tinyauth_test_secret") + + // Get from config + assert.Equal(t, "mysecret", utils.GetSecret("mysecret", "")) + + // Get from file + assert.Equal(t, "secret", utils.GetSecret("", "/tmp/tinyauth_test_secret")) + + // Get from both (config should take precedence) + assert.Equal(t, "mysecret", utils.GetSecret("mysecret", "/tmp/tinyauth_test_secret")) + + // Get from none + assert.Equal(t, "", utils.GetSecret("", "")) + + // Get from non-existing file + assert.Equal(t, "", utils.GetSecret("", "/tmp/non_existing_file")) +} + +func TestParseSecretFile(t *testing.T) { + // Normal case + content := " mysecret \n" + assert.Equal(t, "mysecret", utils.ParseSecretFile(content)) + + // Multiple lines (should take the first non-empty line) + content = "\n\n firstsecret \nsecondsecret\n" + assert.Equal(t, "firstsecret", utils.ParseSecretFile(content)) + + // All empty lines + content = "\n \n \n" + assert.Equal(t, "", utils.ParseSecretFile(content)) + + // Empty content + content = "" + assert.Equal(t, "", utils.ParseSecretFile(content)) +} + +func TestGetBasicAuth(t *testing.T) { + // Normal case + username := "user" + password := "pass" + expected := "dXNlcjpwYXNz" // base64 of "user:pass" + assert.Equal(t, expected, utils.GetBasicAuth(username, password)) + + // Empty username + username = "" + password = "pass" + expected = "OnBhc3M=" // base64 of ":pass" + assert.Equal(t, expected, utils.GetBasicAuth(username, password)) + + // Empty password + username = "user" + password = "" + expected = "dXNlcjo=" // base64 of "user:" + assert.Equal(t, expected, utils.GetBasicAuth(username, password)) +} + +func TestFilterIP(t *testing.T) { + // Exact match IPv4 + ok, err := utils.FilterIP("10.10.0.1", "10.10.0.1") + assert.NilError(t, err) + assert.Equal(t, true, ok) + + // Non-match IPv4 + ok, err = utils.FilterIP("10.10.0.1", "10.10.0.2") + assert.NilError(t, err) + assert.Equal(t, false, ok) + + // CIDR match IPv4 + ok, err = utils.FilterIP("10.10.0.0/24", "10.10.0.2") + assert.NilError(t, err) + assert.Equal(t, true, ok) + + // CIDR match IPv4 with '-' instead of '/' + ok, err = utils.FilterIP("10.10.10.0-24", "10.10.10.5") + assert.NilError(t, err) + assert.Equal(t, true, ok) + + // CIDR non-match IPv4 + ok, err = utils.FilterIP("10.10.0.0/24", "10.5.0.1") + assert.NilError(t, err) + assert.Equal(t, false, ok) + + // Invalid CIDR + ok, err = utils.FilterIP("10.10.0.0/222", "10.0.0.1") + assert.ErrorContains(t, err, "invalid CIDR address") + assert.Equal(t, false, ok) + + // Invalid IP in filter + ok, err = utils.FilterIP("invalid_ip", "10.5.5.5") + assert.ErrorContains(t, err, "invalid IP address in filter") + assert.Equal(t, false, ok) + + // Invalid IP to check + ok, err = utils.FilterIP("10.10.10.10", "invalid_ip") + assert.ErrorContains(t, err, "invalid IP address") + assert.Equal(t, false, ok) +} + +func TestCheckFilter(t *testing.T) { + // Empty filter + assert.Equal(t, true, utils.CheckFilter("", "anystring")) + + // Exact match + assert.Equal(t, true, utils.CheckFilter("hello", "hello")) + + // Regex match + assert.Equal(t, true, utils.CheckFilter("/^h.*o$/", "hello")) + + // Invalid regex + assert.Equal(t, false, utils.CheckFilter("/[unclosed", "test")) + + // Comma-separated values + assert.Equal(t, true, utils.CheckFilter("apple, banana, cherry", "banana")) + + // No match + assert.Equal(t, false, utils.CheckFilter("apple, banana, cherry", "grape")) +} + +func TestGenerateUUID(t *testing.T) { + // Consistent output for same input + id1 := utils.GenerateUUID("teststring") + id2 := utils.GenerateUUID("teststring") + assert.Equal(t, id1, id2) + + // Different output for different input + id3 := utils.GenerateUUID("differentstring") + assert.Assert(t, id1 != id3) +} diff --git a/internal/utils/string_utils.go b/internal/utils/string_utils.go new file mode 100644 index 00000000..8a629adc --- /dev/null +++ b/internal/utils/string_utils.go @@ -0,0 +1,30 @@ +package utils + +import ( + "strings" +) + +func Capitalize(str string) string { + if len(str) == 0 { + return "" + } + return strings.ToUpper(string([]rune(str)[0])) + string([]rune(str)[1:]) +} + +func CoalesceToString(value any) string { + switch v := value.(type) { + case []any: + strs := make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + strs = append(strs, str) + continue + } + } + return strings.Join(strs, ",") + case string: + return v + default: + return "" + } +} diff --git a/internal/utils/string_utils_test.go b/internal/utils/string_utils_test.go new file mode 100644 index 00000000..3677eb62 --- /dev/null +++ b/internal/utils/string_utils_test.go @@ -0,0 +1,50 @@ +package utils_test + +import ( + "testing" + "tinyauth/internal/utils" + + "gotest.tools/v3/assert" +) + +func TestCapitalize(t *testing.T) { + // Test empty string + assert.Equal(t, "", utils.Capitalize("")) + + // Test single character + assert.Equal(t, "A", utils.Capitalize("a")) + + // Test multiple characters + assert.Equal(t, "Hello", utils.Capitalize("hello")) + + // Test already capitalized + assert.Equal(t, "World", utils.Capitalize("World")) + + // Test non-alphabetic first character + assert.Equal(t, "1number", utils.Capitalize("1number")) + + // Test Unicode characters + assert.Equal(t, "Γειά", utils.Capitalize("γειά")) + assert.Equal(t, "Привет", utils.Capitalize("привет")) + +} + +func TestCoalesceToString(t *testing.T) { + // Test with []any containing strings + assert.Equal(t, "a,b,c", utils.CoalesceToString([]any{"a", "b", "c"})) + + // Test with []any containing mixed types + assert.Equal(t, "a,c", utils.CoalesceToString([]any{"a", 1, "c", true})) + + // Test with []any containing no strings + assert.Equal(t, "", utils.CoalesceToString([]any{1, 2, 3})) + + // Test with string input + assert.Equal(t, "hello", utils.CoalesceToString("hello")) + + // Test with non-string, non-[]any input + assert.Equal(t, "", utils.CoalesceToString(123)) + + // Test with nil input + assert.Equal(t, "", utils.CoalesceToString(nil)) +} diff --git a/internal/utils/user_utils.go b/internal/utils/user_utils.go new file mode 100644 index 00000000..0044db4a --- /dev/null +++ b/internal/utils/user_utils.go @@ -0,0 +1,92 @@ +package utils + +import ( + "errors" + "strings" + "tinyauth/internal/config" +) + +func ParseUsers(users string) ([]config.User, error) { + var usersParsed []config.User + + users = strings.TrimSpace(users) + + if users == "" { + return []config.User{}, nil + } + + userList := strings.Split(users, ",") + + if len(userList) == 0 { + return []config.User{}, errors.New("invalid user format") + } + + for _, user := range userList { + if strings.TrimSpace(user) == "" { + continue + } + parsed, err := ParseUser(strings.TrimSpace(user)) + if err != nil { + return []config.User{}, err + } + usersParsed = append(usersParsed, parsed) + } + + return usersParsed, nil +} + +func GetUsers(conf string, file string) ([]config.User, error) { + var users string + + if conf == "" && file == "" { + return []config.User{}, nil + } + + if conf != "" { + users += conf + } + + if file != "" { + contents, err := ReadFile(file) + if err != nil { + return []config.User{}, err + } + if users != "" { + users += "," + } + users += ParseFileToLine(contents) + } + + return ParseUsers(users) +} + +func ParseUser(user string) (config.User, error) { + if strings.Contains(user, "$$") { + user = strings.ReplaceAll(user, "$$", "$") + } + + userSplit := strings.Split(user, ":") + + if len(userSplit) < 2 || len(userSplit) > 3 { + return config.User{}, errors.New("invalid user format") + } + + for _, userPart := range userSplit { + if strings.TrimSpace(userPart) == "" { + return config.User{}, errors.New("invalid user format") + } + } + + if len(userSplit) == 2 { + return config.User{ + Username: strings.TrimSpace(userSplit[0]), + Password: strings.TrimSpace(userSplit[1]), + }, nil + } + + return config.User{ + Username: strings.TrimSpace(userSplit[0]), + Password: strings.TrimSpace(userSplit[1]), + TotpSecret: strings.TrimSpace(userSplit[2]), + }, nil +} diff --git a/internal/utils/user_utils_test.go b/internal/utils/user_utils_test.go new file mode 100644 index 00000000..d04636ae --- /dev/null +++ b/internal/utils/user_utils_test.go @@ -0,0 +1,163 @@ +package utils_test + +import ( + "os" + "testing" + "tinyauth/internal/utils" + + "gotest.tools/v3/assert" +) + +func TestGetUsers(t *testing.T) { + // Setup + file, err := os.Create("/tmp/tinyauth_users_test.txt") + assert.NilError(t, err) + + _, err = file.WriteString(" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G \n user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G ") // Spacing is on purpose + assert.NilError(t, err) + + err = file.Close() + assert.NilError(t, err) + defer os.Remove("/tmp/tinyauth_users_test.txt") + + // Test file + users, err := utils.GetUsers("", "/tmp/tinyauth_users_test.txt") + + assert.NilError(t, err) + + assert.Equal(t, 2, len(users)) + + assert.Equal(t, "user1", users[0].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) + assert.Equal(t, "user2", users[1].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) + + // Test config + users, err = utils.GetUsers("user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G,user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "") + + assert.NilError(t, err) + + assert.Equal(t, 2, len(users)) + + assert.Equal(t, "user3", users[0].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) + assert.Equal(t, "user4", users[1].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) + + // Test both + users, err = utils.GetUsers("user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", "/tmp/tinyauth_users_test.txt") + + assert.NilError(t, err) + + assert.Equal(t, 3, len(users)) + + assert.Equal(t, "user5", users[0].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) + assert.Equal(t, "user1", users[1].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) + assert.Equal(t, "user2", users[2].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[2].Password) + + // Test empty + users, err = utils.GetUsers("", "") + + assert.NilError(t, err) + + assert.Equal(t, 0, len(users)) + + // Test non-existent file + users, err = utils.GetUsers("", "/tmp/non_existent_file.txt") + + assert.ErrorContains(t, err, "no such file or directory") + + assert.Equal(t, 0, len(users)) +} + +func TestParseUsers(t *testing.T) { + // Valid users + users, err := utils.ParseUsers("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G,user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF") // user2 has TOTP + + assert.NilError(t, err) + + assert.Equal(t, 2, len(users)) + + assert.Equal(t, "user1", users[0].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) + assert.Equal(t, "", users[0].TotpSecret) + assert.Equal(t, "user2", users[1].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) + assert.Equal(t, "ABCDEF", users[1].TotpSecret) + + // Valid weirdly spaced users + users, err = utils.ParseUsers(" user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G , user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF ") // Spacing is on purpose + assert.NilError(t, err) + + assert.Equal(t, 2, len(users)) + + assert.Equal(t, "user1", users[0].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[0].Password) + assert.Equal(t, "", users[0].TotpSecret) + assert.Equal(t, "user2", users[1].Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", users[1].Password) + assert.Equal(t, "ABCDEF", users[1].TotpSecret) +} + +func TestParseUser(t *testing.T) { + // Valid user without TOTP + user, err := utils.ParseUser("user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G") + + assert.NilError(t, err) + + assert.Equal(t, "user1", user.Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password) + assert.Equal(t, "", user.TotpSecret) + + // Valid user with TOTP + user, err = utils.ParseUser("user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF") + + assert.NilError(t, err) + + assert.Equal(t, "user2", user.Username) + assert.Equal(t, "$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G", user.Password) + assert.Equal(t, "ABCDEF", user.TotpSecret) + + // Valid user with $$ in password + user, err = utils.ParseUser("user3:pa$$word123") + + assert.NilError(t, err) + + assert.Equal(t, "user3", user.Username) + assert.Equal(t, "pa$word123", user.Password) + assert.Equal(t, "", user.TotpSecret) + + // User with spaces + user, err = utils.ParseUser(" user4 : password123 : TOTPSECRET ") + + assert.NilError(t, err) + + assert.Equal(t, "user4", user.Username) + assert.Equal(t, "password123", user.Password) + assert.Equal(t, "TOTPSECRET", user.TotpSecret) + + // Invalid users + _, err = utils.ParseUser("user1") // Missing password + assert.ErrorContains(t, err, "invalid user format") + + _, err = utils.ParseUser("user1:") + assert.ErrorContains(t, err, "invalid user format") + + _, err = utils.ParseUser(":password123") + assert.ErrorContains(t, err, "invalid user format") + + _, err = utils.ParseUser("user1:password123:ABC:EXTRA") // Too many parts + assert.ErrorContains(t, err, "invalid user format") + + _, err = utils.ParseUser("user1::ABC") + assert.ErrorContains(t, err, "invalid user format") + + _, err = utils.ParseUser(":password123:ABC") + assert.ErrorContains(t, err, "invalid user format") + + _, err = utils.ParseUser(" : : ") + assert.ErrorContains(t, err, "invalid user format") +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go deleted file mode 100644 index 39b1518f..00000000 --- a/internal/utils/utils.go +++ /dev/null @@ -1,350 +0,0 @@ -package utils - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "errors" - "io" - "net" - "net/url" - "os" - "regexp" - "strings" - "tinyauth/internal/types" - - "github.com/traefik/paerser/parser" - "golang.org/x/crypto/hkdf" - - "github.com/google/uuid" - "github.com/rs/zerolog/log" -) - -// Parses a list of comma separated users in a struct -func ParseUsers(users string) (types.Users, error) { - log.Debug().Msg("Parsing users") - - var usersParsed types.Users - - userList := strings.Split(users, ",") - - if len(userList) == 0 { - return types.Users{}, errors.New("invalid user format") - } - - for _, user := range userList { - parsed, err := ParseUser(user) - if err != nil { - return types.Users{}, err - } - usersParsed = append(usersParsed, parsed) - } - - log.Debug().Msg("Parsed users") - return usersParsed, nil -} - -// Get upper domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com) -func GetUpperDomain(urlSrc string) (string, error) { - urlParsed, err := url.Parse(urlSrc) - if err != nil { - return "", err - } - - urlSplitted := strings.Split(urlParsed.Hostname(), ".") - urlFinal := strings.Join(urlSplitted[1:], ".") - - return urlFinal, nil -} - -// Reads a file and returns the contents -func ReadFile(file string) (string, error) { - _, err := os.Stat(file) - if err != nil { - return "", err - } - - data, err := os.ReadFile(file) - if err != nil { - return "", err - } - - return string(data), nil -} - -// Parses a file into a comma separated list of users -func ParseFileToLine(content string) string { - lines := strings.Split(content, "\n") - users := make([]string, 0) - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - users = append(users, strings.TrimSpace(line)) - } - - return strings.Join(users, ",") -} - -// Get the secret from the config or file -func GetSecret(conf string, file string) string { - if conf == "" && file == "" { - return "" - } - - if conf != "" { - return conf - } - - contents, err := ReadFile(file) - if err != nil { - return "" - } - - return ParseSecretFile(contents) -} - -// Get the users from the config or file -func GetUsers(conf string, file string) (types.Users, error) { - var users string - - if conf == "" && file == "" { - return types.Users{}, nil - } - - if conf != "" { - log.Debug().Msg("Using users from config") - users += conf - } - - if file != "" { - contents, err := ReadFile(file) - if err == nil { - log.Debug().Msg("Using users from file") - if users != "" { - users += "," - } - users += ParseFileToLine(contents) - } - } - - return ParseUsers(users) -} - -// Parse the headers in a map[string]string format -func ParseHeaders(headers []string) map[string]string { - headerMap := make(map[string]string) - - for _, header := range headers { - split := strings.SplitN(header, "=", 2) - if len(split) != 2 || strings.TrimSpace(split[0]) == "" || strings.TrimSpace(split[1]) == "" { - log.Warn().Str("header", header).Msg("Invalid header format, skipping") - continue - } - key := SanitizeHeader(strings.TrimSpace(split[0])) - value := SanitizeHeader(strings.TrimSpace(split[1])) - headerMap[key] = value - } - - return headerMap -} - -// Get labels parses a map of labels into a struct with only the needed labels -func GetLabels(labels map[string]string) (types.Labels, error) { - var labelsParsed types.Labels - - err := parser.Decode(labels, &labelsParsed, "tinyauth", "tinyauth.users", "tinyauth.allowed", "tinyauth.headers", "tinyauth.domain", "tinyauth.basic", "tinyauth.oauth", "tinyauth.ip") - if err != nil { - log.Error().Err(err).Msg("Error parsing labels") - return types.Labels{}, err - } - - return labelsParsed, nil -} - -// Check if any of the OAuth providers are configured based on the client id and secret -func OAuthConfigured(config types.Config) bool { - return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") -} - -// Filter helper function -func Filter[T any](slice []T, test func(T) bool) (res []T) { - for _, value := range slice { - if test(value) { - res = append(res, value) - } - } - return res -} - -// Parse user -func ParseUser(user string) (types.User, error) { - if strings.Contains(user, "$$") { - user = strings.ReplaceAll(user, "$$", "$") - } - - userSplit := strings.Split(user, ":") - - if len(userSplit) < 2 || len(userSplit) > 3 { - return types.User{}, errors.New("invalid user format") - } - - for _, userPart := range userSplit { - if strings.TrimSpace(userPart) == "" { - return types.User{}, errors.New("invalid user format") - } - } - - if len(userSplit) == 2 { - return types.User{ - Username: strings.TrimSpace(userSplit[0]), - Password: strings.TrimSpace(userSplit[1]), - }, nil - } - - return types.User{ - Username: strings.TrimSpace(userSplit[0]), - Password: strings.TrimSpace(userSplit[1]), - TotpSecret: strings.TrimSpace(userSplit[2]), - }, nil -} - -// Parse secret file -func ParseSecretFile(contents string) string { - lines := strings.Split(contents, "\n") - - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - return strings.TrimSpace(line) - } - - return "" -} - -// Check if a string matches a regex or if it is included in a comma separated list -func CheckFilter(filter string, str string) bool { - if len(strings.TrimSpace(filter)) == 0 { - return true - } - - if strings.HasPrefix(filter, "/") && strings.HasSuffix(filter, "/") { - re, err := regexp.Compile(filter[1 : len(filter)-1]) - if err != nil { - log.Error().Err(err).Msg("Error compiling regex") - return false - } - - if re.MatchString(str) { - return true - } - } - - filterSplit := strings.Split(filter, ",") - - for _, item := range filterSplit { - if strings.TrimSpace(item) == str { - return true - } - } - - return false -} - -// Capitalize just the first letter of a string -func Capitalize(str string) string { - if len(str) == 0 { - return "" - } - return strings.ToUpper(string([]rune(str)[0])) + string([]rune(str)[1:]) -} - -// Sanitize header removes all control characters from a string -func SanitizeHeader(header string) string { - return strings.Map(func(r rune) rune { - // Allow only printable ASCII characters (32-126) and safe whitespace (space, tab) - if r == ' ' || r == '\t' || (r >= 32 && r <= 126) { - return r - } - return -1 - }, header) -} - -// Generate a static identifier from a string -func GenerateIdentifier(str string) string { - uuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str)) - uuidString := uuid.String() - log.Debug().Str("uuid", uuidString).Msg("Generated UUID") - return strings.Split(uuidString, "-")[0] -} - -// Get a basic auth header from a username and password -func GetBasicAuth(username string, password string) string { - auth := username + ":" + password - return base64.StdEncoding.EncodeToString([]byte(auth)) -} - -// Check if an IP is contained in a CIDR range/matches a single IP -func FilterIP(filter string, ip string) (bool, error) { - ipAddr := net.ParseIP(ip) - - if strings.Contains(filter, "/") { - _, cidr, err := net.ParseCIDR(filter) - if err != nil { - return false, err - } - return cidr.Contains(ipAddr), nil - } - - ipFilter := net.ParseIP(filter) - if ipFilter == nil { - return false, errors.New("invalid IP address in filter") - } - - if ipFilter.Equal(ipAddr) { - return true, nil - } - - return false, nil -} - -func DeriveKey(secret string, info string) (string, error) { - hash := sha256.New - hkdf := hkdf.New(hash, []byte(secret), nil, []byte(info)) // I am not using a salt because I just want two different keys from one secret, maybe bad practice - key := make([]byte, 24) - - _, err := io.ReadFull(hkdf, key) - if err != nil { - return "", err - } - - if bytes.Equal(key, make([]byte, 24)) { - return "", errors.New("derived key is empty") - } - - encodedKey := base64.StdEncoding.EncodeToString(key) - return encodedKey, nil -} - -func CoalesceToString(value any) string { - switch v := value.(type) { - case []any: - log.Debug().Msg("Coalescing []any to string") - strs := make([]string, 0, len(v)) - for _, item := range v { - if str, ok := item.(string); ok { - strs = append(strs, str) - continue - } - log.Warn().Interface("item", item).Msg("Item in []any is not a string, skipping") - } - return strings.Join(strs, ",") - case string: - return v - default: - log.Warn().Interface("value", value).Interface("type", v).Msg("Unsupported type, returning empty string") - return "" - } -} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go deleted file mode 100644 index 5ae7e897..00000000 --- a/internal/utils/utils_test.go +++ /dev/null @@ -1,548 +0,0 @@ -package utils_test - -import ( - "fmt" - "os" - "reflect" - "testing" - "tinyauth/internal/types" - "tinyauth/internal/utils" -) - -func TestParseUsers(t *testing.T) { - t.Log("Testing parse users with a valid string") - - users := "user1:pass1,user2:pass2" - expected := types.Users{ - { - Username: "user1", - Password: "pass1", - }, - { - Username: "user2", - Password: "pass2", - }, - } - - result, err := utils.ParseUsers(users) - if err != nil { - t.Fatalf("Error parsing users: %v", err) - } - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestGetUpperDomain(t *testing.T) { - t.Log("Testing get upper domain with a valid url") - - url := "https://sub1.sub2.domain.com:8080" - expected := "sub2.domain.com" - - result, err := utils.GetUpperDomain(url) - if err != nil { - t.Fatalf("Error getting root url: %v", err) - } - - if expected != result { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestReadFile(t *testing.T) { - t.Log("Creating a test file") - - err := os.WriteFile("/tmp/test.txt", []byte("test"), 0644) - if err != nil { - t.Fatalf("Error creating test file: %v", err) - } - - t.Log("Testing read file with a valid file") - - data, err := utils.ReadFile("/tmp/test.txt") - if err != nil { - t.Fatalf("Error reading file: %v", err) - } - - if data != "test" { - t.Fatalf("Expected test, got %v", data) - } - - t.Log("Cleaning up test file") - - err = os.Remove("/tmp/test.txt") - if err != nil { - t.Fatalf("Error cleaning up test file: %v", err) - } -} - -func TestParseFileToLine(t *testing.T) { - t.Log("Testing parse file to line with a valid string") - - content := "\nuser1:pass1\nuser2:pass2\n" - expected := "user1:pass1,user2:pass2" - - result := utils.ParseFileToLine(content) - - if expected != result { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestGetSecret(t *testing.T) { - t.Log("Testing get secret with an empty config and file") - - conf := "" - file := "/tmp/test.txt" - expected := "test" - - err := os.WriteFile(file, []byte(fmt.Sprintf("\n\n \n\n\n %s \n\n \n ", expected)), 0644) - if err != nil { - t.Fatalf("Error creating test file: %v", err) - } - - result := utils.GetSecret(conf, file) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing get secret with an empty file and a valid config") - - result = utils.GetSecret(expected, "") - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing get secret with both a valid config and file") - - result = utils.GetSecret(expected, file) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Cleaning up test file") - - err = os.Remove(file) - if err != nil { - t.Fatalf("Error cleaning up test file: %v", err) - } -} - -func TestGetUsers(t *testing.T) { - t.Log("Testing get users with a config and no file") - - conf := "user1:pass1,user2:pass2" - file := "" - expected := types.Users{ - { - Username: "user1", - Password: "pass1", - }, - { - Username: "user2", - Password: "pass2", - }, - } - - result, err := utils.GetUsers(conf, file) - if err != nil { - t.Fatalf("Error getting users: %v", err) - } - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing get users with a file and no config") - - conf = "" - file = "/tmp/test.txt" - expected = types.Users{ - { - Username: "user1", - Password: "pass1", - }, - { - Username: "user2", - Password: "pass2", - }, - } - - err = os.WriteFile(file, []byte("user1:pass1\nuser2:pass2"), 0644) - if err != nil { - t.Fatalf("Error creating test file: %v", err) - } - - result, err = utils.GetUsers(conf, file) - if err != nil { - t.Fatalf("Error getting users: %v", err) - } - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing get users with both a config and file") - - conf = "user3:pass3" - expected = types.Users{ - { - Username: "user3", - Password: "pass3", - }, - { - Username: "user1", - Password: "pass1", - }, - { - Username: "user2", - Password: "pass2", - }, - } - - result, err = utils.GetUsers(conf, file) - if err != nil { - t.Fatalf("Error getting users: %v", err) - } - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Cleaning up test file") - - err = os.Remove(file) - if err != nil { - t.Fatalf("Error cleaning up test file: %v", err) - } -} - -func TestGetLabels(t *testing.T) { - t.Log("Testing get labels with a valid map") - - labels := map[string]string{ - "tinyauth.users": "user1,user2", - "tinyauth.oauth.whitelist": "/regex/", - "tinyauth.allowed": "random", - "tinyauth.headers": "X-Header=value", - "tinyauth.oauth.groups": "group1,group2", - } - - expected := types.Labels{ - Users: "user1,user2", - Allowed: "random", - Headers: []string{"X-Header=value"}, - OAuth: types.OAuthLabels{ - Whitelist: "/regex/", - Groups: "group1,group2", - }, - } - - result, err := utils.GetLabels(labels) - if err != nil { - t.Fatalf("Error getting labels: %v", err) - } - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestParseUser(t *testing.T) { - t.Log("Testing parse user with a valid user") - - user := "user:pass:secret" - expected := types.User{ - Username: "user", - Password: "pass", - TotpSecret: "secret", - } - - result, err := utils.ParseUser(user) - if err != nil { - t.Fatalf("Error parsing user: %v", err) - } - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing parse user with an escaped user") - - user = "user:p$$ass$$:secret" - expected = types.User{ - Username: "user", - Password: "p$ass$", - TotpSecret: "secret", - } - - result, err = utils.ParseUser(user) - if err != nil { - t.Fatalf("Error parsing user: %v", err) - } - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing parse user with an invalid user") - - user = "user::pass" - - _, err = utils.ParseUser(user) - if err == nil { - t.Fatalf("Expected error parsing user") - } -} - -func TestCheckFilter(t *testing.T) { - t.Log("Testing check filter with a comma separated list") - - filter := "user1,user2,user3" - str := "user1" - expected := true - - result := utils.CheckFilter(filter, str) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing check filter with a regex filter") - - filter = "/^user[0-9]+$/" - str = "user1" - expected = true - - result = utils.CheckFilter(filter, str) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing check filter with an empty filter") - - filter = "" - str = "user1" - expected = true - - result = utils.CheckFilter(filter, str) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing check filter with an invalid regex filter") - - filter = "/^user[0-9+$/" - str = "user1" - expected = false - - result = utils.CheckFilter(filter, str) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing check filter with a non matching list") - - filter = "user1,user2,user3" - str = "user4" - expected = false - - result = utils.CheckFilter(filter, str) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestSanitizeHeader(t *testing.T) { - t.Log("Testing sanitize header with a valid string") - - str := "X-Header=value" - expected := "X-Header=value" - - result := utils.SanitizeHeader(str) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing sanitize header with an invalid string") - - str = "X-Header=val\nue" - expected = "X-Header=value" - - result = utils.SanitizeHeader(str) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestParseHeaders(t *testing.T) { - t.Log("Testing parse headers with a valid string") - - headers := []string{"X-Hea\x00der1=value1", "X-Header2=value\n2"} - expected := map[string]string{ - "X-Header1": "value1", - "X-Header2": "value2", - } - - result := utils.ParseHeaders(headers) - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing parse headers with an invalid string") - - headers = []string{"X-Header1=", "X-Header2", "=value", "X-Header3=value3"} - expected = map[string]string{"X-Header3": "value3"} - - result = utils.ParseHeaders(headers) - - if !reflect.DeepEqual(expected, result) { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestParseSecretFile(t *testing.T) { - t.Log("Testing parse secret file with a valid file") - - content := "\n\n \n\n\n secret \n\n \n " - expected := "secret" - - result := utils.ParseSecretFile(content) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestFilterIP(t *testing.T) { - t.Log("Testing filter IP with an IP and a valid CIDR") - - ip := "10.10.10.10" - filter := "10.10.10.0/24" - expected := true - - result, err := utils.FilterIP(filter, ip) - if err != nil { - t.Fatalf("Error filtering IP: %v", err) - } - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing filter IP with an IP and a valid IP") - - filter = "10.10.10.10" - expected = true - - result, err = utils.FilterIP(filter, ip) - if err != nil { - t.Fatalf("Error filtering IP: %v", err) - } - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing filter IP with an IP and an non matching CIDR") - - filter = "10.10.15.0/24" - expected = false - - result, err = utils.FilterIP(filter, ip) - if err != nil { - t.Fatalf("Error filtering IP: %v", err) - } - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing filter IP with a non matching IP and a valid CIDR") - - filter = "10.10.10.11" - expected = false - - result, err = utils.FilterIP(filter, ip) - - if err != nil { - t.Fatalf("Error filtering IP: %v", err) - } - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing filter IP with an IP and an invalid CIDR") - - filter = "10.../83" - - _, err = utils.FilterIP(filter, ip) - if err == nil { - t.Fatalf("Expected error filtering IP") - } -} - -func TestDeriveKey(t *testing.T) { - t.Log("Testing the derive key function") - - master := "master" - info := "info" - expected := "gdrdU/fXzclYjiSXRexEatVgV13qQmKl" - - result, err := utils.DeriveKey(master, info) - - if err != nil { - t.Fatalf("Error deriving key: %v", err) - } - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } -} - -func TestCoalesceToString(t *testing.T) { - t.Log("Testing coalesce to string with a string") - - value := any("test") - expected := "test" - - result := utils.CoalesceToString(value) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing coalesce to string with a slice of strings") - - value = []any{any("test1"), any("test2"), any(123)} - expected = "test1,test2" - - result = utils.CoalesceToString(value) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } - - t.Log("Testing coalesce to string with an unsupported type") - - value = 12345 - expected = "" - - result = utils.CoalesceToString(value) - - if result != expected { - t.Fatalf("Expected %v, got %v", expected, result) - } -} diff --git a/main.go b/main.go index 27792d81..94aefe28 100644 --- a/main.go +++ b/main.go @@ -4,12 +4,16 @@ import ( "os" "time" "tinyauth/cmd" + "tinyauth/internal/utils" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) func main() { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Logger().Level(zerolog.FatalLevel) - cmd.Execute() + log.Logger = log.Logger.With().Caller().Logger() + if !utils.ShoudLogJSON(os.Environ(), os.Args) { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + } + cmd.Run() }