[
  {
    "path": ".coderabbit.yaml",
    "content": "issue_enrichment:\n  auto_enrich:\n    enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help improve Tinyauth\ntitle: \"[BUG]\"\nlabels: bug\nassignees: steveiliop56\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Logs**\nPlease include the Tinyauth logs below, make sure to not include sensitive info.\n\n**Device (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Tinyauth [e.g. v2.1.1]\n - Docker [e.g. 27.3.1]\n\n**\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[FEATURE]\"\nlabels: enhancement\nassignees: steveiliop56\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"bun\"\n    directory: \"/frontend\"\n    groups:\n      minor-patch:\n        update-types:\n          - \"patch\"\n          - \"minor\"\n    schedule:\n      interval: \"daily\"\n\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    groups:\n      minor-patch:\n        update-types:\n          - \"patch\"\n          - \"minor\"\n    schedule:\n      interval: \"daily\"\n\n  - package-ecosystem: \"docker\"\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Tinyauth CI\non:\n  push:\n    branches:\n      - main\n  pull_request:\n\njobs:\n  ci:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Setup bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Setup go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"^1.24.0\"\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Install frontend dependencies\n        run: |\n          cd frontend\n          bun install --frozen-lockfile\n\n      - name: Set version\n        run: |\n          echo testing > internal/assets/version\n\n      - name: Lint frontend\n        run: |\n          cd frontend\n          bun run lint\n\n      - name: Build frontend\n        run: |\n          cd frontend\n          bun run build\n\n      - name: Copy frontend\n        run: |\n          cp -r frontend/dist internal/assets/dist\n\n      - name: Run tests\n        run: go test -coverprofile=coverage.txt -v ./...\n\n      - name: Upload coverage reports to Codecov\n        uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/nightly.yml",
    "content": "name: Nightly Release\non:\n  workflow_dispatch:\n  schedule:\n    - cron: \"0 0 * * *\"\n\njobs:\n  create-release:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Delete old release\n        run: gh release delete --cleanup-tag --yes nightly || echo release not found\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          OWNER: ${{ github.repository_owner }}\n          REPO: ${{ github.event.repository.name }}\n\n      - name: Create release\n        uses: softprops/action-gh-release@v2\n        with:\n          prerelease: true\n          tag_name: nightly\n\n  generate-metadata:\n    runs-on: ubuntu-latest\n    needs: create-release\n    outputs:\n      VERSION: ${{ steps.metadata.outputs.VERSION }}\n      COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }}\n      BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: nightly\n\n      - name: Generate metadata\n        id: metadata\n        run: |\n          echo \"VERSION=nightly\" >> \"$GITHUB_OUTPUT\"\n          echo \"COMMIT_HASH=$(git rev-parse HEAD)\" >> \"$GITHUB_OUTPUT\"\n          echo \"BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')\" >> \"$GITHUB_OUTPUT\"\n\n  binary-build:\n    runs-on: ubuntu-latest\n    needs:\n      - create-release\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: nightly\n\n      - name: Install bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Install go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"^1.24.0\"\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Install frontend dependencies\n        run: |\n          cd frontend\n          bun install --frozen-lockfile\n\n      - name: Install backend dependencies\n        run: |\n          go mod download\n\n      - name: Build frontend\n        run: |\n          cd frontend\n          bun run build\n\n      - name: Build\n        run: |\n          cp -r frontend/dist internal/assets/dist\n          go build -ldflags \"-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\" -o tinyauth-amd64 ./cmd/tinyauth\n        env:\n          CGO_ENABLED: 0\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: tinyauth-amd64\n          path: tinyauth-amd64\n\n  binary-build-arm:\n    runs-on: ubuntu-24.04-arm\n    needs:\n      - create-release\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: nightly\n\n      - name: Install bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Install go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"^1.24.0\"\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Install frontend dependencies\n        run: |\n          cd frontend\n          bun install --frozen-lockfile\n\n      - name: Install backend dependencies\n        run: |\n          go mod download\n\n      - name: Build frontend\n        run: |\n          cd frontend\n          bun run build\n\n      - name: Build\n        run: |\n          cp -r frontend/dist internal/assets/dist\n          go build -ldflags \"-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\" -o tinyauth-arm64 ./cmd/tinyauth\n        env:\n          CGO_ENABLED: 0\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: tinyauth-arm64\n          path: tinyauth-arm64\n\n  image-build:\n    runs-on: ubuntu-latest\n    needs:\n      - create-release\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: nightly\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/amd64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-linux-amd64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-build-distroless:\n    runs-on: ubuntu-latest\n    needs:\n      - create-release\n      - generate-metadata\n      - image-build\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: nightly\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/amd64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          file: Dockerfile.distroless\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-distroless-linux-amd64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-build-arm:\n    runs-on: ubuntu-24.04-arm\n    needs:\n      - create-release\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: nightly\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/arm64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-linux-arm64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-build-arm-distroless:\n    runs-on: ubuntu-24.04-arm\n    needs:\n      - create-release\n      - generate-metadata\n      - image-build-arm\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          ref: nightly\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/arm64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          file: Dockerfile.distroless\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-distroless-linux-arm64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-merge:\n    runs-on: ubuntu-latest\n    needs:\n      - image-build\n      - image-build-arm\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@v4\n        with:\n          path: ${{ runner.temp }}/digests\n          pattern: digests-*\n          merge-multiple: true\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n          flavor: |\n            latest=false\n          tags: |\n            type=raw,nightly\n\n      - name: Create manifest list and push\n        working-directory: ${{ runner.temp }}/digests\n        run: |\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)\n\n  image-merge-distroless:\n    runs-on: ubuntu-latest\n    needs:\n      - image-build-distroless\n      - image-build-arm-distroless\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@v4\n        with:\n          path: ${{ runner.temp }}/digests\n          pattern: digests-distroless-*\n          merge-multiple: true\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n          flavor: |\n            latest=false\n          tags: |\n            type=raw,nightly-distroless\n\n      - name: Create manifest list and push\n        working-directory: ${{ runner.temp }}/digests\n        run: |\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)\n\n  update-release:\n    runs-on: ubuntu-latest\n    needs:\n      - binary-build\n      - binary-build-arm\n    steps:\n      - uses: actions/download-artifact@v4\n        with:\n          pattern: tinyauth-*\n          path: binaries\n          merge-multiple: true\n\n      - name: Release\n        uses: softprops/action-gh-release@v2\n        with:\n          files: binaries/*\n          tag_name: nightly\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\non:\n  workflow_dispatch:\n  push:\n    tags:\n      - \"v*\"\n\njobs:\n  generate-metadata:\n    runs-on: ubuntu-latest\n    outputs:\n      VERSION: ${{ steps.metadata.outputs.VERSION }}\n      COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }}\n      BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Generate metadata\n        id: metadata\n        run: |\n          echo \"VERSION=${{ github.ref_name }}\" >> \"$GITHUB_OUTPUT\"\n          echo \"COMMIT_HASH=$(git rev-parse HEAD)\" >> \"$GITHUB_OUTPUT\"\n          echo \"BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')\" >> \"$GITHUB_OUTPUT\"\n\n  binary-build:\n    runs-on: ubuntu-latest\n    needs:\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Install go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"^1.24.0\"\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Install frontend dependencies\n        run: |\n          cd frontend\n          bun install --frozen-lockfile\n\n      - name: Install backend dependencies\n        run: |\n          go mod download\n\n      - name: Build frontend\n        run: |\n          cd frontend\n          bun run build\n\n      - name: Build\n        run: |\n          cp -r frontend/dist internal/assets/dist\n          go build -ldflags \"-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\" -o tinyauth-amd64 ./cmd/tinyauth\n        env:\n          CGO_ENABLED: 0\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: tinyauth-amd64\n          path: tinyauth-amd64\n\n  binary-build-arm:\n    runs-on: ubuntu-24.04-arm\n    needs:\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: Install go\n        uses: actions/setup-go@v5\n        with:\n          go-version: \"^1.24.0\"\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Install frontend dependencies\n        run: |\n          cd frontend\n          bun install --frozen-lockfile\n\n      - name: Install backend dependencies\n        run: |\n          go mod download\n\n      - name: Build frontend\n        run: |\n          cd frontend\n          bun run build\n\n      - name: Build\n        run: |\n          cp -r frontend/dist internal/assets/dist\n          go build -ldflags \"-s -w -X github.com/steveiliop56/tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\" -o tinyauth-arm64 ./cmd/tinyauth\n        env:\n          CGO_ENABLED: 0\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: tinyauth-arm64\n          path: tinyauth-arm64\n\n  image-build:\n    runs-on: ubuntu-latest\n    needs:\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/amd64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-linux-amd64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-build-distroless:\n    runs-on: ubuntu-latest\n    needs:\n      - generate-metadata\n      - image-build\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/amd64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          file: Dockerfile.distroless\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-distroless-linux-amd64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-build-arm:\n    runs-on: ubuntu-24.04-arm\n    needs:\n      - generate-metadata\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/arm64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-linux-arm64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-build-arm-distroless:\n    runs-on: ubuntu-24.04-arm\n    needs:\n      - generate-metadata\n      - image-build-arm\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Initialize submodules\n        run: |\n          git submodule init\n          git submodule update\n\n      - name: Apply patches\n        run: |\n          git apply --directory paerser/ patches/nested_maps.diff\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        uses: docker/build-push-action@v6\n        id: build\n        with:\n          platforms: linux/arm64\n          labels: ${{ steps.meta.outputs.labels }}\n          tags: ghcr.io/${{ github.repository_owner }}/tinyauth\n          outputs: type=image,push-by-digest=true,name-canonical=true,push=true\n          file: Dockerfile.distroless\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          build-args: |\n            VERSION=${{ needs.generate-metadata.outputs.VERSION }}\n            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}\n            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}\n\n      - name: Export digest\n        run: |\n          mkdir -p ${{ runner.temp }}/digests\n          digest=\"${{ steps.build.outputs.digest }}\"\n          touch \"${{ runner.temp }}/digests/${digest#sha256:}\"\n\n      - name: Upload digest\n        uses: actions/upload-artifact@v4\n        with:\n          name: digests-distroless-linux-arm64\n          path: ${{ runner.temp }}/digests/*\n          if-no-files-found: error\n          retention-days: 1\n\n  image-merge:\n    runs-on: ubuntu-latest\n    needs:\n      - image-build\n      - image-build-arm\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@v4\n        with:\n          path: ${{ runner.temp }}/digests\n          pattern: digests-*\n          merge-multiple: true\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n          flavor: |\n            prefix=v,onlatest=false\n          tags: |\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}\n            type=semver,pattern={{major}}.{{minor}}\n\n      - name: Create manifest list and push\n        working-directory: ${{ runner.temp }}/digests\n        run: |\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)\n\n  image-merge-distroless:\n    runs-on: ubuntu-latest\n    needs:\n      - image-build-distroless\n      - image-build-arm-distroless\n    steps:\n      - name: Download digests\n        uses: actions/download-artifact@v4\n        with:\n          path: ${{ runner.temp }}/digests\n          pattern: digests-distroless-*\n          merge-multiple: true\n\n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Docker meta\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/tinyauth\n          flavor: |\n            latest=false\n            prefix=v\n            suffix=-distroless\n          tags: |\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}\n            type=semver,pattern={{major}}.{{minor}}\n\n      - name: Create manifest list and push\n        working-directory: ${{ runner.temp }}/digests\n        run: |\n          docker buildx imagetools create $(jq -cr '.tags | map(\"-t \" + .) | join(\" \")' <<< \"$DOCKER_METADATA_OUTPUT_JSON\") \\\n            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)\n\n  update-release:\n    runs-on: ubuntu-latest\n    needs:\n      - binary-build\n      - binary-build-arm\n    steps:\n      - uses: actions/download-artifact@v4\n        with:\n          pattern: tinyauth-*\n          path: binaries\n          merge-multiple: true\n\n      - name: Release\n        uses: softprops/action-gh-release@v2\n        with:\n          files: binaries/*\n"
  },
  {
    "path": ".github/workflows/sponsors.yml",
    "content": "name: Generate Sponsors List\non:\n  workflow_dispatch:\n\njobs:\n  generate-sponsors:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Generate Sponsors\n        uses: JamesIves/github-sponsors-readme-action@v1\n        with:\n          token: ${{ secrets.SPONSORS_GENERATOR_PAT }}\n          active-only: false\n          file: \"README.md\"\n          template: '<a href=\"https://github.com/{{{ login }}}\"><img src=\"{{{ avatarUrl }}}\" width=\"64px\" alt=\"User avatar: {{{ login }}}\" /></a>&nbsp;&nbsp;'\n\n      - name: Create Pull Request\n        uses: peter-evans/create-pull-request@v7\n        with:\n          token: ${{ secrets.GITHUB_TOKEN }}\n          commit-message: |\n            docs: regenerate readme sponsors list\n          committer: GitHub <noreply@github.com>\n          author: GitHub <noreply@github.com>\n          branch: docs/update-readme\n          title: |\n            docs: regenerate readme sponsors list\n          labels: bot\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: Close stale issues and PRs\non:\n  schedule:\n    - cron: 0 10 * * *\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v9\n        with:\n          days-before-stale: 30\n          stale-pr-message: This PR has been inactive for 30 days and will be marked as stale.\n          stale-issue-message: This issue has been inactive for 30 days and will be marked as stale.\n          close-issue-message: Closed for inactivity.\n          close-pr-message: Closed for inactivity.\n          stale-issue-label: stale\n          stale-pr-label: stale\n          exempt-issue-labels: pinned\n          exempt-pr-labels: pinned\n"
  },
  {
    "path": ".gitignore",
    "content": "# dist\n/internal/assets/dist\n\n# binaries\n/tinyauth\n/tinyauth-arm64\n/tinyauth-amd64\n\n# test docker compose\n/docker-compose.test*\n\n# users file\n/users.txt\n\n# secret test file\n/secret*\n\n# apple stuff\n.DS_Store\n\n# env\n/.env\n\n# tmp directory\n/tmp\n\n# data directory\n/data\n\n# config file\n/config.yml\n\n# binary out\n/tinyauth.db\n/resources\n\n# debug files\n__debug_*\n\n# infisical\n/.infisical.json\n\n# traefik data\n/traefik\n\n# generated markdown (for docs)\n/config.gen.md\n\n# testing config\nconfig.certify.yml\n"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"paerser\"]\n\tpath = paerser\n\turl = https://github.com/traefik/paerser\n\tignore = all\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Connect to server\",\n      \"type\": \"go\",\n      \"request\": \"attach\",\n      \"mode\": \"remote\",\n      \"remotePath\": \"/tinyauth\",\n      \"port\": 4000,\n      \"host\": \"127.0.0.1\",\n      \"debugAdapter\": \"legacy\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".zed/debug.json",
    "content": "[\n  {\n    \"label\": \"Attach to remote Delve\",\n    \"adapter\": \"Delve\",\n    \"mode\": \"remote\",\n    \"remotePath\": \"/tinyauth\",\n    \"request\": \"attach\",\n    \"tcp_connection\": {\n      \"host\": \"127.0.0.1\",\n      \"port\": 4000,\n    },\n  },\n]\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\n.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nContributing to Tinyauth is straightforward. Follow the steps below to set up a development server.\n\n## Requirements\n\n- Bun\n- Golang v1.24.0 or later\n- Git\n- Docker\n- Make\n\n## Cloning the Repository\n\nStart by cloning the repository:\n\n```sh\ngit clone https://github.com/steveiliop56/tinyauth\ncd tinyauth\n```\n\n## Initialize Submodules\n\nThe project uses Git submodules for some dependencies, so you need to initialize them with:\n\n```sh\ngit submodule init\ngit submodule update\n```\n\n## Apply patches\n\nSome of the dependencies must be patched in order to work correctly with the project, you can apply the patches by running:\n\n```sh\ngit apply --directory paerser/ patches/nested_maps.diff\n```\n\n## Installing Requirements\n\nWhile development occurs within Docker, installing the requirements locally is recommended to avoid import errors. Install the Go dependencies:\n\n```sh\ngo mod tidy\n```\n\nFrontend dependencies can be installed as follows:\n\n```sh\ncd frontend/\nbun install\n```\n\n## Create the `.env` file\n\nConfiguration requires an environment file. Copy the `.env.example` file to `.env` and adjust the environment variables as needed.\n\n## Development Workflow\n\nThe development workflow is designed to run entirely within Docker, ensuring compatibility with Traefik and eliminating the need for local builds. A recommended setup involves pointing a subdomain to the local machine:\n\n```\n*.dev.example.com -> 127.0.0.1\ndev.example.com -> 127.0.0.1\n```\n\n> [!NOTE]\n> A domain from [sslip.io](https://sslip.io) can be used if a custom domain is\n  unavailable. For example, set the Tinyauth domain to `tinyauth.127.0.0.1.sslip.io` and the whoami domain to `whoami.127.0.0.1.sslip.io`.\n\nEnsure the domains are correctly configured in the development Docker Compose file, then start the development environment:\n\n```sh\nmake dev\n```\n\nIn case you need to build the binary locally, you can run:\n\n```sh\nmake binary\n```\n\n> [!NOTE]\n> Copying the example `docker-compose.dev.yml` file to `docker-compose.test.yml`\n  is recommended to prevent accidental commits of sensitive information. The make recipe will automatically use `docker-compose.test.yml` as well as `docker-compose.test.prod.yml` (for the `make prod` recipe) if it exists.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Site builder\nFROM oven/bun:1.3.10-alpine AS frontend-builder\n\nWORKDIR /frontend\n\nCOPY ./frontend/package.json ./\nCOPY ./frontend/bun.lock ./\n\nRUN bun install --frozen-lockfile\n\nCOPY ./frontend/public ./public\nCOPY ./frontend/src ./src\nCOPY ./frontend/eslint.config.js ./\nCOPY ./frontend/index.html ./\nCOPY ./frontend/tsconfig.json ./\nCOPY ./frontend/tsconfig.app.json ./\nCOPY ./frontend/tsconfig.node.json ./\nCOPY ./frontend/vite.config.ts ./\n\nRUN bun run build\n\n# Builder\nFROM golang:1.25-alpine3.21 AS builder\n\nARG VERSION\nARG COMMIT_HASH\nARG BUILD_TIMESTAMP\n\nWORKDIR /tinyauth\n\nCOPY ./paerser ./paerser\n\nCOPY go.mod ./\nCOPY go.sum ./\n\nRUN go mod download\n\nCOPY ./cmd ./cmd\nCOPY ./internal ./internal\nCOPY --from=frontend-builder /frontend/dist ./internal/assets/dist\n\nRUN CGO_ENABLED=0 go build -ldflags \"-s -w \\\n    -X github.com/steveiliop56/tinyauth/internal/config.Version=${VERSION} \\\n    -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \\\n    -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}\" ./cmd/tinyauth\n\n# Runner\nFROM alpine:3.23 AS runner\n\nWORKDIR /tinyauth\n\nCOPY --from=builder /tinyauth/tinyauth ./\n\nRUN mkdir -p /data\n\nEXPOSE 3000\n\nVOLUME [\"/data\"]\n\nENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db\n\nENV TINYAUTH_RESOURCES_PATH=/data/resources\n\nENV PATH=$PATH:/tinyauth\n\nHEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD [\"tinyauth\", \"healthcheck\"]\n\nENTRYPOINT [\"tinyauth\"]\n"
  },
  {
    "path": "Dockerfile.dev",
    "content": "FROM golang:1.25-alpine3.21\n\nWORKDIR /tinyauth\n\nCOPY ./paerser ./paerser\n\nCOPY go.mod ./\nCOPY go.sum ./\n\nRUN go mod download\n\nRUN go install github.com/air-verse/air@v1.61.7\nRUN go install github.com/go-delve/delve/cmd/dlv@latest\n\nCOPY ./cmd ./cmd\nCOPY ./internal ./internal\nCOPY ./air.toml ./\n\nEXPOSE 3000\n\nENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db\n\nENV TINYAUTH_RESOURCES_PATH=/data/resources\n\nENTRYPOINT [\"air\", \"-c\", \"air.toml\"]\n"
  },
  {
    "path": "Dockerfile.distroless",
    "content": "# Site builder\nFROM oven/bun:1.3.10-alpine AS frontend-builder\n\nWORKDIR /frontend\n\nCOPY ./frontend/package.json ./\nCOPY ./frontend/bun.lock ./\n\nRUN bun install --frozen-lockfile\n\nCOPY ./frontend/public ./public\nCOPY ./frontend/src ./src\nCOPY ./frontend/eslint.config.js ./\nCOPY ./frontend/index.html ./\nCOPY ./frontend/tsconfig.json ./\nCOPY ./frontend/tsconfig.app.json ./\nCOPY ./frontend/tsconfig.node.json ./\nCOPY ./frontend/vite.config.ts ./\n\nRUN bun run build\n\n# Builder\nFROM golang:1.25-alpine3.21 AS builder\n\nARG VERSION\nARG COMMIT_HASH\nARG BUILD_TIMESTAMP\n\nWORKDIR /tinyauth\n\nCOPY ./paerser ./paerser\n\nCOPY go.mod ./\nCOPY go.sum ./\n\nRUN go mod download\n\nCOPY ./cmd ./cmd\nCOPY ./internal ./internal\nCOPY --from=frontend-builder /frontend/dist ./internal/assets/dist\n\nRUN mkdir -p data\n\nRUN CGO_ENABLED=0 go build -ldflags \"-s -w \\\n    -X github.com/steveiliop56/tinyauth/internal/config.Version=${VERSION} \\\n    -X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \\\n    -X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}\" ./cmd/tinyauth\n\n# Runner\nFROM gcr.io/distroless/static-debian12:latest AS runner\n\nWORKDIR /tinyauth\n\nCOPY --from=builder /tinyauth/tinyauth ./\n\n# Since it's distroless, we need to copy the data directory from the builder stage\nCOPY --from=builder /tinyauth/data /data\n\nEXPOSE 3000\n\nVOLUME [\"/data\"]\n\nENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db\n\nENV TINYAUTH_RESOURCES_PATH=/data/resources\n\nENV PATH=$PATH:/tinyauth\n\nHEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD [\"tinyauth\", \"healthcheck\"]\n\nENTRYPOINT [\"tinyauth\"]\n"
  },
  {
    "path": "FUNDING.yml",
    "content": "github: steveiliop56\nbuy_me_a_coffee: steveiliop56\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>."
  },
  {
    "path": "Makefile",
    "content": "# Go specific stuff\nCGO_ENABLED := 0\nGOOS := $(shell go env GOOS)\nGOARCH := $(shell go env GOARCH)\n\n# Build out\nTAG_NAME := $(shell git describe --abbrev=0 --exact-match 2> /dev/null || echo \"main\")\nCOMMIT_HASH := $(shell git rev-parse HEAD)\nBUILD_TIMESTAMP := $(shell date '+%Y-%m-%dT%H:%M:%S')\nBIN_NAME := tinyauth-$(GOARCH)\n\n# Development vars\nDEV_COMPOSE := $(shell test -f \"docker-compose.test.yml\" && echo \"docker-compose.test.yml\" || echo \"docker-compose.dev.yml\" )\nPROD_COMPOSE := $(shell test -f \"docker-compose.test.prod.yml\" && echo \"docker-compose.test.prod.yml\" || echo \"docker-compose.example.yml\" )\n\n# Deps\ndeps:\n\tbun install --cwd frontend\n\tgo mod download\n\n# Clean data\nclean-data:\n\trm -rf data/\n\n# Clean web UI build\nclean-webui:\n\trm -rf internal/assets/dist\n\trm -rf frontend/dist\n\n# Build the web UI\nwebui: clean-webui\n\tbun run --cwd frontend build\n\tcp -r frontend/dist internal/assets\n\n# Build the binary\nbinary: webui\n\tCGO_ENABLED=$(CGO_ENABLED) go build -ldflags \"-s -w \\\n\t-X github.com/steveiliop56/tinyauth/internal/config.Version=${TAG_NAME} \\\n\t-X github.com/steveiliop56/tinyauth/internal/config.CommitHash=${COMMIT_HASH} \\\n\t-X github.com/steveiliop56/tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}\" \\\n\t-o ${BIN_NAME} ./cmd/tinyauth\n\n# Build for amd64\nbinary-linux-amd64:\n\texport BIN_NAME=tinyauth-amd64\n\texport GOARCH=amd64\n\texport GOOS=linux\n\t$(MAKE) binary\n\n# Build for arm64\nbinary-linux-arm64:\n\texport BIN_NAME=tinyauth-arm64\n\texport GOARCH=arm64\n\texport GOOS=linux\n\t$(MAKE) binary\n\n# Go test\n.PHONY: test\ntest:\n\tgo test -v ./...\n\n# Development\ndev:\n\tdocker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build\n\n# Development - Infisical\ndev-infisical:\n\tinfisical run --env=dev -- docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build\n\n# Production\nprod:\n\tdocker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans\n\n# Production - Infisical\nprod-infisical:\n\tinfisical run --env=dev -- docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans\n\n# SQL\n.PHONY: sql\nsql:\n\tsqlc generate\n\n# Go gen\ngenerate:\n\tgo run ./gen\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n    <img alt=\"Tinyauth\" title=\"Tinyauth\" width=\"96\" src=\"assets/logo-rounded.png\">\n    <h1>Tinyauth</h1>\n    <p>The tiniest authentication and authorization server you have ever seen.</p>\n</div>\n\n<div align=\"center\">\n    <img alt=\"License\" src=\"https://img.shields.io/github/license/steveiliop56/tinyauth\">\n    <img alt=\"Release\" src=\"https://img.shields.io/github/v/release/steveiliop56/tinyauth\">\n    <img alt=\"Issues\" src=\"https://img.shields.io/github/issues/steveiliop56/tinyauth\">\n    <img alt=\"Tinyauth CI\" src=\"https://github.com/steveiliop56/tinyauth/actions/workflows/ci.yml/badge.svg\">\n    <a title=\"Crowdin\" target=\"_blank\" href=\"https://crowdin.com/project/tinyauth\"><img src=\"https://badges.crowdin.net/tinyauth/localized.svg\"></a>\n</div>\n\n<br />\n\nTinyauth is the simplest and tiniest authentication and authorization server you have ever seen. It is designed to both work as an authentication middleware for your apps, offering support for OAuth, LDAP and access-controls, and as a standalone authentication server. It supports all the popular proxies like Traefik, Nginx and Caddy.\n\n![Screenshot](assets/screenshot.png)\n\n> [!WARNING]\n> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.\n\n> [!NOTE]\n> This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag.\n\n## Getting Started\n\nYou can 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 (keep in mind that this file lives in the development branch so it may have updates that are not yet released).\n\n## Demo\n\nIf you are still not sure if Tinyauth suits your needs you can try out the [demo](https://demo.tinyauth.app). The default username is `user` and the default password is `password`.\n\n## Documentation\n\nYou can find documentation and guides on all of the available configuration of Tinyauth in the [website](https://tinyauth.app).\n\nIf you wish to contribute to the documentation head over to the [repository](https://github.com/steveiliop56/tinyauth-docs).\n\n## Discord\n\nTinyauth has a [Discord](https://discord.gg/eHzVaCzRRd) server. Feel free to hop in to chat about self-hosting, homelabs and of course Tinyauth. See you there!\n\n## Contributing\n\nAll contributions to the codebase are welcome! If you have any free time, feel free to pick up an [issue](https://github.com/steveiliop56/tinyauth/issues) or add your own missing features. Make sure to check out the [contributing guide](./CONTRIBUTING.md) for instructions on how to get the development server up and running.\n\n## Localization\n\nIf you like, you can help translate Tinyauth into more languages by visiting the [Crowdin](https://crowdin.com/project/tinyauth) page.\n\n## License\n\nTinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.\n\n## Sponsors\n\nA big thank you to the following people for providing me with more coffee:\n\n<!-- sponsors --><a href=\"https://github.com/erwinkramer\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png\" width=\"64px\" alt=\"User avatar: erwinkramer\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/nicotsx\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png\" width=\"64px\" alt=\"User avatar: nicotsx\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/SimpleHomelab\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png\" width=\"64px\" alt=\"User avatar: SimpleHomelab\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/jmadden91\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png\" width=\"64px\" alt=\"User avatar: jmadden91\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/tribor\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;tribor.png\" width=\"64px\" alt=\"User avatar: tribor\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/eliasbenb\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png\" width=\"64px\" alt=\"User avatar: eliasbenb\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/afunworm\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;afunworm.png\" width=\"64px\" alt=\"User avatar: afunworm\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/chip-well\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;chip-well.png\" width=\"64px\" alt=\"User avatar: chip-well\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/Lancelot-Enguerrand\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;Lancelot-Enguerrand.png\" width=\"64px\" alt=\"User avatar: Lancelot-Enguerrand\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/allgoewer\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;allgoewer.png\" width=\"64px\" alt=\"User avatar: allgoewer\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/NEANC\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;NEANC.png\" width=\"64px\" alt=\"User avatar: NEANC\" /></a>&nbsp;&nbsp;<a href=\"https://github.com/algorist-ahmad\"><img src=\"https:&#x2F;&#x2F;github.com&#x2F;algorist-ahmad.png\" width=\"64px\" alt=\"User avatar: algorist-ahmad\" /></a>&nbsp;&nbsp;<!-- sponsors -->\n\n## Acknowledgements\n\n- **Freepik** for providing the police hat and badge.\n- **Renee French** for the original gopher logo.\n- **Coderabbit AI** for providing free AI code reviews.\n- **Syrhu** for providing the background image of the app.\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=steveiliop56/tinyauth&type=Date)](https://www.star-history.com/#steveiliop56/tinyauth&Date)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nIt is recommended to use the [latest](https://github.com/steveiliop56/tinyauth/releases/latest) available version of tinyauth. This is because it includes security fixes, new features and dependency updates. Older versions, especially major ones, are not supported and won't receive security or patch updates.\n\n## Reporting a Vulnerability\n\nDue to the nature of this app, it needs to be secure. If you discover any security issues or vulnerabilities in the app please contact me as soon as possible at <steve@doesmycode.work>. Please do not use the issues section to report security issues as I won't be able to patch them in time and they may get exploited by malicious actors.\n"
  },
  {
    "path": "air.toml",
    "content": "root = \"/tinyauth\"\ntmp_dir = \"tmp\"\n\n[build]\npre_cmd = [\"mkdir -p internal/assets/dist\", \"mkdir -p /data\", \"echo 'backend running' > internal/assets/dist/index.html\"]\ncmd = \"CGO_ENABLED=0 go build -gcflags=\\\"all=-N -l\\\" -o tmp/tinyauth ./cmd/tinyauth\"\nbin = \"tmp/tinyauth\"\nfull_bin = \"dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false\"\ninclude_ext = [\"go\"]\nexclude_dir = [\"internal/assets/dist\"]\nexclude_regex = [\".*_test\\\\.go\"]\nstop_on_error = true\n\n[color]\nmain = \"magenta\"\nwatcher = \"cyan\"\nbuild = \"yellow\"\nrunner = \"green\"\n\n[misc]\nclean_on_exit = true\n\n[screen]\nclear_on_rebuild = false\nkeep_scroll = true\n"
  },
  {
    "path": "assets/discohook.json",
    "content": "{\n  \"content\": null,\n  \"embeds\": [\n    {\n      \"title\": \"Welcome to Tinyauth Discord!\",\n      \"description\": \"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.\\n\\n**Information**\\n\\n• Github: <https://github.com/steveiliop56/tinyauth>\\n• Website: <https://tinyauth.app>\",\n      \"url\": \"https://tinyauth.app\",\n      \"color\": 7002085,\n      \"author\": {\n        \"name\": \"Tinyauth\"\n      },\n      \"footer\": {\n        \"text\": \"Updated at\"\n      },\n      \"timestamp\": \"2025-06-06T12:25:27.629Z\",\n      \"thumbnail\": {\n        \"url\": \"https://github.com/steveiliop56/tinyauth/blob/main/assets/logo.png?raw=true\"\n      }\n    }\n  ],\n  \"attachments\": []\n}"
  },
  {
    "path": "cmd/tinyauth/create_oidc_client.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/traefik/paerser/cli\"\n)\n\nfunc createOidcClientCmd() *cli.Command {\n\treturn &cli.Command{\n\t\tName:          \"create\",\n\t\tDescription:   \"Create a new OIDC Client\",\n\t\tConfiguration: nil,\n\t\tResources:     nil,\n\t\tAllowArg:      true,\n\t\tRun: func(args []string) error {\n\t\t\tif len(args) == 0 {\n\t\t\t\treturn errors.New(\"client name is required. use tinyauth oidc create <name>\")\n\t\t\t}\n\n\t\t\tclientName := args[0]\n\n\t\t\tmatch, err := regexp.MatchString(\"^[a-zA-Z0-9-]*$\", clientName)\n\n\t\t\tif !match || err != nil {\n\t\t\t\treturn errors.New(\"client name can only contain alphanumeric characters and hyphens\")\n\t\t\t}\n\n\t\t\tuuid := uuid.New()\n\t\t\tclientId := uuid.String()\n\t\t\tclientSecret := \"ta-\" + utils.GenerateString(61)\n\n\t\t\tuclientName := strings.ToUpper(clientName)\n\t\t\tlclientName := strings.ToLower(clientName)\n\n\t\t\tbuilder := strings.Builder{}\n\n\t\t\t// header\n\t\t\tfmt.Fprintf(&builder, \"Created credentials for client %s\\n\\n\", clientName)\n\n\t\t\t// credentials\n\t\t\tfmt.Fprintf(&builder, \"Client Name: %s\\n\", clientName)\n\t\t\tfmt.Fprintf(&builder, \"Client ID: %s\\n\", clientId)\n\t\t\tfmt.Fprintf(&builder, \"Client Secret: %s\\n\\n\", clientSecret)\n\n\t\t\t// env variables\n\t\t\tfmt.Fprint(&builder, \"Environment variables:\\n\\n\")\n\t\t\tfmt.Fprintf(&builder, \"TINYAUTH_OIDC_CLIENTS_%s_CLIENTID=%s\\n\", uclientName, clientId)\n\t\t\tfmt.Fprintf(&builder, \"TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET=%s\\n\", uclientName, clientSecret)\n\t\t\tfmt.Fprintf(&builder, \"TINYAUTH_OIDC_CLIENTS_%s_NAME=%s\\n\\n\", uclientName, utils.Capitalize(lclientName))\n\n\t\t\t// cli flags\n\t\t\tfmt.Fprint(&builder, \"CLI flags:\\n\\n\")\n\t\t\tfmt.Fprintf(&builder, \"--oidc.clients.%s.clientid=%s\\n\", lclientName, clientId)\n\t\t\tfmt.Fprintf(&builder, \"--oidc.clients.%s.clientsecret=%s\\n\", lclientName, clientSecret)\n\t\t\tfmt.Fprintf(&builder, \"--oidc.clients.%s.name=%s\\n\\n\", lclientName, utils.Capitalize(lclientName))\n\n\t\t\t// footer\n\t\t\tfmt.Fprintln(&builder, \"You can use either option to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.\")\n\n\t\t\t// print\n\t\t\tout := builder.String()\n\t\t\tfmt.Print(out)\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/tinyauth/create_user.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/huh\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\t\"github.com/traefik/paerser/cli\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\ntype CreateUserConfig struct {\n\tInteractive bool   `description:\"Create a user interactively.\"`\n\tDocker      bool   `description:\"Format output for docker.\"`\n\tUsername    string `description:\"Username.\"`\n\tPassword    string `description:\"Password.\"`\n}\n\nfunc NewCreateUserConfig() *CreateUserConfig {\n\treturn &CreateUserConfig{\n\t\tInteractive: false,\n\t\tDocker:      false,\n\t\tUsername:    \"\",\n\t\tPassword:    \"\",\n\t}\n}\n\nfunc createUserCmd() *cli.Command {\n\ttCfg := NewCreateUserConfig()\n\n\tloaders := []cli.ResourceLoader{\n\t\t&cli.FlagLoader{},\n\t}\n\n\treturn &cli.Command{\n\t\tName:          \"create\",\n\t\tDescription:   \"Create a user\",\n\t\tConfiguration: tCfg,\n\t\tResources:     loaders,\n\t\tRun: func(_ []string) error {\n\t\t\ttlog.NewSimpleLogger().Init()\n\n\t\t\tif tCfg.Interactive {\n\t\t\t\tform := huh.NewForm(\n\t\t\t\t\thuh.NewGroup(\n\t\t\t\t\t\thuh.NewInput().Title(\"Username\").Value(&tCfg.Username).Validate((func(s string) error {\n\t\t\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\t\t\treturn errors.New(\"username cannot be empty\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn nil\n\t\t\t\t\t\t})),\n\t\t\t\t\t\thuh.NewInput().Title(\"Password\").Value(&tCfg.Password).Validate((func(s string) error {\n\t\t\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\t\t\treturn errors.New(\"password cannot be empty\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn nil\n\t\t\t\t\t\t})),\n\t\t\t\t\t\thuh.NewSelect[bool]().Title(\"Format the output for Docker?\").Options(huh.NewOption(\"Yes\", true), huh.NewOption(\"No\", false)).Value(&tCfg.Docker),\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\t\tvar baseTheme *huh.Theme = huh.ThemeBase()\n\n\t\t\t\terr := form.WithTheme(baseTheme).Run()\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to run interactive prompt: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif tCfg.Username == \"\" || tCfg.Password == \"\" {\n\t\t\t\treturn errors.New(\"username and password cannot be empty\")\n\t\t\t}\n\n\t\t\ttlog.App.Info().Str(\"username\", tCfg.Username).Msg(\"Creating user\")\n\n\t\t\tpasswd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to hash password: %w\", err)\n\t\t\t}\n\n\t\t\t// If docker format is enabled, escape the dollar sign\n\t\t\tpasswdStr := string(passwd)\n\t\t\tif tCfg.Docker {\n\t\t\t\tpasswdStr = strings.ReplaceAll(passwdStr, \"$\", \"$$\")\n\t\t\t}\n\n\t\t\ttlog.App.Info().Str(\"user\", fmt.Sprintf(\"%s:%s\", tCfg.Username, passwdStr)).Msg(\"User created\")\n\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/tinyauth/generate_totp.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/charmbracelet/huh\"\n\t\"github.com/mdp/qrterminal/v3\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"github.com/traefik/paerser/cli\"\n)\n\ntype GenerateTotpConfig struct {\n\tInteractive bool   `description:\"Generate a TOTP secret interactively.\"`\n\tUser        string `description:\"Your current user (username:hash).\"`\n}\n\nfunc NewGenerateTotpConfig() *GenerateTotpConfig {\n\treturn &GenerateTotpConfig{\n\t\tInteractive: false,\n\t\tUser:        \"\",\n\t}\n}\n\nfunc generateTotpCmd() *cli.Command {\n\ttCfg := NewGenerateTotpConfig()\n\n\tloaders := []cli.ResourceLoader{\n\t\t&cli.FlagLoader{},\n\t}\n\n\treturn &cli.Command{\n\t\tName:          \"generate\",\n\t\tDescription:   \"Generate a TOTP secret\",\n\t\tConfiguration: tCfg,\n\t\tResources:     loaders,\n\t\tRun: func(_ []string) error {\n\t\t\ttlog.NewSimpleLogger().Init()\n\n\t\t\tif tCfg.Interactive {\n\t\t\t\tform := huh.NewForm(\n\t\t\t\t\thuh.NewGroup(\n\t\t\t\t\t\thuh.NewInput().Title(\"Current user (username:hash)\").Value(&tCfg.User).Validate((func(s string) error {\n\t\t\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\t\t\treturn errors.New(\"user cannot be empty\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn nil\n\t\t\t\t\t\t})),\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\t\tvar baseTheme *huh.Theme = huh.ThemeBase()\n\n\t\t\t\terr := form.WithTheme(baseTheme).Run()\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to run interactive prompt: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tuser, err := utils.ParseUser(tCfg.User)\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to parse user: %w\", err)\n\t\t\t}\n\n\t\t\tdocker := false\n\t\t\tif strings.Contains(tCfg.User, \"$$\") {\n\t\t\t\tdocker = true\n\t\t\t}\n\n\t\t\tif user.TotpSecret != \"\" {\n\t\t\t\treturn fmt.Errorf(\"user already has a TOTP secret\")\n\t\t\t}\n\n\t\t\tkey, err := totp.Generate(totp.GenerateOpts{\n\t\t\t\tIssuer:      \"Tinyauth\",\n\t\t\t\tAccountName: user.Username,\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to generate TOTP secret: %w\", err)\n\t\t\t}\n\n\t\t\tsecret := key.Secret()\n\n\t\t\ttlog.App.Info().Str(\"secret\", secret).Msg(\"Generated TOTP secret\")\n\n\t\t\ttlog.App.Info().Msg(\"Generated QR code\")\n\n\t\t\tconfig := qrterminal.Config{\n\t\t\t\tLevel:     qrterminal.L,\n\t\t\t\tWriter:    os.Stdout,\n\t\t\t\tBlackChar: qrterminal.BLACK,\n\t\t\t\tWhiteChar: qrterminal.WHITE,\n\t\t\t\tQuietZone: 2,\n\t\t\t}\n\n\t\t\tqrterminal.GenerateWithConfig(key.URL(), config)\n\n\t\t\tuser.TotpSecret = secret\n\n\t\t\t// If using docker escape re-escape it\n\t\t\tif docker {\n\t\t\t\tuser.Password = strings.ReplaceAll(user.Password, \"$\", \"$$\")\n\t\t\t}\n\n\t\t\ttlog.App.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.\")\n\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/tinyauth/healthcheck.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\t\"github.com/traefik/paerser/cli\"\n)\n\ntype healthzResponse struct {\n\tStatus  string `json:\"status\"`\n\tMessage string `json:\"message\"`\n}\n\nfunc healthcheckCmd() *cli.Command {\n\treturn &cli.Command{\n\t\tName:          \"healthcheck\",\n\t\tDescription:   \"Perform a health check\",\n\t\tConfiguration: nil,\n\t\tResources:     nil,\n\t\tAllowArg:      true,\n\t\tRun: func(args []string) error {\n\t\t\ttlog.NewSimpleLogger().Init()\n\n\t\t\tsrvAddr := os.Getenv(\"TINYAUTH_SERVER_ADDRESS\")\n\t\t\tif srvAddr == \"\" {\n\t\t\t\tsrvAddr = \"127.0.0.1\"\n\t\t\t}\n\n\t\t\tsrvPort := os.Getenv(\"TINYAUTH_SERVER_PORT\")\n\t\t\tif srvPort == \"\" {\n\t\t\t\tsrvPort = \"3000\"\n\t\t\t}\n\n\t\t\tappUrl := fmt.Sprintf(\"http://%s:%s\", srvAddr, srvPort)\n\n\t\t\tif len(args) > 0 {\n\t\t\t\tappUrl = args[0]\n\t\t\t}\n\n\t\t\tif appUrl == \"\" {\n\t\t\t\treturn errors.New(\"Could not determine app URL\")\n\t\t\t}\n\n\t\t\ttlog.App.Info().Str(\"app_url\", appUrl).Msg(\"Performing health check\")\n\n\t\t\tclient := http.Client{\n\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t}\n\n\t\t\treq, err := http.NewRequest(\"GET\", appUrl+\"/api/healthz\", nil)\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to create request: %w\", err)\n\t\t\t}\n\n\t\t\tresp, err := client.Do(req)\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to perform request: %w\", err)\n\t\t\t}\n\n\t\t\tif resp.StatusCode != http.StatusOK {\n\t\t\t\treturn fmt.Errorf(\"service is not healthy, got: %s\", resp.Status)\n\t\t\t}\n\n\t\t\tdefer resp.Body.Close()\n\n\t\t\tvar healthResp healthzResponse\n\n\t\t\tbody, err := io.ReadAll(resp.Body)\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to read response: %w\", err)\n\t\t\t}\n\n\t\t\terr = json.Unmarshal(body, &healthResp)\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to decode response: %w\", err)\n\t\t\t}\n\n\t\t\ttlog.App.Info().Interface(\"response\", healthResp).Msg(\"Tinyauth is healthy\")\n\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/tinyauth/tinyauth.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/bootstrap\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/loaders\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/traefik/paerser/cli\"\n)\n\nfunc main() {\n\ttConfig := config.NewDefaultConfiguration()\n\n\tloaders := []cli.ResourceLoader{\n\t\t&loaders.FileLoader{},\n\t\t&loaders.FlagLoader{},\n\t\t&loaders.EnvLoader{},\n\t}\n\n\tcmdTinyauth := &cli.Command{\n\t\tName:          \"tinyauth\",\n\t\tDescription:   \"The simplest way to protect your apps with a login screen\",\n\t\tConfiguration: tConfig,\n\t\tResources:     loaders,\n\t\tRun: func(_ []string) error {\n\t\t\treturn runCmd(*tConfig)\n\t\t},\n\t}\n\n\tcmdUser := &cli.Command{\n\t\tName:        \"user\",\n\t\tDescription: \"Manage Tinyauth users\",\n\t}\n\n\tcmdTotp := &cli.Command{\n\t\tName:        \"totp\",\n\t\tDescription: \"Manage Tinyauth TOTP users\",\n\t}\n\n\tcmdOidc := &cli.Command{\n\t\tName:        \"oidc\",\n\t\tDescription: \"Manage Tinyauth OIDC clients\",\n\t}\n\n\terr := cmdTinyauth.AddCommand(versionCmd())\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add version command\")\n\t}\n\n\terr = cmdUser.AddCommand(verifyUserCmd())\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add verify command\")\n\t}\n\n\terr = cmdTinyauth.AddCommand(healthcheckCmd())\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add healthcheck command\")\n\t}\n\n\terr = cmdTotp.AddCommand(generateTotpCmd())\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add generate command\")\n\t}\n\n\terr = cmdUser.AddCommand(createUserCmd())\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add create command\")\n\t}\n\n\terr = cmdOidc.AddCommand(createOidcClientCmd())\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add create command\")\n\t}\n\n\terr = cmdTinyauth.AddCommand(cmdUser)\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add user command\")\n\t}\n\n\terr = cmdTinyauth.AddCommand(cmdTotp)\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add totp command\")\n\t}\n\n\terr = cmdTinyauth.AddCommand(cmdOidc)\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to add oidc command\")\n\t}\n\n\terr = cli.Execute(cmdTinyauth)\n\n\tif err != nil {\n\t\tlog.Fatal().Err(err).Msg(\"Failed to execute command\")\n\t}\n}\n\nfunc runCmd(cfg config.Config) error {\n\tlogger := tlog.NewLogger(cfg.Log)\n\tlogger.Init()\n\n\ttlog.App.Info().Str(\"version\", config.Version).Msg(\"Starting tinyauth\")\n\n\tapp := bootstrap.NewBootstrapApp(cfg)\n\n\terr := app.Setup()\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to bootstrap app: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/tinyauth/verify_user.go",
    "content": "package main\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/charmbracelet/huh\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"github.com/traefik/paerser/cli\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\ntype VerifyUserConfig struct {\n\tInteractive bool   `description:\"Validate a user interactively.\"`\n\tUsername    string `description:\"Username.\"`\n\tPassword    string `description:\"Password.\"`\n\tTotp        string `description:\"TOTP code.\"`\n\tUser        string `description:\"Hash (username:hash:totp).\"`\n}\n\nfunc NewVerifyUserConfig() *VerifyUserConfig {\n\treturn &VerifyUserConfig{\n\t\tInteractive: false,\n\t\tUsername:    \"\",\n\t\tPassword:    \"\",\n\t\tTotp:        \"\",\n\t\tUser:        \"\",\n\t}\n}\n\nfunc verifyUserCmd() *cli.Command {\n\ttCfg := NewVerifyUserConfig()\n\n\tloaders := []cli.ResourceLoader{\n\t\t&cli.FlagLoader{},\n\t}\n\n\treturn &cli.Command{\n\t\tName:          \"verify\",\n\t\tDescription:   \"Verify a user is set up correctly\",\n\t\tConfiguration: tCfg,\n\t\tResources:     loaders,\n\t\tRun: func(_ []string) error {\n\t\t\ttlog.NewSimpleLogger().Init()\n\n\t\t\tif tCfg.Interactive {\n\t\t\t\tform := huh.NewForm(\n\t\t\t\t\thuh.NewGroup(\n\t\t\t\t\t\thuh.NewInput().Title(\"User (username:hash:totp)\").Value(&tCfg.User).Validate((func(s string) error {\n\t\t\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\t\t\treturn errors.New(\"user cannot be empty\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn nil\n\t\t\t\t\t\t})),\n\t\t\t\t\t\thuh.NewInput().Title(\"Username\").Value(&tCfg.Username).Validate((func(s string) error {\n\t\t\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\t\t\treturn errors.New(\"username cannot be empty\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn nil\n\t\t\t\t\t\t})),\n\t\t\t\t\t\thuh.NewInput().Title(\"Password\").Value(&tCfg.Password).Validate((func(s string) error {\n\t\t\t\t\t\t\tif s == \"\" {\n\t\t\t\t\t\t\t\treturn errors.New(\"password cannot be empty\")\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\treturn nil\n\t\t\t\t\t\t})),\n\t\t\t\t\t\thuh.NewInput().Title(\"TOTP Code (optional)\").Value(&tCfg.Totp),\n\t\t\t\t\t),\n\t\t\t\t)\n\n\t\t\t\tvar baseTheme *huh.Theme = huh.ThemeBase()\n\n\t\t\t\terr := form.WithTheme(baseTheme).Run()\n\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to run interactive prompt: %w\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tuser, err := utils.ParseUser(tCfg.User)\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to parse user: %w\", err)\n\t\t\t}\n\n\t\t\tif user.Username != tCfg.Username {\n\t\t\t\treturn fmt.Errorf(\"username is incorrect\")\n\t\t\t}\n\n\t\t\terr = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password))\n\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"password is incorrect: %w\", err)\n\t\t\t}\n\n\t\t\tif user.TotpSecret == \"\" {\n\t\t\t\tif tCfg.Totp != \"\" {\n\t\t\t\t\ttlog.App.Warn().Msg(\"User does not have TOTP secret\")\n\t\t\t\t}\n\t\t\t\ttlog.App.Info().Msg(\"User verified\")\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\tok := totp.Validate(tCfg.Totp, user.TotpSecret)\n\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"TOTP code incorrect\")\n\t\t\t}\n\n\t\t\ttlog.App.Info().Msg(\"User verified\")\n\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/tinyauth/version.go",
    "content": "package main\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\n\t\"github.com/traefik/paerser/cli\"\n)\n\nfunc versionCmd() *cli.Command {\n\treturn &cli.Command{\n\t\tName:          \"version\",\n\t\tDescription:   \"Print the version number of Tinyauth\",\n\t\tConfiguration: nil,\n\t\tResources:     nil,\n\t\tRun: func(_ []string) error {\n\t\t\tfmt.Printf(\"Version: %s\\n\", config.Version)\n\t\t\tfmt.Printf(\"Commit Hash: %s\\n\", config.CommitHash)\n\t\t\tfmt.Printf(\"Build Timestamp: %s\\n\", config.BuildTimestamp)\n\t\t\treturn nil\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "codecov.yml",
    "content": "coverage:\n  status:\n    project:\n      default:\n        informational: true\n    patch:\n      default:\n        informational: true\n"
  },
  {
    "path": "crowdin.yml",
    "content": "\"base_path\": \".\"\n\"base_url\": \"https://api.crowdin.com\"\n\n\"preserve_hierarchy\": true\n\nfiles:\n  [\n    {\n      \"source\": \"/frontend/src/lib/i18n/locales/en.json\",\n      \"translation\": \"/frontend/src/lib/i18n/locales/%locale%.json\",\n    },\n  ]\n"
  },
  {
    "path": "docker-compose.dev.yml",
    "content": "services:\n  traefik:\n    image: traefik:v3.6\n    command: --api.insecure=true --providers.docker\n    ports:\n      - 80:80\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n\n  whoami:\n    image: traefik/whoami:latest\n    labels:\n      traefik.enable: true\n      traefik.http.routers.whoami.rule: Host(`whoami.127.0.0.1.sslip.io`)\n      traefik.http.routers.whoami.middlewares: tinyauth\n\n  tinyauth-frontend:\n    build:\n      context: .\n      dockerfile: frontend/Dockerfile.dev\n    volumes:\n      - ./frontend/src:/frontend/src\n    ports:\n      - 5173:5173\n    labels:\n      traefik.enable: true\n      traefik.http.routers.tinyauth.rule: Host(`tinyauth.127.0.0.1.sslip.io`)\n\n  tinyauth-backend:\n    build:\n      context: .\n      dockerfile: Dockerfile.dev\n      args:\n        - VERSION=development\n        - COMMIT_HASH=development\n        - BUILD_TIMESTAMP=000-00-00T00:00:00Z\n    env_file: .env\n    volumes:\n      - ./internal:/tinyauth/internal\n      - ./cmd:/tinyauth/cmd\n      - /var/run/docker.sock:/var/run/docker.sock\n      - ./data:/data\n    ports:\n      - 3000:3000\n      - 4000:4000\n    labels:\n      traefik.enable: true\n      traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth-backend:3000/api/auth/traefik\n      traefik.http.middlewares.tinyauth.forwardauth.authResponseHeaders: remote-user, remote-sub, remote-name, remote-email, remote-groups\n"
  },
  {
    "path": "docker-compose.example.yml",
    "content": "services:\n  traefik:\n    image: traefik:v3.6\n    command: --api.insecure=true --providers.docker\n    ports:\n      - 80:80\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock\n\n  whoami:\n    image: traefik/whoami:latest\n    labels:\n      traefik.enable: true\n      traefik.http.routers.whoami.rule: Host(`whoami.example.com`)\n      traefik.http.routers.whoami.middlewares: tinyauth\n\n  tinyauth:\n    image: ghcr.io/steveiliop56/tinyauth:v5\n    environment:\n      - TINYAUTH_APPURL=https://tinyauth.example.com\n      - TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password\n    volumes:\n      - ./data:/data\n    labels:\n      traefik.enable: true\n      traefik.http.routers.tinyauth.rule: Host(`tinyauth.example.com`)\n      traefik.http.middlewares.tinyauth.forwardauth.address: http://tinyauth:3000/api/auth/traefik\n"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n# Stats out\nstats.html\n"
  },
  {
    "path": "frontend/.prettierignore",
    "content": "# Ignore artifacts:\ndist\nnode_modules\nbun.lock\npackage.json\nsrc/lib/i18n/locales"
  },
  {
    "path": "frontend/.prettierrc",
    "content": "{}\n"
  },
  {
    "path": "frontend/Dockerfile.dev",
    "content": "FROM oven/bun:1.2.16-alpine\n\nWORKDIR /frontend\n\nCOPY ./frontend/package.json ./\nCOPY ./frontend/bun.lock ./\n\nRUN bun install\n\nCOPY ./frontend/public ./public\nCOPY ./frontend/src ./src\n\nCOPY ./frontend/eslint.config.js ./\nCOPY ./frontend/index.html ./\nCOPY ./frontend/tsconfig.json ./\nCOPY ./frontend/tsconfig.app.json ./\nCOPY ./frontend/tsconfig.node.json ./\nCOPY ./frontend/vite.config.ts ./\n\nEXPOSE 5173\n\nENTRYPOINT [\"bun\", \"run\", \"dev\"]"
  },
  {
    "path": "frontend/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "frontend/eslint.config.js",
    "content": "import js from \"@eslint/js\";\nimport globals from \"globals\";\nimport reactHooks from \"eslint-plugin-react-hooks\";\nimport reactRefresh from \"eslint-plugin-react-refresh\";\nimport tseslint from \"typescript-eslint\";\nimport pluginQuery from \"@tanstack/eslint-plugin-query\";\n\nexport default tseslint.config(\n  { ignores: [\"dist\"] },\n  {\n    extends: [js.configs.recommended, ...tseslint.configs.recommended],\n    files: [\"**/*.{ts,tsx}\"],\n    languageOptions: {\n      ecmaVersion: 2020,\n      globals: globals.browser,\n    },\n    plugins: {\n      \"react-hooks\": reactHooks,\n      \"react-refresh\": reactRefresh,\n      \"@tanstack/query\": pluginQuery,\n    },\n    rules: {\n      ...reactHooks.configs.recommended.rules,\n      \"react-refresh/only-export-components\": [\n        \"warn\",\n        { allowConstantExport: true },\n      ],\n      \"@tanstack/query/exhaustive-deps\": \"error\",\n    },\n  },\n);\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/favicon-96x96.png\" sizes=\"96x96\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\n    <link rel=\"shortcut icon\" href=\"/favicon.ico\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/apple-touch-icon.png\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"Tinyauth\" />\n    <meta name=\"robots\" content=\"nofollow, noindex\" />\n    <link rel=\"manifest\" href=\"/site.webmanifest\" />\n    <title>Tinyauth</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"tinyauth\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc -b && vite build\",\n    \"lint\": \"eslint .\",\n    \"preview\": \"vite preview\",\n    \"tsc\": \"tsc -b\"\n  },\n  \"dependencies\": {\n    \"@hookform/resolvers\": \"^5.2.2\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-label\": \"^2.1.8\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slot\": \"^1.2.4\",\n    \"@tailwindcss/vite\": \"^4.2.1\",\n    \"@tanstack/react-query\": \"^5.90.21\",\n    \"axios\": \"^1.13.6\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"i18next\": \"^25.8.18\",\n    \"i18next-browser-languagedetector\": \"^8.2.1\",\n    \"i18next-resources-to-backend\": \"^1.2.1\",\n    \"input-otp\": \"^1.4.2\",\n    \"lucide-react\": \"^0.577.0\",\n    \"next-themes\": \"^0.4.6\",\n    \"radix-ui\": \"^1.4.3\",\n    \"react\": \"^19.2.4\",\n    \"react-dom\": \"^19.2.4\",\n    \"react-hook-form\": \"^7.71.2\",\n    \"react-i18next\": \"^16.5.8\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-router\": \"^7.13.1\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.5.0\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"zod\": \"^4.3.6\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^10.0.1\",\n    \"@tanstack/eslint-plugin-query\": \"^5.91.4\",\n    \"@types/node\": \"^25.5.0\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"@vitejs/plugin-react\": \"^5.1.4\",\n    \"eslint\": \"^10.0.3\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.5.2\",\n    \"globals\": \"^17.4.0\",\n    \"prettier\": \"3.8.1\",\n    \"rollup-plugin-visualizer\": \"^7.0.1\",\n    \"tw-animate-css\": \"^1.4.0\",\n    \"typescript\": \"~5.9.3\",\n    \"typescript-eslint\": \"^8.57.0\",\n    \"vite\": \"^7.3.1\"\n  }\n}\n"
  },
  {
    "path": "frontend/public/site.webmanifest",
    "content": "{\n  \"name\": \"Tinyauth\",\n  \"short_name\": \"Tinyauth\",\n  \"icons\": [\n    {\n      \"src\": \"/web-app-manifest-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    },\n    {\n      \"src\": \"/web-app-manifest-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable\"\n    }\n  ],\n  \"theme_color\": \"#171717\",\n  \"background_color\": \"#171717\",\n  \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "frontend/src/App.tsx",
    "content": "import { Navigate } from \"react-router\";\nimport { useUserContext } from \"./context/user-context\";\n\nexport const App = () => {\n  const { isLoggedIn } = useUserContext();\n\n  if (isLoggedIn) {\n    return <Navigate to=\"/logout\" replace />;\n  }\n\n  return <Navigate to=\"/login\" replace />;\n};\n"
  },
  {
    "path": "frontend/src/components/auth/login-form.tsx",
    "content": "import { useTranslation } from \"react-i18next\";\nimport { Input } from \"../ui/input\";\nimport { useForm } from \"react-hook-form\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport {\n  Form,\n  FormControl,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"../ui/form\";\nimport { loginSchema, LoginSchema } from \"@/schemas/login-schema\";\nimport z from \"zod\";\n\ninterface Props {\n  onSubmit: (data: LoginSchema) => void;\n  loading?: boolean;\n  formId?: string;\n}\n\nexport const LoginForm = (props: Props) => {\n  const { onSubmit, loading, formId } = props;\n  const { t } = useTranslation();\n\n  z.config({\n    customError: (iss) =>\n      iss.input === undefined ? t(\"fieldRequired\") : t(\"invalidInput\"),\n  });\n\n  const form = useForm<LoginSchema>({\n    resolver: zodResolver(loginSchema),\n  });\n\n  return (\n    <Form {...form}>\n      <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>\n        <FormField\n          control={form.control}\n          name=\"username\"\n          render={({ field }) => (\n            <FormItem className=\"mb-4 gap-0\">\n              <FormLabel className=\"mb-2\">{t(\"loginUsername\")}</FormLabel>\n              <FormControl className=\"mb-1\">\n                <Input\n                  placeholder={t(\"loginUsername\").toLocaleLowerCase()}\n                  disabled={loading}\n                  autoComplete=\"username\"\n                  {...field}\n                />\n              </FormControl>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n        <FormField\n          control={form.control}\n          name=\"password\"\n          render={({ field }) => (\n            <FormItem className=\"gap-0\">\n              <div className=\"relative mb-1\">\n                <FormLabel className=\"mb-2\">{t(\"loginPassword\")}</FormLabel>\n                <FormControl>\n                  <Input\n                    placeholder={t(\"loginPassword\").toLowerCase()}\n                    type=\"password\"\n                    disabled={loading}\n                    autoComplete=\"current-password\"\n                    {...field}\n                  />\n                </FormControl>\n                <a\n                  href=\"/forgot-password\"\n                  className=\"text-muted-foreground hover:text-muted-foreground/80 text-sm absolute right-0 bottom-[2.565rem]\" // 2.565 is *just* perfect\n                >\n                  {t(\"forgotPasswordTitle\")}\n                </a>\n              </div>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n      </form>\n    </Form>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/auth/totp-form.tsx",
    "content": "import { Form, FormControl, FormField, FormItem } from \"../ui/form\";\nimport {\n  InputOTP,\n  InputOTPGroup,\n  InputOTPSeparator,\n  InputOTPSlot,\n} from \"../ui/input-otp\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \"react-hook-form\";\nimport { totpSchema, TotpSchema } from \"@/schemas/totp-schema\";\nimport { useTranslation } from \"react-i18next\";\nimport z from \"zod\";\n\ninterface Props {\n  formId: string;\n  onSubmit: (code: TotpSchema) => void;\n}\n\nexport const TotpForm = (props: Props) => {\n  const { formId, onSubmit } = props;\n  const { t } = useTranslation();\n\n  z.config({\n    customError: (iss) =>\n      iss.input === undefined ? t(\"fieldRequired\") : t(\"invalidInput\"),\n  });\n\n  const form = useForm<TotpSchema>({\n    resolver: zodResolver(totpSchema),\n  });\n\n  const handleChange = (value: string) => {\n    form.setValue(\"code\", value, { shouldDirty: true, shouldValidate: true });\n\n    if (value.length === 6) {\n      onSubmit({ code: value });\n    }\n  };\n\n  return (\n    <Form {...form}>\n      <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>\n        <FormField\n          control={form.control}\n          name=\"code\"\n          render={({ field }) => (\n            <FormItem>\n              <FormControl>\n                <InputOTP\n                  maxLength={6}\n                  {...field}\n                  autoComplete=\"one-time-code\"\n                  autoFocus\n                  onChange={handleChange}\n                >\n                  <InputOTPGroup>\n                    <InputOTPSlot index={0} />\n                    <InputOTPSlot index={1} />\n                    <InputOTPSlot index={2} />\n                  </InputOTPGroup>\n                  <InputOTPSeparator />\n                  <InputOTPGroup>\n                    <InputOTPSlot index={3} />\n                    <InputOTPSlot index={4} />\n                    <InputOTPSlot index={5} />\n                  </InputOTPGroup>\n                </InputOTP>\n              </FormControl>\n            </FormItem>\n          )}\n        />\n      </form>\n    </Form>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/domain-warning/domain-warning.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"../ui/card\";\nimport { Button } from \"../ui/button\";\nimport { useTranslation } from \"react-i18next\";\nimport { useLocation } from \"react-router\";\n\ninterface Props {\n  onClick: () => void;\n  appUrl: string;\n  currentUrl: string;\n}\n\nexport const DomainWarning = (props: Props) => {\n  const { onClick, appUrl, currentUrl } = props;\n  const { t } = useTranslation();\n  const { search } = useLocation();\n\n  const searchParams = new URLSearchParams(search);\n\n  return (\n    <Card role=\"alert\" aria-live=\"assertive\">\n      <CardHeader>\n        <CardTitle className=\"text-xl\">{t(\"domainWarningTitle\")}</CardTitle>\n      </CardHeader>\n      <CardContent className=\"flex flex-col gap-3 text-sm mb-1.25\">\n        <p className=\"text-muted-foreground\">{t(\"domainWarningSubtitle\")}</p>\n        <pre>\n          <span className=\"text-muted-foreground\">\n            {t(\"domainWarningExpected\")}&nbsp;\n            <span className=\"text-primary\">{appUrl}</span>\n          </span>\n        </pre>\n        <pre>\n          <span className=\"text-muted-foreground\">\n            {t(\"domainWarningCurrent\")}&nbsp;\n            <span className=\"text-primary\">{currentUrl}</span>\n          </span>\n        </pre>\n      </CardContent>\n      <CardFooter className=\"flex flex-col items-stretch gap-3\">\n        <Button\n          onClick={() =>\n            window.location.assign(`${appUrl}/login?${searchParams.toString()}`)\n          }\n          variant=\"outline\"\n        >\n          {t(\"goToCorrectDomainTitle\")}\n        </Button>\n        <Button onClick={onClick} variant=\"warning\">\n          {t(\"ignoreTitle\")}\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/icons/github.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport function GithubIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width={24}\n      height={24}\n      viewBox=\"0 0 24 24\"\n      {...props}\n    >\n      <path\n        fill=\"currentColor\"\n        d=\"M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2\"\n      ></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/google.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport function GoogleIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width={256}\n      height={262}\n      viewBox=\"0 0 256 262\"\n      {...props}\n    >\n      <path\n        fill=\"#4285f4\"\n        d=\"M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027\"\n      ></path>\n      <path\n        fill=\"#34a853\"\n        d=\"M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1\"\n      ></path>\n      <path\n        fill=\"#fbbc05\"\n        d=\"M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z\"\n      ></path>\n      <path\n        fill=\"#eb4335\"\n        d=\"M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251\"\n      ></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/microsoft.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport function MicrosoftIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"2em\"\n      height=\"2em\"\n      viewBox=\"0 0 256 256\"\n      {...props}\n    >\n      <path fill=\"#f1511b\" d=\"M121.666 121.666H0V0h121.666z\"></path>\n      <path fill=\"#80cc28\" d=\"M256 121.666H134.335V0H256z\"></path>\n      <path fill=\"#00adef\" d=\"M121.663 256.002H0V134.336h121.663z\"></path>\n      <path fill=\"#fbbc09\" d=\"M256 256.002H134.335V134.336H256z\"></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/oauth.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport function OAuthIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width={24}\n      height={24}\n      viewBox=\"0 0 24 24\"\n      {...props}\n    >\n      <g\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth={2}\n      >\n        <path d=\"M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0\"></path>\n        <path d=\"M12.556 6c.65 0 1.235.373 1.508.947l2.839 7.848a1.646 1.646 0 0 1-1.01 2.108a1.673 1.673 0 0 1-2.068-.851L13.365 15h-2.73l-.398.905A1.67 1.67 0 0 1 8.26 16.95l-.153-.047a1.647 1.647 0 0 1-1.056-1.956l2.824-7.852a1.66 1.66 0 0 1 1.409-1.087z\"></path>\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/pocket-id.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport function PocketIDIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      xmlSpace=\"preserve\"\n      width={512}\n      height={512}\n      viewBox=\"0 0 512 512\"\n      {...props}\n    >\n      <circle cx=\"256\" cy=\"256\" r=\"256\" />\n      <path\n        d=\"M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z\"\n        className=\"fill-white\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/icons/tailscale.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport function TailscaleIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      xmlSpace=\"preserve\"\n      width={512}\n      height={512}\n      viewBox=\"0 0 512 512\"\n      {...props}\n    >\n      <path\n        className=\"opacity-80\"\n        fill=\"currentColor\"\n        d=\"M65.6 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9S1.8 219 1.8 254.2s28.6 63.9 63.8 63.9m191.6 0c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9m0 193.9c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9m189.2-193.9c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9\"\n      />\n\n      <path\n        d=\"M65.6 127.7c35.3 0 63.9-28.6 63.9-63.9S100.9 0 65.6 0 1.8 28.6 1.8 63.9s28.6 63.8 63.8 63.8m0 384.3c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.8 28.7-63.8 63.9S30.4 512 65.6 512m191.6-384.3c35.3 0 63.9-28.6 63.9-63.9S292.5 0 257.2 0s-63.9 28.6-63.9 63.9 28.6 63.8 63.9 63.8m189.2 0c35.3 0 63.9-28.6 63.9-63.9S481.6 0 446.4 0c-35.3 0-63.9 28.6-63.9 63.9s28.6 63.8 63.9 63.8m0 384.3c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9\"\n        className=\"opacity-20\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/language/language.tsx",
    "content": "import { languages, SupportedLanguage } from \"@/lib/i18n/locales\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"../ui/select\";\nimport { useState } from \"react\";\nimport i18n from \"@/lib/i18n/i18n\";\n\nexport const LanguageSelector = () => {\n  const [language, setLanguage] = useState<SupportedLanguage>(\n    i18n.language as SupportedLanguage,\n  );\n\n  const handleSelect = (option: string) => {\n    setLanguage(option as SupportedLanguage);\n    i18n.changeLanguage(option as SupportedLanguage);\n  };\n\n  return (\n    <Select onValueChange={handleSelect} value={language}>\n      <SelectTrigger>\n        <SelectValue placeholder=\"Select language\" />\n      </SelectTrigger>\n      <SelectContent>\n        {Object.entries(languages).map(([key, value]) => (\n          <SelectItem key={key} value={key}>\n            {value}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/layout/layout.tsx",
    "content": "import { useAppContext } from \"@/context/app-context\";\nimport { LanguageSelector } from \"../language/language\";\nimport { Outlet } from \"react-router\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { DomainWarning } from \"../domain-warning/domain-warning\";\nimport { ThemeToggle } from \"../theme-toggle/theme-toggle\";\n\nconst BaseLayout = ({ children }: { children: React.ReactNode }) => {\n  const { backgroundImage, title } = useAppContext();\n\n  useEffect(() => {\n    document.title = title;\n  }, [title]);\n\n  return (\n    <div\n      className=\"flex flex-col justify-center items-center min-h-svh px-4\"\n      style={{\n        backgroundImage: `url(${backgroundImage})`,\n        backgroundSize: \"cover\",\n        backgroundPosition: \"center\",\n      }}\n    >\n      <div className=\"absolute top-4 right-4 flex flex-row gap-2\">\n        <ThemeToggle />\n        <LanguageSelector />\n      </div>\n      <div className=\"max-w-sm md:min-w-sm min-w-xs\">{children}</div>\n    </div>\n  );\n};\n\nexport const Layout = () => {\n  const { appUrl, warningsEnabled } = useAppContext();\n  const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => {\n    return window.sessionStorage.getItem(\"ignoreDomainWarning\") === \"true\";\n  });\n  const currentUrl = window.location.origin;\n\n  const handleIgnore = useCallback(() => {\n    window.sessionStorage.setItem(\"ignoreDomainWarning\", \"true\");\n    setIgnoreDomainWarning(true);\n  }, [setIgnoreDomainWarning]);\n\n  if (!ignoreDomainWarning && warningsEnabled && appUrl !== currentUrl) {\n    return (\n      <BaseLayout>\n        <DomainWarning\n          appUrl={appUrl}\n          currentUrl={currentUrl}\n          onClick={() => handleIgnore()}\n        />\n      </BaseLayout>\n    );\n  }\n\n  return (\n    <BaseLayout>\n      <Outlet />\n    </BaseLayout>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/providers/theme-provider.tsx",
    "content": "import { createContext, useContext, useEffect, useState } from \"react\";\n\ntype Theme = \"dark\" | \"light\" | \"system\";\n\ntype ThemeProviderProps = {\n  children: React.ReactNode;\n  defaultTheme?: Theme;\n  storageKey?: string;\n};\n\ntype ThemeProviderState = {\n  theme: Theme;\n  setTheme: (theme: Theme) => void;\n};\n\nconst initialState: ThemeProviderState = {\n  theme: \"system\",\n  setTheme: () => null,\n};\n\nconst ThemeProviderContext = createContext<ThemeProviderState>(initialState);\n\nexport function ThemeProvider({\n  children,\n  defaultTheme = \"system\",\n  storageKey = \"vite-ui-theme\",\n  ...props\n}: ThemeProviderProps) {\n  const [theme, setTheme] = useState<Theme>(\n    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,\n  );\n\n  useEffect(() => {\n    const root = window.document.documentElement;\n\n    root.classList.remove(\"light\", \"dark\");\n\n    if (theme === \"system\") {\n      const systemTheme = window.matchMedia(\"(prefers-color-scheme: dark)\")\n        .matches\n        ? \"dark\"\n        : \"light\";\n\n      root.classList.add(systemTheme);\n      return;\n    }\n\n    root.classList.add(theme);\n  }, [theme]);\n\n  const value = {\n    theme,\n    setTheme: (theme: Theme) => {\n      localStorage.setItem(storageKey, theme);\n      setTheme(theme);\n    },\n  };\n\n  return (\n    <ThemeProviderContext.Provider {...props} value={value}>\n      {children}\n    </ThemeProviderContext.Provider>\n  );\n}\n\nexport const useTheme = () => {\n  const context = useContext(ThemeProviderContext);\n\n  if (context === undefined)\n    throw new Error(\"useTheme must be used within a ThemeProvider\");\n\n  return context;\n};\n"
  },
  {
    "path": "frontend/src/components/theme-toggle/theme-toggle.tsx",
    "content": "import { Moon, Sun } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { useTheme } from \"@/components/providers/theme-provider\";\n\nexport function ThemeToggle() {\n  const { setTheme } = useTheme();\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button\n          className=\"bg-card text-card-foreground hover:bg-card/90\"\n          size=\"icon\"\n        >\n          <Sun className=\"h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90\" />\n          <Moon className=\"absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0\" />\n          <span className=\"sr-only\">Toggle theme</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem onClick={() => setTheme(\"light\")}>\n          Light\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme(\"dark\")}>\n          Dark\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme(\"system\")}>\n          System\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "frontend/src/components/ui/button.tsx",
    "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Loader2 } from \"lucide-react\";\n\nconst buttonVariants = cva(\n  \"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\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n        ghost:\n          \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n        warning:\n          \"bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  loading = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean;\n    loading?: boolean;\n  }) {\n  const Comp = asChild ? Slot : \"button\";\n\n  if (loading) {\n    return (\n      <Comp\n        data-slot=\"button\"\n        className={cn(buttonVariants({ variant, size, className }))}\n        disabled\n        {...props}\n      >\n        <Loader2 className=\"animate-spin\" />\n      </Comp>\n    );\n  }\n\n  return (\n    <Comp\n      data-slot=\"button\"\n      className={cn(buttonVariants({ variant, size, className }))}\n      {...props}\n    />\n  );\n}\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "frontend/src/components/ui/card.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\n        \"bg-card text-card-foreground flex flex-col gap-3 rounded-xl border py-6 shadow-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        \"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-title\"\n      className={cn(\"leading-none font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\n        \"col-start-2 row-span-2 row-start-1 self-start justify-self-end\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-content\"\n      className={cn(\"px-6\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-footer\"\n      className={cn(\"flex items-center px-6 [.border-t]:pt-6 mt-2\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardAction,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "frontend/src/components/ui/dropdown-menu.tsx",
    "content": "import * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction DropdownMenu({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />\n}\n\nfunction DropdownMenuPortal({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return (\n    <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />\n  )\n}\n\nfunction DropdownMenuTrigger({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return (\n    <DropdownMenuPrimitive.Trigger\n      data-slot=\"dropdown-menu-trigger\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md\",\n          className\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  )\n}\n\nfunction DropdownMenuGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return (\n    <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />\n  )\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot=\"dropdown-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction DropdownMenuRadioGroup({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup\n      data-slot=\"dropdown-menu-radio-group\"\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot=\"dropdown-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  )\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot=\"dropdown-menu-label\"\n      data-inset={inset}\n      className={cn(\n        \"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuShortcut({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      className={cn(\n        \"text-muted-foreground ml-auto text-xs tracking-widest\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction DropdownMenuSub({\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot=\"dropdown-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  )\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot=\"dropdown-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n}\n"
  },
  {
    "path": "frontend/src/components/ui/form.tsx",
    "content": "import * as React from \"react\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport {\n  Controller,\n  FormProvider,\n  useFormContext,\n  useFormState,\n  type ControllerProps,\n  type FieldPath,\n  type FieldValues,\n} from \"react-hook-form\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Label } from \"@/components/ui/label\";\n\nconst Form = FormProvider;\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n> = {\n  name: TName;\n};\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue,\n);\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  );\n};\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext);\n  const itemContext = React.useContext(FormItemContext);\n  const { getFieldState } = useFormContext();\n  const formState = useFormState({ name: fieldContext.name });\n  const fieldState = getFieldState(fieldContext.name, formState);\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\");\n  }\n\n  const { id } = itemContext;\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  };\n};\n\ntype FormItemContextValue = {\n  id: string;\n};\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n  {} as FormItemContextValue,\n);\n\nfunction FormItem({ className, ...props }: React.ComponentProps<\"div\">) {\n  const id = React.useId();\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div\n        data-slot=\"form-item\"\n        className={cn(\"grid gap-2\", className)}\n        {...props}\n      />\n    </FormItemContext.Provider>\n  );\n}\n\nfunction FormLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  const { error, formItemId } = useFormField();\n\n  return (\n    <Label\n      data-slot=\"form-label\"\n      data-error={!!error}\n      className={cn(\"data-[error=true]:text-destructive\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  );\n}\n\nfunction FormControl({ ...props }: React.ComponentProps<typeof Slot>) {\n  const { error, formItemId, formDescriptionId, formMessageId } =\n    useFormField();\n\n  return (\n    <Slot\n      data-slot=\"form-control\"\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  );\n}\n\nfunction FormDescription({ className, ...props }: React.ComponentProps<\"p\">) {\n  const { formDescriptionId } = useFormField();\n\n  return (\n    <p\n      data-slot=\"form-description\"\n      id={formDescriptionId}\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction FormMessage({ className, ...props }: React.ComponentProps<\"p\">) {\n  const { error, formMessageId } = useFormField();\n  const body = error ? String(error?.message ?? \"\") : props.children;\n\n  if (!body) {\n    return null;\n  }\n\n  return (\n    <p\n      data-slot=\"form-message\"\n      id={formMessageId}\n      className={cn(\"text-destructive text-sm\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  );\n}\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n};\n"
  },
  {
    "path": "frontend/src/components/ui/input-otp.tsx",
    "content": "import * as React from \"react\";\nimport { OTPInput, OTPInputContext } from \"input-otp\";\nimport { MinusIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction InputOTP({\n  className,\n  containerClassName,\n  ...props\n}: React.ComponentProps<typeof OTPInput> & {\n  containerClassName?: string;\n}) {\n  return (\n    <OTPInput\n      data-slot=\"input-otp\"\n      containerClassName={cn(\n        \"flex items-center gap-2 has-disabled:opacity-50\",\n        containerClassName,\n      )}\n      className={cn(\"disabled:cursor-not-allowed\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction InputOTPGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"input-otp-group\"\n      className={cn(\"flex items-center\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction InputOTPSlot({\n  index,\n  className,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  index: number;\n}) {\n  const inputOTPContext = React.useContext(OTPInputContext);\n  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};\n\n  return (\n    <div\n      data-slot=\"input-otp-slot\"\n      data-active={isActive}\n      className={cn(\n        \"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]\",\n        className,\n      )}\n      {...props}\n    >\n      {char}\n      {hasFakeCaret && (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <div className=\"animate-caret-blink bg-foreground h-4 w-px duration-1000\" />\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction InputOTPSeparator({ ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div data-slot=\"input-otp-separator\" role=\"separator\" {...props}>\n      <MinusIcon />\n    </div>\n  );\n}\n\nexport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };\n"
  },
  {
    "path": "frontend/src/components/ui/input.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Input };\n"
  },
  {
    "path": "frontend/src/components/ui/label.tsx",
    "content": "import * as React from \"react\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Label({\n  className,\n  ...props\n}: React.ComponentProps<typeof LabelPrimitive.Root>) {\n  return (\n    <LabelPrimitive.Root\n      data-slot=\"label\"\n      className={cn(\n        \"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Label };\n"
  },
  {
    "path": "frontend/src/components/ui/oauth-button.tsx",
    "content": "import { Loader2 } from \"lucide-react\";\nimport { Button } from \"./button\";\nimport React from \"react\";\nimport { twMerge } from \"tailwind-merge\";\n\ninterface Props extends React.ComponentProps<typeof Button> {\n  title: string;\n  icon: React.ReactNode;\n  onClick?: () => void;\n  loading?: boolean;\n}\n\nexport const OAuthButton = (props: Props) => {\n  const { title, icon, onClick, loading, className, ...rest } = props;\n\n  return (\n    <Button\n      onClick={onClick}\n      className={twMerge(\"rounded-md\", className)}\n      variant=\"outline\"\n      {...rest}\n    >\n      {loading ? (\n        <Loader2 className=\"animate-spin\" />\n      ) : (\n        <>\n          {icon}\n          {title}\n        </>\n      )}\n    </Button>\n  );\n};\n"
  },
  {
    "path": "frontend/src/components/ui/select.tsx",
    "content": "import * as React from \"react\";\nimport * as SelectPrimitive from \"@radix-ui/react-select\";\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Select({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />;\n}\n\nfunction SelectGroup({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />;\n}\n\nfunction SelectValue({\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />;\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\";\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"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\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  );\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"popper\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className,\n        )}\n        position={position}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\",\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"text-muted-foreground px-2 py-1.5 text-xs\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"bg-border pointer-events-none -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectScrollUpButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className,\n      )}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  );\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\n        \"flex cursor-default items-center justify-center py-1\",\n        className,\n      )}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
  },
  {
    "path": "frontend/src/components/ui/separator.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\";\n\nimport { cn } from \"@/lib/utils\";\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator-root\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SeperatorWithChildren({ children }: { children: React.ReactNode }) {\n  return (\n    <div className=\"flex items-center gap-4\">\n      <Separator className=\"flex-1\" />\n      <span className=\"text-sm text-muted-foreground\">{children}</span>\n      <Separator className=\"flex-1\" />\n    </div>\n  );\n}\n\nexport { Separator, SeperatorWithChildren };\n"
  },
  {
    "path": "frontend/src/components/ui/sonner.tsx",
    "content": "import { useTheme } from \"../providers/theme-provider\";\nimport { Toaster as Sonner, ToasterProps } from \"sonner\";\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme } = useTheme();\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  );\n};\n\nexport { Toaster };\n"
  },
  {
    "path": "frontend/src/components/ui/tooltip.tsx",
    "content": "import * as React from \"react\"\nimport { Tooltip as TooltipPrimitive } from \"radix-ui\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction TooltipProvider({\n  delayDuration = 0,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return (\n    <TooltipPrimitive.Provider\n      data-slot=\"tooltip-provider\"\n      delayDuration={delayDuration}\n      {...props}\n    />\n  )\n}\n\nfunction Tooltip({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n}\n\nfunction TooltipTrigger({\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n        <TooltipPrimitive.Arrow className=\"bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" />\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  )\n}\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "frontend/src/context/app-context.tsx",
    "content": "import {\n  appContextSchema,\n  AppContextSchema,\n} from \"@/schemas/app-context-schema\";\nimport { createContext, useContext } from \"react\";\nimport { useSuspenseQuery } from \"@tanstack/react-query\";\nimport axios from \"axios\";\n\nconst AppContext = createContext<AppContextSchema | null>(null);\n\nexport const AppContextProvider = ({\n  children,\n}: {\n  children: React.ReactNode;\n}) => {\n  const { isFetching, data, error } = useSuspenseQuery({\n    queryKey: [\"app\"],\n    queryFn: () => axios.get(\"/api/context/app\").then((res) => res.data),\n  });\n\n  if (error && !isFetching) {\n    throw error;\n  }\n\n  const validated = appContextSchema.safeParse(data);\n\n  if (validated.success === false) {\n    throw validated.error;\n  }\n\n  return (\n    <AppContext.Provider value={validated.data}>{children}</AppContext.Provider>\n  );\n};\n\nexport const useAppContext = () => {\n  const context = useContext(AppContext);\n\n  if (!context) {\n    throw new Error(\"useAppContext must be used within an AppContextProvider\");\n  }\n\n  return context;\n};\n"
  },
  {
    "path": "frontend/src/context/user-context.tsx",
    "content": "import {\n  userContextSchema,\n  UserContextSchema,\n} from \"@/schemas/user-context-schema\";\nimport { createContext, useContext } from \"react\";\nimport { useSuspenseQuery } from \"@tanstack/react-query\";\nimport axios from \"axios\";\n\nconst UserContext = createContext<UserContextSchema | null>(null);\n\nexport const UserContextProvider = ({\n  children,\n}: {\n  children: React.ReactNode;\n}) => {\n  const { isFetching, data, error } = useSuspenseQuery({\n    queryKey: [\"user\"],\n    queryFn: () => axios.get(\"/api/context/user\").then((res) => res.data),\n  });\n\n  if (error && !isFetching) {\n    throw error;\n  }\n\n  const validated = userContextSchema.safeParse(data);\n\n  if (validated.success === false) {\n    throw validated.error;\n  }\n\n  return (\n    <UserContext.Provider value={validated.data}>\n      {children}\n    </UserContext.Provider>\n  );\n};\n\nexport const useUserContext = () => {\n  const context = useContext(UserContext);\n\n  if (!context) {\n    throw new Error(\n      \"useUserContext must be used within an UserContextProvider\",\n    );\n  }\n\n  return context;\n};\n"
  },
  {
    "path": "frontend/src/index.css",
    "content": "@import \"tailwindcss\";\n@import \"tw-animate-css\";\n\n@custom-variant dark (&:is(.dark *));\n\n@theme inline {\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n}\n\n:root {\n  --radius: 0.625rem;\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.145 0 0);\n  --card: oklch(1 0 0);\n  --card-foreground: oklch(0.145 0 0);\n  --popover: oklch(1 0 0);\n  --popover-foreground: oklch(0.145 0 0);\n  --primary: oklch(0.205 0 0);\n  --primary-foreground: oklch(0.985 0 0);\n  --secondary: oklch(0.97 0 0);\n  --secondary-foreground: oklch(0.205 0 0);\n  --muted: oklch(0.97 0 0);\n  --muted-foreground: oklch(0.556 0 0);\n  --accent: oklch(0.97 0 0);\n  --accent-foreground: oklch(0.205 0 0);\n  --destructive: oklch(0.577 0.245 27.325);\n  --border: oklch(0.922 0 0);\n  --input: oklch(0.922 0 0);\n  --ring: oklch(0.708 0 0);\n  --chart-1: oklch(0.646 0.222 41.116);\n  --chart-2: oklch(0.6 0.118 184.704);\n  --chart-3: oklch(0.398 0.07 227.392);\n  --chart-4: oklch(0.828 0.189 84.429);\n  --chart-5: oklch(0.769 0.188 70.08);\n  --sidebar: oklch(0.985 0 0);\n  --sidebar-foreground: oklch(0.145 0 0);\n  --sidebar-primary: oklch(0.205 0 0);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.97 0 0);\n  --sidebar-accent-foreground: oklch(0.205 0 0);\n  --sidebar-border: oklch(0.922 0 0);\n  --sidebar-ring: oklch(0.708 0 0);\n}\n\n.dark {\n  --background: oklch(0.145 0 0);\n  --foreground: oklch(0.985 0 0);\n  --card: oklch(0.205 0 0);\n  --card-foreground: oklch(0.985 0 0);\n  --popover: oklch(0.205 0 0);\n  --popover-foreground: oklch(0.985 0 0);\n  --primary: oklch(0.922 0 0);\n  --primary-foreground: oklch(0.205 0 0);\n  --secondary: oklch(0.269 0 0);\n  --secondary-foreground: oklch(0.985 0 0);\n  --muted: oklch(0.269 0 0);\n  --muted-foreground: oklch(0.708 0 0);\n  --accent: oklch(0.269 0 0);\n  --accent-foreground: oklch(0.985 0 0);\n  --destructive: oklch(0.704 0.191 22.216);\n  --border: oklch(1 0 0 / 10%);\n  --input: oklch(1 0 0 / 15%);\n  --ring: oklch(0.556 0 0);\n  --chart-1: oklch(0.488 0.243 264.376);\n  --chart-2: oklch(0.696 0.17 162.48);\n  --chart-3: oklch(0.769 0.188 70.08);\n  --chart-4: oklch(0.627 0.265 303.9);\n  --chart-5: oklch(0.645 0.246 16.439);\n  --sidebar: oklch(0.205 0 0);\n  --sidebar-foreground: oklch(0.985 0 0);\n  --sidebar-primary: oklch(0.488 0.243 264.376);\n  --sidebar-primary-foreground: oklch(0.985 0 0);\n  --sidebar-accent: oklch(0.269 0 0);\n  --sidebar-accent-foreground: oklch(0.985 0 0);\n  --sidebar-border: oklch(1 0 0 / 10%);\n  --sidebar-ring: oklch(0.556 0 0);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\nh1 {\n  @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;\n}\n\nh2 {\n  @apply scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0;\n}\n\nh3 {\n  @apply scroll-m-20 text-2xl font-semibold tracking-tight;\n}\n\nh4 {\n  @apply scroll-m-20 text-xl font-semibold tracking-tight;\n}\n\np {\n  @apply leading-6;\n}\n\nblockquote {\n  @apply mt-6 border-l-2 pl-6 italic;\n}\n\ntr {\n  @apply m-0 border-t p-0 even:bg-muted;\n}\n\nth {\n  @apply border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right;\n}\n\nul {\n  @apply my-6 ml-6 list-disc [&>li]:mt-2;\n}\n\ncode {\n  @apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all;\n}\n\npre {\n  @apply bg-accent border border-border rounded-md p-2 whitespace-break-spaces break-all;\n}\n\n.lead {\n  @apply text-xl text-muted-foreground;\n}\n\n.large {\n  @apply text-lg font-semibold;\n}\n\nsmall {\n  @apply text-sm font-medium leading-none;\n}\n\n.muted {\n  @apply text-sm text-muted-foreground;\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/oidc.ts",
    "content": "export type OIDCValues = {\n  scope: string;\n  response_type: string;\n  client_id: string;\n  redirect_uri: string;\n  state: string;\n  nonce: string;\n};\n\ninterface IuseOIDCParams {\n  values: OIDCValues;\n  compiled: string;\n  isOidc: boolean;\n  missingParams: string[];\n}\n\nconst optionalParams: string[] = [\"state\", \"nonce\"];\n\nexport function useOIDCParams(params: URLSearchParams): IuseOIDCParams {\n  let compiled: string = \"\";\n  let isOidc = false;\n  const missingParams: string[] = [];\n\n  const values: OIDCValues = {\n    scope: params.get(\"scope\") ?? \"\",\n    response_type: params.get(\"response_type\") ?? \"\",\n    client_id: params.get(\"client_id\") ?? \"\",\n    redirect_uri: params.get(\"redirect_uri\") ?? \"\",\n    state: params.get(\"state\") ?? \"\",\n    nonce: params.get(\"nonce\") ?? \"\",\n  };\n\n  for (const key of Object.keys(values)) {\n    if (!values[key as keyof OIDCValues]) {\n      if (!optionalParams.includes(key)) {\n        missingParams.push(key);\n      }\n    }\n  }\n\n  if (missingParams.length === 0) {\n    isOidc = true;\n  }\n\n  if (isOidc) {\n    compiled = new URLSearchParams(values).toString();\n  }\n\n  return {\n    values,\n    compiled,\n    isOidc,\n    missingParams,\n  };\n}\n"
  },
  {
    "path": "frontend/src/lib/hooks/redirect-uri.ts",
    "content": "type IuseRedirectUri = {\n  url?: URL;\n  valid: boolean;\n  trusted: boolean;\n  allowedProto: boolean;\n  httpsDowngrade: boolean;\n};\n\nexport const useRedirectUri = (\n  redirect_uri: string | null,\n  cookieDomain: string,\n): IuseRedirectUri => {\n  let isValid = false;\n  let isTrusted = false;\n  let isAllowedProto = false;\n  let isHttpsDowngrade = false;\n\n  if (!redirect_uri) {\n    return {\n      valid: isValid,\n      trusted: isTrusted,\n      allowedProto: isAllowedProto,\n      httpsDowngrade: isHttpsDowngrade,\n    };\n  }\n\n  let url: URL;\n\n  try {\n    url = new URL(redirect_uri);\n  } catch {\n    return {\n      valid: isValid,\n      trusted: isTrusted,\n      allowedProto: isAllowedProto,\n      httpsDowngrade: isHttpsDowngrade,\n    };\n  }\n\n  isValid = true;\n\n  if (\n    url.hostname == cookieDomain ||\n    url.hostname.endsWith(`.${cookieDomain}`)\n  ) {\n    isTrusted = true;\n  }\n\n  if (url.protocol == \"http:\" || url.protocol == \"https:\") {\n    isAllowedProto = true;\n  }\n\n  if (window.location.protocol == \"https:\" && url.protocol == \"http:\") {\n    isHttpsDowngrade = true;\n  }\n\n  return {\n    url,\n    valid: isValid,\n    trusted: isTrusted,\n    allowedProto: isAllowedProto,\n    httpsDowngrade: isHttpsDowngrade,\n  };\n};\n"
  },
  {
    "path": "frontend/src/lib/i18n/i18n.ts",
    "content": "import i18n from \"i18next\";\nimport { initReactI18next } from \"react-i18next\";\nimport LanguageDetector from \"i18next-browser-languagedetector\";\nimport resourcesToBackend from \"i18next-resources-to-backend\";\n\ni18n\n  .use(LanguageDetector)\n  .use(initReactI18next)\n  .use(\n    resourcesToBackend(\n      (language: string) => import(`./locales/${language}.json`),\n    ),\n  )\n  .init({\n    fallbackLng: \"en\",\n    debug: import.meta.env.MODE === \"development\",\n    nonExplicitSupportedLngs: true,\n    load: \"currentOnly\",\n    detection: {\n      lookupLocalStorage: \"tinyauth-lang\",\n    },\n  });\n\nexport default i18n;\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/af-ZA.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/ar-SA.json",
    "content": "{\n    \"loginTitle\": \"مرحبا بعودتك، ادخل باستخدام\",\n    \"loginTitleSimple\": \"مرحبا بعودتك، سجل دخولك\",\n    \"loginDivider\": \"أو\",\n    \"loginUsername\": \"اسم المستخدم\",\n    \"loginPassword\": \"كلمة المرور\",\n    \"loginSubmit\": \"تسجيل الدخول\",\n    \"loginFailTitle\": \"فشل تسجيل الدخول\",\n    \"loginFailSubtitle\": \"الرجاء التحقق من اسم المستخدم وكلمة المرور\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"تم تسجيل الدخول\",\n    \"loginSuccessSubtitle\": \"مرحبا بعودتك!\",\n    \"loginOauthFailTitle\": \"حدث خطأ\",\n    \"loginOauthFailSubtitle\": \"أخفق الحصول على رابط OAuth\",\n    \"loginOauthSuccessTitle\": \"إعادة توجيه\",\n    \"loginOauthSuccessSubtitle\": \"إعادة توجيه إلى مزود OAuth الخاص بك\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"متابعة\",\n    \"continueRedirectingTitle\": \"إعادة توجيه...\",\n    \"continueRedirectingSubtitle\": \"يجب إعادة توجيهك إلى التطبيق قريبا\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"إعادة توجيه غير آمنة\",\n    \"continueInsecureRedirectSubtitle\": \"أنت تحاول إعادة التوجيه من <code>https</code> إلى <code>http</code>، هل أنت متأكد أنك تريد المتابعة؟\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"فشل تسجيل الخروج\",\n    \"logoutFailSubtitle\": \"يرجى إعادة المحاولة\",\n    \"logoutSuccessTitle\": \"تم تسجيل الخروج\",\n    \"logoutSuccessSubtitle\": \"تم تسجيل خروجك\",\n    \"logoutTitle\": \"تسجيل الخروج\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"الصفحة غير موجودة\",\n    \"notFoundSubtitle\": \"الصفحة التي تبحث عنها غير موجودة.\",\n    \"notFoundButton\": \"انتقل إلى الرئيسية\",\n    \"totpFailTitle\": \"أخفق التحقق من الرمز\",\n    \"totpFailSubtitle\": \"الرجاء التحقق من الرمز الخاص بك وحاول مرة أخرى\",\n    \"totpSuccessTitle\": \"تم التحقق\",\n    \"totpSuccessSubtitle\": \"إعادة توجيه إلى تطبيقك\",\n    \"totpTitle\": \"أدخل رمز TOTP الخاص بك\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"غير مرخص\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"حاول مجددا\",\n    \"cancelTitle\": \"إلغاء\",\n    \"forgotPasswordTitle\": \"نسيت كلمة المرور؟\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"حدث خطأ\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"تجاهل\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/ca-ES.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/cs-CZ.json",
    "content": "{\n    \"loginTitle\": \"Vítejte zpět, přihlaste se pomocí\",\n    \"loginTitleSimple\": \"Vítejte zpět, přihlaste se prosím\",\n    \"loginDivider\": \"Nebo\",\n    \"loginUsername\": \"Uživatelské jméno\",\n    \"loginPassword\": \"Heslo\",\n    \"loginSubmit\": \"Přihlásit\",\n    \"loginFailTitle\": \"Přihlášení se nezdařilo\",\n    \"loginFailSubtitle\": \"Zkontrolujte prosím své uživatelské jméno a heslo\",\n    \"loginFailRateLimit\": \"Přiliš mnoho neúspěšných pokusů přihlášení. Zkuste to prosím později\",\n    \"loginSuccessTitle\": \"Přihlášen\",\n    \"loginSuccessSubtitle\": \"Vítejte zpět!\",\n    \"loginOauthFailTitle\": \"Došlo k chybě\",\n    \"loginOauthFailSubtitle\": \"Nepodařilo se získat OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Přesměrování\",\n    \"loginOauthSuccessSubtitle\": \"Přesměrování k poskytovateli OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"Automatické přesměrování OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Pokračovat\",\n    \"continueRedirectingTitle\": \"Přesměrování...\",\n    \"continueRedirectingSubtitle\": \"Brzy budete přesměrováni do aplikace\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Nezabezpečené přesměrování\",\n    \"continueInsecureRedirectSubtitle\": \"Pokoušíte se přesměrovat z <code>https</code> na <code>http</code>, které není bezpečné. Opravdu chcete pokračovat?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Odhlášení se nezdařilo\",\n    \"logoutFailSubtitle\": \"Zkuste to prosím znovu\",\n    \"logoutSuccessTitle\": \"Odhlášen\",\n    \"logoutSuccessSubtitle\": \"Byl jste odhlášen\",\n    \"logoutTitle\": \"Odhlásit\",\n    \"logoutUsernameSubtitle\": \"Jste přihlášen jako <code>{{username}}</code>. Pro odhlášení klikněte na tlačítko níže.\",\n    \"logoutOauthSubtitle\": \"Jste přihlášen jako <code>{{username}}</code> pomocí {{provider}} poskytovatele OAuth. Pro odhlášení klikněte na tlačítko níže.\",\n    \"notFoundTitle\": \"Stránka nenalezena\",\n    \"notFoundSubtitle\": \"Stránka, kterou hledáte, neexistuje.\",\n    \"notFoundButton\": \"Jít domů\",\n    \"totpFailTitle\": \"Nepodařilo se ověřit kód\",\n    \"totpFailSubtitle\": \"Zkontrolujte prosím kód a zkuste to znovu\",\n    \"totpSuccessTitle\": \"Ověřeno\",\n    \"totpSuccessSubtitle\": \"Přesměrování do aplikace\",\n    \"totpTitle\": \"Zadejte TOTP kód\",\n    \"totpSubtitle\": \"Zadejte prosím kód z ověřovací aplikace.\",\n    \"unauthorizedTitle\": \"Nepovoleno\",\n    \"unauthorizedResourceSubtitle\": \"Uživatel s uživatelským jménem <code>{{username}}</code> není oprávněn k přístupu ke zdroji <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Uživatel s uživatelským jménem <code>{{username}}</code> není oprávněn k přihlášení.\",\n    \"unauthorizedGroupsSubtitle\": \"Uživatel s uživatelským jménem <code>{{username}}</code> není ve skupině potřebné k přístupu ke zdroji <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Vaše IP adresa <code>{{ip}}</code> není oprávněna k přístupu ke zdroji <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Zkusit znovu\",\n    \"cancelTitle\": \"Zrušit\",\n    \"forgotPasswordTitle\": \"Zapomněli jste heslo?\",\n    \"failedToFetchProvidersTitle\": \"Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.\",\n    \"errorTitle\": \"Došlo k chybě\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Nastala chyba při pokusu o provedení této akce. Pro více informací prosím zkontrolujte konzolu.\",\n    \"forgotPasswordMessage\": \"Heslo můžete obnovit změnou proměnné `USERS`.\",\n    \"fieldRequired\": \"Toto pole je povinné\",\n    \"invalidInput\": \"Neplatný údaj\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/da-DK.json",
    "content": "{\n    \"loginTitle\": \"Velkommen tilbage, log ind med\",\n    \"loginTitleSimple\": \"Velkommen tilbage, log venligst ind\",\n    \"loginDivider\": \"Eller\",\n    \"loginUsername\": \"Brugernavn\",\n    \"loginPassword\": \"Adgangskode\",\n    \"loginSubmit\": \"Log ind\",\n    \"loginFailTitle\": \"Login mislykkedes\",\n    \"loginFailSubtitle\": \"Tjek venligst dit brugernavn og adgangskode\",\n    \"loginFailRateLimit\": \"Du har forsøgt at logge ind for mange gange, prøv igen senere\",\n    \"loginSuccessTitle\": \"Logget ind\",\n    \"loginSuccessSubtitle\": \"Velkommen tilbage!\",\n    \"loginOauthFailTitle\": \"Der opstod en fejl\",\n    \"loginOauthFailSubtitle\": \"Kunne ikke hente OAuth-URL\",\n    \"loginOauthSuccessTitle\": \"Omdirigerer\",\n    \"loginOauthSuccessSubtitle\": \"Omdirigerer til din OAuth-udbyder\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Fortsæt\",\n    \"continueRedirectingTitle\": \"Omdirigerer...\",\n    \"continueRedirectingSubtitle\": \"Du bør blive omdirigeret til appen snart\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Usikker omdirigering\",\n    \"continueInsecureRedirectSubtitle\": \"Du forsøger at omdirigere fra <code>https</code> til <code>http</code>, som ikke er sikker. Er du sikker på, at du vil fortsætte?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Log ud mislykkedes\",\n    \"logoutFailSubtitle\": \"Prøv venligst igen\",\n    \"logoutSuccessTitle\": \"Logget ud\",\n    \"logoutSuccessSubtitle\": \"Du er blevet logget ud\",\n    \"logoutTitle\": \"Log ud\",\n    \"logoutUsernameSubtitle\": \"Du er i øjeblikket logget ind som <code>{{username}}</code>. Klik på knappen nedenfor for at logge ud.\",\n    \"logoutOauthSubtitle\": \"Du er i øjeblikket logget ind som <code>{{username}}</code> via {{provider}} OAuth-udbyderen. Klik på knappen nedenfor for at logge ud.\",\n    \"notFoundTitle\": \"Siden blev ikke fundet\",\n    \"notFoundSubtitle\": \"Siden du leder efter, findes ikke.\",\n    \"notFoundButton\": \"Gå til forsiden\",\n    \"totpFailTitle\": \"Verificering af kode mislykkedes\",\n    \"totpFailSubtitle\": \"Tjek venligst din kode og prøv igen\",\n    \"totpSuccessTitle\": \"Verificeret\",\n    \"totpSuccessSubtitle\": \"Omdirigerer til din app\",\n    \"totpTitle\": \"Indtast din TOTP-kode\",\n    \"totpSubtitle\": \"Indtast venligst koden fra din to-faktor-godkendelsesapp.\",\n    \"unauthorizedTitle\": \"Uautoriseret\",\n    \"unauthorizedResourceSubtitle\": \"Brugeren med brugernavnet <code>{{username}}</code> har ikke tilladelse til at tilgå ressourcen <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Brugeren med brugernavnet <code>{{username}}</code> har ikke tilladelse til at logge ind.\",\n    \"unauthorizedGroupsSubtitle\": \"Brugeren med brugernavnet <code>{{username}}</code> er ikke i de grupper, som ressourcen <code>{{resource}}</code> kræver.\",\n    \"unauthorizedIpSubtitle\": \"Din IP adresse <code>{{ip}}</code> er ikke autoriseret til at tilgå ressourcen <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Prøv igen\",\n    \"cancelTitle\": \"Annuller\",\n    \"forgotPasswordTitle\": \"Glemt din adgangskode?\",\n    \"failedToFetchProvidersTitle\": \"Kunne ikke indlæse godkendelsesudbydere. Tjek venligst din konfiguration.\",\n    \"errorTitle\": \"Der opstod en fejl\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Der opstod en fejl under forsøget på at udføre denne handling. Tjek venligst konsollen for mere information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/de-DE.json",
    "content": "{\n    \"loginTitle\": \"Willkommen zurück, logge dich ein mit\",\n    \"loginTitleSimple\": \"Willkommen zurück, bitte anmelden\",\n    \"loginDivider\": \"Oder\",\n    \"loginUsername\": \"Benutzername\",\n    \"loginPassword\": \"Passwort\",\n    \"loginSubmit\": \"Anmelden\",\n    \"loginFailTitle\": \"Login fehlgeschlagen\",\n    \"loginFailSubtitle\": \"Bitte überprüfe deinen Benutzernamen und Passwort\",\n    \"loginFailRateLimit\": \"Zu viele fehlgeschlagene Loginversuche. Versuche es später erneut\",\n    \"loginSuccessTitle\": \"Angemeldet\",\n    \"loginSuccessSubtitle\": \"Willkommen zurück!\",\n    \"loginOauthFailTitle\": \"Ein Fehler ist aufgetreten\",\n    \"loginOauthFailSubtitle\": \"Fehler beim Abrufen der OAuth-URL\",\n    \"loginOauthSuccessTitle\": \"Leite weiter\",\n    \"loginOauthSuccessSubtitle\": \"Weiterleitung zu Ihrem OAuth-Provider\",\n    \"loginOauthAutoRedirectTitle\": \"Automatische OAuth-Weiterleitung\",\n    \"loginOauthAutoRedirectSubtitle\": \"Sie werden automatisch zu Ihrem OAuth-Anbieter weitergeleitet, um sich zu authentifizieren.\",\n    \"loginOauthAutoRedirectButton\": \"Jetzt weiterleiten\",\n    \"continueTitle\": \"Weiter\",\n    \"continueRedirectingTitle\": \"Leite weiter...\",\n    \"continueRedirectingSubtitle\": \"Sie sollten in Kürze zur App weitergeleitet werden\",\n    \"continueRedirectManually\": \"Manuell weiterleiten\",\n    \"continueInsecureRedirectTitle\": \"Unsichere Weiterleitung\",\n    \"continueInsecureRedirectSubtitle\": \"Sie versuchen von <code>https</code> auf <code>http</code> weiterzuleiten, was unsicher ist. Sind Sie sicher, dass Sie fortfahren möchten?\",\n    \"continueUntrustedRedirectTitle\": \"Nicht vertrauenswürdige Weiterleitung\",\n    \"continueUntrustedRedirectSubtitle\": \"Sie versuchen auf eine Domain umzuleiten, die nicht mit Ihrer konfigurierten Domain übereinstimmt (<code>{{cookieDomain}}</code>). Sind Sie sicher, dass Sie fortfahren möchten?\",\n    \"logoutFailTitle\": \"Abmelden fehlgeschlagen\",\n    \"logoutFailSubtitle\": \"Bitte versuchen Sie es erneut\",\n    \"logoutSuccessTitle\": \"Abgemeldet\",\n    \"logoutSuccessSubtitle\": \"Sie wurden abgemeldet\",\n    \"logoutTitle\": \"Abmelden\",\n    \"logoutUsernameSubtitle\": \"Sie sind derzeit als <code>{{username}}</code> angemeldet. Klicken Sie auf den Button unten, um sich abzumelden.\",\n    \"logoutOauthSubtitle\": \"Sie sind derzeit als <code>{{username}}</code> über den OAuth-Anbieter {{provider}} angemeldet. Klicken Sie auf den Button unten, um sich abzumelden.\",\n    \"notFoundTitle\": \"Seite nicht gefunden\",\n    \"notFoundSubtitle\": \"Die gesuchte Seite existiert nicht.\",\n    \"notFoundButton\": \"Zurück\",\n    \"totpFailTitle\": \"Fehler beim Verifizieren des Codes\",\n    \"totpFailSubtitle\": \"Bitte überprüfen Sie Ihren Code und versuchen Sie es erneut\",\n    \"totpSuccessTitle\": \"Verifiziert\",\n    \"totpSuccessSubtitle\": \"Leite zur App weiter\",\n    \"totpTitle\": \"Geben Sie Ihren TOTP Code ein\",\n    \"totpSubtitle\": \"Bitte geben Sie den Code aus Ihrer Authenticator-App ein.\",\n    \"unauthorizedTitle\": \"Unautorisiert\",\n    \"unauthorizedResourceSubtitle\": \"Der Benutzer mit Benutzername <code>{{username}}</code> ist nicht berechtigt, auf die Ressource <code>{{resource}}</code> zuzugreifen.\",\n    \"unauthorizedLoginSubtitle\": \"Der Benutzer mit Benutzername <code>{{username}}</code> ist nicht berechtigt, sich anzumelden.\",\n    \"unauthorizedGroupsSubtitle\": \"Der Benutzer mit Benutzername <code>{{username}}</code> ist nicht in den Gruppen, die von der Ressource <code>{{resource}}</code> benötigt werden.\",\n    \"unauthorizedIpSubtitle\": \"Ihre IP-Adresse <code>{{ip}}</code> ist nicht berechtigt, auf die Ressource <code>{{resource}}</code> zuzugreifen.\",\n    \"unauthorizedButton\": \"Erneut versuchen\",\n    \"cancelTitle\": \"Abbrechen\",\n    \"forgotPasswordTitle\": \"Passwort vergessen?\",\n    \"failedToFetchProvidersTitle\": \"Fehler beim Laden der Authentifizierungsanbieter. Bitte überprüfen Sie Ihre Konfiguration.\",\n    \"errorTitle\": \"Ein Fehler ist aufgetreten\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Beim Versuch, diese Aktion auszuführen, ist ein Fehler aufgetreten. Bitte überprüfen Sie die Konsole für weitere Informationen.\",\n    \"forgotPasswordMessage\": \"Das Passwort kann durch Änderung der 'USERS' Variable zurückgesetzt werden.\",\n    \"fieldRequired\": \"Dieses Feld ist notwendig\",\n    \"invalidInput\": \"Ungültige Eingabe\",\n    \"domainWarningTitle\": \"Ungültige Domain\",\n    \"domainWarningSubtitle\": \"Diese Instanz ist so konfiguriert, dass sie von <code>{{appUrl}}</code> aufgerufen werden kann, aber <code>{{currentUrl}}</code> wird verwendet. Wenn Sie fortfahren, können Probleme bei der Authentifizierung auftreten.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignorieren\",\n    \"goToCorrectDomainTitle\": \"Zur korrekten Domain gehen\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/el-GR.json",
    "content": "{\n    \"loginTitle\": \"Καλώς ήρθατε, συνδεθείτε με\",\n    \"loginTitleSimple\": \"Καλώς ήρθατε, παρακαλώ συνδεθείτε\",\n    \"loginDivider\": \"Ή\",\n    \"loginUsername\": \"Όνομα Χρήστη\",\n    \"loginPassword\": \"Κωδικόs πρόσβασης\",\n    \"loginSubmit\": \"Είσοδος\",\n    \"loginFailTitle\": \"Αποτυχία σύνδεσης\",\n    \"loginFailSubtitle\": \"Παρακαλώ ελέγξτε το όνομα χρήστη και τον κωδικό πρόσβασης\",\n    \"loginFailRateLimit\": \"Αποτύχατε να συνδεθείτε πάρα πολλές φορές. Παρακαλώ προσπαθήστε ξανά αργότερα\",\n    \"loginSuccessTitle\": \"Συνδεδεμένος\",\n    \"loginSuccessSubtitle\": \"Καλώς ήρθατε!\",\n    \"loginOauthFailTitle\": \"Παρουσιάστηκε ένα σφάλμα\",\n    \"loginOauthFailSubtitle\": \"Αποτυχία λήψης OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Ανακατεύθυνση\",\n    \"loginOauthSuccessSubtitle\": \"Ανακατεύθυνση στον πάροχο OAuth σας\",\n    \"loginOauthAutoRedirectTitle\": \"Αυτόματη Ανακατεύθυνση OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"Θα ανακατευθυνθείτε αυτόματα στον πάροχο OAuth σας για να επαληθευτείτε.\",\n    \"loginOauthAutoRedirectButton\": \"Ανακατεύθυνση τώρα\",\n    \"continueTitle\": \"Συνέχεια\",\n    \"continueRedirectingTitle\": \"Ανακατεύθυνση...\",\n    \"continueRedirectingSubtitle\": \"Θα μεταφερθείτε σύντομα στην εφαρμογή σας\",\n    \"continueRedirectManually\": \"Χειροκίνητη ανακατεύθυνση\",\n    \"continueInsecureRedirectTitle\": \"Μη ασφαλής ανακατεύθυνση\",\n    \"continueInsecureRedirectSubtitle\": \"Προσπαθείτε να ανακατευθύνετε από <code>https</code> σε <code>http</code> το οποίο δεν είναι ασφαλές. Είστε σίγουροι ότι θέλετε να συνεχίσετε;\",\n    \"continueUntrustedRedirectTitle\": \"Μη έμπιστη ανακατεύθυνση\",\n    \"continueUntrustedRedirectSubtitle\": \"Προσπαθείτε να ανακατευθύνετε σε ένα domain που δεν ταιριάζει με το ρυθμισμένο domain σας (<code>{{cookieDomain}}</code>). Είστε βέβαιοι ότι θέλετε να συνεχίσετε;\",\n    \"logoutFailTitle\": \"Αποτυχία αποσύνδεσης\",\n    \"logoutFailSubtitle\": \"Παρακαλώ δοκιμάστε ξανά\",\n    \"logoutSuccessTitle\": \"Αποσυνδεδεμένος\",\n    \"logoutSuccessSubtitle\": \"Έχετε αποσυνδεθεί\",\n    \"logoutTitle\": \"Αποσύνδεση\",\n    \"logoutUsernameSubtitle\": \"Αυτή τη στιγμή είστε συνδεδεμένοι ως <code>{{username}}</code>. Κάντε κλικ στο παρακάτω κουμπί για να αποσυνδεθείτε.\",\n    \"logoutOauthSubtitle\": \"Αυτή τη στιγμή είστε συνδεδεμένοι ως <code>{{username}}</code> χρησιμοποιώντας την υπηρεσία παροχής {{provider}} OAuth. Κάντε κλικ στο παρακάτω κουμπί για να αποσυνδεθείτε.\",\n    \"notFoundTitle\": \"Η σελίδα δε βρέθηκε\",\n    \"notFoundSubtitle\": \"Η σελίδα που ψάχνετε δεν υπάρχει.\",\n    \"notFoundButton\": \"Μετάβαση στην αρχική\",\n    \"totpFailTitle\": \"Αποτυχία επαλήθευσης κωδικού\",\n    \"totpFailSubtitle\": \"Παρακαλώ ελέγξτε τον κώδικά σας και προσπαθήστε ξανά\",\n    \"totpSuccessTitle\": \"Επαληθεύθηκε\",\n    \"totpSuccessSubtitle\": \"Ανακατεύθυνση στην εφαρμογή σας\",\n    \"totpTitle\": \"Εισάγετε τον κωδικό TOTP\",\n    \"totpSubtitle\": \"Παρακαλώ εισάγετε τον κωδικό από την εφαρμογή ελέγχου ταυτότητας.\",\n    \"unauthorizedTitle\": \"Σφάλμα μη εξουσιοδότησης\",\n    \"unauthorizedResourceSubtitle\": \"Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν έχει άδεια πρόσβασης στον πόρο <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν είναι εξουσιοδοτημένος να συνδεθεί.\",\n    \"unauthorizedGroupsSubtitle\": \"Ο χρήστης με όνομα χρήστη <code>{{username}}</code> δεν είναι στις ομάδες που απαιτούνται από τον πόρο <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Η διεύθυνση IP σας <code>{{ip}}</code> δεν είναι εξουσιοδοτημένη να έχει πρόσβαση στον πόρο <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Προσπαθήστε ξανά\",\n    \"cancelTitle\": \"Ακύρωση\",\n    \"forgotPasswordTitle\": \"Ξεχάσατε το συνθηματικό σας;\",\n    \"failedToFetchProvidersTitle\": \"Αποτυχία φόρτωσης παρόχων πιστοποίησης. Παρακαλώ ελέγξτε τις ρυθμίσεις σας.\",\n    \"errorTitle\": \"Παρουσιάστηκε ένα σφάλμα\",\n    \"errorSubtitleInfo\": \"Το ακόλουθο σφάλμα προέκυψε κατά την επεξεργασία του αιτήματός σας:\",\n    \"errorSubtitle\": \"Παρουσιάστηκε σφάλμα κατά την προσπάθεια εκτέλεσης αυτής της ενέργειας. Ελέγξτε την κονσόλα για περισσότερες πληροφορίες.\",\n    \"forgotPasswordMessage\": \"Μπορείτε να επαναφέρετε τον κωδικό πρόσβασής σας αλλάζοντας τη μεταβλητή περιβάλλοντος `USERS`.\",\n    \"fieldRequired\": \"Αυτό το πεδίο είναι υποχρεωτικό\",\n    \"invalidInput\": \"Μη έγκυρη καταχώρηση\",\n    \"domainWarningTitle\": \"Μη έγκυρο domain\",\n    \"domainWarningSubtitle\": \"Έχετε επισκεφθεί αυτή την εφαρμογή από λανθασμένο domain. Αν προχωρήσετε, ενδέχεται να αντιμετωπίσετε προβλήματα με τον έλεγχο ταυτότητας.\",\n    \"domainWarningCurrent\": \"Τρέχον:\",\n    \"domainWarningExpected\": \"Αναμένεται:\",\n    \"ignoreTitle\": \"Παράβλεψη\",\n    \"goToCorrectDomainTitle\": \"Μεταβείτε στο σωστό domain\",\n    \"authorizeTitle\": \"Εξουσιοδότηση\",\n    \"authorizeCardTitle\": \"Συνέχεια στην εφαρμογή {{app}};\",\n    \"authorizeSubtitle\": \"Θα θέλατε να συνεχίσετε σε αυτή την εφαρμογή; Παρακαλώ ελέγξτε προσεκτικά τα δικαιώματα που ζητούνται από την εφαρμογή.\",\n    \"authorizeSubtitleOAuth\": \"Θα θέλατε να συνεχίσετε σε αυτή την εφαρμογή;\",\n    \"authorizeLoadingTitle\": \"Φόρτωση...\",\n    \"authorizeLoadingSubtitle\": \"Παρακαλώ περιμένετε όσο φορτώνουμε τις απαραίτητες πληροφορίες.\",\n    \"authorizeSuccessTitle\": \"Εξουσιοδοτημένος\",\n    \"authorizeSuccessSubtitle\": \"Θα μεταφερθείτε στην εφαρμογή σε λίγα δευτερόλεπτα.\",\n    \"authorizeErrorClientInfo\": \"Παρουσιάστηκε σφάλμα κατά τη φόρτωση των πληροφοριών. Παρακαλώ προσπαθήστε ξανά αργότερα.\",\n    \"authorizeErrorMissingParams\": \"Οι παρακάτω απαραίτητες πληροφορίες λείπουν από το αίτημά σας: {{missingParams}}\",\n    \"openidScopeName\": \"Σύνδεση OpenID\",\n    \"openidScopeDescription\": \"Επιτρέπει στην εφαρμογή την πρόσβαση στις πληροφορίες σύνδεσης OpenID.\",\n    \"emailScopeName\": \"Ηλεκτρονικό ταχυδρομείο\",\n    \"emailScopeDescription\": \"Επιτρέπει στην εφαρμογή να έχει πρόσβαση στη διεύθυνση ηλεκτρονικού ταχυδρομείου σας.\",\n    \"profileScopeName\": \"Προφίλ\",\n    \"profileScopeDescription\": \"Επιτρέπει στην εφαρμογή να έχει πρόσβαση στις πληροφορίες του προφίλ σας.\",\n    \"groupsScopeName\": \"Ομάδες\",\n    \"groupsScopeDescription\": \"Επιτρέπει στην εφαρμογή την πρόσβαση στις πληροφορίες ομάδας σας.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/en-US.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/en.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/es-ES.json",
    "content": "{\n    \"loginTitle\": \"Bienvenido de vuelta, inicie sesión con\",\n    \"loginTitleSimple\": \"Bienvenido de vuelta, por favor inicie sesión\",\n    \"loginDivider\": \"O\",\n    \"loginUsername\": \"Usuario\",\n    \"loginPassword\": \"Contraseña\",\n    \"loginSubmit\": \"Iniciar sesión\",\n    \"loginFailTitle\": \"Fallo al iniciar sesión\",\n    \"loginFailSubtitle\": \"Por favor revise su usuario y contraseña\",\n    \"loginFailRateLimit\": \"Muchos inicios de sesión consecutivos fallidos. Por favor inténtelo más tarde\",\n    \"loginSuccessTitle\": \"Sesión iniciada\",\n    \"loginSuccessSubtitle\": \"¡Bienvenido de vuelta!\",\n    \"loginOauthFailTitle\": \"Ocurrió un error\",\n    \"loginOauthFailSubtitle\": \"Error al obtener la URL de OAuth\",\n    \"loginOauthSuccessTitle\": \"Redireccionando\",\n    \"loginOauthSuccessSubtitle\": \"Redireccionando a tu proveedor de OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continuar\",\n    \"continueRedirectingTitle\": \"Redireccionando...\",\n    \"continueRedirectingSubtitle\": \"Pronto será redirigido a la aplicación\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Redirección insegura\",\n    \"continueInsecureRedirectSubtitle\": \"Está intentando redirigir desde <code>https</code> a <code>http</code> lo cual no es seguro. ¿Está seguro que desea continuar?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Fallo al cerrar sesión\",\n    \"logoutFailSubtitle\": \"Por favor intente nuevamente\",\n    \"logoutSuccessTitle\": \"Sesión cerrada\",\n    \"logoutSuccessSubtitle\": \"Su sesión ha sido cerrada\",\n    \"logoutTitle\": \"Cerrar sesión\",\n    \"logoutUsernameSubtitle\": \"Actualmente está conectado como <code>{{username}}</code>. Haga clic en el botón de abajo para cerrar sesión.\",\n    \"logoutOauthSubtitle\": \"Actualmente está conectado como <code>{{username}}</code> usando {{provider}} como su proveedor de OAuth. Haga clic en el botón de abajo para cerrar sesión.\",\n    \"notFoundTitle\": \"Página no encontrada\",\n    \"notFoundSubtitle\": \"La página que está buscando no existe.\",\n    \"notFoundButton\": \"Volver al inicio\",\n    \"totpFailTitle\": \"Error al verificar código\",\n    \"totpFailSubtitle\": \"Por favor compruebe su código e inténtelo de nuevo\",\n    \"totpSuccessTitle\": \"Verificado\",\n    \"totpSuccessSubtitle\": \"Redirigiendo a su aplicación\",\n    \"totpTitle\": \"Ingrese su código TOTP\",\n    \"totpSubtitle\": \"Por favor introduzca el código de su aplicación de autenticación.\",\n    \"unauthorizedTitle\": \"No autorizado\",\n    \"unauthorizedResourceSubtitle\": \"El usuario con nombre de usuario <code>{{username}}</code> no está autorizado para acceder al recurso <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"El usuario con nombre de usuario <code>{{username}}</code> no está autorizado a iniciar sesión.\",\n    \"unauthorizedGroupsSubtitle\": \"El usuario con nombre de usuario <code>{{username}}</code> no está en los grupos requeridos por el recurso <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Inténtelo de nuevo\",\n    \"cancelTitle\": \"Cancelar\",\n    \"forgotPasswordTitle\": \"¿Olvidó su contraseña?\",\n    \"failedToFetchProvidersTitle\": \"Error al cargar los proveedores de autenticación. Por favor revise su configuración.\",\n    \"errorTitle\": \"Ha ocurrido un error\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Ocurrió un error mientras se trataba de realizar esta acción. Por favor, revise la consola para más información.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/fi-FI.json",
    "content": "{\n    \"loginTitle\": \"Tervetuloa takaisin, kirjaudu sisään käyttäen\",\n    \"loginTitleSimple\": \"Tervetuloa takaisin, ole hyvä ja kirjaudu\",\n    \"loginDivider\": \"Tai\",\n    \"loginUsername\": \"Käyttäjätunnus\",\n    \"loginPassword\": \"Salasana\",\n    \"loginSubmit\": \"Kirjaudu\",\n    \"loginFailTitle\": \"Kirjautuminen epäonnistui\",\n    \"loginFailSubtitle\": \"Tarkista käyttäjätunnuksesi ja salasanasi\",\n    \"loginFailRateLimit\": \"Kirjautuminen epäonnistui liian monta kertaa. Yritä myöhemmin uudelleen\",\n    \"loginSuccessTitle\": \"Olet kirjautunut sisään\",\n    \"loginSuccessSubtitle\": \"Tervetuloa takaisin!\",\n    \"loginOauthFailTitle\": \"Tapahtui virhe\",\n    \"loginOauthFailSubtitle\": \"OAuthin URL-osoitteen haku epäonnistui\",\n    \"loginOauthSuccessTitle\": \"Uudelleenohjataan\",\n    \"loginOauthSuccessSubtitle\": \"Uudelleenohjaus OAuth -palveluntarjoajallesi\",\n    \"loginOauthAutoRedirectTitle\": \"Automaattinen OAuth -uudelleenohjaus\",\n    \"loginOauthAutoRedirectSubtitle\": \"Sinut ohjataan automaattisesti OAuth -palveluntarjoajallesi todentamista varten.\",\n    \"loginOauthAutoRedirectButton\": \"Siirry nyt\",\n    \"continueTitle\": \"Jatka\",\n    \"continueRedirectingTitle\": \"Uudelleenohjataan...\",\n    \"continueRedirectingSubtitle\": \"Sinun pitäisi ohjautua sovellukseen pian\",\n    \"continueRedirectManually\": \"Siirrä minut manuaalisesti\",\n    \"continueInsecureRedirectTitle\": \"Turvaton uudelleenohjaus\",\n    \"continueInsecureRedirectSubtitle\": \"Yrität siirtyä suojatusta <code>https</code> -sivusta suojaamattomalle <code>http</code> -sivulle. Oletko varma, että haluat jatkaa?\",\n    \"continueUntrustedRedirectTitle\": \"Ei-luotettu uudelleenohjaus\",\n    \"continueUntrustedRedirectSubtitle\": \"Yrität uudelleenohjata domainiin, joka ei vastaa määritettyä verkkotunnusta (<code>{{cookieDomain}}</code>). Oletko varma, että haluat jatkaa?\",\n    \"logoutFailTitle\": \"Uloskirjautuminen epäonnistui\",\n    \"logoutFailSubtitle\": \"Ole hyvä ja yritä uudelleen\",\n    \"logoutSuccessTitle\": \"Kirjauduttu ulos\",\n    \"logoutSuccessSubtitle\": \"Sinut on kirjattu ulos\",\n    \"logoutTitle\": \"Kirjaudu ulos\",\n    \"logoutUsernameSubtitle\": \"Olet kirjautuneena sisään tunnuksella <code>{{username}}</code>. Kirjaudu ulos alla olevasta painikkeesta.\",\n    \"logoutOauthSubtitle\": \"Olet kirjautuneena sisään tunnuksella <code>{{username}}</code> OAuth palvelun {{provider}} kautta. Kirjaudu ulos alla olevasta painikkeesta.\",\n    \"notFoundTitle\": \"Sivua ei löydy\",\n    \"notFoundSubtitle\": \"Sivua, jota etsit ei ole olemassa.\",\n    \"notFoundButton\": \"Palaa kotinäkymään\",\n    \"totpFailTitle\": \"Koodin vahvistus epäonnistui\",\n    \"totpFailSubtitle\": \"Tarkista koodisi ja yritä uudelleen\",\n    \"totpSuccessTitle\": \"Vahvistettu\",\n    \"totpSuccessSubtitle\": \"Uudelleenohjataan sovelluksellesi\",\n    \"totpTitle\": \"Syötä TOTP -koodisi\",\n    \"totpSubtitle\": \"Ole hyvä ja syötä koodi todennussovelluksestasi.\",\n    \"unauthorizedTitle\": \"Ei sallittu\",\n    \"unauthorizedResourceSubtitle\": \"Käyttäjällä <code>{{username}}</code> ei ole pääsyä kohteeseen <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Käyttäjällä <code>{{username}}</code> ei ole lupaa kirjautua.\",\n    \"unauthorizedGroupsSubtitle\": \"Käyttäjä <code>{{username}}</code> ei ole ryhmässä, joka vaaditaan pääsyyn kohteeseen <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"IP osoitteestasi <code>{{ip}}</code> ei ole pääsyä kohteeseen <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Yritä uudelleen\",\n    \"cancelTitle\": \"Peruuta\",\n    \"forgotPasswordTitle\": \"Unohditko salasanasi?\",\n    \"failedToFetchProvidersTitle\": \"Todennuspalvelujen tarjoajien lataaminen epäonnistui. Tarkista määrityksesi.\",\n    \"errorTitle\": \"Tapahtui virhe\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Tapahtui virhe yritettäessä suorittaa tämä toiminto. Ole hyvä ja tarkista konsoli saadaksesi lisätietoja.\",\n    \"forgotPasswordMessage\": \"Voit nollata salasanasi vaihtamalla ympäristömuuttujan `USERS`.\",\n    \"fieldRequired\": \"Tämä kenttä on pakollinen\",\n    \"invalidInput\": \"Virheellinen syöte\",\n    \"domainWarningTitle\": \"Virheellinen verkkotunnus\",\n    \"domainWarningSubtitle\": \"Tämä instanssi on määritelty käyttämään osoitetta <code>{{appUrl}}</code>, mutta nykyinen osoite on <code>{{currentUrl}}</code>. Jos jatkat, saatat törmätä ongelmiin autentikoinnissa.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Jätä huomiotta\",\n    \"goToCorrectDomainTitle\": \"Siirry oikeaan verkkotunnukseen\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/fr-FR.json",
    "content": "{\n    \"loginTitle\": \"Bienvenue, connectez-vous avec\",\n    \"loginTitleSimple\": \"De retour parmi nous, veuillez vous connecter\",\n    \"loginDivider\": \"Ou\",\n    \"loginUsername\": \"Nom d'utilisateur\",\n    \"loginPassword\": \"Mot de passe\",\n    \"loginSubmit\": \"Se connecter\",\n    \"loginFailTitle\": \"Échec de la connexion\",\n    \"loginFailSubtitle\": \"Veuillez vérifier votre nom d'utilisateur et votre mot de passe\",\n    \"loginFailRateLimit\": \"Vous avez échoué trop de fois à vous connecter. Veuillez réessayer ultérieurement\",\n    \"loginSuccessTitle\": \"Connecté\",\n    \"loginSuccessSubtitle\": \"Bienvenue !\",\n    \"loginOauthFailTitle\": \"Une erreur s'est produite\",\n    \"loginOauthFailSubtitle\": \"Impossible d'obtenir l'URL OAuth\",\n    \"loginOauthSuccessTitle\": \"Redirection\",\n    \"loginOauthSuccessSubtitle\": \"Redirection vers votre fournisseur OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"Redirection automatique OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"Vous allez être automatiquement redirigé vers votre fournisseur OAuth pour vous authentifier.\",\n    \"loginOauthAutoRedirectButton\": \"Rediriger\",\n    \"continueTitle\": \"Continuer\",\n    \"continueRedirectingTitle\": \"Redirection...\",\n    \"continueRedirectingSubtitle\": \"Vous devriez être redirigé vers l'application bientôt\",\n    \"continueRedirectManually\": \"Redirection manuelle\",\n    \"continueInsecureRedirectTitle\": \"Redirection non sécurisée\",\n    \"continueInsecureRedirectSubtitle\": \"Vous tentez de rediriger de <code>https</code> vers <code>http</code>, ce qui n'est pas sécurisé. Êtes-vous sûr de vouloir continuer ?\",\n    \"continueUntrustedRedirectTitle\": \"Redirection non sécurisée\",\n    \"continueUntrustedRedirectSubtitle\": \"Vous essayez de rediriger vers un domaine qui ne correspond pas à votre domaine configuré (<code>{{cookieDomain}}</code>). Êtes-vous sûr de vouloir continuer ?\",\n    \"logoutFailTitle\": \"Échec de la déconnexion\",\n    \"logoutFailSubtitle\": \"Veuillez réessayer\",\n    \"logoutSuccessTitle\": \"Déconnecté\",\n    \"logoutSuccessSubtitle\": \"Vous avez été déconnecté\",\n    \"logoutTitle\": \"Déconnexion\",\n    \"logoutUsernameSubtitle\": \"Vous êtes actuellement connecté en tant que <code>{{username}}</code>. Cliquez sur le bouton ci-dessous pour vous déconnecter.\",\n    \"logoutOauthSubtitle\": \"Vous êtes actuellement connecté en tant que <code>{{username}}</code> via le fournisseur OAuth {{provider}}. Cliquez sur le bouton ci-dessous pour vous déconnecter.\",\n    \"notFoundTitle\": \"Page introuvable\",\n    \"notFoundSubtitle\": \"La page recherchée n'existe pas.\",\n    \"notFoundButton\": \"Retour à la page d'accueil\",\n    \"totpFailTitle\": \"Échec de la vérification du code\",\n    \"totpFailSubtitle\": \"Veuillez vérifier votre code et réessayer\",\n    \"totpSuccessTitle\": \"Vérifié\",\n    \"totpSuccessSubtitle\": \"Redirection vers votre application\",\n    \"totpTitle\": \"Saisissez votre code TOTP\",\n    \"totpSubtitle\": \"Veuillez saisir le code de votre application d'authentification.\",\n    \"unauthorizedTitle\": \"Non autorisé\",\n    \"unauthorizedResourceSubtitle\": \"L'utilisateur avec le nom d'utilisateur <code>{{username}}</code> n'est pas autorisé à accéder à la ressource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"L'utilisateur avec le nom d'utilisateur <code>{{username}}</code> n'est pas autorisé à se connecter.\",\n    \"unauthorizedGroupsSubtitle\": \"L'utilisateur avec le nom d'utilisateur <code>{{username}}</code> n'appartient pas aux groupes requis par la ressource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Votre adresse IP <code>{{ip}}</code> n'est pas autorisée à accéder à la ressource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Réessayer\",\n    \"cancelTitle\": \"Annuler\",\n    \"forgotPasswordTitle\": \"Mot de passe oublié ?\",\n    \"failedToFetchProvidersTitle\": \"Échec du chargement des fournisseurs d'authentification. Veuillez vérifier votre configuration.\",\n    \"errorTitle\": \"Une erreur est survenue\",\n    \"errorSubtitleInfo\": \"L'erreur suivante s'est produite lors du traitement de votre requête :\",\n    \"errorSubtitle\": \"Une erreur est survenue lors de l'exécution de cette action. Veuillez consulter la console pour plus d'informations.\",\n    \"forgotPasswordMessage\": \"Vous pouvez réinitialiser votre mot de passe en modifiant la variable d'environnement `USERS`.\",\n    \"fieldRequired\": \"Ce champ est obligatoire\",\n    \"invalidInput\": \"Saisie non valide\",\n    \"domainWarningTitle\": \"Domaine invalide\",\n    \"domainWarningSubtitle\": \"Cette instance est configurée pour être accédée depuis <code>{{appUrl}}</code>, mais <code>{{currentUrl}}</code> est utilisé. Si vous continuez, vous pourriez rencontrer des problèmes d'authentification.\",\n    \"domainWarningCurrent\": \"Actuellement :\",\n    \"domainWarningExpected\": \"Attendu :\",\n    \"ignoreTitle\": \"Ignorer\",\n    \"goToCorrectDomainTitle\": \"Aller au bon domaine\",\n    \"authorizeTitle\": \"Autoriser\",\n    \"authorizeCardTitle\": \"Continuer vers {{app}} ?\",\n    \"authorizeSubtitle\": \"Voulez-vous continuer vers cette application ? Veuillez examiner attentivement les autorisations demandées par l'application.\",\n    \"authorizeSubtitleOAuth\": \"Voulez-vous continuer vers cette application ?\",\n    \"authorizeLoadingTitle\": \"Chargement...\",\n    \"authorizeLoadingSubtitle\": \"Veuillez patienter pendant que nous chargeons les informations du client.\",\n    \"authorizeSuccessTitle\": \"Autorisé\",\n    \"authorizeSuccessSubtitle\": \"Vous allez être redirigé vers l'application dans quelques secondes.\",\n    \"authorizeErrorClientInfo\": \"Une erreur est survenue lors du chargement des informations du client. Veuillez réessayer plus tard.\",\n    \"authorizeErrorMissingParams\": \"Les paramètres suivants sont manquants : {{missingParams}}\",\n    \"openidScopeName\": \"Connexion OpenID\",\n    \"openidScopeDescription\": \"Autorise l'application à accéder à vos informations \\\"OpenID Connect\\\".\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Autorise l'application à accéder à votre adresse e-mail.\",\n    \"profileScopeName\": \"Profil\",\n    \"profileScopeDescription\": \"Autorise l'application à accéder aux informations de votre profil.\",\n    \"groupsScopeName\": \"Groupes\",\n    \"groupsScopeDescription\": \"Autorise une application à accéder aux informations de votre groupe.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/he-IL.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/hu-HU.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Üdvözöljük, kérem jelentkezzen be\",\n    \"loginDivider\": \"Vagy\",\n    \"loginUsername\": \"Felhasználónév\",\n    \"loginPassword\": \"Jelszó\",\n    \"loginSubmit\": \"Bejelentkezés\",\n    \"loginFailTitle\": \"Sikertelen bejelentkezés\",\n    \"loginFailSubtitle\": \"Kérjük, ellenőrizze a felhasználónevét és jelszavát\",\n    \"loginFailRateLimit\": \"Túl sokszor próbálkoztál bejelentkezni. Próbáld újra később\",\n    \"loginSuccessTitle\": \"Bejelentkezve\",\n    \"loginSuccessSubtitle\": \"Üdvözöljük!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Átirányítás\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Átirányítás...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Sikertelen kijelentkezés\",\n    \"logoutFailSubtitle\": \"Próbálja újra\",\n    \"logoutSuccessTitle\": \"Kijelentkezve\",\n    \"logoutSuccessSubtitle\": \"Kijelentkeztél\",\n    \"logoutTitle\": \"Kijelentkezés\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Ugrás a kezdőlapra\",\n    \"totpFailTitle\": \"Érvénytelen kód\",\n    \"totpFailSubtitle\": \"Kérjük ellenőrizze a kódot és próbálja újra\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Próbálja újra\",\n    \"cancelTitle\": \"Mégse\",\n    \"forgotPasswordTitle\": \"Elfelejtette jelszavát?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"Hiba történt\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"Ez egy kötelező mező\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/it-IT.json",
    "content": "{\n    \"loginTitle\": \"Bentornato, accedi con\",\n    \"loginTitleSimple\": \"Bentornato, accedi al tuo account\",\n    \"loginDivider\": \"Oppure\",\n    \"loginUsername\": \"Nome utente\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Accesso\",\n    \"loginFailTitle\": \"Accesso non riuscito\",\n    \"loginFailSubtitle\": \"Verifica che il nome utente e la password siano corretti\",\n    \"loginFailRateLimit\": \"Hai effettuato troppi tentativi errati. Riprova più tardi\",\n    \"loginSuccessTitle\": \"Accesso effettuato\",\n    \"loginSuccessSubtitle\": \"Bentornato!\",\n    \"loginOauthFailTitle\": \"Si è verificato un errore\",\n    \"loginOauthFailSubtitle\": \"Impossibile ottenere l'URL di OAuth\",\n    \"loginOauthSuccessTitle\": \"Reindirizzamento\",\n    \"loginOauthSuccessSubtitle\": \"Reindirizzamento al tuo provider OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"Reindirizzamento automatico OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"Verrai automaticamente reindirizzato al tuo provider OAuth per l'autenticazione.\",\n    \"loginOauthAutoRedirectButton\": \"Reindirizza ora\",\n    \"continueTitle\": \"Prosegui\",\n    \"continueRedirectingTitle\": \"Reindirizzamento...\",\n    \"continueRedirectingSubtitle\": \"Dovresti essere reindirizzato all'app a breve\",\n    \"continueRedirectManually\": \"Reindirizzami manualmente\",\n    \"continueInsecureRedirectTitle\": \"Destinazione non sicura\",\n    \"continueInsecureRedirectSubtitle\": \"Stai tentando un reindirizzamento da <code>https</code> a <code>http</code>, il che non è sicuro. Vuoi continuare davvero?\",\n    \"continueUntrustedRedirectTitle\": \"Destinazione non attendibile\",\n    \"continueUntrustedRedirectSubtitle\": \"Stai tentando un reindirizzamento a un dominio che non corrisponde al dominio configurato (<code>{{cookieDomain}}</code>). Vuoi continuare davvero?\",\n    \"logoutFailTitle\": \"Disconnessione fallita\",\n    \"logoutFailSubtitle\": \"Riprova\",\n    \"logoutSuccessTitle\": \"Disconnessione effettuata\",\n    \"logoutSuccessSubtitle\": \"Sei stato disconnesso\",\n    \"logoutTitle\": \"Disconnessione\",\n    \"logoutUsernameSubtitle\": \"Hai effettuato l'accesso come <code>{{username}}</code>. Clicca sul pulsante qui sotto per disconnetterti.\",\n    \"logoutOauthSubtitle\": \"Hai effettuato l'accesso come <code>{{username}}</code> attraverso il provider OAuth {{provider}}. Clicca sul pulsante qui sotto per uscire.\",\n    \"notFoundTitle\": \"Pagina non trovata\",\n    \"notFoundSubtitle\": \"La pagina che stai cercando non esiste.\",\n    \"notFoundButton\": \"Vai alla home\",\n    \"totpFailTitle\": \"Errore nella verifica del codice\",\n    \"totpFailSubtitle\": \"Si prega di controllare il codice e riprovare\",\n    \"totpSuccessTitle\": \"Verificato\",\n    \"totpSuccessSubtitle\": \"Reindirizzamento alla tua app\",\n    \"totpTitle\": \"Inserisci il tuo codice TOTP\",\n    \"totpSubtitle\": \"Inserisci il codice dalla tua app di autenticazione.\",\n    \"unauthorizedTitle\": \"Non autorizzato\",\n    \"unauthorizedResourceSubtitle\": \"L'utente <code>{{username}}</code> non è autorizzato ad accedere alla risorsa <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"L'utente <code>{{username}}</code> non è autorizzato a effettuare l'accesso.\",\n    \"unauthorizedGroupsSubtitle\": \"L'utente <code>{{username}}</code> non fa parte dei gruppi richiesti dalla risorsa <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Il tuo indirizzo IP <code>{{ip}}</code> non è autorizzato ad accedere alla risorsa <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Riprova\",\n    \"cancelTitle\": \"Annulla\",\n    \"forgotPasswordTitle\": \"Password dimenticata?\",\n    \"failedToFetchProvidersTitle\": \"Impossibile caricare i provider di autenticazione. Si prega di controllare la configurazione.\",\n    \"errorTitle\": \"Si è verificato un errore\",\n    \"errorSubtitleInfo\": \"Si è verificato il seguente errore durante l'elaborazione della richiesta:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"Puoi reimpostare la tua password modificando la variabile d'ambiente `USERS`.\",\n    \"fieldRequired\": \"Questo campo è obbligatorio\",\n    \"invalidInput\": \"Input non valido\",\n    \"domainWarningTitle\": \"Dominio non valido\",\n    \"domainWarningSubtitle\": \"Questa istanza è configurata per essere accessibile da <code>{{appUrl}}</code>, ma la stai visitando da <code>{{currentUrl}}</code>. Se procedi, potresti incorrere in problemi di autenticazione.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignora\",\n    \"goToCorrectDomainTitle\": \"Vai al dominio corretto\",\n    \"authorizeTitle\": \"Autorizza\",\n    \"authorizeCardTitle\": \"Continuare su {{app}}?\",\n    \"authorizeSubtitle\": \"Vuoi continuare su quest'app? Verifica attentamente i permessi richiesti dall'app.\",\n    \"authorizeSubtitleOAuth\": \"Vuoi continuare su quest'app?\",\n    \"authorizeLoadingTitle\": \"Caricamento...\",\n    \"authorizeLoadingSubtitle\": \"Attendi il caricamento delle informazioni del client.\",\n    \"authorizeSuccessTitle\": \"Autorizzato\",\n    \"authorizeSuccessSubtitle\": \"Verrai reindirizzato all'app in pochi secondi.\",\n    \"authorizeErrorClientInfo\": \"Si è verificato un errore durante il caricamento delle informazioni del client. Riprova.\",\n    \"authorizeErrorMissingParams\": \"I seguenti parametri sono mancanti: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Permetti all'app di accedere alle tue informazioni OpenID Connect.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Consenti all'app di accedere al tuo indirizzo email.\",\n    \"profileScopeName\": \"Profilo\",\n    \"profileScopeDescription\": \"Consenti all'app di accedere alle informazioni del tuo profilo.\",\n    \"groupsScopeName\": \"Gruppi\",\n    \"groupsScopeDescription\": \"Consenti all'app di accedere alle informazioni sui tuoi gruppi.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/ja-JP.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/ko-KR.json",
    "content": "{\n    \"loginTitle\": \"다시 오신 것을 환영합니다. 아래 방법으로 로그인하세요\",\n    \"loginTitleSimple\": \"다시 오신 것을 환영합니다. 로그인해 주세요\",\n    \"loginDivider\": \"또는\",\n    \"loginUsername\": \"사용자 이름\",\n    \"loginPassword\": \"비밀번호\",\n    \"loginSubmit\": \"로그인\",\n    \"loginFailTitle\": \"로그인 실패\",\n    \"loginFailSubtitle\": \"사용자 이름과 비밀번호를 확인해 주세요\",\n    \"loginFailRateLimit\": \"로그인을 너무 많이 시도했습니다. 나중에 다시 시도해 주세요\",\n    \"loginSuccessTitle\": \"로그인 성공\",\n    \"loginSuccessSubtitle\": \"다시 오신 것을 환영합니다!\",\n    \"loginOauthFailTitle\": \"오류가 발생했습니다\",\n    \"loginOauthFailSubtitle\": \"OAuth URL을 가져오는 데 실패했습니다\",\n    \"loginOauthSuccessTitle\": \"리디렉션 중\",\n    \"loginOauthSuccessSubtitle\": \"OAuth 제공자로 리디렉션 중입니다\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth 자동 리디렉션\",\n    \"loginOauthAutoRedirectSubtitle\": \"인증을 위해 OAuth 제공자로 자동 리디렉션됩니다.\",\n    \"loginOauthAutoRedirectButton\": \"지금 리디렉션\",\n    \"continueTitle\": \"계속\",\n    \"continueRedirectingTitle\": \"리디렉션 중...\",\n    \"continueRedirectingSubtitle\": \"곧 앱으로 리디렉션됩니다\",\n    \"continueRedirectManually\": \"직접 리디렉션하기\",\n    \"continueInsecureRedirectTitle\": \"안전하지 않은 리디렉션\",\n    \"continueInsecureRedirectSubtitle\": \"<code>https</code>에서 <code>http</code>로 리디렉션하려고 합니다. 이는 안전하지 않습니다. 계속하시겠습니까?\",\n    \"continueUntrustedRedirectTitle\": \"신뢰할 수 없는 리디렉션\",\n    \"continueUntrustedRedirectSubtitle\": \"설정된 도메인(<code>{{cookieDomain}}</code>)과 일치하지 않는 도메인으로 리디렉션하려고 합니다. 계속하시겠습니까?\",\n    \"logoutFailTitle\": \"로그아웃 실패\",\n    \"logoutFailSubtitle\": \"다시 시도해 주세요\",\n    \"logoutSuccessTitle\": \"로그아웃 완료\",\n    \"logoutSuccessSubtitle\": \"로그아웃되었습니다\",\n    \"logoutTitle\": \"로그아웃\",\n    \"logoutUsernameSubtitle\": \"현재 <code>{{username}}</code>(으)로 로그인되어 있습니다. 아래 버튼을 클릭하여 로그아웃하세요.\",\n    \"logoutOauthSubtitle\": \"현재 {{provider}} OAuth 제공자를 통해 <code>{{username}}</code>(으)로 로그인되어 있습니다. 아래 버튼을 클릭하여 로그아웃하세요.\",\n    \"notFoundTitle\": \"페이지를 찾을 수 없습니다\",\n    \"notFoundSubtitle\": \"찾으시는 페이지가 존재하지 않습니다.\",\n    \"notFoundButton\": \"홈으로 가기\",\n    \"totpFailTitle\": \"코드 확인 실패\",\n    \"totpFailSubtitle\": \"코드를 확인하고 다시 시도해 주세요\",\n    \"totpSuccessTitle\": \"확인 완료\",\n    \"totpSuccessSubtitle\": \"앱으로 리디렉션 중입니다\",\n    \"totpTitle\": \"TOTP 코드 입력\",\n    \"totpSubtitle\": \"인증 앱의 코드를 입력해 주세요.\",\n    \"unauthorizedTitle\": \"권한 없음\",\n    \"unauthorizedResourceSubtitle\": \"사용자 이름 <code>{{username}}</code>은(는) 리소스 <code>{{resource}}</code>에 접근할 권한이 없습니다.\",\n    \"unauthorizedLoginSubtitle\": \"사용자 이름 <code>{{username}}</code>은(는) 로그인할 권한이 없습니다.\",\n    \"unauthorizedGroupsSubtitle\": \"사용자 이름 <code>{{username}}</code>은(는) 리소스 <code>{{resource}}</code>에서 요구하는 그룹에 속해 있지 않습니다.\",\n    \"unauthorizedIpSubtitle\": \"IP 주소 <code>{{ip}}</code>는 리소스 <code>{{resource}}</code>에 접근할 권한이 없습니다.\",\n    \"unauthorizedButton\": \"다시 시도\",\n    \"cancelTitle\": \"취소\",\n    \"forgotPasswordTitle\": \"비밀번호를 잊으셨나요?\",\n    \"failedToFetchProvidersTitle\": \"인증 제공자를 불러오는 데 실패했습니다. 설정을 확인해 주세요.\",\n    \"errorTitle\": \"오류가 발생했습니다\",\n    \"errorSubtitleInfo\": \"요청 처리 중 다음 오류가 발생했습니다:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"USERS 환경 변수를 변경하여 비밀번호를 재설정할 수 있습니다.\",\n    \"fieldRequired\": \"필수 입력 항목입니다\",\n    \"invalidInput\": \"잘못된 입력입니다\",\n    \"domainWarningTitle\": \"잘못된 도메인\",\n    \"domainWarningSubtitle\": \"잘못된 도메인에서 이 인스턴스에 접근하고 있습니다. 계속 진행하면 인증 문제가 발생할 수 있습니다.\",\n    \"domainWarningCurrent\": \"현재:\",\n    \"domainWarningExpected\": \"예상:\",\n    \"ignoreTitle\": \"무시\",\n    \"goToCorrectDomainTitle\": \"올바른 도메인으로 이동\",\n    \"authorizeTitle\": \"권한 부여\",\n    \"authorizeCardTitle\": \"{{app}}(으)로 계속하시겠습니까?\",\n    \"authorizeSubtitle\": \"이 앱으로 계속하시겠습니까? 앱에서 요청한 권한을 주의 깊게 검토해 주세요.\",\n    \"authorizeSubtitleOAuth\": \"이 앱으로 계속하시겠습니까?\",\n    \"authorizeLoadingTitle\": \"로딩 중...\",\n    \"authorizeLoadingSubtitle\": \"클라이언트 정보를 불러오는 동안 기다려 주세요.\",\n    \"authorizeSuccessTitle\": \"권한 부여 완료\",\n    \"authorizeSuccessSubtitle\": \"몇 초 후에 앱으로 리디렉션됩니다.\",\n    \"authorizeErrorClientInfo\": \"클라이언트 정보를 불러오는 중 오류가 발생했습니다. 나중에 다시 시도해 주세요.\",\n    \"authorizeErrorMissingParams\": \"다음 매개변수가 누락되었습니다: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"앱이 OpenID Connect 정보에 접근할 수 있도록 허용합니다.\",\n    \"emailScopeName\": \"이메일\",\n    \"emailScopeDescription\": \"앱이 이메일 주소에 접근할 수 있도록 허용합니다.\",\n    \"profileScopeName\": \"프로필\",\n    \"profileScopeDescription\": \"앱이 프로필 정보에 접근할 수 있도록 허용합니다.\",\n    \"groupsScopeName\": \"그룹\",\n    \"groupsScopeDescription\": \"앱이 그룹 정보에 접근할 수 있도록 허용합니다.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/nl-NL.json",
    "content": "{\n    \"loginTitle\": \"Welkom terug, log in met\",\n    \"loginTitleSimple\": \"Welkom terug, log in\",\n    \"loginDivider\": \"Of\",\n    \"loginUsername\": \"Gebruikersnaam\",\n    \"loginPassword\": \"Wachtwoord\",\n    \"loginSubmit\": \"Log in\",\n    \"loginFailTitle\": \"Mislukt om in te loggen\",\n    \"loginFailSubtitle\": \"Controleer je gebruikersnaam en wachtwoord\",\n    \"loginFailRateLimit\": \"Inloggen is te vaak mislukt. Probeer het later opnieuw\",\n    \"loginSuccessTitle\": \"Ingelogd\",\n    \"loginSuccessSubtitle\": \"Welkom terug!\",\n    \"loginOauthFailTitle\": \"Er is een fout opgetreden\",\n    \"loginOauthFailSubtitle\": \"Fout bij het ophalen van OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Omleiden\",\n    \"loginOauthSuccessSubtitle\": \"Omleiden naar je OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth automatische omleiding\",\n    \"loginOauthAutoRedirectSubtitle\": \"Je wordt automatisch omgeleid naar je OAuth provider om te authenticeren.\",\n    \"loginOauthAutoRedirectButton\": \"Nu omleiden\",\n    \"continueTitle\": \"Ga verder\",\n    \"continueRedirectingTitle\": \"Omleiden...\",\n    \"continueRedirectingSubtitle\": \"Je wordt naar de app doorgestuurd\",\n    \"continueRedirectManually\": \"Stuur mij handmatig door\",\n    \"continueInsecureRedirectTitle\": \"Onveilige doorverwijzing\",\n    \"continueInsecureRedirectSubtitle\": \"Je probeert door te verwijzen van <code>https</code> naar <code>http</code> die niet veilig is. Weet je zeker dat je wilt doorgaan?\",\n    \"continueUntrustedRedirectTitle\": \"Niet-vertrouwde doorverwijzing\",\n    \"continueUntrustedRedirectSubtitle\": \"Je probeert door te sturen naar een domein dat niet overeenkomt met je geconfigureerde domein (<code>{{cookieDomain}}</code>). Weet je zeker dat je wilt doorgaan?\",\n    \"logoutFailTitle\": \"Afmelden mislukt\",\n    \"logoutFailSubtitle\": \"Probeer het opnieuw\",\n    \"logoutSuccessTitle\": \"Afgemeld\",\n    \"logoutSuccessSubtitle\": \"Je bent afgemeld\",\n    \"logoutTitle\": \"Afmelden\",\n    \"logoutUsernameSubtitle\": \"Je bent momenteel ingelogd als <code>{{username}}</code>. Klik op de onderstaande knop om uit te loggen.\",\n    \"logoutOauthSubtitle\": \"Je bent momenteel ingelogd als <code>{{username}}</code> met behulp van de {{provider}} OAuth provider. Klik op de onderstaande knop om uit te loggen.\",\n    \"notFoundTitle\": \"Pagina niet gevonden\",\n    \"notFoundSubtitle\": \"De pagina die je zoekt bestaat niet.\",\n    \"notFoundButton\": \"Naar startpagina\",\n    \"totpFailTitle\": \"Verifiëren van code mislukt\",\n    \"totpFailSubtitle\": \"Controleer je code en probeer het opnieuw\",\n    \"totpSuccessTitle\": \"Geverifiëerd\",\n    \"totpSuccessSubtitle\": \"Omleiden naar je app\",\n    \"totpTitle\": \"Voer je TOTP-code in\",\n    \"totpSubtitle\": \"Voer de code van je authenticator-app in.\",\n    \"unauthorizedTitle\": \"Ongeautoriseerd\",\n    \"unauthorizedResourceSubtitle\": \"De gebruiker met gebruikersnaam <code>{{username}}</code> is niet gemachtigd om de bron <code>{{resource}}</code> te gebruiken.\",\n    \"unauthorizedLoginSubtitle\": \"De gebruiker met gebruikersnaam <code>{{username}}</code> is niet gemachtigd om in te loggen.\",\n    \"unauthorizedGroupsSubtitle\": \"De gebruiker met gebruikersnaam <code>{{username}}</code> maakt geen deel uit van de groepen die vereist zijn door de bron <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Jouw IP-adres <code>{{ip}}</code> is niet gemachtigd om de bron <code>{{resource}}</code> te gebruiken.\",\n    \"unauthorizedButton\": \"Opnieuw proberen\",\n    \"cancelTitle\": \"Annuleren\",\n    \"forgotPasswordTitle\": \"Wachtwoord vergeten?\",\n    \"failedToFetchProvidersTitle\": \"Fout bij het laden van de authenticatie-providers. Controleer je configuratie.\",\n    \"errorTitle\": \"Er is een fout opgetreden\",\n    \"errorSubtitleInfo\": \"De volgende fout is opgetreden bij het verwerken van het verzoek:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"Je kunt je wachtwoord opnieuw instellen door de `USERS` omgevingsvariabele te wijzigen.\",\n    \"fieldRequired\": \"Dit veld is verplicht\",\n    \"invalidInput\": \"Ongeldige invoer\",\n    \"domainWarningTitle\": \"Ongeldig domein\",\n    \"domainWarningSubtitle\": \"Deze instantie is geconfigureerd voor toegang tot <code>{{appUrl}}</code>, maar <code>{{currentUrl}}</code> wordt gebruikt. Als je doorgaat, kun je problemen ondervinden met authenticatie.\",\n    \"domainWarningCurrent\": \"Huidig:\",\n    \"domainWarningExpected\": \"Verwacht:\",\n    \"ignoreTitle\": \"Negeren\",\n    \"goToCorrectDomainTitle\": \"Ga naar het juiste domein\",\n    \"authorizeTitle\": \"Autoriseren\",\n    \"authorizeCardTitle\": \"Doorgaan naar {{app}}?\",\n    \"authorizeSubtitle\": \"Doorgaan naar deze app? Controleer de machtigingen die door de app worden gevraagd.\",\n    \"authorizeSubtitleOAuth\": \"Doorgaan naar deze app?\",\n    \"authorizeLoadingTitle\": \"Laden...\",\n    \"authorizeLoadingSubtitle\": \"Even geduld bij het laden van de cliëntinformatie.\",\n    \"authorizeSuccessTitle\": \"Geautoriseerd\",\n    \"authorizeSuccessSubtitle\": \"Je wordt binnen enkele seconden doorgestuurd naar de app.\",\n    \"authorizeErrorClientInfo\": \"Er is een fout opgetreden tijdens het laden van de cliëntinformatie. Probeer het later opnieuw.\",\n    \"authorizeErrorMissingParams\": \"De volgende parameters ontbreken: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Hiermee kan de app toegang krijgen tot jouw OpenID Connect-informatie.\",\n    \"emailScopeName\": \"E-mail\",\n    \"emailScopeDescription\": \"Hiermee kan de app toegang krijgen tot jouw e-mailadres.\",\n    \"profileScopeName\": \"Profiel\",\n    \"profileScopeDescription\": \"Hiermee kan de app toegang krijgen tot je profielinformatie.\",\n    \"groupsScopeName\": \"Groepen\",\n    \"groupsScopeDescription\": \"Hiermee kan de app toegang krijgen tot jouw groepsinformatie.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/no-NO.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/pl-PL.json",
    "content": "{\n    \"loginTitle\": \"Witaj ponownie, zaloguj się przez\",\n    \"loginTitleSimple\": \"Witaj ponownie, zaloguj się\",\n    \"loginDivider\": \"Lub\",\n    \"loginUsername\": \"Nazwa użytkownika\",\n    \"loginPassword\": \"Hasło\",\n    \"loginSubmit\": \"Zaloguj się\",\n    \"loginFailTitle\": \"Nie udało się zalogować\",\n    \"loginFailSubtitle\": \"Sprawdź swoją nazwę użytkownika i hasło\",\n    \"loginFailRateLimit\": \"Zbyt wiele razy nie udało Ci się zalogować. Spróbuj ponownie później\",\n    \"loginSuccessTitle\": \"Zalogowano\",\n    \"loginSuccessSubtitle\": \"Witaj ponownie!\",\n    \"loginOauthFailTitle\": \"Wystąpił błąd\",\n    \"loginOauthFailSubtitle\": \"Nie udało się uzyskać adresu URL OAuth\",\n    \"loginOauthSuccessTitle\": \"Przekierowywanie\",\n    \"loginOauthSuccessSubtitle\": \"Przekierowywanie do Twojego dostawcy OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"Automatyczne przekierowanie OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"Nastąpi automatyczne przekierowanie do dostawcy OAuth w celu uwierzytelnienia.\",\n    \"loginOauthAutoRedirectButton\": \"Przekieruj teraz\",\n    \"continueTitle\": \"Kontynuuj\",\n    \"continueRedirectingTitle\": \"Przekierowywanie...\",\n    \"continueRedirectingSubtitle\": \"Wkrótce powinieneś zostać przekierowany do aplikacji\",\n    \"continueRedirectManually\": \"Przekieruj mnie ręcznie\",\n    \"continueInsecureRedirectTitle\": \"Niezabezpieczone przekierowanie\",\n    \"continueInsecureRedirectSubtitle\": \"Próbujesz przekierować z <code>https</code> do <code>http</code>, co nie jest bezpieczne. Czy na pewno chcesz kontynuować?\",\n    \"continueUntrustedRedirectTitle\": \"Niezaufane przekierowanie\",\n    \"continueUntrustedRedirectSubtitle\": \"Próbujesz przekierować do domeny, która nie pasuje do skonfigurowanej domeny (<code>{{cookieDomain}}</code>). Czy na pewno chcesz kontynuować?\",\n    \"logoutFailTitle\": \"Nie udało się wylogować\",\n    \"logoutFailSubtitle\": \"Spróbuj ponownie\",\n    \"logoutSuccessTitle\": \"Wylogowano\",\n    \"logoutSuccessSubtitle\": \"Zostałeś wylogowany\",\n    \"logoutTitle\": \"Wyloguj się\",\n    \"logoutUsernameSubtitle\": \"Jesteś obecnie zalogowany jako <code>{{username}}</code>. Kliknij poniższy przycisk, aby się wylogować.\",\n    \"logoutOauthSubtitle\": \"Obecnie jesteś zalogowany jako <code>{{username}}</code> przy użyciu dostawcy {{provider}} OAuth. Kliknij poniższy przycisk, aby się wylogować.\",\n    \"notFoundTitle\": \"Nie znaleziono strony\",\n    \"notFoundSubtitle\": \"Strona, której szukasz nie istnieje.\",\n    \"notFoundButton\": \"Wróć do strony głównej\",\n    \"totpFailTitle\": \"Nie udało się zweryfikować kodu\",\n    \"totpFailSubtitle\": \"Sprawdź swój kod i spróbuj ponownie\",\n    \"totpSuccessTitle\": \"Zweryfikowano\",\n    \"totpSuccessSubtitle\": \"Przekierowywanie do aplikacji\",\n    \"totpTitle\": \"Wprowadź kod TOTP\",\n    \"totpSubtitle\": \"Wpisz kod z aplikacji uwierzytelniającej.\",\n    \"unauthorizedTitle\": \"Nieautoryzowany\",\n    \"unauthorizedResourceSubtitle\": \"Użytkownik o nazwie użytkownika <code>{{username}}</code> nie ma uprawnień dostępu do zasobu <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Użytkownik o nazwie <code>{{username}}</code> nie jest upoważniony do zalogowania się.\",\n    \"unauthorizedGroupsSubtitle\": \"Użytkownik o nazwie <code>{{username}}</code> nie należy do grup wymaganych przez zasób <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Twój adres IP <code>{{ip}}</code> nie ma autoryzacji do dostępu do zasobu <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Spróbuj ponownie\",\n    \"cancelTitle\": \"Anuluj\",\n    \"forgotPasswordTitle\": \"Nie pamiętasz hasła?\",\n    \"failedToFetchProvidersTitle\": \"Nie udało się załadować dostawców uwierzytelniania. Sprawdź swoją konfigurację.\",\n    \"errorTitle\": \"Wystąpił błąd\",\n    \"errorSubtitleInfo\": \"Podczas przetwarzania żądania wystąpił następujący błąd:\",\n    \"errorSubtitle\": \"Wystąpił błąd podczas próby wykonania tej czynności. Sprawdź konsolę, aby uzyskać więcej informacji.\",\n    \"forgotPasswordMessage\": \"Możesz zresetować hasło, zmieniając zmienną środowiskową `USERS`.\",\n    \"fieldRequired\": \"To pole jest wymagane\",\n    \"invalidInput\": \"Nieprawidłowe dane wejściowe\",\n    \"domainWarningTitle\": \"Nieprawidłowa domena\",\n    \"domainWarningSubtitle\": \"Ta instancja jest skonfigurowana do uzyskania dostępu z <code>{{appUrl}}</code>, ale <code>{{currentUrl}}</code> jest w użyciu. Jeśli będziesz kontynuować, mogą wystąpić problemy z uwierzytelnianiem.\",\n    \"domainWarningCurrent\": \"Bieżąca:\",\n    \"domainWarningExpected\": \"Oczekiwana:\",\n    \"ignoreTitle\": \"Zignoruj\",\n    \"goToCorrectDomainTitle\": \"Przejdź do prawidłowej domeny\",\n    \"authorizeTitle\": \"Autoryzuj\",\n    \"authorizeCardTitle\": \"Kontynuować do {{app}}?\",\n    \"authorizeSubtitle\": \"Czy chcesz kontynuować do tej aplikacji? Uważnie zapoznaj się z uprawnieniami żądanymi przez aplikację.\",\n    \"authorizeSubtitleOAuth\": \"Czy chcesz kontynuować do tej aplikacji?\",\n    \"authorizeLoadingTitle\": \"Wczytywanie...\",\n    \"authorizeLoadingSubtitle\": \"Proszę czekać, aż załadujemy informacje o kliencie.\",\n    \"authorizeSuccessTitle\": \"Autoryzowano\",\n    \"authorizeSuccessSubtitle\": \"Za kilka sekund nastąpi przekierowanie do aplikacji.\",\n    \"authorizeErrorClientInfo\": \"Wystąpił błąd podczas ładowania informacji o kliencie. Spróbuj ponownie później.\",\n    \"authorizeErrorMissingParams\": \"Brakuje następujących parametrów: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Zezwala aplikacji na dostęp do informacji o OpenID Connect.\",\n    \"emailScopeName\": \"E-mail\",\n    \"emailScopeDescription\": \"Zezwala aplikacji na dostęp do adresów e-mail.\",\n    \"profileScopeName\": \"Profil\",\n    \"profileScopeDescription\": \"Zezwala aplikacji na dostęp do informacji o porfilu.\",\n    \"groupsScopeName\": \"Grupy\",\n    \"groupsScopeDescription\": \"Zezwala aplikacji na dostęp do informacji o grupie.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/pt-BR.json",
    "content": "{\n    \"loginTitle\": \"Bem-vindo de volta, acesse com\",\n    \"loginTitleSimple\": \"Bem-vindo de volta, faça o login\",\n    \"loginDivider\": \"Ou\",\n    \"loginUsername\": \"Nome de usuário\",\n    \"loginPassword\": \"Senha\",\n    \"loginSubmit\": \"Entrar\",\n    \"loginFailTitle\": \"Falha ao iniciar sessão\",\n    \"loginFailSubtitle\": \"Por favor, verifique seu usuário e senha\",\n    \"loginFailRateLimit\": \"Você falhou em iniciar sessão muitas vezes, por favor tente novamente mais tarde\",\n    \"loginSuccessTitle\": \"Sessão Iniciada\",\n    \"loginSuccessSubtitle\": \"Bem-vindo de volta!\",\n    \"loginOauthFailTitle\": \"Ocorreu um erro\",\n    \"loginOauthFailSubtitle\": \"Falha ao obter URL de OAuth\",\n    \"loginOauthSuccessTitle\": \"Redirecionando\",\n    \"loginOauthSuccessSubtitle\": \"Redirecionando para seu provedor OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"Redirecionamento automático do OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"Você será automaticamente redirecionado para seu provedor OAuth para autenticar.\",\n    \"loginOauthAutoRedirectButton\": \"Redirecionar agora\",\n    \"continueTitle\": \"Continuar\",\n    \"continueRedirectingTitle\": \"Redirecionando...\",\n    \"continueRedirectingSubtitle\": \"Você deve ser redirecionado para o aplicativo em breve\",\n    \"continueRedirectManually\": \"Redirecionar-me manualmente\",\n    \"continueInsecureRedirectTitle\": \"Redirecionamento inseguro\",\n    \"continueInsecureRedirectSubtitle\": \"Você está tentando redirecionar de <code>https</code> para <code>http</code>, você tem certeza que deseja continuar?\",\n    \"continueUntrustedRedirectTitle\": \"Redirecionamento não confiável\",\n    \"continueUntrustedRedirectSubtitle\": \"Você está tentando redirecionar para um domínio que não corresponde ao seu domínio configurado (<code>{{cookieDomain}}</code>). Tem certeza que deseja continuar?\",\n    \"logoutFailTitle\": \"Falha ao encerrar sessão\",\n    \"logoutFailSubtitle\": \"Por favor, tente novamente\",\n    \"logoutSuccessTitle\": \"Sessão encerrada\",\n    \"logoutSuccessSubtitle\": \"Você foi desconectado\",\n    \"logoutTitle\": \"Sair\",\n    \"logoutUsernameSubtitle\": \"Você está atualmente logado como <code>{{username}}</code>, clique no botão abaixo para sair.\",\n    \"logoutOauthSubtitle\": \"Você está atualmente logado como <code>{{username}}</code> usando o provedor {{provider}} OAuth, clique no botão abaixo para sair.\",\n    \"notFoundTitle\": \"Página não encontrada\",\n    \"notFoundSubtitle\": \"A página que você está procurando não existe.\",\n    \"notFoundButton\": \"Voltar para a tela inicial\",\n    \"totpFailTitle\": \"Falha ao verificar código\",\n    \"totpFailSubtitle\": \"Por favor, verifique seu código e tente novamente\",\n    \"totpSuccessTitle\": \"Verificado\",\n    \"totpSuccessSubtitle\": \"Redirecionando para o seu aplicativo\",\n    \"totpTitle\": \"Insira o seu código TOTP\",\n    \"totpSubtitle\": \"Por favor, insira o código do seu aplicativo de autenticação.\",\n    \"unauthorizedTitle\": \"Não autorizado\",\n    \"unauthorizedResourceSubtitle\": \"O usuário com nome de usuário <code>{{username}}</code> não está autorizado a acessar o recurso <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"O usuário com o nome <code>{{username}}</code> não está autorizado a acessar.\",\n    \"unauthorizedGroupsSubtitle\": \"O usuário  <code>{{username}}</code> não está autorizado a acessar o recurso <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Seu endereço IP <code>{{ip}}</code> não está autorizado a acessar o recurso <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Tentar novamente\",\n    \"cancelTitle\": \"Cancelar\",\n    \"forgotPasswordTitle\": \"Esqueceu sua senha?\",\n    \"failedToFetchProvidersTitle\": \"Falha ao carregar provedores de autenticação. Verifique sua configuração.\",\n    \"errorTitle\": \"Ocorreu um erro\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Ocorreu um erro ao tentar executar esta ação. Por favor, verifique o console para mais informações.\",\n    \"forgotPasswordMessage\": \"Você pode redefinir sua senha alterando a variável de ambiente `USERS`.\",\n    \"fieldRequired\": \"Este campo é obrigatório\",\n    \"invalidInput\": \"Entrada Inválida\",\n    \"domainWarningTitle\": \"Domínio inválido\",\n    \"domainWarningSubtitle\": \"Esta instância está configurada para ser acessada de <code>{{appUrl}}</code>, mas <code>{{currentUrl}}</code> está sendo usado. Se você continuar, você pode encontrar problemas com a autenticação.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignorar\",\n    \"goToCorrectDomainTitle\": \"Ir para o domínio correto\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/pt-PT.json",
    "content": "{\n    \"loginTitle\": \"Bem-vindo de volta, inicia sessão com\",\n    \"loginTitleSimple\": \"Bem-vindo de volta, inicia sessão\",\n    \"loginDivider\": \"Ou\",\n    \"loginUsername\": \"Nome de utilizador\",\n    \"loginPassword\": \"Palavra-passe\",\n    \"loginSubmit\": \"Iniciar sessão\",\n    \"loginFailTitle\": \"Falha ao iniciar sessão\",\n    \"loginFailSubtitle\": \"Verifica o nome de utilizador e a palavra-passe\",\n    \"loginFailRateLimit\": \"Falhaste o início de sessão demasiadas vezes. Tenta novamente mais tarde\",\n    \"loginSuccessTitle\": \"Sessão iniciada\",\n    \"loginSuccessSubtitle\": \"Bem-vindo de volta!\",\n    \"loginOauthFailTitle\": \"Ocorreu um erro\",\n    \"loginOauthFailSubtitle\": \"Não foi possível obter o URL OAuth\",\n    \"loginOauthSuccessTitle\": \"A redirecionar\",\n    \"loginOauthSuccessSubtitle\": \"A redirecionar para o teu fornecedor OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"Redirecionamento automático OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"Vais ser redirecionado automaticamente para o teu fornecedor OAuth para autenticação.\",\n    \"loginOauthAutoRedirectButton\": \"Redirecionar agora\",\n    \"continueTitle\": \"Continuar\",\n    \"continueRedirectingTitle\": \"A redirecionar...\",\n    \"continueRedirectingSubtitle\": \"Deverás ser redirecionado para a aplicação em breve\",\n    \"continueRedirectManually\": \"Redirecionar manualmente\",\n    \"continueInsecureRedirectTitle\": \"Redirecionamento inseguro\",\n    \"continueInsecureRedirectSubtitle\": \"Estás a tentar redirecionar de <code>https</code> para <code>http</code>, o que não é seguro. Tens a certeza de que queres continuar?\",\n    \"continueUntrustedRedirectTitle\": \"Redirecionamento não fidedigno\",\n    \"continueUntrustedRedirectSubtitle\": \"Estás a tentar redirecionar para um domínio que não corresponde ao domínio configurado (<code>{{cookieDomain}}</code>). Tens a certeza de que queres continuar?\",\n    \"logoutFailTitle\": \"Falha ao terminar sessão\",\n    \"logoutFailSubtitle\": \"Tenta novamente\",\n    \"logoutSuccessTitle\": \"Sessão terminada\",\n    \"logoutSuccessSubtitle\": \"Terminaste a sessão com sucesso\",\n    \"logoutTitle\": \"Terminar sessão\",\n    \"logoutUsernameSubtitle\": \"Estás com sessão iniciada como <code>{{username}}</code>. Clica no botão abaixo para terminar sessão.\",\n    \"logoutOauthSubtitle\": \"Estás com sessão iniciada como <code>{{username}}</code> através do fornecedor OAuth {{provider}}. Clica no botão abaixo para terminar sessão.\",\n    \"notFoundTitle\": \"Página não encontrada\",\n    \"notFoundSubtitle\": \"A página que procuras não existe.\",\n    \"notFoundButton\": \"Ir para o início\",\n    \"totpFailTitle\": \"Falha na verificação do código\",\n    \"totpFailSubtitle\": \"Verifica o código e tenta novamente\",\n    \"totpSuccessTitle\": \"Verificado\",\n    \"totpSuccessSubtitle\": \"A redirecionar para a tua aplicação\",\n    \"totpTitle\": \"Introduz o teu código TOTP\",\n    \"totpSubtitle\": \"Introduz o código da tua aplicação de autenticação.\",\n    \"unauthorizedTitle\": \"Não autorizado\",\n    \"unauthorizedResourceSubtitle\": \"O utilizador com o nome <code>{{username}}</code> não tem autorização para aceder ao recurso <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"O utilizador com o nome <code>{{username}}</code> não tem autorização para iniciar sessão.\",\n    \"unauthorizedGroupsSubtitle\": \"O utilizador com o nome <code>{{username}}</code> não pertence aos grupos exigidos pelo recurso <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"O teu endereço IP <code>{{ip}}</code> não tem autorização para aceder ao recurso <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Tentar novamente\",\n    \"cancelTitle\": \"Cancelar\",\n    \"forgotPasswordTitle\": \"Esqueceste-te da palavra-passe?\",\n    \"failedToFetchProvidersTitle\": \"Falha ao carregar os fornecedores de autenticação. Verifica a configuração.\",\n    \"errorTitle\": \"Ocorreu um erro\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Ocorreu um erro ao tentar executar esta ação. Consulta a consola para mais informações.\",\n    \"forgotPasswordMessage\": \"Podes redefinir a tua palavra-passe alterando a variável de ambiente `USERS`.\",\n    \"fieldRequired\": \"Este campo é obrigatório\",\n    \"invalidInput\": \"Entrada inválida\",\n    \"domainWarningTitle\": \"Domínio inválido\",\n    \"domainWarningSubtitle\": \"Esta instância está configurada para ser acedida a partir de <code>{{appUrl}}</code>, mas está a ser usado <code>{{currentUrl}}</code>. Se continuares, poderás ter problemas de autenticação.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignorar\",\n    \"goToCorrectDomainTitle\": \"Ir para o domínio correto\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/ro-RO.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/ru-RU.json",
    "content": "{\n    \"loginTitle\": \"С возвращением, войти с\",\n    \"loginTitleSimple\": \"С возвращением, пожалуйста войдите\",\n    \"loginDivider\": \"Или\",\n    \"loginUsername\": \"Имя пользователя\",\n    \"loginPassword\": \"Пароль\",\n    \"loginSubmit\": \"Войти\",\n    \"loginFailTitle\": \"Вход не удался\",\n    \"loginFailSubtitle\": \"Проверьте имя пользователя и пароль\",\n    \"loginFailRateLimit\": \"Слишком много ошибок входа. Попробуйте позже\",\n    \"loginSuccessTitle\": \"Вход выполнен\",\n    \"loginSuccessSubtitle\": \"С возвращением!\",\n    \"loginOauthFailTitle\": \"Произошла ошибка\",\n    \"loginOauthFailSubtitle\": \"Не удалось получить ссылку OAuth\",\n    \"loginOauthSuccessTitle\": \"Перенаправление\",\n    \"loginOauthSuccessSubtitle\": \"Перенаправление к поставщику OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth автоматическое перенаправление\",\n    \"loginOauthAutoRedirectSubtitle\": \"Вы будете автоматически перенаправлены для авторизации у вашего поставщика OAuth.\",\n    \"loginOauthAutoRedirectButton\": \"Перенаправить сейчас\",\n    \"continueTitle\": \"Продолжить\",\n    \"continueRedirectingTitle\": \"Перенаправление...\",\n    \"continueRedirectingSubtitle\": \"Скоро вы будете перенаправлены в приложение\",\n    \"continueRedirectManually\": \"Перенаправить вручную\",\n    \"continueInsecureRedirectTitle\": \"Небезопасное перенаправление\",\n    \"continueInsecureRedirectSubtitle\": \"Попытка перенаправления с <code>https</code> на <code>http</code>, уверены, что хотите продолжить?\",\n    \"continueUntrustedRedirectTitle\": \"Недоверенное перенаправление\",\n    \"continueUntrustedRedirectSubtitle\": \"Вы пытаетесь перенаправить на домен, который не соответствует вашему настроенному домену (<code>{{cookieDomain}}</code>). Вы уверены, что хотите продолжить?\",\n    \"logoutFailTitle\": \"Не удалось выйти\",\n    \"logoutFailSubtitle\": \"Попробуйте ещё раз\",\n    \"logoutSuccessTitle\": \"Выход\",\n    \"logoutSuccessSubtitle\": \"Вы вышли\",\n    \"logoutTitle\": \"Выйти\",\n    \"logoutUsernameSubtitle\": \"Вход выполнен как <code>{{username}}</code>, нажмите на кнопку ниже, чтобы выйти.\",\n    \"logoutOauthSubtitle\": \"Вход выполнен как <code>{{username}}</code> с использованием {{provider}} OAuth, нажмите кнопку ниже, чтобы выйти.\",\n    \"notFoundTitle\": \"Страница не найдена\",\n    \"notFoundSubtitle\": \"Эта страница не существует.\",\n    \"notFoundButton\": \"На главную\",\n    \"totpFailTitle\": \"Не удалось проверить код\",\n    \"totpFailSubtitle\": \"Пожалуйста, проверьте свой код и повторите попытку\",\n    \"totpSuccessTitle\": \"Подтверждён\",\n    \"totpSuccessSubtitle\": \"Перенаправление в приложение\",\n    \"totpTitle\": \"Введите код TOTP\",\n    \"totpSubtitle\": \"Пожалуйста, введите код из вашего приложения авторизации.\",\n    \"unauthorizedTitle\": \"Доступ запрещён\",\n    \"unauthorizedResourceSubtitle\": \"Пользователю <code>{{username}}</code> не разрешён доступ к <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Пользователю <code>{{username}}</code> не разрешён вход.\",\n    \"unauthorizedGroupsSubtitle\": \"Пользователь <code>{{username}}</code> не состоит в группах, которым разрешён доступ к <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Вашему IP-адресу <code>{{ip}}</code> не разрешён доступ к ресурсу <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Повторить\",\n    \"cancelTitle\": \"Отмена\",\n    \"forgotPasswordTitle\": \"Забыли пароль?\",\n    \"failedToFetchProvidersTitle\": \"Не удалось загрузить поставщика авторизации. Пожалуйста, проверьте конфигурацию.\",\n    \"errorTitle\": \"Произошла ошибка\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"Произошла ошибка при попытке выполнить это действие. Проверьте консоль для дополнительной информации.\",\n    \"forgotPasswordMessage\": \"Вы можете сбросить свой пароль, изменив переменную окружения `USERS`.\",\n    \"fieldRequired\": \"Это поле является обязательным\",\n    \"invalidInput\": \"Недопустимый ввод\",\n    \"domainWarningTitle\": \"Неверный домен\",\n    \"domainWarningSubtitle\": \"Этот экземпляр настроен на доступ к нему из <code>{{appUrl}}</code>, но <code>{{currentUrl}}</code> в настоящее время используется. Если вы продолжите, то могут возникнуть проблемы с авторизацией.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Игнорировать\",\n    \"goToCorrectDomainTitle\": \"Перейти к правильному домену\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/sr-SP.json",
    "content": "{\n    \"loginTitle\": \"Добродошли назад, пријавите се са\",\n    \"loginTitleSimple\": \"Добродошли назад, молим вас пријавите се\",\n    \"loginDivider\": \"Или\",\n    \"loginUsername\": \"Корисничко име\",\n    \"loginPassword\": \"Лозинка\",\n    \"loginSubmit\": \"Пријава\",\n    \"loginFailTitle\": \"Неуспешна пријава\",\n    \"loginFailSubtitle\": \"Молим вас проверите ваше корисничко име и лозинку\",\n    \"loginFailRateLimit\": \"Нисте успели да се пријавите превише пута. Молим вас покушајте касније\",\n    \"loginSuccessTitle\": \"Пријављени\",\n    \"loginSuccessSubtitle\": \"Добродошли назад!\",\n    \"loginOauthFailTitle\": \"Појавила се грешка\",\n    \"loginOauthFailSubtitle\": \"Неуспело преузимање OAuth адресе\",\n    \"loginOauthSuccessTitle\": \"Преусмеравање\",\n    \"loginOauthSuccessSubtitle\": \"Преусмеравање на вашег OAuth провајдера\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth аутоматско преусмерење\",\n    \"loginOauthAutoRedirectSubtitle\": \"Бићете аутоматски преусмерени на вашег OAuth провајдера за аутентификацију.\",\n    \"loginOauthAutoRedirectButton\": \"Преусмери сада\",\n    \"continueTitle\": \"Настави\",\n    \"continueRedirectingTitle\": \"Преусмеравање...\",\n    \"continueRedirectingSubtitle\": \"Требали би сте ускоро да будете преусмерени на апликацију\",\n    \"continueRedirectManually\": \"Преусмери ме ручно\",\n    \"continueInsecureRedirectTitle\": \"Небезбедно преусмеравање\",\n    \"continueInsecureRedirectSubtitle\": \"Покушавате да преусмерите са <code>https</code> на <code>http</code> што није безбедно. Да ли желите да наставите?\",\n    \"continueUntrustedRedirectTitle\": \"Неповерљиво преусмерење\",\n    \"continueUntrustedRedirectSubtitle\": \"Покушавате да преусмерите на домен који се не поклапа са вашим подешеним доменом (<code>{{cookieDomain}}</code>). Да ли заиста желите да наставите?\",\n    \"logoutFailTitle\": \"Неуспешно одјављивање\",\n    \"logoutFailSubtitle\": \"Молим вас покушајте поново\",\n    \"logoutSuccessTitle\": \"Одјављени\",\n    \"logoutSuccessSubtitle\": \"Одјављени сте\",\n    \"logoutTitle\": \"Одјава\",\n    \"logoutUsernameSubtitle\": \"Тренутно сте пријављени као <code>{{username}}</code>. Кликните на дугме испод да се одјавите.\",\n    \"logoutOauthSubtitle\": \"Тренутно сте пријављени као <code>{{username}}</code> користећи {{provider}} OAuth провајдера. Кликните на дугме испод да се одјавите.\",\n    \"notFoundTitle\": \"Страница није пронађена\",\n    \"notFoundSubtitle\": \"Страница коју тражите не постоји.\",\n    \"notFoundButton\": \"На почетак\",\n    \"totpFailTitle\": \"Неуспело потврђивање кода\",\n    \"totpFailSubtitle\": \"Молим вас проверите ваш код и покушајте поново\",\n    \"totpSuccessTitle\": \"Потврђен\",\n    \"totpSuccessSubtitle\": \"Преусмеравање на вашу апликацију\",\n    \"totpTitle\": \"Унесите ваш TOTP код\",\n    \"totpSubtitle\": \"Молим вас унесите код из ваше апликације за аутентификацију.\",\n    \"unauthorizedTitle\": \"Неауторизован\",\n    \"unauthorizedResourceSubtitle\": \"Корисник са корисничким именом <code>{{username}}</code> није ауторизован да приступи ресурсу <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Корисник са корисничким именом <code>{{username}}</code> није ауторизован за пријављивање.\",\n    \"unauthorizedGroupsSubtitle\": \"Корисник са корисничким именом <code>{{username}}</code> није у групама које захтева ресурс <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Ваша IP адреса <code>{{ip}}</code> није ауторизована да приступи ресурсу <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Покушајте поново\",\n    \"cancelTitle\": \"Поништи\",\n    \"forgotPasswordTitle\": \"Заборавили сте лозинку?\",\n    \"failedToFetchProvidersTitle\": \"Није успело учитавање провајдера аутентификације. Молим вас проверите ваша подешавања.\",\n    \"errorTitle\": \"Појавила се грешка\",\n    \"errorSubtitleInfo\": \"Појавила се следећа грешка током обраде вашег захтева:\",\n    \"errorSubtitle\": \"Појавила се грешка при покушају извршавања ове радње. Молим вас проверите конзолу за додатне информације.\",\n    \"forgotPasswordMessage\": \"Можете поништити вашу лозинку променом `USERS` променљиве окружења.\",\n    \"fieldRequired\": \"Ово поље је неопходно\",\n    \"invalidInput\": \"Неисправан унос\",\n    \"domainWarningTitle\": \"Неисправан домен\",\n    \"domainWarningSubtitle\": \"Ова инстанца је подешена да јој се приступа са <code>{{appUrl}}</code>, али се користи <code>{{currentUrl}}</code>. Ако наставите, можете искусити проблеме са аутентификацијом.\",\n    \"domainWarningCurrent\": \"Тренутни:\",\n    \"domainWarningExpected\": \"Очекивани:\",\n    \"ignoreTitle\": \"Игнориши\",\n    \"goToCorrectDomainTitle\": \"Иди на исправан домен\",\n    \"authorizeTitle\": \"Ауторизуј\",\n    \"authorizeCardTitle\": \"Наставити на {{app}}?\",\n    \"authorizeSubtitle\": \"Да ли желите да наставите на ову апликацију? Пажљиво проверите дозволе које вам тражи апликација.\",\n    \"authorizeSubtitleOAuth\": \"Да ли желите да наставите на ову апликацију?\",\n    \"authorizeLoadingTitle\": \"Учитавање...\",\n    \"authorizeLoadingSubtitle\": \"Молим вас сачекајте док ми учитамо информације о клијенту.\",\n    \"authorizeSuccessTitle\": \"Ауторизован\",\n    \"authorizeSuccessSubtitle\": \"Бићете преусмерени на апликацију за неколико секунди.\",\n    \"authorizeErrorClientInfo\": \"Појавила се грешка током учитавања информација о клијенту. Молим вас покушајте поново касније.\",\n    \"authorizeErrorMissingParams\": \"Следећи параметри недостају: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID повезивање\",\n    \"openidScopeDescription\": \"Омогућава апликацији да приступа информацији о вашој OpenID вези.\",\n    \"emailScopeName\": \"Е-пошта\",\n    \"emailScopeDescription\": \"Омогућава апликацији да приступа вашој адреси е-поште.\",\n    \"profileScopeName\": \"Профил\",\n    \"profileScopeDescription\": \"Омогућава апликацији да приступа информацијама о вашем профилу.\",\n    \"groupsScopeName\": \"Групе\",\n    \"groupsScopeDescription\": \"Омогућава апликацији да приступа информацијама о вашој групи.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/sv-SE.json",
    "content": "{\n    \"loginTitle\": \"Välkommen tillbaka, logga in med\",\n    \"loginTitleSimple\": \"Välkommen tillbaka, logga in\",\n    \"loginDivider\": \"Eller\",\n    \"loginUsername\": \"Användarnamn\",\n    \"loginPassword\": \"Lösenord\",\n    \"loginSubmit\": \"Logga in\",\n    \"loginFailTitle\": \"Kunde inte logga in\",\n    \"loginFailSubtitle\": \"Kontrollera ditt användarnamn och lösenord\",\n    \"loginFailRateLimit\": \"Du misslyckades med att logga in för många gånger. Försök igen senare\",\n    \"loginSuccessTitle\": \"Inloggad\",\n    \"loginSuccessSubtitle\": \"Välkommen tillbaka!\",\n    \"loginOauthFailTitle\": \"Ett fel har uppstått\",\n    \"loginOauthFailSubtitle\": \"Kunde inte hämta OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Omdirigerar\",\n    \"loginOauthSuccessSubtitle\": \"Omdirigera till din OAuth leverantör\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Fortsätt\",\n    \"continueRedirectingTitle\": \"Omdirigerar...\",\n    \"continueRedirectingSubtitle\": \"Du bör omdirigeras till appen snart\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Osäker omdirigering\",\n    \"continueInsecureRedirectSubtitle\": \"Du försöker omdirigera från <code>https</code> till <code>http</code> som inte är säker. Är du säker på att du vill fortsätta?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Kunde inte logga ut.\",\n    \"logoutFailSubtitle\": \"Vänligen försök igen\",\n    \"logoutSuccessTitle\": \"Utloggad\",\n    \"logoutSuccessSubtitle\": \"Du har blivit utloggad\",\n    \"logoutTitle\": \"Logga ut\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Obehörig\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Forgot your password?\",\n    \"failedToFetchProvidersTitle\": \"Failed to load authentication providers. Please check your configuration.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/tr-TR.json",
    "content": "{\n    \"loginTitle\": \"Tekrar Hoş Geldiniz, giriş yapın\",\n    \"loginTitleSimple\": \"Tekrar hoş geldiniz, lütfen giriş yapın\",\n    \"loginDivider\": \"Ya da\",\n    \"loginUsername\": \"Kullanıcı Adı\",\n    \"loginPassword\": \"Şifre\",\n    \"loginSubmit\": \"Giriş Yap\",\n    \"loginFailTitle\": \"Giriş yapılamadı\",\n    \"loginFailSubtitle\": \"Lütfen kullanıcı adınızı ve şifrenizi kontrol edin\",\n    \"loginFailRateLimit\": \"Çok fazla kez giriş yapma girişiminde bulundunuz. Lütfen daha sonra tekrar deneyin\",\n    \"loginSuccessTitle\": \"Giriş yapıldı\",\n    \"loginSuccessSubtitle\": \"Tekrar hoş geldiniz!\",\n    \"loginOauthFailTitle\": \"Hata oluştu\",\n    \"loginOauthFailSubtitle\": \"OAuth URL'si alınamadı\",\n    \"loginOauthSuccessTitle\": \"Yönlendiriliyor\",\n    \"loginOauthSuccessSubtitle\": \"OAuth sağlayıcınıza yönlendiriliyor\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Otomatik Yönlendirme\",\n    \"loginOauthAutoRedirectSubtitle\": \"Kimlik doğrulama işlemi için otomatik olarak OAuth sağlayıcınıza yönlendirileceksiniz.\",\n    \"loginOauthAutoRedirectButton\": \"Şimdi Yönlendir\",\n    \"continueTitle\": \"Devam et\",\n    \"continueRedirectingTitle\": \"Yönlendiriliyor...\",\n    \"continueRedirectingSubtitle\": \"Kısa süre içinde uygulamaya yönlendirileceksiniz\",\n    \"continueRedirectManually\": \"Beni manuel olarak yönlendir\",\n    \"continueInsecureRedirectTitle\": \"Güvenli olmayan yönlendirme\",\n    \"continueInsecureRedirectSubtitle\": \"<code>http</code> adresinden <code>http</code> adresine yönlendirme yapmaya çalışıyorsunuz, bu güvenli değil. Devam etmek istediğinizden emin misiniz?\",\n    \"continueUntrustedRedirectTitle\": \"Güvenilmeyen yönlendirme\",\n    \"continueUntrustedRedirectSubtitle\": \"Yapılandırdığınız alan adıyla eşleşmeyen bir alana yönlendirme yapmaya çalışıyorsunuz (<code>{{cookieDomain}}</code>). Devam etmek istediğinize emin misiniz?\",\n    \"logoutFailTitle\": \"Çıkış Yapılamadı\",\n    \"logoutFailSubtitle\": \"Lütfen tekrar deneyin\",\n    \"logoutSuccessTitle\": \"Çıkış yapıldı\",\n    \"logoutSuccessSubtitle\": \"Çıkış yaptınız\",\n    \"logoutTitle\": \"Çıkış yap\",\n    \"logoutUsernameSubtitle\": \"<code>{{username}}</code> olarak giriş yapmış durumdasınız. Çıkış yapmak için aşağıdaki düğmeye tıklayın.\",\n    \"logoutOauthSubtitle\": \"Şu anda {{provider}} OAuth sağlayıcısını kullanarak <code>{{username}}</code> olarak oturum açmış durumdasınız. Oturumunuzu kapatmak için aşağıdaki düğmeye tıklayın.\",\n    \"notFoundTitle\": \"Sayfa bulunamadı\",\n    \"notFoundSubtitle\": \"Aradığınız sayfa mevcut değil.\",\n    \"notFoundButton\": \"Ana sayfaya git\",\n    \"totpFailTitle\": \"Kod doğrulanamadı\",\n    \"totpFailSubtitle\": \"Lütfen kodunuzu kontrol edin ve tekrar deneyin\",\n    \"totpSuccessTitle\": \"Doğrulandı\",\n    \"totpSuccessSubtitle\": \"Uygulamanıza yönlendiriliyor\",\n    \"totpTitle\": \"TOTP kodunuzu girin\",\n    \"totpSubtitle\": \"Lütfen kimlik doğrulama uygulamanızdan aldığınız kodu girin.\",\n    \"unauthorizedTitle\": \"Yetkisiz\",\n    \"unauthorizedResourceSubtitle\": \"Kullanıcı adı <code>{{username}}</code> olan kullanıcının <code>{{resource}}</code> kaynağına erişim yetkisi bulunmamaktadır.\",\n    \"unauthorizedLoginSubtitle\": \"Kullanıcı adı <code>{{username}}</code> olan kullanıcının oturum açma yetkisi yok.\",\n    \"unauthorizedGroupsSubtitle\": \"Kullanıcı adı <code>{{username}}</code> olan kullanıcı, <code>{{resource}}</code> kaynağının gerektirdiği gruplarda bulunmuyor.\",\n    \"unauthorizedIpSubtitle\": \"IP adresiniz <code>{{ip}}</code>, <code>{{resource}}</code> kaynağına erişim yetkisine sahip değil.\",\n    \"unauthorizedButton\": \"Tekrar deneyin\",\n    \"cancelTitle\": \"İptal\",\n    \"forgotPasswordTitle\": \"Şifrenizi mi unuttunuz?\",\n    \"failedToFetchProvidersTitle\": \"Kimlik doğrulama sağlayıcıları yüklenemedi. Lütfen yapılandırmanızı kontrol edin.\",\n    \"errorTitle\": \"Bir hata oluştu\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"Parolanızı `USERS` ortam değişkenini değiştirerek sıfırlayabilirsiniz.\",\n    \"fieldRequired\": \"Bu alan zorunludur\",\n    \"invalidInput\": \"Geçersiz girdi\",\n    \"domainWarningTitle\": \"Geçersiz alan adı\",\n    \"domainWarningSubtitle\": \"Bu örnek, <code>{{appUrl}}</code> adresinden erişilecek şekilde yapılandırılmıştır, ancak <code>{{currentUrl}}</code> kullanılmaktadır. Devam ederseniz, kimlik doğrulama ile ilgili sorunlarla karşılaşabilirsiniz.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Yoksay\",\n    \"goToCorrectDomainTitle\": \"Doğru alana gidin\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/uk-UA.json",
    "content": "{\n    \"loginTitle\": \"З поверненням, увійдіть через\",\n    \"loginTitleSimple\": \"З поверненням, будь ласка, авторизуйтесь\",\n    \"loginDivider\": \"Або\",\n    \"loginUsername\": \"Ім'я користувача\",\n    \"loginPassword\": \"Пароль\",\n    \"loginSubmit\": \"Увійти\",\n    \"loginFailTitle\": \"Не вдалося авторизуватися\",\n    \"loginFailSubtitle\": \"Перевірте ім'я користувача та пароль\",\n    \"loginFailRateLimit\": \"Ви не змогли увійти занадто багато разів. Будь ласка, спробуйте ще раз пізніше\",\n    \"loginSuccessTitle\": \"Вхід здійснено\",\n    \"loginSuccessSubtitle\": \"З поверненням!\",\n    \"loginOauthFailTitle\": \"Виникла помилка\",\n    \"loginOauthFailSubtitle\": \"Не вдалося отримати OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Перенаправляємо\",\n    \"loginOauthSuccessSubtitle\": \"Перенаправляємо до вашого провайдера OAuth\",\n    \"loginOauthAutoRedirectTitle\": \"Автоматичне переспрямування OAuth\",\n    \"loginOauthAutoRedirectSubtitle\": \"Ви будете автоматично перенаправлені до вашого провайдера OAuth для автентифікації.\",\n    \"loginOauthAutoRedirectButton\": \"Перейти зараз\",\n    \"continueTitle\": \"Продовжити\",\n    \"continueRedirectingTitle\": \"Перенаправлення...\",\n    \"continueRedirectingSubtitle\": \"Незабаром ви будете перенаправлені в додаток\",\n    \"continueRedirectManually\": \"Перенаправити мене вручну\",\n    \"continueInsecureRedirectTitle\": \"Небезпечне перенаправлення\",\n    \"continueInsecureRedirectSubtitle\": \"Ви намагаєтесь перенаправити з <code>https</code> на <code>http</code> який не є безпечним. Ви впевнені, що хочете продовжити?\",\n    \"continueUntrustedRedirectTitle\": \"Недовірене перенаправлення\",\n    \"continueUntrustedRedirectSubtitle\": \"Ви намагаєтесь перенаправити на домен, який не збігається з вашим налаштованим доменом (<code>{{cookieDomain}}</code>). Впевнені, що хочете продовжити?\",\n    \"logoutFailTitle\": \"Не вдалося вийти\",\n    \"logoutFailSubtitle\": \"Будь ласка, спробуйте знову\",\n    \"logoutSuccessTitle\": \"Ви вийшли\",\n    \"logoutSuccessSubtitle\": \"Ви вийшли з системи\",\n    \"logoutTitle\": \"Вийти\",\n    \"logoutUsernameSubtitle\": \"Зараз ви увійшли як <code>{{username}}</code>. Натисніть кнопку нижче для виходу.\",\n    \"logoutOauthSubtitle\": \"Наразі ви увійшли як <code>{{username}}</code> використовуючи провайдера {{provider}} OAuth. Натисніть кнопку нижче, щоб вийти.\",\n    \"notFoundTitle\": \"Сторінку не знайдено\",\n    \"notFoundSubtitle\": \"Сторінка, яку ви шукаєте, не існує.\",\n    \"notFoundButton\": \"На головну\",\n    \"totpFailTitle\": \"Не вдалося перевірити код\",\n    \"totpFailSubtitle\": \"Перевірте ваш код і спробуйте ще раз\",\n    \"totpSuccessTitle\": \"Перевірено\",\n    \"totpSuccessSubtitle\": \"Перенаправлення до вашого додатку\",\n    \"totpTitle\": \"Введіть ваш TOTP код\",\n    \"totpSubtitle\": \"Будь ласка, введіть код з вашого додатку для автентифікації.\",\n    \"unauthorizedTitle\": \"Доступ обмежено\",\n    \"unauthorizedResourceSubtitle\": \"Користувач з ім'ям користувача <code>{{username}}</code> не має права доступу до ресурсу <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"Користувач з іменем <code>{{username}}</code> не авторизований для входу.\",\n    \"unauthorizedGroupsSubtitle\": \"Користувач з іменем <code>{{username}}</code> не входить до груп, що необхідні для ресурсу <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Ваша IP-адреса <code>{{ip}}</code> не авторизована для доступу до ресурсу <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Спробуйте ще раз\",\n    \"cancelTitle\": \"Скасовувати\",\n    \"forgotPasswordTitle\": \"Забули пароль?\",\n    \"failedToFetchProvidersTitle\": \"Не вдалося завантажити провайдерів автентифікації. Будь ласка, перевірте вашу конфігурацію.\",\n    \"errorTitle\": \"Виникла помилка\",\n    \"errorSubtitleInfo\": \"Під час обробки запиту сталась помилка:\",\n    \"errorSubtitle\": \"An error occurred while trying to perform this action. Please check the console for more information.\",\n    \"forgotPasswordMessage\": \"Ви можете скинути пароль, змінивши змінну середовища \\\"USERS\\\".\",\n    \"fieldRequired\": \"Це поле обов'язкове для заповнення\",\n    \"invalidInput\": \"Невірне введення\",\n    \"domainWarningTitle\": \"Невірний домен\",\n    \"domainWarningSubtitle\": \"Даний ресурс налаштований для доступу з <code>{{appUrl}}</code>, але використовується <code>{{currentUrl}}</code>. Якщо ви продовжите, можуть виникнути проблеми з автентифікацією.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ігнорувати\",\n    \"goToCorrectDomainTitle\": \"Перейти за коректним доменом\",\n    \"authorizeTitle\": \"Авторизуватись\",\n    \"authorizeCardTitle\": \"Перейти до {{app}}?\",\n    \"authorizeSubtitle\": \"Чи хочете ви продовжити роботу з цим додатком? Будь ласка, уважно перегляньте дозволи, які вимагає додаток.\",\n    \"authorizeSubtitleOAuth\": \"Бажаєте продовжити роботу з цим додатком?\",\n    \"authorizeLoadingTitle\": \"Завантаження...\",\n    \"authorizeLoadingSubtitle\": \"Будь ласка, зачекайте, поки ми завантажуємо клієнтську інформацію.\",\n    \"authorizeSuccessTitle\": \"Авторизовано\",\n    \"authorizeSuccessSubtitle\": \"Вас буде перенаправлено до програми за декілька секунд.\",\n    \"authorizeErrorClientInfo\": \"Під час завантаження даних клієнта сталася помилка. Будь ласка, спробуйте ще раз пізніше.\",\n    \"authorizeErrorMissingParams\": \"Відсутні наступні параметри: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Дозволяє програмі отримувати доступ до вашої інформації OpenID Connect.\",\n    \"emailScopeName\": \"Електронна пошта\",\n    \"emailScopeDescription\": \"Дозволяє програмі отримувати доступ до вашої адреси електронної пошти.\",\n    \"profileScopeName\": \"Профіль\",\n    \"profileScopeDescription\": \"Дозволяє програмі отримувати доступ до інформації вашого профілю.\",\n    \"groupsScopeName\": \"Групи\",\n    \"groupsScopeDescription\": \"Дозволяє програмі отримувати доступ до інформації про групу.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/vi-VN.json",
    "content": "{\n    \"loginTitle\": \"Welcome back, login with\",\n    \"loginTitleSimple\": \"Welcome back, please login\",\n    \"loginDivider\": \"Or\",\n    \"loginUsername\": \"Username\",\n    \"loginPassword\": \"Password\",\n    \"loginSubmit\": \"Login\",\n    \"loginFailTitle\": \"Failed to log in\",\n    \"loginFailSubtitle\": \"Please check your username and password\",\n    \"loginFailRateLimit\": \"You failed to login too many times. Please try again later\",\n    \"loginSuccessTitle\": \"Logged in\",\n    \"loginSuccessSubtitle\": \"Welcome back!\",\n    \"loginOauthFailTitle\": \"An error occurred\",\n    \"loginOauthFailSubtitle\": \"Failed to get OAuth URL\",\n    \"loginOauthSuccessTitle\": \"Redirecting\",\n    \"loginOauthSuccessSubtitle\": \"Redirecting to your OAuth provider\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth Auto Redirect\",\n    \"loginOauthAutoRedirectSubtitle\": \"You will be automatically redirected to your OAuth provider to authenticate.\",\n    \"loginOauthAutoRedirectButton\": \"Redirect now\",\n    \"continueTitle\": \"Continue\",\n    \"continueRedirectingTitle\": \"Redirecting...\",\n    \"continueRedirectingSubtitle\": \"You should be redirected to the app soon\",\n    \"continueRedirectManually\": \"Redirect me manually\",\n    \"continueInsecureRedirectTitle\": \"Insecure redirect\",\n    \"continueInsecureRedirectSubtitle\": \"You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?\",\n    \"continueUntrustedRedirectTitle\": \"Untrusted redirect\",\n    \"continueUntrustedRedirectSubtitle\": \"You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?\",\n    \"logoutFailTitle\": \"Failed to log out\",\n    \"logoutFailSubtitle\": \"Please try again\",\n    \"logoutSuccessTitle\": \"Logged out\",\n    \"logoutSuccessSubtitle\": \"You have been logged out\",\n    \"logoutTitle\": \"Logout\",\n    \"logoutUsernameSubtitle\": \"You are currently logged in as <code>{{username}}</code>. Click the button below to logout.\",\n    \"logoutOauthSubtitle\": \"You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.\",\n    \"notFoundTitle\": \"Page not found\",\n    \"notFoundSubtitle\": \"The page you are looking for does not exist.\",\n    \"notFoundButton\": \"Go home\",\n    \"totpFailTitle\": \"Failed to verify code\",\n    \"totpFailSubtitle\": \"Please check your code and try again\",\n    \"totpSuccessTitle\": \"Verified\",\n    \"totpSuccessSubtitle\": \"Redirecting to your app\",\n    \"totpTitle\": \"Enter your TOTP code\",\n    \"totpSubtitle\": \"Please enter the code from your authenticator app.\",\n    \"unauthorizedTitle\": \"Unauthorized\",\n    \"unauthorizedResourceSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedLoginSubtitle\": \"The user with username <code>{{username}}</code> is not authorized to login.\",\n    \"unauthorizedGroupsSubtitle\": \"The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.\",\n    \"unauthorizedIpSubtitle\": \"Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.\",\n    \"unauthorizedButton\": \"Try again\",\n    \"cancelTitle\": \"Cancel\",\n    \"forgotPasswordTitle\": \"Bạn quên mật khẩu?\",\n    \"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.\",\n    \"errorTitle\": \"An error occurred\",\n    \"errorSubtitleInfo\": \"The following error occurred while processing your request:\",\n    \"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.\",\n    \"forgotPasswordMessage\": \"You can reset your password by changing the `USERS` environment variable.\",\n    \"fieldRequired\": \"This field is required\",\n    \"invalidInput\": \"Invalid input\",\n    \"domainWarningTitle\": \"Invalid Domain\",\n    \"domainWarningSubtitle\": \"This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"Ignore\",\n    \"goToCorrectDomainTitle\": \"Go to correct domain\",\n    \"authorizeTitle\": \"Authorize\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"Loading...\",\n    \"authorizeLoadingSubtitle\": \"Please wait while we load the client information.\",\n    \"authorizeSuccessTitle\": \"Authorized\",\n    \"authorizeSuccessSubtitle\": \"You will be redirected to the app in a few seconds.\",\n    \"authorizeErrorClientInfo\": \"An error occurred while loading the client information. Please try again later.\",\n    \"authorizeErrorMissingParams\": \"The following parameters are missing: {{missingParams}}\",\n    \"openidScopeName\": \"OpenID Connect\",\n    \"openidScopeDescription\": \"Allows the app to access your OpenID Connect information.\",\n    \"emailScopeName\": \"Email\",\n    \"emailScopeDescription\": \"Allows the app to access your email address.\",\n    \"profileScopeName\": \"Profile\",\n    \"profileScopeDescription\": \"Allows the app to access your profile information.\",\n    \"groupsScopeName\": \"Groups\",\n    \"groupsScopeDescription\": \"Allows the app to access your group information.\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/zh-CN.json",
    "content": "{\n    \"loginTitle\": \"欢迎回来，请使用以下方式登录\",\n    \"loginTitleSimple\": \"欢迎回来，请登录\",\n    \"loginDivider\": \"或\",\n    \"loginUsername\": \"用户名\",\n    \"loginPassword\": \"密码\",\n    \"loginSubmit\": \"登录\",\n    \"loginFailTitle\": \"登录失败\",\n    \"loginFailSubtitle\": \"请检查您的用户名和密码\",\n    \"loginFailRateLimit\": \"您登录失败次数过多。请稍后再试\",\n    \"loginSuccessTitle\": \"已登录\",\n    \"loginSuccessSubtitle\": \"欢迎回来！\",\n    \"loginOauthFailTitle\": \"发生错误\",\n    \"loginOauthFailSubtitle\": \"获取 OAuth URL 失败\",\n    \"loginOauthSuccessTitle\": \"重定向中\",\n    \"loginOauthSuccessSubtitle\": \"重定向到您的 OAuth 提供商\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth自动重定向\",\n    \"loginOauthAutoRedirectSubtitle\": \"您将被自动重定向到您的 OAuth 提供商进行身份验证。\",\n    \"loginOauthAutoRedirectButton\": \"立即跳转\",\n    \"continueTitle\": \"继续\",\n    \"continueRedirectingTitle\": \"正在重定向……\",\n    \"continueRedirectingSubtitle\": \"您应该很快被重定向到应用\",\n    \"continueRedirectManually\": \"请手动跳转\",\n    \"continueInsecureRedirectTitle\": \"不安全的重定向\",\n    \"continueInsecureRedirectSubtitle\": \"您正在尝试从<code>https</code>重定向到<code>http</code>可能存在风险。您确定要继续吗？\",\n    \"continueUntrustedRedirectTitle\": \"不可信的重定向\",\n    \"continueUntrustedRedirectSubtitle\": \"您尝试跳转的域名与配置的域名（<code>{{cookieDomain}}</code>）不匹配。是否继续？\",\n    \"logoutFailTitle\": \"注销失败\",\n    \"logoutFailSubtitle\": \"请重试\",\n    \"logoutSuccessTitle\": \"已登出\",\n    \"logoutSuccessSubtitle\": \"您已登出\",\n    \"logoutTitle\": \"登出\",\n    \"logoutUsernameSubtitle\": \"您当前登录用户为<code>{{username}}</code>。点击下方按钮注销。\",\n    \"logoutOauthSubtitle\": \"您当前以<code>{{username}}</code>登录，使用的是{{provider}} OAuth 提供商。点击下方按钮注销。\",\n    \"notFoundTitle\": \"无法找到页面\",\n    \"notFoundSubtitle\": \"您访问的页面不存在。\",\n    \"notFoundButton\": \"回到主页\",\n    \"totpFailTitle\": \"无法验证代码\",\n    \"totpFailSubtitle\": \"请检查您的代码并重试\",\n    \"totpSuccessTitle\": \"已验证\",\n    \"totpSuccessSubtitle\": \"重定向到您的应用\",\n    \"totpTitle\": \"输入您的 TOTP 代码\",\n    \"totpSubtitle\": \"请输入您身份验证器应用中的代码。\",\n    \"unauthorizedTitle\": \"未授权\",\n    \"unauthorizedResourceSubtitle\": \"用户名为<code>{{username}}</code>的用户无权访问资源<code>{{resource}}</code>。\",\n    \"unauthorizedLoginSubtitle\": \"用户名为<code>{{username}}</code>的用户无权登录。\",\n    \"unauthorizedGroupsSubtitle\": \"用户名为<code>{{username}}</code>的用户不在资源<code>{{resource}}</code>所需的组中。\",\n    \"unauthorizedIpSubtitle\": \"用户 <code>{{ip}}</code> 无权访问资源 <code>{{resource}}</code>。\",\n    \"unauthorizedButton\": \"重试\",\n    \"cancelTitle\": \"取消\",\n    \"forgotPasswordTitle\": \"忘记密码？\",\n    \"failedToFetchProvidersTitle\": \"加载身份验证提供程序失败，请检查您的配置。\",\n    \"errorTitle\": \"发生了错误\",\n    \"errorSubtitleInfo\": \"处理您的请求时发生了以下错误：\",\n    \"errorSubtitle\": \"执行此操作时发生错误，请检查控制台以获取更多信息。\",\n    \"forgotPasswordMessage\": \"您可以通过更改 `USERS ` 环境变量重置您的密码。\",\n    \"fieldRequired\": \"必填字段\",\n    \"invalidInput\": \"无效的输入\",\n    \"domainWarningTitle\": \"无效域名\",\n    \"domainWarningSubtitle\": \"您正在从一个错误的域名访问此实例。如继续，您可能会遇到身份验证问题。\",\n    \"domainWarningCurrent\": \"当前：\",\n    \"domainWarningExpected\": \"预期：\",\n    \"ignoreTitle\": \"忽略\",\n    \"goToCorrectDomainTitle\": \"转到正确的域名\",\n    \"authorizeTitle\": \"授权\",\n    \"authorizeCardTitle\": \"继续访问 {{app}}？\",\n    \"authorizeSubtitle\": \"您想继续使用此应用程序吗？请仔细查看该应用程序请求的权限\",\n    \"authorizeSubtitleOAuth\": \"您想要继续使用此应用吗？\",\n    \"authorizeLoadingTitle\": \"正在加载...\",\n    \"authorizeLoadingSubtitle\": \"正在加载客户端信息，请稍候。\",\n    \"authorizeSuccessTitle\": \"已授权\",\n    \"authorizeSuccessSubtitle\": \"您将在几秒钟内被重定向到应用程序。\",\n    \"authorizeErrorClientInfo\": \"加载客户端信息时发生错误。请稍后再试。\",\n    \"authorizeErrorMissingParams\": \"参数缺失：{{missingParams}}\",\n    \"openidScopeName\": \"OpenID 连接\",\n    \"openidScopeDescription\": \"允许应用访问您的 OpenID 连接信息。\",\n    \"emailScopeName\": \"邮箱\",\n    \"emailScopeDescription\": \"允许应用访问您的邮箱地址。\",\n    \"profileScopeName\": \"个人资料\",\n    \"profileScopeDescription\": \"允许应用访问您的个人信息。\",\n    \"groupsScopeName\": \"分组\",\n    \"groupsScopeDescription\": \"允许应用程序访问您的群组信息。\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales/zh-TW.json",
    "content": "{\n    \"loginTitle\": \"歡迎回來，請使用以下方式登入\",\n    \"loginTitleSimple\": \"歡迎回來，請登入\",\n    \"loginDivider\": \"或\",\n    \"loginUsername\": \"帳號\",\n    \"loginPassword\": \"密碼\",\n    \"loginSubmit\": \"登入\",\n    \"loginFailTitle\": \"登入失敗\",\n    \"loginFailSubtitle\": \"請檢查您的帳號與密碼\",\n    \"loginFailRateLimit\": \"登入失敗次數過多，請稍後再試\",\n    \"loginSuccessTitle\": \"登入成功\",\n    \"loginSuccessSubtitle\": \"歡迎回來！\",\n    \"loginOauthFailTitle\": \"發生錯誤\",\n    \"loginOauthFailSubtitle\": \"無法取得 OAuth 網址\",\n    \"loginOauthSuccessTitle\": \"重新導向中\",\n    \"loginOauthSuccessSubtitle\": \"正在將您重新導向至 OAuth 供應商\",\n    \"loginOauthAutoRedirectTitle\": \"OAuth 自動跳轉\",\n    \"loginOauthAutoRedirectSubtitle\": \"自動跳轉到 OAuth 供應商進行身份驗證。\",\n    \"loginOauthAutoRedirectButton\": \"立即重新導向\",\n    \"continueTitle\": \"繼續\",\n    \"continueRedirectingTitle\": \"重新導向中……\",\n    \"continueRedirectingSubtitle\": \"您即將被重新導向至應用程式\",\n    \"continueRedirectManually\": \"手動重新導向\",\n    \"continueInsecureRedirectTitle\": \"不安全的重新導向\",\n    \"continueInsecureRedirectSubtitle\": \"您正嘗試從安全的 <code>https</code> 重新導向至不安全的 <code>http</code>。您確定要繼續嗎？\",\n    \"continueUntrustedRedirectTitle\": \"不受信任的重新導向\",\n    \"continueUntrustedRedirectSubtitle\": \"你嘗試重新導向的域名與設定不符(<code>{{cookieDomain}}</code>)。你確定要繼續嗎？\",\n    \"logoutFailTitle\": \"登出失敗\",\n    \"logoutFailSubtitle\": \"請再試一次\",\n    \"logoutSuccessTitle\": \"登出成功\",\n    \"logoutSuccessSubtitle\": \"您已成功登出\",\n    \"logoutTitle\": \"登出\",\n    \"logoutUsernameSubtitle\": \"您目前以 <code>{{username}}</code> 的身分登入。點擊下方按鈕以登出。\",\n    \"logoutOauthSubtitle\": \"您目前使用 {{provider}} OAuth 供應商並以 <code>{{username}}</code> 的身分登入。點擊下方按鈕以登出。\",\n    \"notFoundTitle\": \"找不到頁面\",\n    \"notFoundSubtitle\": \"您要尋找的頁面不存在。\",\n    \"notFoundButton\": \"回到首頁\",\n    \"totpFailTitle\": \"驗證失敗\",\n    \"totpFailSubtitle\": \"請檢查您的驗證碼並再試一次\",\n    \"totpSuccessTitle\": \"驗證成功\",\n    \"totpSuccessSubtitle\": \"正在重新導向至您的應用程式\",\n    \"totpTitle\": \"輸入您的 TOTP 驗證碼\",\n    \"totpSubtitle\": \"請輸入您驗證器應用程式中的代碼。\",\n    \"unauthorizedTitle\": \"未經授權\",\n    \"unauthorizedResourceSubtitle\": \"使用者 <code>{{username}}</code> 未被授權存取資源 <code>{{resource}}</code>。\",\n    \"unauthorizedLoginSubtitle\": \"使用者 <code>{{username}}</code> 未被授權登入。\",\n    \"unauthorizedGroupsSubtitle\": \"使用者 <code>{{username}}</code> 不在存取資源 <code>{{resource}}</code> 所需的群組中。\",\n    \"unauthorizedIpSubtitle\": \"您的 IP 位址 <code>{{ip}}</code> 未被授權存取資源 <code>{{resource}}</code>。\",\n    \"unauthorizedButton\": \"再試一次\",\n    \"cancelTitle\": \"取消\",\n    \"forgotPasswordTitle\": \"忘記密碼？\",\n    \"failedToFetchProvidersTitle\": \"載入驗證供應商失敗。請檢查您的設定。\",\n    \"errorTitle\": \"發生錯誤\",\n    \"errorSubtitleInfo\": \"處理您的請求時，發生了以下錯誤：\",\n    \"errorSubtitle\": \"執行此操作時發生錯誤。請檢查主控台以獲取更多資訊。\",\n    \"forgotPasswordMessage\": \"透過修改 `USERS` 環境變數，你可以重設你的密碼。\",\n    \"fieldRequired\": \"此為必填欄位\",\n    \"invalidInput\": \"無效的輸入\",\n    \"domainWarningTitle\": \"無效的網域\",\n    \"domainWarningSubtitle\": \"此服務設定為透過 <code>{{appUrl}}</code> 存取，但目前使用的是 <code>{{currentUrl}}</code>。若繼續操作，可能會遇到驗證問題。\",\n    \"domainWarningCurrent\": \"Current:\",\n    \"domainWarningExpected\": \"Expected:\",\n    \"ignoreTitle\": \"忽略\",\n    \"goToCorrectDomainTitle\": \"前往正確域名\",\n    \"authorizeTitle\": \"授權\",\n    \"authorizeCardTitle\": \"Continue to {{app}}?\",\n    \"authorizeSubtitle\": \"Would you like to continue to this app? Please carefully review the permissions requested by the app.\",\n    \"authorizeSubtitleOAuth\": \"Would you like to continue to this app?\",\n    \"authorizeLoadingTitle\": \"正在載入…\",\n    \"authorizeLoadingSubtitle\": \"正在加载客户端信息，请稍候。\",\n    \"authorizeSuccessTitle\": \"已授權\",\n    \"authorizeSuccessSubtitle\": \"幾秒鐘內您將會被重新導向至應用程式。\",\n    \"authorizeErrorClientInfo\": \"載入用戶端資訊時發生錯誤。請稍後再試。\",\n    \"authorizeErrorMissingParams\": \"下列參數遺失：{{missingParams}}\",\n    \"openidScopeName\": \"OpenID 連接\",\n    \"openidScopeDescription\": \"允許該應用程式存取您的 OpenID Connect 資訊。\",\n    \"emailScopeName\": \"電子郵件\",\n    \"emailScopeDescription\": \"允許該應用程式存取您的電子郵件地址。\",\n    \"profileScopeName\": \"個人檔案\",\n    \"profileScopeDescription\": \"允許該應用程式存取您的個人資料。\",\n    \"groupsScopeName\": \"群組\",\n    \"groupsScopeDescription\": \"允許該應用程式存取您的群組資訊。\"\n}\n"
  },
  {
    "path": "frontend/src/lib/i18n/locales.ts",
    "content": "export const languages = {\n  \"af-ZA\": \"Afrikaans\",\n  \"ar-SA\": \"العربية\",\n  \"ca-ES\": \"Català\",\n  \"cs-CZ\": \"Čeština\",\n  \"da-DK\": \"Dansk\",\n  \"de-DE\": \"Deutsch\",\n  \"el-GR\": \"Ελληνικά\",\n  \"en-US\": \"English\",\n  \"es-ES\": \"Español\",\n  \"fi-FI\": \"Suomi\",\n  \"fr-FR\": \"Français\",\n  \"he-IL\": \"עברית\",\n  \"hu-HU\": \"Magyar\",\n  \"it-IT\": \"Italiano\",\n  \"ja-JP\": \"日本語\",\n  \"ko-KR\": \"한국어\",\n  \"nl-NL\": \"Nederlands\",\n  \"no-NO\": \"Norsk\",\n  \"pl-PL\": \"Polski\",\n  \"pt-BR\": \"Português (Brasil)\",\n  \"pt-PT\": \"Português (Portugal)\",\n  \"ro-RO\": \"Română\",\n  \"ru-RU\": \"Русский\",\n  \"sr-SP\": \"Српски\",\n  \"sv-SE\": \"Svenska\",\n  \"tr-TR\": \"Türkçe\",\n  \"uk-UA\": \"Українська\",\n  \"vi-VN\": \"Tiếng Việt\",\n  \"zh-CN\": \"简体中文\",\n  \"zh-TW\": \"繁體中文\",\n};\n\nexport type SupportedLanguage = keyof typeof languages;\n\nexport const getLanguageName = (language: SupportedLanguage): string =>\n  languages[language];\n"
  },
  {
    "path": "frontend/src/lib/utils.ts",
    "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport const capitalize = (str: string) => {\n  return str.charAt(0).toUpperCase() + str.slice(1);\n};\n"
  },
  {
    "path": "frontend/src/main.tsx",
    "content": "import { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport \"./index.css\";\nimport { Layout } from \"./components/layout/layout.tsx\";\nimport { BrowserRouter, Route, Routes } from \"react-router\";\nimport { LoginPage } from \"./pages/login-page.tsx\";\nimport { App } from \"./App.tsx\";\nimport { ErrorPage } from \"./pages/error-page.tsx\";\nimport { NotFoundPage } from \"./pages/not-found-page.tsx\";\nimport { ContinuePage } from \"./pages/continue-page.tsx\";\nimport { TotpPage } from \"./pages/totp-page.tsx\";\nimport { ForgotPasswordPage } from \"./pages/forgot-password-page.tsx\";\nimport { LogoutPage } from \"./pages/logout-page.tsx\";\nimport { UnauthorizedPage } from \"./pages/unauthorized-page.tsx\";\nimport { QueryClient, QueryClientProvider } from \"@tanstack/react-query\";\nimport { AppContextProvider } from \"./context/app-context.tsx\";\nimport { UserContextProvider } from \"./context/user-context.tsx\";\nimport { Toaster } from \"@/components/ui/sonner\";\nimport { ThemeProvider } from \"./components/providers/theme-provider.tsx\";\nimport { AuthorizePage } from \"./pages/authorize-page.tsx\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\n\nconst queryClient = new QueryClient();\n\ncreateRoot(document.getElementById(\"root\")!).render(\n  <StrictMode>\n    <QueryClientProvider client={queryClient}>\n      <AppContextProvider>\n        <UserContextProvider>\n          <TooltipProvider>\n            <ThemeProvider defaultTheme=\"system\" storageKey=\"tinyauth-theme\">\n              <BrowserRouter>\n                <Routes>\n                  <Route element={<Layout />} errorElement={<ErrorPage />}>\n                    <Route path=\"/\" element={<App />} />\n                    <Route path=\"/login\" element={<LoginPage />} />\n                    <Route path=\"/authorize\" element={<AuthorizePage />} />\n                    <Route path=\"/logout\" element={<LogoutPage />} />\n                    <Route path=\"/continue\" element={<ContinuePage />} />\n                    <Route path=\"/totp\" element={<TotpPage />} />\n                    <Route\n                      path=\"/forgot-password\"\n                      element={<ForgotPasswordPage />}\n                    />\n                    <Route\n                      path=\"/unauthorized\"\n                      element={<UnauthorizedPage />}\n                    />\n                    <Route path=\"/error\" element={<ErrorPage />} />\n                    <Route path=\"*\" element={<NotFoundPage />} />\n                  </Route>\n                </Routes>\n              </BrowserRouter>\n              <Toaster />\n            </ThemeProvider>\n          </TooltipProvider>\n        </UserContextProvider>\n      </AppContextProvider>\n    </QueryClientProvider>\n  </StrictMode>,\n);\n"
  },
  {
    "path": "frontend/src/pages/authorize-page.tsx",
    "content": "import { useUserContext } from \"@/context/user-context\";\nimport { useMutation, useQuery } from \"@tanstack/react-query\";\nimport { Navigate, useNavigate } from \"react-router\";\nimport { useLocation } from \"react-router\";\nimport {\n  Card,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n  CardFooter,\n  CardContent,\n} from \"@/components/ui/card\";\nimport { getOidcClientInfoSchema } from \"@/schemas/oidc-schemas\";\nimport { Button } from \"@/components/ui/button\";\nimport axios from \"axios\";\nimport { toast } from \"sonner\";\nimport { useOIDCParams } from \"@/lib/hooks/oidc\";\nimport { useTranslation } from \"react-i18next\";\nimport { TFunction } from \"i18next\";\nimport { Mail, Shield, User, Users } from \"lucide-react\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\ntype Scope = {\n  id: string;\n  name: string;\n  description: string;\n  icon: React.ReactNode;\n};\n\nconst scopeMapIconProps = {\n  className: \"stroke-muted-foreground stroke-[1.75] h-4\",\n};\n\nconst createScopeMap = (t: TFunction<\"translation\", undefined>): Scope[] => {\n  return [\n    {\n      id: \"openid\",\n      name: t(\"openidScopeName\"),\n      description: t(\"openidScopeDescription\"),\n      icon: <Shield {...scopeMapIconProps} />,\n    },\n    {\n      id: \"email\",\n      name: t(\"emailScopeName\"),\n      description: t(\"emailScopeDescription\"),\n      icon: <Mail {...scopeMapIconProps} />,\n    },\n    {\n      id: \"profile\",\n      name: t(\"profileScopeName\"),\n      description: t(\"profileScopeDescription\"),\n      icon: <User {...scopeMapIconProps} />,\n    },\n    {\n      id: \"groups\",\n      name: t(\"groupsScopeName\"),\n      description: t(\"groupsScopeDescription\"),\n      icon: <Users {...scopeMapIconProps} />,\n    },\n  ];\n};\n\nexport const AuthorizePage = () => {\n  const { isLoggedIn } = useUserContext();\n  const { search } = useLocation();\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const scopeMap = createScopeMap(t);\n\n  const searchParams = new URLSearchParams(search);\n  const {\n    values: props,\n    missingParams,\n    isOidc,\n    compiled: compiledOIDCParams,\n  } = useOIDCParams(searchParams);\n  const scopes = props.scope ? props.scope.split(\" \").filter(Boolean) : [];\n\n  const getClientInfo = useQuery({\n    queryKey: [\"client\", props.client_id],\n    queryFn: async () => {\n      const res = await fetch(`/api/oidc/clients/${props.client_id}`);\n      const data = await getOidcClientInfoSchema.parseAsync(await res.json());\n      return data;\n    },\n    enabled: isOidc,\n  });\n\n  const authorizeMutation = useMutation({\n    mutationFn: () => {\n      return axios.post(\"/api/oidc/authorize\", {\n        scope: props.scope,\n        response_type: props.response_type,\n        client_id: props.client_id,\n        redirect_uri: props.redirect_uri,\n        state: props.state,\n        nonce: props.nonce,\n      });\n    },\n    mutationKey: [\"authorize\", props.client_id],\n    onSuccess: (data) => {\n      toast.info(t(\"authorizeSuccessTitle\"), {\n        description: t(\"authorizeSuccessSubtitle\"),\n      });\n      window.location.replace(data.data.redirect_uri);\n    },\n    onError: (error) => {\n      window.location.replace(\n        `/error?error=${encodeURIComponent(error.message)}`,\n      );\n    },\n  });\n\n  if (missingParams.length > 0) {\n    return (\n      <Navigate\n        to={`/error?error=${encodeURIComponent(t(\"authorizeErrorMissingParams\", { missingParams: missingParams.join(\", \") }))}`}\n        replace\n      />\n    );\n  }\n\n  if (!isLoggedIn) {\n    return <Navigate to={`/login?${compiledOIDCParams}`} replace />;\n  }\n\n  if (getClientInfo.isLoading) {\n    return (\n      <Card className=\"gap-0\">\n        <CardHeader>\n          <CardTitle className=\"text-xl\">\n            {t(\"authorizeLoadingTitle\")}\n          </CardTitle>\n        </CardHeader>\n        <CardContent>\n          <CardDescription>{t(\"authorizeLoadingSubtitle\")}</CardDescription>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  if (getClientInfo.isError) {\n    return (\n      <Navigate\n        to={`/error?error=${encodeURIComponent(t(\"authorizeErrorClientInfo\"))}`}\n        replace\n      />\n    );\n  }\n\n  return (\n    <Card>\n      <CardHeader className=\"mb-2\">\n        <div className=\"flex flex-col gap-3 items-center justify-center text-center\">\n          <div className=\"bg-accent-foreground box-content text-muted text-xl font-bold font-sans rounded-lg size-8 p-2 flex items-center justify-center\">\n            {getClientInfo.data?.name.slice(0, 1) || \"U\"}\n          </div>\n          <CardTitle className=\"text-xl\">\n            {t(\"authorizeCardTitle\", {\n              app: getClientInfo.data?.name || \"Unknown\",\n            })}\n          </CardTitle>\n          <CardDescription className=\"text-sm max-w-sm\">\n            {scopes.includes(\"openid\")\n              ? t(\"authorizeSubtitle\")\n              : t(\"authorizeSubtitleOAuth\")}\n          </CardDescription>\n        </div>\n      </CardHeader>\n      {scopes.includes(\"openid\") && (\n        <CardContent className=\"mb-2\">\n          <div className=\"flex flex-wrap gap-2 items-center justify-center\">\n            {scopes.map((id) => {\n              const scope = scopeMap.find((s) => s.id === id);\n              if (!scope) return null;\n              return (\n                <Tooltip key={scope.id}>\n                  <TooltipTrigger className=\"flex flex-row justify-center items-center gap-1 rounded-full bg-secondary font-light pl-2 pr-4 py-1 border-border border\">\n                    <div>{scope.icon}</div>\n                    <div className=\"text-sm text-accent-foreground\">\n                      {scope.name}\n                    </div>\n                  </TooltipTrigger>\n                  <TooltipContent>{scope.description}</TooltipContent>\n                </Tooltip>\n              );\n            })}\n          </div>\n        </CardContent>\n      )}\n      <CardFooter className=\"flex flex-col items-stretch gap-3\">\n        <Button\n          onClick={() => authorizeMutation.mutate()}\n          loading={authorizeMutation.isPending}\n        >\n          {t(\"authorizeTitle\")}\n        </Button>\n        <Button\n          onClick={() => navigate(\"/\")}\n          disabled={authorizeMutation.isPending}\n          variant=\"outline\"\n        >\n          {t(\"cancelTitle\")}\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/continue-page.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useAppContext } from \"@/context/app-context\";\nimport { useUserContext } from \"@/context/user-context\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { Navigate, useLocation, useNavigate } from \"react-router\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useRedirectUri } from \"@/lib/hooks/redirect-uri\";\n\nexport const ContinuePage = () => {\n  const { cookieDomain, warningsEnabled } = useAppContext();\n  const { isLoggedIn } = useUserContext();\n  const { search } = useLocation();\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [showRedirectButton, setShowRedirectButton] = useState(false);\n  const hasRedirected = useRef(false);\n\n  const searchParams = new URLSearchParams(search);\n  const redirectUri = searchParams.get(\"redirect_uri\");\n\n  const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(\n    redirectUri,\n    cookieDomain,\n  );\n\n  const urlHref = url?.href;\n\n  const hasValidRedirect = valid && allowedProto;\n  const showUntrustedWarning = hasValidRedirect && !trusted && warningsEnabled;\n  const showInsecureWarning =\n    hasValidRedirect && httpsDowngrade && warningsEnabled;\n  const shouldAutoRedirect =\n    isLoggedIn &&\n    hasValidRedirect &&\n    !showUntrustedWarning &&\n    !showInsecureWarning;\n\n  const redirectToTarget = useCallback(() => {\n    if (!urlHref || hasRedirected.current) {\n      return;\n    }\n\n    hasRedirected.current = true;\n    window.location.assign(urlHref);\n  }, [urlHref]);\n\n  const handleRedirect = useCallback(() => {\n    setIsLoading(true);\n    redirectToTarget();\n  }, [redirectToTarget]);\n\n  useEffect(() => {\n    if (!shouldAutoRedirect) {\n      return;\n    }\n\n    const auto = setTimeout(() => {\n      redirectToTarget();\n    }, 100);\n\n    const reveal = setTimeout(() => {\n      setShowRedirectButton(true);\n    }, 5000);\n\n    return () => {\n      clearTimeout(auto);\n      clearTimeout(reveal);\n    };\n  }, [shouldAutoRedirect, redirectToTarget]);\n\n  if (!isLoggedIn) {\n    return (\n      <Navigate\n        to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : \"\"}`}\n        replace\n      />\n    );\n  }\n\n  if (!hasValidRedirect) {\n    return <Navigate to=\"/logout\" replace />;\n  }\n\n  if (showUntrustedWarning) {\n    return (\n      <Card role=\"alert\" aria-live=\"assertive\">\n        <CardHeader className=\"gap-1.5\">\n          <CardTitle className=\"text-xl\">\n            {t(\"continueUntrustedRedirectTitle\")}\n          </CardTitle>\n          <CardDescription>\n            <Trans\n              i18nKey=\"continueUntrustedRedirectSubtitle\"\n              t={t}\n              components={{\n                code: <code />,\n              }}\n              values={{ cookieDomain }}\n              shouldUnescape={true}\n            />\n          </CardDescription>\n        </CardHeader>\n        <CardFooter className=\"flex flex-col items-stretch gap-3\">\n          <Button\n            onClick={handleRedirect}\n            loading={isLoading}\n            variant=\"destructive\"\n          >\n            {t(\"continueTitle\")}\n          </Button>\n          <Button\n            onClick={() => navigate(\"/logout\")}\n            variant=\"outline\"\n            disabled={isLoading}\n          >\n            {t(\"cancelTitle\")}\n          </Button>\n        </CardFooter>\n      </Card>\n    );\n  }\n\n  if (showInsecureWarning) {\n    return (\n      <Card role=\"alert\" aria-live=\"assertive\">\n        <CardHeader className=\"gap-1.5\">\n          <CardTitle className=\"text-xl\">\n            {t(\"continueInsecureRedirectTitle\")}\n          </CardTitle>\n          <CardDescription>\n            <Trans\n              i18nKey=\"continueInsecureRedirectSubtitle\"\n              t={t}\n              components={{\n                code: <code />,\n              }}\n            />\n          </CardDescription>\n        </CardHeader>\n        <CardFooter className=\"flex flex-col items-stretch gap-3\">\n          <Button\n            onClick={handleRedirect}\n            loading={isLoading}\n            variant=\"warning\"\n          >\n            {t(\"continueTitle\")}\n          </Button>\n          <Button\n            onClick={() => navigate(\"/logout\")}\n            variant=\"outline\"\n            disabled={isLoading}\n          >\n            {t(\"cancelTitle\")}\n          </Button>\n        </CardFooter>\n      </Card>\n    );\n  }\n\n  return (\n    <Card>\n      <CardHeader className=\"gap-1.5\">\n        <CardTitle className=\"text-xl\">\n          {t(\"continueRedirectingTitle\")}\n        </CardTitle>\n        <CardDescription>{t(\"continueRedirectingSubtitle\")}</CardDescription>\n      </CardHeader>\n      {showRedirectButton && (\n        <CardFooter>\n          <Button className=\"w-full\" onClick={handleRedirect}>\n            {t(\"continueRedirectManually\")}\n          </Button>\n        </CardFooter>\n      )}\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/error-page.tsx",
    "content": "import {\n  Card,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useTranslation } from \"react-i18next\";\nimport { useLocation } from \"react-router\";\n\nexport const ErrorPage = () => {\n  const { t } = useTranslation();\n  const { search } = useLocation();\n  const searchParams = new URLSearchParams(search);\n  const error = searchParams.get(\"error\") ?? \"\";\n\n  return (\n    <Card>\n      <CardHeader className=\"gap-1.5\">\n        <CardTitle className=\"text-xl\">{t(\"errorTitle\")}</CardTitle>\n        <CardDescription className=\"flex flex-col gap-3\">\n          {error ? (\n            <>\n              <p>{t(\"errorSubtitleInfo\")}</p>\n              <pre>{error}</pre>\n            </>\n          ) : (\n            <>\n              <p>{t(\"errorSubtitle\")}</p>\n            </>\n          )}\n        </CardDescription>\n      </CardHeader>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/forgot-password-page.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { useAppContext } from \"@/context/app-context\";\nimport { useTranslation } from \"react-i18next\";\nimport Markdown from \"react-markdown\";\nimport { useNavigate } from \"react-router\";\n\nexport const ForgotPasswordPage = () => {\n  const { forgotPasswordMessage } = useAppContext();\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle className=\"text-xl\">{t(\"forgotPasswordTitle\")}</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <CardDescription>\n          <Markdown>\n            {forgotPasswordMessage !== \"\"\n              ? forgotPasswordMessage\n              : t(\"forgotPasswordMessage\")}\n          </Markdown>\n        </CardDescription>\n      </CardContent>\n      <CardFooter>\n        <Button\n          className=\"w-full\"\n          variant=\"outline\"\n          onClick={() => {\n            navigate(\"/login\");\n          }}\n        >\n          {t(\"notFoundButton\")}\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/login-page.tsx",
    "content": "import { LoginForm } from \"@/components/auth/login-form\";\nimport { GithubIcon } from \"@/components/icons/github\";\nimport { GoogleIcon } from \"@/components/icons/google\";\nimport { MicrosoftIcon } from \"@/components/icons/microsoft\";\nimport { OAuthIcon } from \"@/components/icons/oauth\";\nimport { PocketIDIcon } from \"@/components/icons/pocket-id\";\nimport { TailscaleIcon } from \"@/components/icons/tailscale\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardHeader,\n  CardTitle,\n  CardDescription,\n  CardContent,\n  CardFooter,\n} from \"@/components/ui/card\";\nimport { OAuthButton } from \"@/components/ui/oauth-button\";\nimport { SeperatorWithChildren } from \"@/components/ui/separator\";\nimport { useAppContext } from \"@/context/app-context\";\nimport { useUserContext } from \"@/context/user-context\";\nimport { useOIDCParams } from \"@/lib/hooks/oidc\";\nimport { LoginSchema } from \"@/schemas/login-schema\";\nimport { useMutation } from \"@tanstack/react-query\";\nimport axios, { AxiosError } from \"axios\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Navigate, useLocation } from \"react-router\";\nimport { toast } from \"sonner\";\n\nconst iconMap: Record<string, React.ReactNode> = {\n  google: <GoogleIcon />,\n  github: <GithubIcon />,\n  tailscale: <TailscaleIcon />,\n  microsoft: <MicrosoftIcon />,\n  pocketid: <PocketIDIcon />,\n};\n\nexport const LoginPage = () => {\n  const { isLoggedIn } = useUserContext();\n  const { providers, title, oauthAutoRedirect } = useAppContext();\n  const { search } = useLocation();\n  const { t } = useTranslation();\n\n  const [showRedirectButton, setShowRedirectButton] = useState(false);\n\n  const hasAutoRedirectedRef = useRef(false);\n\n  const redirectTimer = useRef<number | null>(null);\n  const redirectButtonTimer = useRef<number | null>(null);\n\n  const formId = useId();\n\n  const searchParams = new URLSearchParams(search);\n  const {\n    values: props,\n    isOidc,\n    compiled: compiledOIDCParams,\n  } = useOIDCParams(searchParams);\n\n  const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(\n    providers.find((provider) => provider.id === oauthAutoRedirect) !==\n      undefined && props.redirect_uri,\n  );\n\n  const oauthProviders = providers.filter(\n    (provider) => provider.id !== \"local\" && provider.id !== \"ldap\",\n  );\n  const userAuthConfigured =\n    providers.find(\n      (provider) => provider.id === \"local\" || provider.id === \"ldap\",\n    ) !== undefined;\n\n  const {\n    mutate: oauthMutate,\n    data: oauthData,\n    isPending: oauthIsPending,\n    variables: oauthVariables,\n  } = useMutation({\n    mutationFn: (provider: string) =>\n      axios.get(\n        `/api/oauth/url/${provider}${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : \"\"}`,\n      ),\n    mutationKey: [\"oauth\"],\n    onSuccess: (data) => {\n      toast.info(t(\"loginOauthSuccessTitle\"), {\n        description: t(\"loginOauthSuccessSubtitle\"),\n      });\n\n      redirectTimer.current = window.setTimeout(() => {\n        window.location.replace(data.data.url);\n      }, 500);\n\n      if (isOauthAutoRedirect) {\n        redirectButtonTimer.current = window.setTimeout(() => {\n          setShowRedirectButton(true);\n        }, 5000);\n      }\n    },\n    onError: () => {\n      setIsOauthAutoRedirect(false);\n      toast.error(t(\"loginOauthFailTitle\"), {\n        description: t(\"loginOauthFailSubtitle\"),\n      });\n    },\n  });\n\n  const { mutate: loginMutate, isPending: loginIsPending } = useMutation({\n    mutationFn: (values: LoginSchema) => axios.post(\"/api/user/login\", values),\n    mutationKey: [\"login\"],\n    onSuccess: (data) => {\n      if (data.data.totpPending) {\n        window.location.replace(\n          `/totp${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : \"\"}`,\n        );\n        return;\n      }\n\n      toast.success(t(\"loginSuccessTitle\"), {\n        description: t(\"loginSuccessSubtitle\"),\n      });\n\n      redirectTimer.current = window.setTimeout(() => {\n        if (isOidc) {\n          window.location.replace(`/authorize?${compiledOIDCParams}`);\n          return;\n        }\n        window.location.replace(\n          `/continue${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : \"\"}`,\n        );\n      }, 500);\n    },\n    onError: (error: AxiosError) => {\n      toast.error(t(\"loginFailTitle\"), {\n        description:\n          error.response?.status === 429\n            ? t(\"loginFailRateLimit\")\n            : t(\"loginFailSubtitle\"),\n      });\n    },\n  });\n\n  useEffect(() => {\n    if (\n      !isLoggedIn &&\n      isOauthAutoRedirect &&\n      !hasAutoRedirectedRef.current &&\n      props.redirect_uri\n    ) {\n      hasAutoRedirectedRef.current = true;\n      oauthMutate(oauthAutoRedirect);\n    }\n  }, [\n    isLoggedIn,\n    oauthMutate,\n    hasAutoRedirectedRef,\n    oauthAutoRedirect,\n    isOauthAutoRedirect,\n    props.redirect_uri,\n  ]);\n\n  useEffect(() => {\n    return () => {\n      if (redirectTimer.current) {\n        clearTimeout(redirectTimer.current);\n      }\n\n      if (redirectButtonTimer.current) {\n        clearTimeout(redirectButtonTimer.current);\n      }\n    };\n  }, [redirectTimer, redirectButtonTimer]);\n\n  if (isLoggedIn && isOidc) {\n    return <Navigate to={`/authorize?${compiledOIDCParams}`} replace />;\n  }\n\n  if (isLoggedIn && props.redirect_uri !== \"\") {\n    return (\n      <Navigate\n        to={`/continue${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : \"\"}`}\n        replace\n      />\n    );\n  }\n\n  if (isLoggedIn) {\n    return <Navigate to=\"/logout\" replace />;\n  }\n\n  if (isOauthAutoRedirect) {\n    return (\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"text-xl\">\n            {t(\"loginOauthAutoRedirectTitle\")}\n          </CardTitle>\n          <CardDescription>\n            {t(\"loginOauthAutoRedirectSubtitle\")}\n          </CardDescription>\n        </CardHeader>\n        {showRedirectButton && (\n          <CardFooter className=\"flex flex-col items-stretch\">\n            <Button\n              onClick={() => {\n                if (oauthData?.data.url) {\n                  window.location.replace(oauthData.data.url);\n                } else {\n                  setIsOauthAutoRedirect(false);\n                  toast.error(t(\"loginOauthFailTitle\"), {\n                    description: t(\"loginOauthFailSubtitle\"),\n                  });\n                }\n              }}\n            >\n              {t(\"loginOauthAutoRedirectButton\")}\n            </Button>\n          </CardFooter>\n        )}\n      </Card>\n    );\n  }\n  return (\n    <Card>\n      <CardHeader className=\"gap-1.5\">\n        <CardTitle className=\"text-center text-xl\">{title}</CardTitle>\n        {providers.length > 0 && (\n          <CardDescription className=\"text-center\">\n            {oauthProviders.length !== 0\n              ? t(\"loginTitle\")\n              : t(\"loginTitleSimple\")}\n          </CardDescription>\n        )}\n      </CardHeader>\n      <CardContent className=\"flex flex-col gap-4\">\n        {oauthProviders.length !== 0 && (\n          <div className=\"flex flex-col gap-2.5 items-center justify-center\">\n            {oauthProviders.map((provider) => (\n              <OAuthButton\n                key={provider.id}\n                title={provider.name}\n                icon={iconMap[provider.id] ?? <OAuthIcon />}\n                className=\"w-full\"\n                onClick={() => oauthMutate(provider.id)}\n                loading={oauthIsPending && oauthVariables === provider.id}\n                disabled={oauthIsPending || loginIsPending}\n              />\n            ))}\n          </div>\n        )}\n        {userAuthConfigured && oauthProviders.length !== 0 && (\n          <SeperatorWithChildren>{t(\"loginDivider\")}</SeperatorWithChildren>\n        )}\n        {userAuthConfigured && (\n          <LoginForm\n            onSubmit={(values) => loginMutate(values)}\n            loading={loginIsPending || oauthIsPending}\n            formId={formId}\n          />\n        )}\n        {providers.length == 0 && (\n          <pre className=\"break-normal! text-sm text-red-600\">\n            {t(\"failedToFetchProvidersTitle\")}\n          </pre>\n        )}\n      </CardContent>\n      {userAuthConfigured && (\n        <CardFooter>\n          <Button\n            className=\"w-full\"\n            type=\"submit\"\n            form={formId}\n            loading={loginIsPending || oauthIsPending}\n          >\n            {t(\"loginSubmit\")}\n          </Button>\n        </CardFooter>\n      )}\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/logout-page.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useUserContext } from \"@/context/user-context\";\nimport { useMutation } from \"@tanstack/react-query\";\nimport axios from \"axios\";\nimport { useEffect, useRef } from \"react\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { Navigate } from \"react-router\";\nimport { toast } from \"sonner\";\n\nexport const LogoutPage = () => {\n  const { provider, username, isLoggedIn, email, oauthName } = useUserContext();\n  const { t } = useTranslation();\n\n  const redirectTimer = useRef<number | null>(null);\n\n  const logoutMutation = useMutation({\n    mutationFn: () => axios.post(\"/api/user/logout\"),\n    mutationKey: [\"logout\"],\n    onSuccess: () => {\n      toast.success(t(\"logoutSuccessTitle\"), {\n        description: t(\"logoutSuccessSubtitle\"),\n      });\n\n      redirectTimer.current = window.setTimeout(() => {\n        window.location.replace(\"/login\");\n      }, 500);\n    },\n    onError: () => {\n      toast.error(t(\"logoutFailTitle\"), {\n        description: t(\"logoutFailSubtitle\"),\n      });\n    },\n  });\n\n  useEffect(() => {\n    return () => {\n      if (redirectTimer.current) {\n        clearTimeout(redirectTimer.current);\n      }\n    };\n  }, [redirectTimer]);\n\n  if (!isLoggedIn) {\n    return <Navigate to=\"/login\" replace />;\n  }\n\n  return (\n    <Card>\n      <CardHeader className=\"gap-1.5\">\n        <CardTitle className=\"text-xl\">{t(\"logoutTitle\")}</CardTitle>\n        <CardDescription>\n          {provider !== \"local\" && provider !== \"ldap\" ? (\n            <Trans\n              i18nKey=\"logoutOauthSubtitle\"\n              t={t}\n              components={{\n                code: <code />,\n              }}\n              values={{\n                username: email,\n                provider: oauthName,\n              }}\n              shouldUnescape={true}\n            />\n          ) : (\n            <Trans\n              i18nKey=\"logoutUsernameSubtitle\"\n              t={t}\n              components={{\n                code: <code />,\n              }}\n              values={{\n                username,\n              }}\n              shouldUnescape={true}\n            />\n          )}\n        </CardDescription>\n      </CardHeader>\n      <CardFooter>\n        <Button\n          className=\"w-full\"\n          variant=\"outline\"\n          loading={logoutMutation.isPending}\n          onClick={() => logoutMutation.mutate()}\n        >\n          {t(\"logoutTitle\")}\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/not-found-page.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useNavigate } from \"react-router\";\n\nexport const NotFoundPage = () => {\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n  const [loading, setLoading] = useState(false);\n\n  const handleRedirect = () => {\n    setLoading(true);\n    navigate(\"/\");\n  };\n\n  return (\n    <Card>\n      <CardHeader className=\"gap-1.5\">\n        <CardTitle className=\"text-xl\">{t(\"notFoundTitle\")}</CardTitle>\n        <CardDescription>{t(\"notFoundSubtitle\")}</CardDescription>\n      </CardHeader>\n      <CardFooter>\n        <Button\n          variant=\"outline\"\n          className=\"w-full\"\n          onClick={handleRedirect}\n          loading={loading}\n        >\n          {t(\"notFoundButton\")}\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/totp-page.tsx",
    "content": "import { TotpForm } from \"@/components/auth/totp-form\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useUserContext } from \"@/context/user-context\";\nimport { TotpSchema } from \"@/schemas/totp-schema\";\nimport { useMutation } from \"@tanstack/react-query\";\nimport axios from \"axios\";\nimport { useEffect, useId, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Navigate, useLocation } from \"react-router\";\nimport { toast } from \"sonner\";\nimport { useOIDCParams } from \"@/lib/hooks/oidc\";\n\nexport const TotpPage = () => {\n  const { totpPending } = useUserContext();\n  const { t } = useTranslation();\n  const { search } = useLocation();\n  const formId = useId();\n\n  const redirectTimer = useRef<number | null>(null);\n\n  const searchParams = new URLSearchParams(search);\n  const {\n    values: props,\n    isOidc,\n    compiled: compiledOIDCParams,\n  } = useOIDCParams(searchParams);\n\n  const totpMutation = useMutation({\n    mutationFn: (values: TotpSchema) => axios.post(\"/api/user/totp\", values),\n    mutationKey: [\"totp\"],\n    onSuccess: () => {\n      toast.success(t(\"totpSuccessTitle\"), {\n        description: t(\"totpSuccessSubtitle\"),\n      });\n\n      redirectTimer.current = window.setTimeout(() => {\n        if (isOidc) {\n          window.location.replace(`/authorize?${compiledOIDCParams}`);\n          return;\n        }\n\n        window.location.replace(\n          `/continue${props.redirect_uri ? `?redirect_uri=${encodeURIComponent(props.redirect_uri)}` : \"\"}`,\n        );\n      }, 500);\n    },\n    onError: () => {\n      toast.error(t(\"totpFailTitle\"), {\n        description: t(\"totpFailSubtitle\"),\n      });\n    },\n  });\n\n  useEffect(() => {\n    return () => {\n      if (redirectTimer.current) {\n        clearTimeout(redirectTimer.current);\n      }\n    };\n  }, [redirectTimer]);\n\n  if (!totpPending) {\n    return <Navigate to=\"/\" replace />;\n  }\n\n  return (\n    <Card>\n      <CardHeader className=\"gap-1.5\">\n        <CardTitle className=\"text-xl\">{t(\"totpTitle\")}</CardTitle>\n        <CardDescription>{t(\"totpSubtitle\")}</CardDescription>\n      </CardHeader>\n      <CardContent className=\"flex flex-col items-center\">\n        <TotpForm\n          formId={formId}\n          onSubmit={(values) => totpMutation.mutate(values)}\n        />\n      </CardContent>\n      <CardFooter>\n        <Button\n          className=\"w-full\"\n          form={formId}\n          type=\"submit\"\n          loading={totpMutation.isPending}\n        >\n          {t(\"continueTitle\")}\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/pages/unauthorized-page.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { useState } from \"react\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { Navigate, useLocation, useNavigate } from \"react-router\";\n\nexport const UnauthorizedPage = () => {\n  const { search } = useLocation();\n  const { t } = useTranslation();\n  const navigate = useNavigate();\n\n  const [loading, setLoading] = useState(false);\n\n  const searchParams = new URLSearchParams(search);\n  const username = searchParams.get(\"username\");\n  const resource = searchParams.get(\"resource\");\n  const groupErr = searchParams.get(\"groupErr\");\n  const ip = searchParams.get(\"ip\");\n\n  const handleRedirect = () => {\n    setLoading(true);\n    navigate(\"/login\");\n  };\n\n  if (!username && !ip) {\n    return <Navigate to=\"/\" />;\n  }\n\n  let i18nKey = \"unauthorizedLoginSubtitle\";\n\n  if (resource) {\n    i18nKey = \"unauthorizedResourceSubtitle\";\n  }\n\n  if (groupErr === \"true\") {\n    i18nKey = \"unauthorizedGroupsSubtitle\";\n  }\n\n  if (ip) {\n    i18nKey = \"unauthorizedIpSubtitle\";\n  }\n\n  return (\n    <Card>\n      <CardHeader className=\"gap-1.5\">\n        <CardTitle className=\"text-xl\">{t(\"unauthorizedTitle\")}</CardTitle>\n        <CardDescription>\n          <Trans\n            i18nKey={i18nKey}\n            t={t}\n            components={{\n              code: <code />,\n            }}\n            values={{\n              username,\n              resource,\n              ip,\n            }}\n          />\n        </CardDescription>\n      </CardHeader>\n      <CardFooter>\n        <Button\n          variant=\"outline\"\n          className=\"w-full\"\n          onClick={handleRedirect}\n          loading={loading}\n        >\n          {t(\"unauthorizedButton\")}\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n};\n"
  },
  {
    "path": "frontend/src/schemas/app-context-schema.ts",
    "content": "import { z } from \"zod\";\n\nexport const providerSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  oauth: z.boolean(),\n});\n\nexport const appContextSchema = z.object({\n  providers: z.array(providerSchema),\n  title: z.string(),\n  appUrl: z.string(),\n  cookieDomain: z.string(),\n  forgotPasswordMessage: z.string(),\n  backgroundImage: z.string(),\n  oauthAutoRedirect: z.string(),\n  warningsEnabled: z.boolean(),\n});\n\nexport type AppContextSchema = z.infer<typeof appContextSchema>;\n"
  },
  {
    "path": "frontend/src/schemas/login-schema.ts",
    "content": "import { z } from \"zod\";\n\nexport const loginSchema = z.object({\n  username: z.string(),\n  password: z.string(),\n});\n\nexport type LoginSchema = z.infer<typeof loginSchema>;\n"
  },
  {
    "path": "frontend/src/schemas/oidc-schemas.ts",
    "content": "import { z } from \"zod\";\n\nexport const getOidcClientInfoSchema = z.object({\n  name: z.string(),\n});\n"
  },
  {
    "path": "frontend/src/schemas/totp-schema.ts",
    "content": "import { z } from \"zod\";\n\nexport const totpSchema = z.object({\n  code: z.string(),\n});\n\nexport type TotpSchema = z.infer<typeof totpSchema>;\n"
  },
  {
    "path": "frontend/src/schemas/user-context-schema.ts",
    "content": "import { z } from \"zod\";\n\nexport const userContextSchema = z.object({\n  isLoggedIn: z.boolean(),\n  username: z.string(),\n  name: z.string(),\n  email: z.string(),\n  provider: z.string(),\n  oauth: z.boolean(),\n  totpPending: z.boolean(),\n  oauthName: z.string(),\n});\n\nexport type UserContextSchema = z.infer<typeof userContextSchema>;\n"
  },
  {
    "path": "frontend/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "frontend/tsconfig.app.json",
    "content": "{\n  \"compilerOptions\": {\n    // Resolve paths\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    },\n\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.app.tsbuildinfo\",\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n  \"files\": [],\n  \"references\": [\n    { \"path\": \"./tsconfig.app.json\" },\n    { \"path\": \"./tsconfig.node.json\" }\n  ],\n  \"compilerOptions\": {\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "frontend/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"tsBuildInfoFile\": \"./node_modules/.tmp/tsconfig.node.tsbuildinfo\",\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"isolatedModules\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "frontend/vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport path from \"path\";\nimport tailwindcss from \"@tailwindcss/vite\";\nimport { visualizer } from \"rollup-plugin-visualizer\";\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [react(), tailwindcss(), visualizer()],\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n  build: {\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          ui: [\n            \"@radix-ui/react-dropdown-menu\",\n            \"@radix-ui/react-label\",\n            \"@radix-ui/react-select\",\n            \"@radix-ui/react-separator\",\n            \"@radix-ui/react-slot\",\n            \"input-otp\",\n            \"tailwindcss\",\n            \"tailwind-merge\",\n            \"sonner\",\n            \"lucide-react\",\n          ],\n          i18n: [\n            \"i18next\",\n            \"i18next-browser-languagedetector\",\n            \"i18next-resources-to-backend\",\n          ],\n          util: [\"zod\", \"axios\", \"react-hook-form\"],\n        },\n      },\n    },\n  },\n  server: {\n    host: \"0.0.0.0\",\n    proxy: {\n      \"/api\": {\n        target: \"http://tinyauth-backend:3000/api\",\n        changeOrigin: true,\n        rewrite: (path) => path.replace(/^\\/api/, \"\"),\n      },\n      \"/resources\": {\n        target: \"http://tinyauth-backend:3000/resources\",\n        changeOrigin: true,\n        rewrite: (path) => path.replace(/^\\/resources/, \"\"),\n      },\n      \"/.well-known\": {\n        target: \"http://tinyauth-backend:3000/.well-known\",\n        changeOrigin: true,\n        rewrite: (path) => path.replace(/^\\/\\.well-known/, \"\"),\n      },\n    },\n    allowedHosts: true,\n  },\n});\n"
  },
  {
    "path": "gen/gen.go",
    "content": "package main\n\nimport (\n\t\"log/slog\"\n\t\"reflect\"\n)\n\nfunc main() {\n\tslog.Info(\"generating example env file\")\n\tgenerateExampleEnv()\n\tslog.Info(\"generating config reference markdown file\")\n\tgenerateMarkdown()\n}\n\nfunc walkAndBuild[T any](parent reflect.Type, parentValue reflect.Value,\n\tparentPath string, entries *[]T,\n\tbuildEntry func(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]T),\n\tbuildMap func(child reflect.StructField, parentPath string, entries *[]T),\n\tbuildChildPath func(parentPath string, childName string) string,\n) {\n\tfor i := 0; i < parent.NumField(); i++ {\n\t\tfield := parent.Field(i)\n\t\tfieldType := field.Type\n\t\tfieldValue := parentValue.Field(i)\n\n\t\tswitch fieldType.Kind() {\n\t\tcase reflect.Struct:\n\t\t\tchildPath := buildChildPath(parentPath, field.Name)\n\t\t\twalkAndBuild[T](fieldType, fieldValue, childPath, entries, buildEntry, buildMap, buildChildPath)\n\t\tcase reflect.Map:\n\t\t\tbuildMap(field, parentPath, entries)\n\t\tcase reflect.Bool, reflect.String, reflect.Slice, reflect.Int:\n\t\t\tbuildEntry(field, fieldValue, parentPath, entries)\n\t\tdefault:\n\t\t\tslog.Info(\"unknown type\", \"type\", fieldType.Kind())\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "gen/gen_env.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"os\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n)\n\ntype EnvEntry struct {\n\tName        string\n\tDescription string\n\tValue       any\n}\n\nfunc generateExampleEnv() {\n\tcfg := config.NewDefaultConfiguration()\n\tentries := make([]EnvEntry, 0)\n\n\troot := reflect.TypeOf(cfg).Elem()\n\trootValue := reflect.ValueOf(cfg).Elem()\n\trootPath := \"TINYAUTH_\"\n\n\twalkAndBuild(root, rootValue, rootPath, &entries, buildEnvEntry, buildEnvMapEntry, buildEnvChildPath)\n\tcompiled := compileEnv(entries)\n\n\terr := os.Remove(\".env.example\")\n\tif err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\tslog.Error(\"failed to remove example env file\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n\n\terr = os.WriteFile(\".env.example\", compiled, 0644)\n\tif err != nil {\n\t\tslog.Error(\"failed to write example env file\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc buildEnvEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]EnvEntry) {\n\tdesc := child.Tag.Get(\"description\")\n\ttag := child.Tag.Get(\"yaml\")\n\n\tif tag == \"-\" {\n\t\treturn\n\t}\n\n\tvalue := childValue.Interface()\n\n\tentry := EnvEntry{\n\t\tName:        parentPath + strings.ToUpper(child.Name),\n\t\tDescription: desc,\n\t}\n\n\tswitch childValue.Kind() {\n\tcase reflect.Slice:\n\t\tsl, ok := value.([]string)\n\t\tif !ok {\n\t\t\tslog.Error(\"invalid default value\", \"value\", value)\n\t\t\treturn\n\t\t}\n\t\tentry.Value = strings.Join(sl, \",\")\n\tcase reflect.String:\n\t\tst, ok := value.(string)\n\t\tif !ok {\n\t\t\tslog.Error(\"invalid default value\", \"value\", value)\n\t\t\treturn\n\t\t}\n\t\tif st != \"\" {\n\t\t\tentry.Value = fmt.Sprintf(`\"%s\"`, st)\n\t\t} else {\n\t\t\tentry.Value = \"\"\n\t\t}\n\tdefault:\n\t\tentry.Value = value\n\t}\n\t*entries = append(*entries, entry)\n}\n\nfunc buildEnvMapEntry(child reflect.StructField, parentPath string, entries *[]EnvEntry) {\n\tfieldType := child.Type\n\n\tif fieldType.Key().Kind() != reflect.String {\n\t\tslog.Info(\"unsupported map key type\", \"type\", fieldType.Key().Kind())\n\t\treturn\n\t}\n\n\tmapPath := parentPath + strings.ToUpper(child.Name) + \"_name_\"\n\tvalueType := fieldType.Elem()\n\n\tif valueType.Kind() == reflect.Struct {\n\t\tzeroValue := reflect.New(valueType).Elem()\n\t\twalkAndBuild(valueType, zeroValue, mapPath, entries, buildEnvEntry, buildEnvMapEntry, buildEnvChildPath)\n\t}\n}\n\nfunc buildEnvChildPath(parent string, child string) string {\n\treturn parent + strings.ToUpper(child) + \"_\"\n}\n\nfunc compileEnv(entries []EnvEntry) []byte {\n\tbuffer := bytes.Buffer{}\n\n\tbuffer.WriteString(\"# This file is automatically generated by gen/gen_env.go. Do not edit manually.\\n\\n\")\n\tbuffer.WriteString(\"# Tinyauth example configuration\\n\\n\")\n\n\tpreviousSection := \"\"\n\n\tfor _, entry := range entries {\n\t\tif strings.Count(entry.Name, \"_\") > 1 {\n\t\t\tsection := strings.Split(strings.TrimPrefix(entry.Name, \"TINYAUTH_\"), \"_\")[0]\n\t\t\tif section != previousSection {\n\t\t\t\tbuffer.WriteString(\"\\n# \" + strings.ToLower(section) + \" config\\n\\n\")\n\t\t\t\tpreviousSection = section\n\t\t\t}\n\t\t}\n\t\tbuffer.WriteString(\"# \")\n\t\tbuffer.WriteString(entry.Description)\n\t\tbuffer.WriteString(\"\\n\")\n\t\tbuffer.WriteString(entry.Name)\n\t\tbuffer.WriteString(\"=\")\n\t\tfmt.Fprintf(&buffer, \"%v\", entry.Value)\n\t\tbuffer.WriteString(\"\\n\")\n\t}\n\n\treturn buffer.Bytes()\n}\n"
  },
  {
    "path": "gen/gen_md.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"os\"\n\t\"reflect\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n)\n\ntype MarkdownEntry struct {\n\tEnv         string\n\tFlag        string\n\tDescription string\n\tDefault     any\n}\n\nfunc generateMarkdown() {\n\tcfg := config.NewDefaultConfiguration()\n\tentries := make([]MarkdownEntry, 0)\n\n\troot := reflect.TypeOf(cfg).Elem()\n\trootValue := reflect.ValueOf(cfg).Elem()\n\trootPath := \"tinyauth.\"\n\n\twalkAndBuild(root, rootValue, rootPath, &entries, buildMdEntry, buildMdMapEntry, buildMdChildPath)\n\tcompiled := compileMd(entries)\n\n\terr := os.Remove(\"config.gen.md\")\n\tif err != nil && !errors.Is(err, fs.ErrNotExist) {\n\t\tslog.Error(\"failed to remove example env file\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n\n\terr = os.WriteFile(\"config.gen.md\", compiled, 0644)\n\tif err != nil {\n\t\tslog.Error(\"failed to write example env file\", \"error\", err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) {\n\tdesc := child.Tag.Get(\"description\")\n\ttag := child.Tag.Get(\"yaml\")\n\n\tif tag == \"-\" {\n\t\treturn\n\t}\n\n\tvalue := childValue.Interface()\n\n\tentry := MarkdownEntry{\n\t\tEnv:         strings.ToUpper(strings.ReplaceAll(parentPath, \".\", \"_\")) + strings.ToUpper(child.Name),\n\t\tFlag:        fmt.Sprintf(\"--%s%s\", strings.TrimPrefix(parentPath, \"tinyauth.\"), strings.ToLower(child.Name)),\n\t\tDescription: desc,\n\t}\n\n\tswitch childValue.Kind() {\n\tcase reflect.Slice:\n\t\tsl, ok := value.([]string)\n\t\tif !ok {\n\t\t\tslog.Error(\"invalid default value\", \"value\", value)\n\t\t\treturn\n\t\t}\n\t\tentry.Default = fmt.Sprintf(\"`%s`\", strings.Join(sl, \",\"))\n\tdefault:\n\t\tentry.Default = fmt.Sprintf(\"`%v`\", value)\n\t}\n\t*entries = append(*entries, entry)\n}\n\nfunc buildMdMapEntry(child reflect.StructField, parentPath string, entries *[]MarkdownEntry) {\n\tfieldType := child.Type\n\n\tif fieldType.Key().Kind() != reflect.String {\n\t\tslog.Info(\"unsupported map key type\", \"type\", fieldType.Key().Kind())\n\t\treturn\n\t}\n\n\ttag := child.Tag.Get(\"yaml\")\n\n\tif tag == \"-\" {\n\t\treturn\n\t}\n\n\tmapPath := parentPath + tag + \".[name].\"\n\tvalueType := fieldType.Elem()\n\n\tif valueType.Kind() == reflect.Struct {\n\t\tzeroValue := reflect.New(valueType).Elem()\n\t\twalkAndBuild(valueType, zeroValue, mapPath, entries, buildMdEntry, buildMdMapEntry, buildMdChildPath)\n\t}\n}\n\nfunc buildMdChildPath(parent string, child string) string {\n\treturn parent + strings.ToLower(child) + \".\"\n}\n\nfunc compileMd(entries []MarkdownEntry) []byte {\n\tbuffer := bytes.Buffer{}\n\n\tbuffer.WriteString(\"<!--- This file is automatically generated by gen/gen_md.go. Do not edit manually. --->\\n\\n\")\n\tbuffer.WriteString(\"# Tinyauth configuration reference\\n\\n\")\n\tbuffer.WriteString(\"| Environment | Flag | Description | Default |\\n\")\n\tbuffer.WriteString(\"| - | - | - | - |\\n\")\n\n\tpreviousSection := \"\"\n\n\tfor _, entry := range entries {\n\t\tif strings.Count(entry.Env, \"_\") > 1 {\n\t\t\tsection := strings.Split(strings.TrimPrefix(entry.Env, \"TINYAUTH_\"), \"_\")[0]\n\t\t\tif section != previousSection {\n\t\t\t\tbuffer.WriteString(\"\\n## \" + strings.ToLower(section) + \"\\n\\n\")\n\t\t\t\tbuffer.WriteString(\"| Environment | Flag | Description | Default |\\n\")\n\t\t\t\tbuffer.WriteString(\"| - | - | - | - |\\n\")\n\t\t\t\tpreviousSection = section\n\t\t\t}\n\t\t}\n\t\tfmt.Fprintf(&buffer, \"| `%s` | `%s` | %s | %s |\\n\", entry.Env, entry.Flag, entry.Description, entry.Default)\n\t}\n\n\treturn buffer.Bytes()\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/steveiliop56/tinyauth\n\ngo 1.25.0\n\nreplace github.com/traefik/paerser v0.2.2 => ./paerser\n\nrequire (\n\tgithub.com/cenkalti/backoff/v5 v5.0.3\n\tgithub.com/charmbracelet/huh v0.8.0\n\tgithub.com/docker/docker v28.5.2+incompatible\n\tgithub.com/gin-gonic/gin v1.12.0\n\tgithub.com/go-jose/go-jose/v4 v4.1.3\n\tgithub.com/go-ldap/ldap/v3 v3.4.12\n\tgithub.com/golang-migrate/migrate/v4 v4.19.1\n\tgithub.com/google/go-querystring v1.2.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/mdp/qrterminal/v3 v3.2.1\n\tgithub.com/pquerna/otp v1.5.0\n\tgithub.com/rs/zerolog v1.34.0\n\tgithub.com/traefik/paerser v0.2.2\n\tgithub.com/weppos/publicsuffix-go v0.50.3\n\tgolang.org/x/crypto v0.49.0\n\tgolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546\n\tgolang.org/x/oauth2 v0.36.0\n\tgotest.tools/v3 v3.5.2\n\tmodernc.org/sqlite v1.46.1\n)\n\nrequire (\n\tgithub.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect\n\tgithub.com/BurntSushi/toml v1.4.0 // indirect\n\tgithub.com/Masterminds/goutils v1.1.1 // indirect\n\tgithub.com/Masterminds/semver/v3 v3.2.1 // indirect\n\tgithub.com/Masterminds/sprig/v3 v3.2.3 // indirect\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/boombuler/barcode v1.0.2 // indirect\n\tgithub.com/bytedance/gopkg v0.1.3 // indirect\n\tgithub.com/bytedance/sonic v1.15.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.5.0 // indirect\n\tgithub.com/catppuccin/go v0.3.0 // indirect\n\tgithub.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect\n\tgithub.com/charmbracelet/bubbletea v1.3.6 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect\n\tgithub.com/charmbracelet/lipgloss v1.1.0 // indirect\n\tgithub.com/charmbracelet/x/ansi v0.9.3 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.13 // indirect\n\tgithub.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect\n\tgithub.com/charmbracelet/x/term v0.2.1 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/containerd/errdefs v1.0.0 // indirect\n\tgithub.com/containerd/errdefs/pkg v0.3.0 // indirect\n\tgithub.com/containerd/log v0.1.0 // indirect\n\tgithub.com/distribution/reference v0.6.0 // indirect\n\tgithub.com/docker/go-connections v0.5.0 // indirect\n\tgithub.com/docker/go-units v0.5.0 // indirect\n\tgithub.com/dustin/go-humanize v1.0.1 // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.12 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect\n\tgithub.com/go-logr/logr v1.4.3 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.30.1 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.19.2 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/huandu/xstrings v1.5.0 // indirect\n\tgithub.com/imdario/mergo v0.3.11 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.16 // indirect\n\tgithub.com/mattn/go-sqlite3 v1.14.32 // indirect\n\tgithub.com/mitchellh/copystructure v1.2.0 // indirect\n\tgithub.com/mitchellh/hashstructure/v2 v2.0.2 // indirect\n\tgithub.com/mitchellh/reflectwalk v1.0.2 // indirect\n\tgithub.com/moby/docker-image-spec v1.3.1 // indirect\n\tgithub.com/moby/sys/atomicwriter v0.1.0 // indirect\n\tgithub.com/moby/term v0.5.2 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/termenv v0.16.0 // indirect\n\tgithub.com/ncruces/go-strftime v1.0.0 // indirect\n\tgithub.com/opencontainers/go-digest v1.0.0 // indirect\n\tgithub.com/opencontainers/image-spec v1.1.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/quic-go/qpack v0.6.0 // indirect\n\tgithub.com/quic-go/quic-go v0.59.0 // indirect\n\tgithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/shopspring/decimal v1.4.0 // indirect\n\tgithub.com/spf13/cast v1.10.0 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.1 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgo.mongodb.org/mongo-driver/v2 v2.5.0 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.1.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect\n\tgo.opentelemetry.io/otel v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.37.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.37.0 // indirect\n\tgolang.org/x/arch v0.22.0 // indirect\n\tgolang.org/x/net v0.51.0 // indirect\n\tgolang.org/x/sync v0.20.0 // indirect\n\tgolang.org/x/sys v0.42.0 // indirect\n\tgolang.org/x/term v0.41.0 // indirect\n\tgolang.org/x/text v0.35.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.10 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tmodernc.org/libc v1.67.6 // indirect\n\tmodernc.org/mathutil v1.7.1 // indirect\n\tmodernc.org/memory v1.11.0 // indirect\n\trsc.io/qr v0.2.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=\ngithub.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=\ngithub.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=\ngithub.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=\ngithub.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=\ngithub.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=\ngithub.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=\ngithub.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=\ngithub.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=\ngithub.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=\ngithub.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=\ngithub.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=\ngithub.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=\ngithub.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=\ngithub.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=\ngithub.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=\ngithub.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=\ngithub.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=\ngithub.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=\ngithub.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=\ngithub.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=\ngithub.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=\ngithub.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=\ngithub.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=\ngithub.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=\ngithub.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=\ngithub.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=\ngithub.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=\ngithub.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=\ngithub.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=\ngithub.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=\ngithub.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=\ngithub.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=\ngithub.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=\ngithub.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=\ngithub.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=\ngithub.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=\ngithub.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=\ngithub.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=\ngithub.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=\ngithub.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=\ngithub.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=\ngithub.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=\ngithub.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=\ngithub.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=\ngithub.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=\ngithub.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=\ngithub.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=\ngithub.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=\ngithub.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=\ngithub.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=\ngithub.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=\ngithub.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=\ngithub.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=\ngithub.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=\ngithub.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=\ngithub.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=\ngithub.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=\ngithub.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=\ngithub.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=\ngithub.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=\ngithub.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=\ngithub.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=\ngithub.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=\ngithub.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=\ngithub.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=\ngithub.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=\ngithub.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=\ngithub.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=\ngithub.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=\ngithub.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=\ngithub.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=\ngithub.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=\ngithub.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=\ngithub.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=\ngithub.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=\ngithub.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=\ngithub.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=\ngithub.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=\ngithub.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=\ngithub.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=\ngithub.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=\ngithub.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=\ngithub.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=\ngithub.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=\ngithub.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=\ngithub.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=\ngithub.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=\ngithub.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=\ngithub.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=\ngithub.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=\ngithub.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=\ngithub.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=\ngithub.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=\ngithub.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=\ngithub.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=\ngithub.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=\ngithub.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=\ngithub.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=\ngithub.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=\ngithub.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=\ngithub.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=\ngithub.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=\ngithub.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=\ngithub.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=\ngithub.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=\ngithub.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=\ngithub.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=\ngithub.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=\ngithub.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=\ngithub.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=\ngithub.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=\ngithub.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=\ngithub.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=\ngithub.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=\ngithub.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=\ngithub.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=\ngithub.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=\ngithub.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=\ngithub.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=\ngithub.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=\ngithub.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=\ngithub.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=\ngithub.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=\ngithub.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=\ngithub.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=\ngithub.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=\ngithub.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=\ngithub.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=\ngithub.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=\ngithub.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=\ngithub.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=\ngithub.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=\ngithub.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=\ngithub.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=\ngithub.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=\ngithub.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=\ngithub.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=\ngithub.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=\ngithub.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=\ngithub.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=\ngithub.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=\ngithub.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=\ngithub.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=\ngithub.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=\ngithub.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=\ngithub.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=\ngithub.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=\ngithub.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=\ngithub.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=\ngithub.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=\ngithub.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=\ngithub.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=\ngithub.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=\ngithub.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE=\ngithub.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=\ngo.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=\ngo.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=\ngo.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=\ngo.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=\ngo.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=\ngo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=\ngo.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=\ngo.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=\ngo.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=\ngo.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=\ngo.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=\ngo.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=\ngo.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=\ngo.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=\ngo.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=\ngo.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngolang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=\ngolang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=\ngolang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=\ngolang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=\ngolang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=\ngolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=\ngolang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=\ngolang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=\ngolang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=\ngolang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=\ngolang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=\ngolang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=\ngolang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=\ngolang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=\ngolang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=\ngolang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=\ngolang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=\ngolang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=\ngolang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=\ngolang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=\ngolang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=\ngolang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=\ngolang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=\ngolang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=\ngolang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=\ngolang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=\ngolang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=\ngoogle.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=\ngoogle.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=\ngoogle.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=\ngoogle.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=\ngopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=\ngotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=\nmodernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=\nmodernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=\nmodernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=\nmodernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=\nmodernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=\nmodernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=\nmodernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=\nmodernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=\nmodernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=\nmodernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=\nmodernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=\nmodernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=\nmodernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=\nmodernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=\nmodernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=\nmodernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=\nmodernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=\nmodernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=\nmodernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=\nmodernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=\nmodernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=\nmodernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=\nmodernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=\nmodernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=\nmodernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=\nmodernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=\nmodernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=\nmodernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=\nrsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=\nrsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=\n"
  },
  {
    "path": "internal/assets/assets.go",
    "content": "package assets\n\nimport (\n\t\"embed\"\n)\n\n// Frontend\n//\n//go:embed dist\nvar FrontendAssets embed.FS\n\n// Migrations\n//\n//go:embed migrations/*.sql\nvar Migrations embed.FS\n"
  },
  {
    "path": "internal/assets/migrations/000001_init_sqlite.down.sql",
    "content": "DROP TABLE IF EXISTS \"sessions\";"
  },
  {
    "path": "internal/assets/migrations/000001_init_sqlite.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"sessions\" (\n    \"uuid\" TEXT NOT NULL PRIMARY KEY UNIQUE,\n    \"username\" TEXT NOT NULL,\n    \"email\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"provider\" TEXT NOT NULL,\n    \"totp_pending\" BOOLEAN NOT NULL,\n    \"oauth_groups\" TEXT NULL,\n    \"expiry\" INTEGER NOT NULL\n);"
  },
  {
    "path": "internal/assets/migrations/000002_oauth_name.down.sql",
    "content": "ALTER TABLE \"sessions\" DROP COLUMN \"oauth_name\";"
  },
  {
    "path": "internal/assets/migrations/000002_oauth_name.up.sql",
    "content": "ALTER TABLE \"sessions\" ADD COLUMN \"oauth_name\" TEXT;\n\nUPDATE \"sessions\"\nSET \"oauth_name\" = CASE\n  WHEN LOWER(\"provider\") = 'github' THEN 'GitHub'\n  WHEN LOWER(\"provider\") = 'google' THEN 'Google'\n  ELSE UPPER(SUBSTR(\"provider\", 1, 1)) || SUBSTR(\"provider\", 2)\nEND\nWHERE \"oauth_name\" IS NULL AND \"provider\" IS NOT NULL;\n\n"
  },
  {
    "path": "internal/assets/migrations/000003_oauth_sub.down.sql",
    "content": "ALTER TABLE \"sessions\" DROP COLUMN \"oauth_sub\";"
  },
  {
    "path": "internal/assets/migrations/000003_oauth_sub.up.sql",
    "content": "ALTER TABLE \"sessions\" ADD COLUMN \"oauth_sub\" TEXT;\n"
  },
  {
    "path": "internal/assets/migrations/000004_created_at.down.sql",
    "content": "ALTER TABLE \"sessions\" DROP COLUMN \"created_at\";\n"
  },
  {
    "path": "internal/assets/migrations/000004_created_at.up.sql",
    "content": "ALTER TABLE \"sessions\" ADD COLUMN \"created_at\" INTEGER NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "internal/assets/migrations/000005_oidc_session.down.sql",
    "content": "DROP TABLE IF EXISTS \"oidc_tokens\";\nDROP TABLE IF EXISTS \"oidc_userinfo\";\nDROP TABLE IF EXISTS \"oidc_codes\";\n"
  },
  {
    "path": "internal/assets/migrations/000005_oidc_session.up.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"oidc_codes\" (\n    \"sub\" TEXT NOT NULL UNIQUE,\n    \"code_hash\" TEXT NOT NULL PRIMARY KEY UNIQUE,\n    \"scope\" TEXT NOT NULL,\n    \"redirect_uri\" TEXT NOT NULL,\n    \"client_id\" TEXT NOT NULL,\n    \"expires_at\" INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"oidc_tokens\" (\n    \"sub\" TEXT NOT NULL UNIQUE,\n    \"access_token_hash\" TEXT NOT NULL PRIMARY KEY UNIQUE,\n    \"refresh_token_hash\" TEXT NOT NULL,\n    \"scope\" TEXT NOT NULL,\n    \"client_id\" TEXT NOT NULL,\n    \"token_expires_at\" INTEGER NOT NULL,\n    \"refresh_token_expires_at\" INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS \"oidc_userinfo\" (\n    \"sub\" TEXT NOT NULL UNIQUE PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"preferred_username\" TEXT NOT NULL,\n    \"email\" TEXT NOT NULL,\n    \"groups\" TEXT NOT NULL,\n    \"updated_at\" INTEGER NOT NULL\n);\n"
  },
  {
    "path": "internal/assets/migrations/000006_oidc_nonce.down.sql",
    "content": "ALTER TABLE \"oidc_codes\" DROP COLUMN \"nonce\";\nALTER TABLE \"oidc_tokens\" DROP COLUMN \"nonce\";\n"
  },
  {
    "path": "internal/assets/migrations/000006_oidc_nonce.up.sql",
    "content": "ALTER TABLE \"oidc_codes\" ADD COLUMN \"nonce\" TEXT DEFAULT \"\";\nALTER TABLE \"oidc_tokens\" ADD COLUMN \"nonce\" TEXT DEFAULT \"\";\n"
  },
  {
    "path": "internal/bootstrap/app_bootstrap.go",
    "content": "package bootstrap\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n)\n\ntype BootstrapApp struct {\n\tconfig  config.Config\n\tcontext struct {\n\t\tappUrl              string\n\t\tuuid                string\n\t\tcookieDomain        string\n\t\tsessionCookieName   string\n\t\tcsrfCookieName      string\n\t\tredirectCookieName  string\n\t\tusers               []config.User\n\t\toauthProviders      map[string]config.OAuthServiceConfig\n\t\tconfiguredProviders []controller.Provider\n\t\toidcClients         []config.OIDCClientConfig\n\t}\n\tservices Services\n}\n\nfunc NewBootstrapApp(config config.Config) *BootstrapApp {\n\treturn &BootstrapApp{\n\t\tconfig: config,\n\t}\n}\n\nfunc (app *BootstrapApp) Setup() error {\n\t// get app url\n\tappUrl, err := url.Parse(app.config.AppURL)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tapp.context.appUrl = appUrl.Scheme + \"://\" + appUrl.Host\n\n\t// validate session config\n\tif app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {\n\t\treturn fmt.Errorf(\"session max lifetime cannot be less than session expiry\")\n\t}\n\n\t// Parse users\n\tusers, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tapp.context.users = users\n\n\t// Setup OAuth providers\n\tapp.context.oauthProviders = app.config.OAuth.Providers\n\n\tfor name, provider := range app.context.oauthProviders {\n\t\tsecret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)\n\t\tprovider.ClientSecret = secret\n\t\tprovider.ClientSecretFile = \"\"\n\n\t\tif provider.RedirectURL == \"\" {\n\t\t\tprovider.RedirectURL = app.context.appUrl + \"/api/oauth/callback/\" + name\n\t\t}\n\n\t\tapp.context.oauthProviders[name] = provider\n\t}\n\n\tfor id, provider := range app.context.oauthProviders {\n\t\tif provider.Name == \"\" {\n\t\t\tif name, ok := config.OverrideProviders[id]; ok {\n\t\t\t\tprovider.Name = name\n\t\t\t} else {\n\t\t\t\tprovider.Name = utils.Capitalize(id)\n\t\t\t}\n\t\t}\n\t\tapp.context.oauthProviders[id] = provider\n\t}\n\n\t// Setup OIDC clients\n\tfor id, client := range app.config.OIDC.Clients {\n\t\tclient.ID = id\n\t\tapp.context.oidcClients = append(app.context.oidcClients, client)\n\t}\n\n\t// Get cookie domain\n\tcookieDomain, err := utils.GetCookieDomain(app.context.appUrl)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tapp.context.cookieDomain = cookieDomain\n\n\t// Cookie names\n\tapp.context.uuid = utils.GenerateUUID(appUrl.Hostname())\n\tcookieId := strings.Split(app.context.uuid, \"-\")[0]\n\tapp.context.sessionCookieName = fmt.Sprintf(\"%s-%s\", config.SessionCookieName, cookieId)\n\tapp.context.csrfCookieName = fmt.Sprintf(\"%s-%s\", config.CSRFCookieName, cookieId)\n\tapp.context.redirectCookieName = fmt.Sprintf(\"%s-%s\", config.RedirectCookieName, cookieId)\n\n\t// Dumps\n\ttlog.App.Trace().Interface(\"config\", app.config).Msg(\"Config dump\")\n\ttlog.App.Trace().Interface(\"users\", app.context.users).Msg(\"Users dump\")\n\ttlog.App.Trace().Interface(\"oauthProviders\", app.context.oauthProviders).Msg(\"OAuth providers dump\")\n\ttlog.App.Trace().Str(\"cookieDomain\", app.context.cookieDomain).Msg(\"Cookie domain\")\n\ttlog.App.Trace().Str(\"sessionCookieName\", app.context.sessionCookieName).Msg(\"Session cookie name\")\n\ttlog.App.Trace().Str(\"csrfCookieName\", app.context.csrfCookieName).Msg(\"CSRF cookie name\")\n\ttlog.App.Trace().Str(\"redirectCookieName\", app.context.redirectCookieName).Msg(\"Redirect cookie name\")\n\n\t// Database\n\tdb, err := app.SetupDatabase(app.config.Database.Path)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to setup database: %w\", err)\n\t}\n\n\t// Queries\n\tqueries := repository.New(db)\n\n\t// Services\n\tservices, err := app.initServices(queries)\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to initialize services: %w\", err)\n\t}\n\n\tapp.services = services\n\n\t// Configured providers\n\tconfiguredProviders := make([]controller.Provider, 0)\n\n\tfor id, provider := range app.context.oauthProviders {\n\t\tconfiguredProviders = append(configuredProviders, controller.Provider{\n\t\t\tName:  provider.Name,\n\t\t\tID:    id,\n\t\t\tOAuth: true,\n\t\t})\n\t}\n\n\tsort.Slice(configuredProviders, func(i, j int) bool {\n\t\treturn configuredProviders[i].Name < configuredProviders[j].Name\n\t})\n\n\tif services.authService.LocalAuthConfigured() {\n\t\tconfiguredProviders = append(configuredProviders, controller.Provider{\n\t\t\tName:  \"Local\",\n\t\t\tID:    \"local\",\n\t\t\tOAuth: false,\n\t\t})\n\t}\n\n\tif services.authService.LdapAuthConfigured() {\n\t\tconfiguredProviders = append(configuredProviders, controller.Provider{\n\t\t\tName:  \"LDAP\",\n\t\t\tID:    \"ldap\",\n\t\t\tOAuth: false,\n\t\t})\n\t}\n\n\ttlog.App.Debug().Interface(\"providers\", configuredProviders).Msg(\"Authentication providers\")\n\n\tif len(configuredProviders) == 0 {\n\t\treturn fmt.Errorf(\"no authentication providers configured\")\n\t}\n\n\tapp.context.configuredProviders = configuredProviders\n\n\t// Setup router\n\trouter, err := app.setupRouter()\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to setup routes: %w\", err)\n\t}\n\n\t// Start db cleanup routine\n\ttlog.App.Debug().Msg(\"Starting database cleanup routine\")\n\tgo app.dbCleanup(queries)\n\n\t// If analytics are not disabled, start heartbeat\n\tif app.config.Analytics.Enabled {\n\t\ttlog.App.Debug().Msg(\"Starting heartbeat routine\")\n\t\tgo app.heartbeat()\n\t}\n\n\t// If we have an socket path, bind to it\n\tif app.config.Server.SocketPath != \"\" {\n\t\tif _, err := os.Stat(app.config.Server.SocketPath); err == nil {\n\t\t\ttlog.App.Info().Msgf(\"Removing existing socket file %s\", app.config.Server.SocketPath)\n\t\t\terr := os.Remove(app.config.Server.SocketPath)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to remove existing socket file: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\ttlog.App.Info().Msgf(\"Starting server on unix socket %s\", app.config.Server.SocketPath)\n\t\tif err := router.RunUnix(app.config.Server.SocketPath); err != nil {\n\t\t\ttlog.App.Fatal().Err(err).Msg(\"Failed to start server\")\n\t\t}\n\n\t\treturn nil\n\t}\n\n\t// Start server\n\taddress := fmt.Sprintf(\"%s:%d\", app.config.Server.Address, app.config.Server.Port)\n\ttlog.App.Info().Msgf(\"Starting server on %s\", address)\n\tif err := router.Run(address); err != nil {\n\t\ttlog.App.Fatal().Err(err).Msg(\"Failed to start server\")\n\t}\n\n\treturn nil\n}\n\nfunc (app *BootstrapApp) heartbeat() {\n\tticker := time.NewTicker(time.Duration(12) * time.Hour)\n\tdefer ticker.Stop()\n\n\ttype heartbeat struct {\n\t\tUUID    string `json:\"uuid\"`\n\t\tVersion string `json:\"version\"`\n\t}\n\n\tvar body heartbeat\n\n\tbody.UUID = app.context.uuid\n\tbody.Version = config.Version\n\n\tbodyJson, err := json.Marshal(body)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to marshal heartbeat body\")\n\t\treturn\n\t}\n\n\tclient := &http.Client{\n\t\tTimeout: 30 * time.Second, // The server should never take more than 30 seconds to respond\n\t}\n\n\theartbeatURL := config.ApiServer + \"/v1/instances/heartbeat\"\n\n\tfor range ticker.C {\n\t\ttlog.App.Debug().Msg(\"Sending heartbeat\")\n\n\t\treq, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson))\n\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to create heartbeat request\")\n\t\t\tcontinue\n\t\t}\n\n\t\treq.Header.Add(\"Content-Type\", \"application/json\")\n\n\t\tres, err := client.Do(req)\n\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to send heartbeat\")\n\t\t\tcontinue\n\t\t}\n\n\t\tres.Body.Close()\n\n\t\tif res.StatusCode != 200 && res.StatusCode != 201 {\n\t\t\ttlog.App.Debug().Str(\"status\", res.Status).Msg(\"Heartbeat returned non-200/201 status\")\n\t\t}\n\t}\n}\n\nfunc (app *BootstrapApp) dbCleanup(queries *repository.Queries) {\n\tticker := time.NewTicker(time.Duration(30) * time.Minute)\n\tdefer ticker.Stop()\n\tctx := context.Background()\n\n\tfor range ticker.C {\n\t\ttlog.App.Debug().Msg(\"Cleaning up old database sessions\")\n\t\terr := queries.DeleteExpiredSessions(ctx, time.Now().Unix())\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to clean up old database sessions\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/bootstrap/db_bootstrap.go",
    "content": "package bootstrap\n\nimport (\n\t\"database/sql\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/assets\"\n\n\t\"github.com/golang-migrate/migrate/v4\"\n\t\"github.com/golang-migrate/migrate/v4/database/sqlite3\"\n\t\"github.com/golang-migrate/migrate/v4/source/iofs\"\n\t_ \"modernc.org/sqlite\"\n)\n\nfunc (app *BootstrapApp) SetupDatabase(databasePath string) (*sql.DB, error) {\n\tdir := filepath.Dir(databasePath)\n\n\tif err := os.MkdirAll(dir, 0750); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create database directory %s: %w\", dir, err)\n\t}\n\n\tdb, err := sql.Open(\"sqlite\", databasePath)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to open database: %w\", err)\n\t}\n\n\t// Limit to 1 connection to sequence writes, this may need to be revisited in the future\n\t// if the sqlite connection starts being a bottleneck\n\tdb.SetMaxOpenConns(1)\n\n\tmigrations, err := iofs.New(assets.Migrations, \"migrations\")\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create migrations: %w\", err)\n\t}\n\n\ttarget, err := sqlite3.WithInstance(db, &sqlite3.Config{})\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create sqlite3 instance: %w\", err)\n\t}\n\n\tmigrator, err := migrate.NewWithInstance(\"iofs\", migrations, \"sqlite3\", target)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create migrator: %w\", err)\n\t}\n\n\tif err := migrator.Up(); err != nil && err != migrate.ErrNoChange {\n\t\treturn nil, fmt.Errorf(\"failed to migrate database: %w\", err)\n\t}\n\n\treturn db, nil\n}\n"
  },
  {
    "path": "internal/bootstrap/router_bootstrap.go",
    "content": "package bootstrap\n\nimport (\n\t\"fmt\"\n\t\"slices\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/middleware\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nvar DEV_MODES = []string{\"main\", \"test\", \"development\"}\n\nfunc (app *BootstrapApp) setupRouter() (*gin.Engine, error) {\n\tif !slices.Contains(DEV_MODES, config.Version) {\n\t\tgin.SetMode(gin.ReleaseMode)\n\t}\n\n\tengine := gin.New()\n\tengine.Use(gin.Recovery())\n\n\tif len(app.config.Auth.TrustedProxies) > 0 {\n\t\terr := engine.SetTrustedProxies(app.config.Auth.TrustedProxies)\n\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to set trusted proxies: %w\", err)\n\t\t}\n\t}\n\n\tcontextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{\n\t\tCookieDomain: app.context.cookieDomain,\n\t}, app.services.authService, app.services.oauthBrokerService)\n\n\terr := contextMiddleware.Init()\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to initialize context middleware: %w\", err)\n\t}\n\n\tengine.Use(contextMiddleware.Middleware())\n\n\tuiMiddleware := middleware.NewUIMiddleware()\n\n\terr = uiMiddleware.Init()\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to initialize UI middleware: %w\", err)\n\t}\n\n\tengine.Use(uiMiddleware.Middleware())\n\n\tzerologMiddleware := middleware.NewZerologMiddleware()\n\n\terr = zerologMiddleware.Init()\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to initialize zerolog middleware: %w\", err)\n\t}\n\n\tengine.Use(zerologMiddleware.Middleware())\n\n\tapiRouter := engine.Group(\"/api\")\n\n\tcontextController := controller.NewContextController(controller.ContextControllerConfig{\n\t\tProviders:             app.context.configuredProviders,\n\t\tTitle:                 app.config.UI.Title,\n\t\tAppURL:                app.config.AppURL,\n\t\tCookieDomain:          app.context.cookieDomain,\n\t\tForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,\n\t\tBackgroundImage:       app.config.UI.BackgroundImage,\n\t\tOAuthAutoRedirect:     app.config.OAuth.AutoRedirect,\n\t\tWarningsEnabled:       app.config.UI.WarningsEnabled,\n\t}, apiRouter)\n\n\tcontextController.SetupRoutes()\n\n\toauthController := controller.NewOAuthController(controller.OAuthControllerConfig{\n\t\tAppURL:             app.config.AppURL,\n\t\tSecureCookie:       app.config.Auth.SecureCookie,\n\t\tCSRFCookieName:     app.context.csrfCookieName,\n\t\tRedirectCookieName: app.context.redirectCookieName,\n\t\tCookieDomain:       app.context.cookieDomain,\n\t}, apiRouter, app.services.authService, app.services.oauthBrokerService)\n\n\toauthController.SetupRoutes()\n\n\toidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)\n\n\toidcController.SetupRoutes()\n\n\tproxyController := controller.NewProxyController(controller.ProxyControllerConfig{\n\t\tAppURL: app.config.AppURL,\n\t}, apiRouter, app.services.accessControlService, app.services.authService)\n\n\tproxyController.SetupRoutes()\n\n\tuserController := controller.NewUserController(controller.UserControllerConfig{\n\t\tCookieDomain: app.context.cookieDomain,\n\t}, apiRouter, app.services.authService)\n\n\tuserController.SetupRoutes()\n\n\tresourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{\n\t\tPath:    app.config.Resources.Path,\n\t\tEnabled: app.config.Resources.Enabled,\n\t}, &engine.RouterGroup)\n\n\tresourcesController.SetupRoutes()\n\n\thealthController := controller.NewHealthController(apiRouter)\n\n\thealthController.SetupRoutes()\n\n\twellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, app.services.oidcService, engine)\n\n\twellknownController.SetupRoutes()\n\n\treturn engine, nil\n}\n"
  },
  {
    "path": "internal/bootstrap/service_bootstrap.go",
    "content": "package bootstrap\n\nimport (\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n)\n\ntype Services struct {\n\taccessControlService *service.AccessControlsService\n\tauthService          *service.AuthService\n\tdockerService        *service.DockerService\n\tldapService          *service.LdapService\n\toauthBrokerService   *service.OAuthBrokerService\n\toidcService          *service.OIDCService\n}\n\nfunc (app *BootstrapApp) initServices(queries *repository.Queries) (Services, error) {\n\tservices := Services{}\n\n\tldapService := service.NewLdapService(service.LdapServiceConfig{\n\t\tAddress:      app.config.Ldap.Address,\n\t\tBindDN:       app.config.Ldap.BindDN,\n\t\tBindPassword: app.config.Ldap.BindPassword,\n\t\tBaseDN:       app.config.Ldap.BaseDN,\n\t\tInsecure:     app.config.Ldap.Insecure,\n\t\tSearchFilter: app.config.Ldap.SearchFilter,\n\t\tAuthCert:     app.config.Ldap.AuthCert,\n\t\tAuthKey:      app.config.Ldap.AuthKey,\n\t})\n\n\terr := ldapService.Init()\n\n\tif err != nil {\n\t\ttlog.App.Warn().Err(err).Msg(\"Failed to setup LDAP service, starting without it\")\n\t\tldapService.Unconfigure()\n\t}\n\n\tservices.ldapService = ldapService\n\n\tdockerService := service.NewDockerService()\n\n\terr = dockerService.Init()\n\n\tif err != nil {\n\t\treturn Services{}, err\n\t}\n\n\tservices.dockerService = dockerService\n\n\taccessControlsService := service.NewAccessControlsService(dockerService, app.config.Apps)\n\n\terr = accessControlsService.Init()\n\n\tif err != nil {\n\t\treturn Services{}, err\n\t}\n\n\tservices.accessControlService = accessControlsService\n\n\tauthService := service.NewAuthService(service.AuthServiceConfig{\n\t\tUsers:              app.context.users,\n\t\tOauthWhitelist:     app.config.OAuth.Whitelist,\n\t\tSessionExpiry:      app.config.Auth.SessionExpiry,\n\t\tSessionMaxLifetime: app.config.Auth.SessionMaxLifetime,\n\t\tSecureCookie:       app.config.Auth.SecureCookie,\n\t\tCookieDomain:       app.context.cookieDomain,\n\t\tLoginTimeout:       app.config.Auth.LoginTimeout,\n\t\tLoginMaxRetries:    app.config.Auth.LoginMaxRetries,\n\t\tSessionCookieName:  app.context.sessionCookieName,\n\t\tIP:                 app.config.Auth.IP,\n\t\tLDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL,\n\t}, dockerService, services.ldapService, queries)\n\n\terr = authService.Init()\n\n\tif err != nil {\n\t\treturn Services{}, err\n\t}\n\n\tservices.authService = authService\n\n\toauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)\n\n\terr = oauthBrokerService.Init()\n\n\tif err != nil {\n\t\treturn Services{}, err\n\t}\n\n\tservices.oauthBrokerService = oauthBrokerService\n\n\toidcService := service.NewOIDCService(service.OIDCServiceConfig{\n\t\tClients:        app.config.OIDC.Clients,\n\t\tPrivateKeyPath: app.config.OIDC.PrivateKeyPath,\n\t\tPublicKeyPath:  app.config.OIDC.PublicKeyPath,\n\t\tIssuer:         app.config.AppURL,\n\t\tSessionExpiry:  app.config.Auth.SessionExpiry,\n\t}, queries)\n\n\terr = oidcService.Init()\n\n\tif err != nil {\n\t\treturn Services{}, err\n\t}\n\n\tservices.oidcService = oidcService\n\n\treturn services, nil\n}\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "package config\n\n// Default configuration\nfunc NewDefaultConfiguration() *Config {\n\treturn &Config{\n\t\tDatabase: DatabaseConfig{\n\t\t\tPath: \"./tinyauth.db\",\n\t\t},\n\t\tAnalytics: AnalyticsConfig{\n\t\t\tEnabled: true,\n\t\t},\n\t\tResources: ResourcesConfig{\n\t\t\tEnabled: true,\n\t\t\tPath:    \"./resources\",\n\t\t},\n\t\tServer: ServerConfig{\n\t\t\tPort:    3000,\n\t\t\tAddress: \"0.0.0.0\",\n\t\t},\n\t\tAuth: AuthConfig{\n\t\t\tSessionExpiry:      86400, // 1 day\n\t\t\tSessionMaxLifetime: 0,     // disabled\n\t\t\tLoginTimeout:       300,   // 5 minutes\n\t\t\tLoginMaxRetries:    3,\n\t\t},\n\t\tUI: UIConfig{\n\t\t\tTitle:                 \"Tinyauth\",\n\t\t\tForgotPasswordMessage: \"You can change your password by changing the configuration.\",\n\t\t\tBackgroundImage:       \"/background.jpg\",\n\t\t\tWarningsEnabled:       true,\n\t\t},\n\t\tLdap: LdapConfig{\n\t\t\tInsecure:      false,\n\t\t\tSearchFilter:  \"(uid=%s)\",\n\t\t\tGroupCacheTTL: 900, // 15 minutes\n\t\t},\n\t\tLog: LogConfig{\n\t\t\tLevel: \"info\",\n\t\t\tJson:  false,\n\t\t\tStreams: LogStreams{\n\t\t\t\tHTTP: LogStreamConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t\tLevel:   \"\",\n\t\t\t\t},\n\t\t\t\tApp: LogStreamConfig{\n\t\t\t\t\tEnabled: true,\n\t\t\t\t\tLevel:   \"\",\n\t\t\t\t},\n\t\t\t\tAudit: LogStreamConfig{\n\t\t\t\t\tEnabled: false,\n\t\t\t\t\tLevel:   \"\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tOIDC: OIDCConfig{\n\t\t\tPrivateKeyPath: \"./tinyauth_oidc_key\",\n\t\t\tPublicKeyPath:  \"./tinyauth_oidc_key.pub\",\n\t\t},\n\t\tExperimental: ExperimentalConfig{\n\t\t\tConfigFile: \"\",\n\t\t},\n\t}\n}\n\n// Version information, set at build time\n\nvar Version = \"development\"\nvar CommitHash = \"development\"\nvar BuildTimestamp = \"0000-00-00T00:00:00Z\"\n\n// Cookie name templates\n\nvar SessionCookieName = \"tinyauth-session\"\nvar CSRFCookieName = \"tinyauth-csrf\"\nvar RedirectCookieName = \"tinyauth-redirect\"\n\n// Main app config\n\ntype Config struct {\n\tAppURL       string             `description:\"The base URL where the app is hosted.\" yaml:\"appUrl\"`\n\tDatabase     DatabaseConfig     `description:\"Database configuration.\" yaml:\"database\"`\n\tAnalytics    AnalyticsConfig    `description:\"Analytics configuration.\" yaml:\"analytics\"`\n\tResources    ResourcesConfig    `description:\"Resources configuration.\" yaml:\"resources\"`\n\tServer       ServerConfig       `description:\"Server configuration.\" yaml:\"server\"`\n\tAuth         AuthConfig         `description:\"Authentication configuration.\" yaml:\"auth\"`\n\tApps         map[string]App     `description:\"Application ACLs configuration.\" yaml:\"apps\"`\n\tOAuth        OAuthConfig        `description:\"OAuth configuration.\" yaml:\"oauth\"`\n\tOIDC         OIDCConfig         `description:\"OIDC configuration.\" yaml:\"oidc\"`\n\tUI           UIConfig           `description:\"UI customization.\" yaml:\"ui\"`\n\tLdap         LdapConfig         `description:\"LDAP configuration.\" yaml:\"ldap\"`\n\tExperimental ExperimentalConfig `description:\"Experimental features, use with caution.\" yaml:\"experimental\"`\n\tLog          LogConfig          `description:\"Logging configuration.\" yaml:\"log\"`\n}\n\ntype DatabaseConfig struct {\n\tPath string `description:\"The path to the database, including file name.\" yaml:\"path\"`\n}\n\ntype AnalyticsConfig struct {\n\tEnabled bool `description:\"Enable periodic version information collection.\" yaml:\"enabled\"`\n}\n\ntype ResourcesConfig struct {\n\tEnabled bool   `description:\"Enable the resources server.\" yaml:\"enabled\"`\n\tPath    string `description:\"The directory where resources are stored.\" yaml:\"path\"`\n}\n\ntype ServerConfig struct {\n\tPort       int    `description:\"The port on which the server listens.\" yaml:\"port\"`\n\tAddress    string `description:\"The address on which the server listens.\" yaml:\"address\"`\n\tSocketPath string `description:\"The path to the Unix socket.\" yaml:\"socketPath\"`\n}\n\ntype AuthConfig struct {\n\tIP                 IPConfig `description:\"IP whitelisting config options.\" yaml:\"ip\"`\n\tUsers              []string `description:\"Comma-separated list of users (username:hashed_password).\" yaml:\"users\"`\n\tUsersFile          string   `description:\"Path to the users file.\" yaml:\"usersFile\"`\n\tSecureCookie       bool     `description:\"Enable secure cookies.\" yaml:\"secureCookie\"`\n\tSessionExpiry      int      `description:\"Session expiry time in seconds.\" yaml:\"sessionExpiry\"`\n\tSessionMaxLifetime int      `description:\"Maximum session lifetime in seconds.\" yaml:\"sessionMaxLifetime\"`\n\tLoginTimeout       int      `description:\"Login timeout in seconds.\" yaml:\"loginTimeout\"`\n\tLoginMaxRetries    int      `description:\"Maximum login retries.\" yaml:\"loginMaxRetries\"`\n\tTrustedProxies     []string `description:\"Comma-separated list of trusted proxy addresses.\" yaml:\"trustedProxies\"`\n}\n\ntype IPConfig struct {\n\tAllow []string `description:\"List of allowed IPs or CIDR ranges.\" yaml:\"allow\"`\n\tBlock []string `description:\"List of blocked IPs or CIDR ranges.\" yaml:\"block\"`\n}\n\ntype OAuthConfig struct {\n\tWhitelist    []string                      `description:\"Comma-separated list of allowed OAuth domains.\" yaml:\"whitelist\"`\n\tAutoRedirect string                        `description:\"The OAuth provider to use for automatic redirection.\" yaml:\"autoRedirect\"`\n\tProviders    map[string]OAuthServiceConfig `description:\"OAuth providers configuration.\" yaml:\"providers\"`\n}\n\ntype OIDCConfig struct {\n\tPrivateKeyPath string                      `description:\"Path to the private key file, including file name.\" yaml:\"privateKeyPath\"`\n\tPublicKeyPath  string                      `description:\"Path to the public key file, including file name.\" yaml:\"publicKeyPath\"`\n\tClients        map[string]OIDCClientConfig `description:\"OIDC clients configuration.\" yaml:\"clients\"`\n}\n\ntype UIConfig struct {\n\tTitle                 string `description:\"The title of the UI.\" yaml:\"title\"`\n\tForgotPasswordMessage string `description:\"Message displayed on the forgot password page.\" yaml:\"forgotPasswordMessage\"`\n\tBackgroundImage       string `description:\"Path to the background image.\" yaml:\"backgroundImage\"`\n\tWarningsEnabled       bool   `description:\"Enable UI warnings.\" yaml:\"warningsEnabled\"`\n}\n\ntype LdapConfig struct {\n\tAddress       string `description:\"LDAP server address.\" yaml:\"address\"`\n\tBindDN        string `description:\"Bind DN for LDAP authentication.\" yaml:\"bindDn\"`\n\tBindPassword  string `description:\"Bind password for LDAP authentication.\" yaml:\"bindPassword\"`\n\tBaseDN        string `description:\"Base DN for LDAP searches.\" yaml:\"baseDn\"`\n\tInsecure      bool   `description:\"Allow insecure LDAP connections.\" yaml:\"insecure\"`\n\tSearchFilter  string `description:\"LDAP search filter.\" yaml:\"searchFilter\"`\n\tAuthCert      string `description:\"Certificate for mTLS authentication.\" yaml:\"authCert\"`\n\tAuthKey       string `description:\"Certificate key for mTLS authentication.\" yaml:\"authKey\"`\n\tGroupCacheTTL int    `description:\"Cache duration for LDAP group membership in seconds.\" yaml:\"groupCacheTTL\"`\n}\n\ntype LogConfig struct {\n\tLevel   string     `description:\"Log level (trace, debug, info, warn, error).\" yaml:\"level\"`\n\tJson    bool       `description:\"Enable JSON formatted logs.\" yaml:\"json\"`\n\tStreams LogStreams `description:\"Configuration for specific log streams.\" yaml:\"streams\"`\n}\n\ntype LogStreams struct {\n\tHTTP  LogStreamConfig `description:\"HTTP request logging.\" yaml:\"http\"`\n\tApp   LogStreamConfig `description:\"Application logging.\" yaml:\"app\"`\n\tAudit LogStreamConfig `description:\"Audit logging.\" yaml:\"audit\"`\n}\n\ntype LogStreamConfig struct {\n\tEnabled bool   `description:\"Enable this log stream.\" yaml:\"enabled\"`\n\tLevel   string `description:\"Log level for this stream. Use global if empty.\" yaml:\"level\"`\n}\n\ntype ExperimentalConfig struct {\n\tConfigFile string `description:\"Path to config file.\" yaml:\"-\"`\n}\n\n// Config loader options\n\nconst DefaultNamePrefix = \"TINYAUTH_\"\n\n// OAuth/OIDC config\n\ntype Claims struct {\n\tSub               string `json:\"sub\"`\n\tName              string `json:\"name\"`\n\tEmail             string `json:\"email\"`\n\tPreferredUsername string `json:\"preferred_username\"`\n\tGroups            any    `json:\"groups\"`\n}\n\ntype OAuthServiceConfig struct {\n\tClientID         string   `description:\"OAuth client ID.\" yaml:\"clientId\"`\n\tClientSecret     string   `description:\"OAuth client secret.\" yaml:\"clientSecret\"`\n\tClientSecretFile string   `description:\"Path to the file containing the OAuth client secret.\" yaml:\"clientSecretFile\"`\n\tScopes           []string `description:\"OAuth scopes.\" yaml:\"scopes\"`\n\tRedirectURL      string   `description:\"OAuth redirect URL.\" yaml:\"redirectUrl\"`\n\tAuthURL          string   `description:\"OAuth authorization URL.\" yaml:\"authUrl\"`\n\tTokenURL         string   `description:\"OAuth token URL.\" yaml:\"tokenUrl\"`\n\tUserinfoURL      string   `description:\"OAuth userinfo URL.\" yaml:\"userinfoUrl\"`\n\tInsecure         bool     `description:\"Allow insecure OAuth connections.\" yaml:\"insecure\"`\n\tName             string   `description:\"Provider name in UI.\" yaml:\"name\"`\n}\n\ntype OIDCClientConfig struct {\n\tID                  string   `description:\"OIDC client ID.\" yaml:\"-\"`\n\tClientID            string   `description:\"OIDC client ID.\" yaml:\"clientId\"`\n\tClientSecret        string   `description:\"OIDC client secret.\" yaml:\"clientSecret\"`\n\tClientSecretFile    string   `description:\"Path to the file containing the OIDC client secret.\" yaml:\"clientSecretFile\"`\n\tTrustedRedirectURIs []string `description:\"List of trusted redirect URIs.\" yaml:\"trustedRedirectUris\"`\n\tName                string   `description:\"Client name in UI.\" yaml:\"name\"`\n}\n\nvar OverrideProviders = map[string]string{\n\t\"google\": \"Google\",\n\t\"github\": \"GitHub\",\n}\n\n// User/session related stuff\n\ntype User struct {\n\tUsername   string\n\tPassword   string\n\tTotpSecret string\n}\n\ntype LdapUser struct {\n\tDN     string\n\tGroups []string\n}\n\ntype UserSearch struct {\n\tUsername string\n\tType     string // local, ldap or unknown\n}\n\ntype UserContext struct {\n\tUsername    string\n\tName        string\n\tEmail       string\n\tIsLoggedIn  bool\n\tIsBasicAuth bool\n\tOAuth       bool\n\tProvider    string\n\tTotpPending bool\n\tOAuthGroups string\n\tTotpEnabled bool\n\tOAuthName   string\n\tOAuthSub    string\n\tLdapGroups  string\n}\n\n// API responses and queries\n\ntype UnauthorizedQuery struct {\n\tUsername string `url:\"username\"`\n\tResource string `url:\"resource\"`\n\tGroupErr bool   `url:\"groupErr\"`\n\tIP       string `url:\"ip\"`\n}\n\ntype RedirectQuery struct {\n\tRedirectURI string `url:\"redirect_uri\"`\n}\n\n// ACLs\n\ntype Apps struct {\n\tApps map[string]App `description:\"App ACLs configuration.\" yaml:\"apps\"`\n}\n\ntype App struct {\n\tConfig   AppConfig   `description:\"App configuration.\" yaml:\"config\"`\n\tUsers    AppUsers    `description:\"User access configuration.\" yaml:\"users\"`\n\tOAuth    AppOAuth    `description:\"OAuth access configuration.\" yaml:\"oauth\"`\n\tIP       AppIP       `description:\"IP access configuration.\" yaml:\"ip\"`\n\tResponse AppResponse `description:\"Response customization.\" yaml:\"response\"`\n\tPath     AppPath     `description:\"Path access configuration.\" yaml:\"path\"`\n\tLDAP     AppLDAP     `description:\"LDAP access configuration.\" yaml:\"ldap\"`\n}\n\ntype AppConfig struct {\n\tDomain string `description:\"The domain of the app.\" yaml:\"domain\"`\n}\n\ntype AppUsers struct {\n\tAllow string `description:\"Comma-separated list of allowed users.\" yaml:\"allow\"`\n\tBlock string `description:\"Comma-separated list of blocked users.\" yaml:\"block\"`\n}\n\ntype AppOAuth struct {\n\tWhitelist string `description:\"Comma-separated list of allowed OAuth groups.\" yaml:\"whitelist\"`\n\tGroups    string `description:\"Comma-separated list of required OAuth groups.\" yaml:\"groups\"`\n}\n\ntype AppLDAP struct {\n\tGroups string `description:\"Comma-separated list of required LDAP groups.\" yaml:\"groups\"`\n}\n\ntype AppIP struct {\n\tAllow  []string `description:\"List of allowed IPs or CIDR ranges.\" yaml:\"allow\"`\n\tBlock  []string `description:\"List of blocked IPs or CIDR ranges.\" yaml:\"block\"`\n\tBypass []string `description:\"List of IPs or CIDR ranges that bypass authentication.\" yaml:\"bypass\"`\n}\n\ntype AppResponse struct {\n\tHeaders   []string     `description:\"Custom headers to add to the response.\" yaml:\"headers\"`\n\tBasicAuth AppBasicAuth `description:\"Basic authentication for the app.\" yaml:\"basicAuth\"`\n}\n\ntype AppBasicAuth struct {\n\tUsername     string `description:\"Basic auth username.\" yaml:\"username\"`\n\tPassword     string `description:\"Basic auth password.\" yaml:\"password\"`\n\tPasswordFile string `description:\"Path to the file containing the basic auth password.\" yaml:\"passwordFile\"`\n}\n\ntype AppPath struct {\n\tAllow string `description:\"Comma-separated list of allowed paths.\" yaml:\"allow\"`\n\tBlock string `description:\"Comma-separated list of blocked paths.\" yaml:\"block\"`\n}\n\n// API server\n\nvar ApiServer = \"https://api.tinyauth.app\"\n"
  },
  {
    "path": "internal/controller/context_controller.go",
    "content": "package controller\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype UserContextResponse struct {\n\tStatus      int    `json:\"status\"`\n\tMessage     string `json:\"message\"`\n\tIsLoggedIn  bool   `json:\"isLoggedIn\"`\n\tUsername    string `json:\"username\"`\n\tName        string `json:\"name\"`\n\tEmail       string `json:\"email\"`\n\tProvider    string `json:\"provider\"`\n\tOAuth       bool   `json:\"oauth\"`\n\tTotpPending bool   `json:\"totpPending\"`\n\tOAuthName   string `json:\"oauthName\"`\n}\n\ntype AppContextResponse struct {\n\tStatus                int        `json:\"status\"`\n\tMessage               string     `json:\"message\"`\n\tProviders             []Provider `json:\"providers\"`\n\tTitle                 string     `json:\"title\"`\n\tAppURL                string     `json:\"appUrl\"`\n\tCookieDomain          string     `json:\"cookieDomain\"`\n\tForgotPasswordMessage string     `json:\"forgotPasswordMessage\"`\n\tBackgroundImage       string     `json:\"backgroundImage\"`\n\tOAuthAutoRedirect     string     `json:\"oauthAutoRedirect\"`\n\tWarningsEnabled       bool       `json:\"warningsEnabled\"`\n}\n\ntype Provider struct {\n\tName  string `json:\"name\"`\n\tID    string `json:\"id\"`\n\tOAuth bool   `json:\"oauth\"`\n}\n\ntype ContextControllerConfig struct {\n\tProviders             []Provider\n\tTitle                 string\n\tAppURL                string\n\tCookieDomain          string\n\tForgotPasswordMessage string\n\tBackgroundImage       string\n\tOAuthAutoRedirect     string\n\tWarningsEnabled       bool\n}\n\ntype ContextController struct {\n\tconfig ContextControllerConfig\n\trouter *gin.RouterGroup\n}\n\nfunc NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController {\n\tif !config.WarningsEnabled {\n\t\ttlog.App.Warn().Msg(\"UI warnings are disabled. This may expose users to security risks. Proceed with caution.\")\n\t}\n\n\treturn &ContextController{\n\t\tconfig: config,\n\t\trouter: router,\n\t}\n}\n\nfunc (controller *ContextController) SetupRoutes() {\n\tcontextGroup := controller.router.Group(\"/context\")\n\tcontextGroup.GET(\"/user\", controller.userContextHandler)\n\tcontextGroup.GET(\"/app\", controller.appContextHandler)\n}\n\nfunc (controller *ContextController) userContextHandler(c *gin.Context) {\n\tcontext, err := utils.GetContext(c)\n\n\tuserContext := UserContextResponse{\n\t\tStatus:      200,\n\t\tMessage:     \"Success\",\n\t\tIsLoggedIn:  context.IsLoggedIn,\n\t\tUsername:    context.Username,\n\t\tName:        context.Name,\n\t\tEmail:       context.Email,\n\t\tProvider:    context.Provider,\n\t\tOAuth:       context.OAuth,\n\t\tTotpPending: context.TotpPending,\n\t\tOAuthName:   context.OAuthName,\n\t}\n\n\tif err != nil {\n\t\ttlog.App.Debug().Err(err).Msg(\"No user context found in request\")\n\t\tuserContext.Status = 401\n\t\tuserContext.Message = \"Unauthorized\"\n\t\tuserContext.IsLoggedIn = false\n\t\tc.JSON(200, userContext)\n\t\treturn\n\t}\n\n\tc.JSON(200, userContext)\n}\n\nfunc (controller *ContextController) appContextHandler(c *gin.Context) {\n\tappUrl, err := url.Parse(controller.config.AppURL)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to parse app URL\")\n\t\tc.JSON(500, gin.H{\n\t\t\t\"status\":  500,\n\t\t\t\"message\": \"Internal Server Error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(200, AppContextResponse{\n\t\tStatus:                200,\n\t\tMessage:               \"Success\",\n\t\tProviders:             controller.config.Providers,\n\t\tTitle:                 controller.config.Title,\n\t\tAppURL:                fmt.Sprintf(\"%s://%s\", appUrl.Scheme, appUrl.Host),\n\t\tCookieDomain:          controller.config.CookieDomain,\n\t\tForgotPasswordMessage: controller.config.ForgotPasswordMessage,\n\t\tBackgroundImage:       controller.config.BackgroundImage,\n\t\tOAuthAutoRedirect:     controller.config.OAuthAutoRedirect,\n\t\tWarningsEnabled:       controller.config.WarningsEnabled,\n\t})\n}\n"
  },
  {
    "path": "internal/controller/context_controller_test.go",
    "content": "package controller_test\n\nimport (\n\t\"encoding/json\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"gotest.tools/v3/assert\"\n)\n\nvar contextControllerCfg = controller.ContextControllerConfig{\n\tProviders: []controller.Provider{\n\t\t{\n\t\t\tName:  \"Local\",\n\t\t\tID:    \"local\",\n\t\t\tOAuth: false,\n\t\t},\n\t\t{\n\t\t\tName:  \"Google\",\n\t\t\tID:    \"google\",\n\t\t\tOAuth: true,\n\t\t},\n\t},\n\tTitle:                 \"Test App\",\n\tAppURL:                \"http://localhost:8080\",\n\tCookieDomain:          \"localhost\",\n\tForgotPasswordMessage: \"Contact admin to reset your password.\",\n\tBackgroundImage:       \"/assets/bg.jpg\",\n\tOAuthAutoRedirect:     \"google\",\n\tWarningsEnabled:       true,\n}\n\nvar contextCtrlTestContext = config.UserContext{\n\tUsername:    \"testuser\",\n\tName:        \"testuser\",\n\tEmail:       \"test@example.com\",\n\tIsLoggedIn:  true,\n\tIsBasicAuth: false,\n\tOAuth:       false,\n\tProvider:    \"local\",\n\tTotpPending: false,\n\tOAuthGroups: \"\",\n\tTotpEnabled: false,\n\tOAuthSub:    \"\",\n}\n\nfunc setupContextController(middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {\n\ttlog.NewSimpleLogger().Init()\n\n\t// Setup\n\tgin.SetMode(gin.TestMode)\n\trouter := gin.Default()\n\trecorder := httptest.NewRecorder()\n\n\tif middlewares != nil {\n\t\tfor _, m := range *middlewares {\n\t\t\trouter.Use(m)\n\t\t}\n\t}\n\n\tgroup := router.Group(\"/api\")\n\n\tctrl := controller.NewContextController(contextControllerCfg, group)\n\tctrl.SetupRoutes()\n\n\treturn router, recorder\n}\n\nfunc TestAppContextHandler(t *testing.T) {\n\texpectedRes := controller.AppContextResponse{\n\t\tStatus:                200,\n\t\tMessage:               \"Success\",\n\t\tProviders:             contextControllerCfg.Providers,\n\t\tTitle:                 contextControllerCfg.Title,\n\t\tAppURL:                contextControllerCfg.AppURL,\n\t\tCookieDomain:          contextControllerCfg.CookieDomain,\n\t\tForgotPasswordMessage: contextControllerCfg.ForgotPasswordMessage,\n\t\tBackgroundImage:       contextControllerCfg.BackgroundImage,\n\t\tOAuthAutoRedirect:     contextControllerCfg.OAuthAutoRedirect,\n\t\tWarningsEnabled:       contextControllerCfg.WarningsEnabled,\n\t}\n\n\trouter, recorder := setupContextController(nil)\n\treq := httptest.NewRequest(\"GET\", \"/api/context/app\", nil)\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\n\tvar ctrlRes controller.AppContextResponse\n\n\terr := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes)\n\n\tassert.NilError(t, err)\n\tassert.DeepEqual(t, expectedRes, ctrlRes)\n}\n\nfunc TestUserContextHandler(t *testing.T) {\n\texpectedRes := controller.UserContextResponse{\n\t\tStatus:      200,\n\t\tMessage:     \"Success\",\n\t\tIsLoggedIn:  contextCtrlTestContext.IsLoggedIn,\n\t\tUsername:    contextCtrlTestContext.Username,\n\t\tName:        contextCtrlTestContext.Name,\n\t\tEmail:       contextCtrlTestContext.Email,\n\t\tProvider:    contextCtrlTestContext.Provider,\n\t\tOAuth:       contextCtrlTestContext.OAuth,\n\t\tTotpPending: contextCtrlTestContext.TotpPending,\n\t\tOAuthName:   contextCtrlTestContext.OAuthName,\n\t}\n\n\t// Test with context\n\trouter, recorder := setupContextController(&[]gin.HandlerFunc{\n\t\tfunc(c *gin.Context) {\n\t\t\tc.Set(\"context\", &contextCtrlTestContext)\n\t\t\tc.Next()\n\t\t},\n\t})\n\n\treq := httptest.NewRequest(\"GET\", \"/api/context/user\", nil)\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\n\tvar ctrlRes controller.UserContextResponse\n\n\terr := json.Unmarshal(recorder.Body.Bytes(), &ctrlRes)\n\n\tassert.NilError(t, err)\n\tassert.DeepEqual(t, expectedRes, ctrlRes)\n\n\t// Test no context\n\texpectedRes = controller.UserContextResponse{\n\t\tStatus:     401,\n\t\tMessage:    \"Unauthorized\",\n\t\tIsLoggedIn: false,\n\t}\n\n\trouter, recorder = setupContextController(nil)\n\treq = httptest.NewRequest(\"GET\", \"/api/context/user\", nil)\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\n\terr = json.Unmarshal(recorder.Body.Bytes(), &ctrlRes)\n\n\tassert.NilError(t, err)\n\tassert.DeepEqual(t, expectedRes, ctrlRes)\n}\n"
  },
  {
    "path": "internal/controller/health_controller.go",
    "content": "package controller\n\nimport \"github.com/gin-gonic/gin\"\n\ntype HealthController struct {\n\trouter *gin.RouterGroup\n}\n\nfunc NewHealthController(router *gin.RouterGroup) *HealthController {\n\treturn &HealthController{\n\t\trouter: router,\n\t}\n}\n\nfunc (controller *HealthController) SetupRoutes() {\n\tcontroller.router.GET(\"/healthz\", controller.healthHandler)\n\tcontroller.router.HEAD(\"/healthz\", controller.healthHandler)\n}\n\nfunc (controller *HealthController) healthHandler(c *gin.Context) {\n\tc.JSON(200, gin.H{\n\t\t\"status\":  \"ok\",\n\t\t\"message\": \"Healthy\",\n\t})\n}\n"
  },
  {
    "path": "internal/controller/oauth_controller.go",
    "content": "package controller\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/go-querystring/query\"\n)\n\ntype OAuthRequest struct {\n\tProvider string `uri:\"provider\" binding:\"required\"`\n}\n\ntype OAuthControllerConfig struct {\n\tCSRFCookieName     string\n\tRedirectCookieName string\n\tSecureCookie       bool\n\tAppURL             string\n\tCookieDomain       string\n}\n\ntype OAuthController struct {\n\tconfig OAuthControllerConfig\n\trouter *gin.RouterGroup\n\tauth   *service.AuthService\n\tbroker *service.OAuthBrokerService\n}\n\nfunc NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService, broker *service.OAuthBrokerService) *OAuthController {\n\treturn &OAuthController{\n\t\tconfig: config,\n\t\trouter: router,\n\t\tauth:   auth,\n\t\tbroker: broker,\n\t}\n}\n\nfunc (controller *OAuthController) SetupRoutes() {\n\toauthGroup := controller.router.Group(\"/oauth\")\n\toauthGroup.GET(\"/url/:provider\", controller.oauthURLHandler)\n\toauthGroup.GET(\"/callback/:provider\", controller.oauthCallbackHandler)\n}\n\nfunc (controller *OAuthController) oauthURLHandler(c *gin.Context) {\n\tvar req OAuthRequest\n\n\terr := c.BindUri(&req)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to bind URI\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"status\":  400,\n\t\t\t\"message\": \"Bad Request\",\n\t\t})\n\t\treturn\n\t}\n\n\tservice, exists := controller.broker.GetService(req.Provider)\n\n\tif !exists {\n\t\ttlog.App.Warn().Msgf(\"OAuth provider not found: %s\", req.Provider)\n\t\tc.JSON(404, gin.H{\n\t\t\t\"status\":  404,\n\t\t\t\"message\": \"Not Found\",\n\t\t})\n\t\treturn\n\t}\n\n\tservice.GenerateVerifier()\n\tstate := service.GenerateState()\n\tauthURL := service.GetAuthURL(state)\n\tc.SetCookie(controller.config.CSRFCookieName, state, int(time.Hour.Seconds()), \"/\", fmt.Sprintf(\".%s\", controller.config.CookieDomain), controller.config.SecureCookie, true)\n\n\tredirectURI := c.Query(\"redirect_uri\")\n\tisRedirectSafe := utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain)\n\n\tif !isRedirectSafe {\n\t\ttlog.App.Warn().Str(\"redirect_uri\", redirectURI).Msg(\"Unsafe redirect URI detected, ignoring\")\n\t\tredirectURI = \"\"\n\t}\n\n\tif redirectURI != \"\" && isRedirectSafe {\n\t\ttlog.App.Debug().Msg(\"Setting redirect URI cookie\")\n\t\tc.SetCookie(controller.config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), \"/\", fmt.Sprintf(\".%s\", controller.config.CookieDomain), controller.config.SecureCookie, true)\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"status\":  200,\n\t\t\"message\": \"OK\",\n\t\t\"url\":     authURL,\n\t})\n}\n\nfunc (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {\n\tvar req OAuthRequest\n\n\terr := c.BindUri(&req)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to bind URI\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"status\":  400,\n\t\t\t\"message\": \"Bad Request\",\n\t\t})\n\t\treturn\n\t}\n\n\tstate := c.Query(\"state\")\n\tcsrfCookie, err := c.Cookie(controller.config.CSRFCookieName)\n\n\tif err != nil || state != csrfCookie {\n\t\ttlog.App.Warn().Err(err).Msg(\"CSRF token mismatch or cookie missing\")\n\t\tc.SetCookie(controller.config.CSRFCookieName, \"\", -1, \"/\", fmt.Sprintf(\".%s\", controller.config.CookieDomain), controller.config.SecureCookie, true)\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\tc.SetCookie(controller.config.CSRFCookieName, \"\", -1, \"/\", fmt.Sprintf(\".%s\", controller.config.CookieDomain), controller.config.SecureCookie, true)\n\n\tcode := c.Query(\"code\")\n\tservice, exists := controller.broker.GetService(req.Provider)\n\n\tif !exists {\n\t\ttlog.App.Warn().Msgf(\"OAuth provider not found: %s\", req.Provider)\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\terr = service.VerifyCode(code)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to verify OAuth code\")\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\tuser, err := controller.broker.GetUser(req.Provider)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to get user from OAuth provider\")\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\tif user.Email == \"\" {\n\t\ttlog.App.Error().Msg(\"OAuth provider did not return an email\")\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\tif !controller.auth.IsEmailWhitelisted(user.Email) {\n\t\ttlog.App.Warn().Str(\"email\", user.Email).Msg(\"Email not whitelisted\")\n\t\ttlog.AuditLoginFailure(c, user.Email, req.Provider, \"email not whitelisted\")\n\n\t\tqueries, err := query.Values(config.UnauthorizedQuery{\n\t\t\tUsername: user.Email,\n\t\t})\n\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to encode unauthorized query\")\n\t\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\t\treturn\n\t\t}\n\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/unauthorized?%s\", controller.config.AppURL, queries.Encode()))\n\t\treturn\n\t}\n\n\tvar name string\n\n\tif strings.TrimSpace(user.Name) != \"\" {\n\t\ttlog.App.Debug().Msg(\"Using name from OAuth provider\")\n\t\tname = user.Name\n\t} else {\n\t\ttlog.App.Debug().Msg(\"No name from OAuth provider, using pseudo name\")\n\t\tname = fmt.Sprintf(\"%s (%s)\", utils.Capitalize(strings.Split(user.Email, \"@\")[0]), strings.Split(user.Email, \"@\")[1])\n\t}\n\n\tvar username string\n\n\tif strings.TrimSpace(user.PreferredUsername) != \"\" {\n\t\ttlog.App.Debug().Msg(\"Using preferred username from OAuth provider\")\n\t\tusername = user.PreferredUsername\n\t} else {\n\t\ttlog.App.Debug().Msg(\"No preferred username from OAuth provider, using pseudo username\")\n\t\tusername = strings.Replace(user.Email, \"@\", \"_\", 1)\n\t}\n\n\tsessionCookie := repository.Session{\n\t\tUsername:    username,\n\t\tName:        name,\n\t\tEmail:       user.Email,\n\t\tProvider:    req.Provider,\n\t\tOAuthGroups: utils.CoalesceToString(user.Groups),\n\t\tOAuthName:   service.GetName(),\n\t\tOAuthSub:    user.Sub,\n\t}\n\n\ttlog.App.Trace().Interface(\"session_cookie\", sessionCookie).Msg(\"Creating session cookie\")\n\n\terr = controller.auth.CreateSessionCookie(c, &sessionCookie)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to create session cookie\")\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\ttlog.AuditLoginSuccess(c, sessionCookie.Username, sessionCookie.Provider)\n\n\tredirectURI, err := c.Cookie(controller.config.RedirectCookieName)\n\n\tif err != nil || !utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) {\n\t\ttlog.App.Debug().Msg(\"No redirect URI cookie found, redirecting to app root\")\n\t\tc.Redirect(http.StatusTemporaryRedirect, controller.config.AppURL)\n\t\treturn\n\t}\n\n\tqueries, err := query.Values(config.RedirectQuery{\n\t\tRedirectURI: redirectURI,\n\t})\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to encode redirect URI query\")\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\tc.SetCookie(controller.config.RedirectCookieName, \"\", -1, \"/\", fmt.Sprintf(\".%s\", controller.config.CookieDomain), controller.config.SecureCookie, true)\n\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/continue?%s\", controller.config.AppURL, queries.Encode()))\n}\n"
  },
  {
    "path": "internal/controller/oidc_controller.go",
    "content": "package controller\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/go-querystring/query\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n)\n\ntype OIDCControllerConfig struct{}\n\ntype OIDCController struct {\n\tconfig OIDCControllerConfig\n\trouter *gin.RouterGroup\n\toidc   *service.OIDCService\n}\n\ntype AuthorizeCallback struct {\n\tCode  string `url:\"code\"`\n\tState string `url:\"state,omitempty\"`\n}\n\ntype TokenRequest struct {\n\tGrantType    string `form:\"grant_type\" binding:\"required\" url:\"grant_type\"`\n\tCode         string `form:\"code\" url:\"code\"`\n\tRedirectURI  string `form:\"redirect_uri\" url:\"redirect_uri\"`\n\tRefreshToken string `form:\"refresh_token\" url:\"refresh_token\"`\n\tClientSecret string `form:\"client_secret\" url:\"client_secret\"`\n\tClientID     string `form:\"client_id\" url:\"client_id\"`\n}\n\ntype CallbackError struct {\n\tError            string `url:\"error\"`\n\tErrorDescription string `url:\"error_description\"`\n\tState            string `url:\"state\"`\n}\n\ntype ErrorScreen struct {\n\tError string `url:\"error\"`\n}\n\ntype ClientRequest struct {\n\tClientID string `uri:\"id\" binding:\"required\"`\n}\n\ntype ClientCredentials struct {\n\tClientID     string\n\tClientSecret string\n}\n\nfunc NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {\n\treturn &OIDCController{\n\t\tconfig: config,\n\t\toidc:   oidcService,\n\t\trouter: router,\n\t}\n}\n\nfunc (controller *OIDCController) SetupRoutes() {\n\toidcGroup := controller.router.Group(\"/oidc\")\n\toidcGroup.GET(\"/clients/:id\", controller.GetClientInfo)\n\toidcGroup.POST(\"/authorize\", controller.Authorize)\n\toidcGroup.POST(\"/token\", controller.Token)\n\toidcGroup.GET(\"/userinfo\", controller.Userinfo)\n}\n\nfunc (controller *OIDCController) GetClientInfo(c *gin.Context) {\n\tvar req ClientRequest\n\n\terr := c.BindUri(&req)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to bind URI\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"status\":  400,\n\t\t\t\"message\": \"Bad Request\",\n\t\t})\n\t\treturn\n\t}\n\n\tclient, ok := controller.oidc.GetClient(req.ClientID)\n\n\tif !ok {\n\t\ttlog.App.Warn().Str(\"client_id\", req.ClientID).Msg(\"Client not found\")\n\t\tc.JSON(404, gin.H{\n\t\t\t\"status\":  404,\n\t\t\t\"message\": \"Client not found\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"status\": 200,\n\t\t\"client\": client.ClientID,\n\t\t\"name\":   client.Name,\n\t})\n}\n\nfunc (controller *OIDCController) Authorize(c *gin.Context) {\n\tif !controller.oidc.IsConfigured() {\n\t\tcontroller.authorizeError(c, errors.New(\"err_oidc_not_configured\"), \"OIDC not configured\", \"This instance is not configured for OIDC\", \"\", \"\", \"\")\n\t\treturn\n\t}\n\n\tuserContext, err := utils.GetContext(c)\n\n\tif err != nil {\n\t\tcontroller.authorizeError(c, err, \"Failed to get user context\", \"User is not logged in or the session is invalid\", \"\", \"\", \"\")\n\t\treturn\n\t}\n\n\tif !userContext.IsLoggedIn {\n\t\tcontroller.authorizeError(c, errors.New(\"err user not logged in\"), \"User not logged in\", \"The user is not logged in\", \"\", \"\", \"\")\n\t\treturn\n\t}\n\n\tvar req service.AuthorizeRequest\n\n\terr = c.BindJSON(&req)\n\tif err != nil {\n\t\tcontroller.authorizeError(c, err, \"Failed to bind JSON\", \"The client provided an invalid authorization request\", \"\", \"\", \"\")\n\t\treturn\n\t}\n\n\tclient, ok := controller.oidc.GetClient(req.ClientID)\n\n\tif !ok {\n\t\tcontroller.authorizeError(c, err, \"Client not found\", \"The client ID is invalid\", \"\", \"\", \"\")\n\t\treturn\n\t}\n\n\terr = controller.oidc.ValidateAuthorizeParams(req)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to validate authorize params\")\n\t\tif err.Error() != \"invalid_request_uri\" {\n\t\t\tcontroller.authorizeError(c, err, \"Failed validate authorize params\", \"Invalid request parameters\", req.RedirectURI, err.Error(), req.State)\n\t\t\treturn\n\t\t}\n\t\tcontroller.authorizeError(c, err, \"Redirect URI not trusted\", \"The provided redirect URI is not trusted\", \"\", \"\", \"\")\n\t\treturn\n\t}\n\n\t// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.\n\tsub := utils.GenerateUUID(fmt.Sprintf(\"%s:%s\", userContext.Username, client.ID))\n\tcode := utils.GenerateString(32)\n\n\t// Before storing the code, delete old session\n\terr = controller.oidc.DeleteOldSession(c, sub)\n\tif err != nil {\n\t\tcontroller.authorizeError(c, err, \"Failed to delete old sessions\", \"Failed to delete old sessions\", req.RedirectURI, \"server_error\", req.State)\n\t\treturn\n\t}\n\n\terr = controller.oidc.StoreCode(c, sub, code, req)\n\n\tif err != nil {\n\t\tcontroller.authorizeError(c, err, \"Failed to store code\", \"Failed to store code\", req.RedirectURI, \"server_error\", req.State)\n\t\treturn\n\t}\n\n\t// We also need a snapshot of the user that authorized this (skip if no openid scope)\n\tif slices.Contains(strings.Fields(req.Scope), \"openid\") {\n\t\terr = controller.oidc.StoreUserinfo(c, sub, userContext, req)\n\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to insert user info into database\")\n\t\t\tcontroller.authorizeError(c, err, \"Failed to store user info\", \"Failed to store user info\", req.RedirectURI, \"server_error\", req.State)\n\t\t\treturn\n\t\t}\n\t}\n\n\tqueries, err := query.Values(AuthorizeCallback{\n\t\tCode:  code,\n\t\tState: req.State,\n\t})\n\n\tif err != nil {\n\t\tcontroller.authorizeError(c, err, \"Failed to build query\", \"Failed to build query\", req.RedirectURI, \"server_error\", req.State)\n\t\treturn\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"status\":       200,\n\t\t\"redirect_uri\": fmt.Sprintf(\"%s?%s\", req.RedirectURI, queries.Encode()),\n\t})\n}\n\nfunc (controller *OIDCController) Token(c *gin.Context) {\n\tif !controller.oidc.IsConfigured() {\n\t\ttlog.App.Warn().Msg(\"OIDC not configured\")\n\t\tc.JSON(404, gin.H{\n\t\t\t\"error\": \"not_found\",\n\t\t})\n\t\treturn\n\t}\n\n\tvar req TokenRequest\n\n\terr := c.Bind(&req)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to bind token request\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"error\": \"invalid_request\",\n\t\t})\n\t\treturn\n\t}\n\n\terr = controller.oidc.ValidateGrantType(req.GrantType)\n\tif err != nil {\n\t\ttlog.App.Warn().Str(\"grant_type\", req.GrantType).Msg(\"Unsupported grant type\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"error\": err.Error(),\n\t\t})\n\t\treturn\n\t}\n\n\t// First we try form values\n\tcreds := ClientCredentials{\n\t\tClientID:     req.ClientID,\n\t\tClientSecret: req.ClientSecret,\n\t}\n\n\t// If it fails, we try basic auth\n\tif creds.ClientID == \"\" || creds.ClientSecret == \"\" {\n\t\ttlog.App.Debug().Msg(\"Tried form values and they are empty, trying basic auth\")\n\n\t\tclientId, clientSecret, ok := c.Request.BasicAuth()\n\n\t\tif !ok {\n\t\t\ttlog.App.Error().Msg(\"Missing authorization header\")\n\t\t\tc.Header(\"www-authenticate\", \"basic\")\n\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\"error\": \"invalid_client\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tcreds.ClientID = clientId\n\t\tcreds.ClientSecret = clientSecret\n\t}\n\n\t// END - we don't support other authentication methods\n\n\tclient, ok := controller.oidc.GetClient(creds.ClientID)\n\n\tif !ok {\n\t\ttlog.App.Warn().Str(\"client_id\", creds.ClientID).Msg(\"Client not found\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"error\": \"invalid_client\",\n\t\t})\n\t\treturn\n\t}\n\n\tif client.ClientSecret != creds.ClientSecret {\n\t\ttlog.App.Warn().Str(\"client_id\", creds.ClientID).Msg(\"Invalid client secret\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"error\": \"invalid_client\",\n\t\t})\n\t\treturn\n\t}\n\n\tvar tokenResponse service.TokenResponse\n\n\tswitch req.GrantType {\n\tcase \"authorization_code\":\n\t\tentry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, service.ErrCodeNotFound) {\n\t\t\t\ttlog.App.Warn().Msg(\"Code not found\")\n\t\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\t\"error\": \"invalid_grant\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif errors.Is(err, service.ErrCodeExpired) {\n\t\t\t\ttlog.App.Warn().Msg(\"Code expired\")\n\t\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\t\"error\": \"invalid_grant\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif errors.Is(err, service.ErrInvalidClient) {\n\t\t\t\ttlog.App.Warn().Msg(\"Invalid client ID\")\n\t\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\t\"error\": \"invalid_client\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\t\t\ttlog.App.Warn().Err(err).Msg(\"Failed to get OIDC code entry\")\n\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\"error\": \"server_error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tif entry.RedirectURI != req.RedirectURI {\n\t\t\ttlog.App.Warn().Str(\"redirect_uri\", req.RedirectURI).Msg(\"Redirect URI mismatch\")\n\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\"error\": \"invalid_grant\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\ttokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)\n\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to generate access token\")\n\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\"error\": \"server_error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\ttokenResponse = tokenRes\n\tcase \"refresh_token\":\n\t\ttokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken, creds.ClientID)\n\n\t\tif err != nil {\n\t\t\tif errors.Is(err, service.ErrTokenExpired) {\n\t\t\t\ttlog.App.Error().Err(err).Msg(\"Refresh token expired\")\n\t\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\t\"error\": \"invalid_grant\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif errors.Is(err, service.ErrInvalidClient) {\n\t\t\t\ttlog.App.Error().Err(err).Msg(\"Invalid client\")\n\t\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\t\"error\": \"invalid_grant\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to refresh access token\")\n\t\t\tc.JSON(400, gin.H{\n\t\t\t\t\"error\": \"server_error\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\ttokenResponse = tokenRes\n\t}\n\n\tc.Header(\"cache-control\", \"no-store\")\n\tc.Header(\"pragma\", \"no-cache\")\n\n\tc.JSON(200, tokenResponse)\n}\n\nfunc (controller *OIDCController) Userinfo(c *gin.Context) {\n\tif !controller.oidc.IsConfigured() {\n\t\ttlog.App.Warn().Msg(\"OIDC not configured\")\n\t\tc.JSON(404, gin.H{\n\t\t\t\"error\": \"not_found\",\n\t\t})\n\t\treturn\n\t}\n\n\tauthorization := c.GetHeader(\"Authorization\")\n\n\ttokenType, token, ok := strings.Cut(authorization, \" \")\n\n\tif !ok {\n\t\ttlog.App.Warn().Msg(\"OIDC userinfo accessed without authorization header\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"error\": \"invalid_grant\",\n\t\t})\n\t\treturn\n\t}\n\n\tif strings.ToLower(tokenType) != \"bearer\" {\n\t\ttlog.App.Warn().Msg(\"OIDC userinfo accessed with invalid token type\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"error\": \"invalid_grant\",\n\t\t})\n\t\treturn\n\t}\n\n\tentry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))\n\n\tif err != nil {\n\t\tif err == service.ErrTokenNotFound {\n\t\t\ttlog.App.Warn().Msg(\"OIDC userinfo accessed with invalid token\")\n\t\t\tc.JSON(401, gin.H{\n\t\t\t\t\"error\": \"invalid_grant\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\ttlog.App.Err(err).Msg(\"Failed to get token entry\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"error\": \"server_error\",\n\t\t})\n\t\treturn\n\t}\n\n\t// If we don't have the openid scope, return an error\n\tif !slices.Contains(strings.Split(entry.Scope, \",\"), \"openid\") {\n\t\ttlog.App.Warn().Msg(\"OIDC userinfo accessed without openid scope\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"error\": \"invalid_scope\",\n\t\t})\n\t\treturn\n\t}\n\n\tuser, err := controller.oidc.GetUserinfo(c, entry.Sub)\n\n\tif err != nil {\n\t\ttlog.App.Err(err).Msg(\"Failed to get user entry\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"error\": \"server_error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(200, controller.oidc.CompileUserinfo(user, entry.Scope))\n}\n\nfunc (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {\n\ttlog.App.Error().Err(err).Msg(reason)\n\n\tif callback != \"\" {\n\t\terrorQueries := CallbackError{\n\t\t\tError: callbackError,\n\t\t}\n\n\t\tif reasonUser != \"\" {\n\t\t\terrorQueries.ErrorDescription = reasonUser\n\t\t}\n\n\t\tif state != \"\" {\n\t\t\terrorQueries.State = state\n\t\t}\n\n\t\tqueries, err := query.Values(errorQueries)\n\n\t\tif err != nil {\n\t\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\":       200,\n\t\t\t\"redirect_uri\": fmt.Sprintf(\"%s?%s\", callback, queries.Encode()),\n\t\t})\n\t\treturn\n\t}\n\n\terrorQueries := ErrorScreen{\n\t\tError: reasonUser,\n\t}\n\n\tqueries, err := query.Values(errorQueries)\n\n\tif err != nil {\n\t\tc.AbortWithStatus(http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"status\":       200,\n\t\t\"redirect_uri\": fmt.Sprintf(\"%s/error?%s\", controller.oidc.GetIssuer(), queries.Encode()),\n\t})\n}\n"
  },
  {
    "path": "internal/controller/oidc_controller_test.go",
    "content": "package controller_test\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/go-querystring/query\"\n\t\"github.com/steveiliop56/tinyauth/internal/bootstrap\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\t\"gotest.tools/v3/assert\"\n)\n\nvar oidcServiceConfig = service.OIDCServiceConfig{\n\tClients: map[string]config.OIDCClientConfig{\n\t\t\"client1\": {\n\t\t\tClientID:         \"some-client-id\",\n\t\t\tClientSecret:     \"some-client-secret\",\n\t\t\tClientSecretFile: \"\",\n\t\t\tTrustedRedirectURIs: []string{\n\t\t\t\t\"https://example.com/oauth/callback\",\n\t\t\t},\n\t\t\tName: \"Client 1\",\n\t\t},\n\t},\n\tPrivateKeyPath: \"/tmp/tinyauth_oidc_key\",\n\tPublicKeyPath:  \"/tmp/tinyauth_oidc_key.pub\",\n\tIssuer:         \"https://example.com\",\n\tSessionExpiry:  3600,\n}\n\nvar oidcCtrlTestContext = config.UserContext{\n\tUsername:    \"test\",\n\tName:        \"Test\",\n\tEmail:       \"test@example.com\",\n\tIsLoggedIn:  true,\n\tIsBasicAuth: false,\n\tOAuth:       false,\n\tProvider:    \"ldap\", // ldap in order to test the groups\n\tTotpPending: false,\n\tOAuthGroups: \"\",\n\tTotpEnabled: false,\n\tOAuthName:   \"\",\n\tOAuthSub:    \"\",\n\tLdapGroups:  \"test1,test2\",\n}\n\n// Test is not amazing, but it will confirm the OIDC server works\nfunc TestOIDCController(t *testing.T) {\n\ttlog.NewSimpleLogger().Init()\n\n\t// Create an app instance\n\tapp := bootstrap.NewBootstrapApp(config.Config{})\n\n\t// Get db\n\tdb, err := app.SetupDatabase(\"/tmp/tinyauth.db\")\n\tassert.NilError(t, err)\n\n\t// Create queries\n\tqueries := repository.New(db)\n\n\t// Create a new OIDC Servicee\n\toidcService := service.NewOIDCService(oidcServiceConfig, queries)\n\terr = oidcService.Init()\n\tassert.NilError(t, err)\n\n\t// Create test router\n\tgin.SetMode(gin.TestMode)\n\trouter := gin.Default()\n\n\trouter.Use(func(c *gin.Context) {\n\t\tc.Set(\"context\", &oidcCtrlTestContext)\n\t\tc.Next()\n\t})\n\n\tgroup := router.Group(\"/api\")\n\n\t// Register oidc controller\n\toidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, oidcService, group)\n\toidcController.SetupRoutes()\n\n\t// Get redirect URL test\n\trecorder := httptest.NewRecorder()\n\n\tmarshalled, err := json.Marshal(service.AuthorizeRequest{\n\t\tScope:        \"openid profile email groups\",\n\t\tResponseType: \"code\",\n\t\tClientID:     \"some-client-id\",\n\t\tRedirectURI:  \"https://example.com/oauth/callback\",\n\t\tState:        \"some-state\",\n\t})\n\n\tassert.NilError(t, err)\n\n\treq, err := http.NewRequest(\"POST\", \"/api/oidc/authorize\", strings.NewReader(string(marshalled)))\n\tassert.NilError(t, err)\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, http.StatusOK, recorder.Code)\n\n\tresJson := map[string]any{}\n\n\terr = json.Unmarshal(recorder.Body.Bytes(), &resJson)\n\tassert.NilError(t, err)\n\n\tredirect_uri, ok := resJson[\"redirect_uri\"].(string)\n\tassert.Assert(t, ok)\n\n\tu, err := url.Parse(redirect_uri)\n\tassert.NilError(t, err)\n\n\tm, err := url.ParseQuery(u.RawQuery)\n\tassert.NilError(t, err)\n\tassert.Equal(t, m[\"state\"][0], \"some-state\")\n\n\tcode := m[\"code\"][0]\n\n\t// Exchange code for token\n\trecorder = httptest.NewRecorder()\n\n\tparams, err := query.Values(controller.TokenRequest{\n\t\tGrantType:   \"authorization_code\",\n\t\tCode:        code,\n\t\tRedirectURI: \"https://example.com/oauth/callback\",\n\t})\n\n\tassert.NilError(t, err)\n\n\treq, err = http.NewRequest(\"POST\", \"/api/oidc/token\", strings.NewReader(params.Encode()))\n\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"content-type\", \"application/x-www-form-urlencoded\")\n\treq.SetBasicAuth(\"some-client-id\", \"some-client-secret\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, http.StatusOK, recorder.Code)\n\n\tresJson = map[string]any{}\n\n\terr = json.Unmarshal(recorder.Body.Bytes(), &resJson)\n\tassert.NilError(t, err)\n\n\taccessToken, ok := resJson[\"access_token\"].(string)\n\tassert.Assert(t, ok)\n\n\t_, ok = resJson[\"id_token\"].(string)\n\tassert.Assert(t, ok)\n\n\trefreshToken, ok := resJson[\"refresh_token\"].(string)\n\tassert.Assert(t, ok)\n\n\texpires_in, ok := resJson[\"expires_in\"].(float64)\n\tassert.Assert(t, ok)\n\tassert.Equal(t, expires_in, float64(oidcServiceConfig.SessionExpiry))\n\n\t// Ensure code is expired\n\trecorder = httptest.NewRecorder()\n\n\tparams, err = query.Values(controller.TokenRequest{\n\t\tGrantType:   \"authorization_code\",\n\t\tCode:        code,\n\t\tRedirectURI: \"https://example.com/oauth/callback\",\n\t})\n\n\tassert.NilError(t, err)\n\n\treq, err = http.NewRequest(\"POST\", \"/api/oidc/token\", strings.NewReader(params.Encode()))\n\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"content-type\", \"application/x-www-form-urlencoded\")\n\treq.SetBasicAuth(\"some-client-id\", \"some-client-secret\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, http.StatusBadRequest, recorder.Code)\n\n\t// Test userinfo\n\trecorder = httptest.NewRecorder()\n\n\treq, err = http.NewRequest(\"GET\", \"/api/oidc/userinfo\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"authorization\", fmt.Sprintf(\"Bearer %s\", accessToken))\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, http.StatusOK, recorder.Code)\n\n\tresJson = map[string]any{}\n\n\terr = json.Unmarshal(recorder.Body.Bytes(), &resJson)\n\tassert.NilError(t, err)\n\n\t_, ok = resJson[\"sub\"].(string)\n\tassert.Assert(t, ok)\n\n\tname, ok := resJson[\"name\"].(string)\n\tassert.Assert(t, ok)\n\tassert.Equal(t, name, oidcCtrlTestContext.Name)\n\n\temail, ok := resJson[\"email\"].(string)\n\tassert.Assert(t, ok)\n\tassert.Equal(t, email, oidcCtrlTestContext.Email)\n\n\tpreferred_username, ok := resJson[\"preferred_username\"].(string)\n\tassert.Assert(t, ok)\n\tassert.Equal(t, preferred_username, oidcCtrlTestContext.Username)\n\n\t// Not sure why this is failing, will look into it later\n\tigroups, ok := resJson[\"groups\"].([]any)\n\tassert.Assert(t, ok)\n\n\tgroups := make([]string, len(igroups))\n\tfor i, group := range igroups {\n\t\tgroups[i], ok = group.(string)\n\t\tassert.Assert(t, ok)\n\t}\n\n\tassert.DeepEqual(t, strings.Split(oidcCtrlTestContext.LdapGroups, \",\"), groups)\n\n\t// Test refresh token\n\trecorder = httptest.NewRecorder()\n\n\tparams, err = query.Values(controller.TokenRequest{\n\t\tGrantType:    \"refresh_token\",\n\t\tRefreshToken: refreshToken,\n\t})\n\n\tassert.NilError(t, err)\n\n\treq, err = http.NewRequest(\"POST\", \"/api/oidc/token\", strings.NewReader(params.Encode()))\n\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\treq.SetBasicAuth(\"some-client-id\", \"some-client-secret\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, http.StatusOK, recorder.Code)\n\n\tresJson = map[string]any{}\n\n\terr = json.Unmarshal(recorder.Body.Bytes(), &resJson)\n\n\tassert.NilError(t, err)\n\n\tnewToken, ok := resJson[\"access_token\"].(string)\n\tassert.Assert(t, ok)\n\tassert.Assert(t, newToken != accessToken)\n\n\t// Ensure old token is invalid\n\trecorder = httptest.NewRecorder()\n\treq, err = http.NewRequest(\"GET\", \"/api/oidc/userinfo\", nil)\n\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"authorization\", fmt.Sprintf(\"Bearer %s\", accessToken))\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, http.StatusUnauthorized, recorder.Code)\n\n\t// Test new token\n\trecorder = httptest.NewRecorder()\n\treq, err = http.NewRequest(\"GET\", \"/api/oidc/userinfo\", nil)\n\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"authorization\", fmt.Sprintf(\"Bearer %s\", newToken))\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, http.StatusOK, recorder.Code)\n}\n"
  },
  {
    "path": "internal/controller/proxy_controller.go",
    "content": "package controller\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/go-querystring/query\"\n)\n\ntype AuthModuleType int\n\nconst (\n\tAuthRequest AuthModuleType = iota\n\tExtAuthz\n\tForwardAuth\n)\n\nvar BrowserUserAgentRegex = regexp.MustCompile(\"Chrome|Gecko|AppleWebKit|Opera|Edge\")\n\ntype Proxy struct {\n\tProxy string `uri:\"proxy\" binding:\"required\"`\n}\n\ntype ProxyContext struct {\n\tHost      string\n\tProto     string\n\tPath      string\n\tMethod    string\n\tType      AuthModuleType\n\tIsBrowser bool\n}\n\ntype ProxyControllerConfig struct {\n\tAppURL string\n}\n\ntype ProxyController struct {\n\tconfig ProxyControllerConfig\n\trouter *gin.RouterGroup\n\tacls   *service.AccessControlsService\n\tauth   *service.AuthService\n}\n\nfunc NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController {\n\treturn &ProxyController{\n\t\tconfig: config,\n\t\trouter: router,\n\t\tacls:   acls,\n\t\tauth:   auth,\n\t}\n}\n\nfunc (controller *ProxyController) SetupRoutes() {\n\tproxyGroup := controller.router.Group(\"/auth\")\n\tproxyGroup.Any(\"/:proxy\", controller.proxyHandler)\n}\n\nfunc (controller *ProxyController) proxyHandler(c *gin.Context) {\n\t// Load proxy context based on the request type\n\tproxyCtx, err := controller.getProxyContext(c)\n\n\tif err != nil {\n\t\ttlog.App.Warn().Err(err).Msg(\"Failed to get proxy context\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"status\":  400,\n\t\t\t\"message\": \"Bad request\",\n\t\t})\n\t\treturn\n\t}\n\n\ttlog.App.Trace().Interface(\"ctx\", proxyCtx).Msg(\"Got proxy context\")\n\n\t// Get acls\n\tacls, err := controller.acls.GetAccessControls(proxyCtx.Host)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to get access controls for resource\")\n\t\tcontroller.handleError(c, proxyCtx)\n\t\treturn\n\t}\n\n\ttlog.App.Trace().Interface(\"acls\", acls).Msg(\"ACLs for resource\")\n\n\tclientIP := c.ClientIP()\n\n\tif controller.auth.IsBypassedIP(acls.IP, clientIP) {\n\t\tcontroller.setHeaders(c, acls)\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\":  200,\n\t\t\t\"message\": \"Authenticated\",\n\t\t})\n\t\treturn\n\t}\n\n\tauthEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls.Path)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to check if auth is enabled for resource\")\n\t\tcontroller.handleError(c, proxyCtx)\n\t\treturn\n\t}\n\n\tif !authEnabled {\n\t\ttlog.App.Debug().Msg(\"Authentication disabled for resource, allowing access\")\n\t\tcontroller.setHeaders(c, acls)\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\":  200,\n\t\t\t\"message\": \"Authenticated\",\n\t\t})\n\t\treturn\n\t}\n\n\tif !controller.auth.CheckIP(acls.IP, clientIP) {\n\t\tif !controller.useFriendlyError(proxyCtx) {\n\t\t\tc.JSON(401, gin.H{\n\t\t\t\t\"status\":  401,\n\t\t\t\t\"message\": \"Unauthorized\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tqueries, err := query.Values(config.UnauthorizedQuery{\n\t\t\tResource: strings.Split(proxyCtx.Host, \".\")[0],\n\t\t\tIP:       clientIP,\n\t\t})\n\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to encode unauthorized query\")\n\t\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\t\treturn\n\t\t}\n\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/unauthorized?%s\", controller.config.AppURL, queries.Encode()))\n\t\treturn\n\t}\n\n\tvar userContext config.UserContext\n\n\tcontext, err := utils.GetContext(c)\n\n\tif err != nil {\n\t\ttlog.App.Debug().Msg(\"No user context found in request, treating as not logged in\")\n\t\tuserContext = config.UserContext{\n\t\t\tIsLoggedIn: false,\n\t\t}\n\t} else {\n\t\tuserContext = context\n\t}\n\n\ttlog.App.Trace().Interface(\"context\", userContext).Msg(\"User context from request\")\n\n\tif userContext.IsLoggedIn {\n\t\tuserAllowed := controller.auth.IsUserAllowed(c, userContext, acls)\n\n\t\tif !userAllowed {\n\t\t\ttlog.App.Warn().Str(\"user\", userContext.Username).Str(\"resource\", strings.Split(proxyCtx.Host, \".\")[0]).Msg(\"User not allowed to access resource\")\n\n\t\t\tif !controller.useFriendlyError(proxyCtx) {\n\t\t\t\tc.JSON(403, gin.H{\n\t\t\t\t\t\"status\":  403,\n\t\t\t\t\t\"message\": \"Forbidden\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tqueries, err := query.Values(config.UnauthorizedQuery{\n\t\t\t\tResource: strings.Split(proxyCtx.Host, \".\")[0],\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to encode unauthorized query\")\n\t\t\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif userContext.OAuth {\n\t\t\t\tqueries.Set(\"username\", userContext.Email)\n\t\t\t} else {\n\t\t\t\tqueries.Set(\"username\", userContext.Username)\n\t\t\t}\n\n\t\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/unauthorized?%s\", controller.config.AppURL, queries.Encode()))\n\t\t\treturn\n\t\t}\n\n\t\tif userContext.OAuth || userContext.Provider == \"ldap\" {\n\t\t\tvar groupOK bool\n\n\t\t\tif userContext.OAuth {\n\t\t\t\tgroupOK = controller.auth.IsInOAuthGroup(c, userContext, acls.OAuth.Groups)\n\t\t\t} else {\n\t\t\t\tgroupOK = controller.auth.IsInLdapGroup(c, userContext, acls.LDAP.Groups)\n\t\t\t}\n\n\t\t\tif !groupOK {\n\t\t\t\ttlog.App.Warn().Str(\"user\", userContext.Username).Str(\"resource\", strings.Split(proxyCtx.Host, \".\")[0]).Msg(\"User groups do not match resource requirements\")\n\n\t\t\t\tif !controller.useFriendlyError(proxyCtx) {\n\t\t\t\t\tc.JSON(403, gin.H{\n\t\t\t\t\t\t\"status\":  403,\n\t\t\t\t\t\t\"message\": \"Forbidden\",\n\t\t\t\t\t})\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tqueries, err := query.Values(config.UnauthorizedQuery{\n\t\t\t\t\tResource: strings.Split(proxyCtx.Host, \".\")[0],\n\t\t\t\t\tGroupErr: true,\n\t\t\t\t})\n\n\t\t\t\tif err != nil {\n\t\t\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to encode unauthorized query\")\n\t\t\t\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tif userContext.OAuth {\n\t\t\t\t\tqueries.Set(\"username\", userContext.Email)\n\t\t\t\t} else {\n\t\t\t\t\tqueries.Set(\"username\", userContext.Username)\n\t\t\t\t}\n\n\t\t\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/unauthorized?%s\", controller.config.AppURL, queries.Encode()))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tc.Header(\"Remote-User\", utils.SanitizeHeader(userContext.Username))\n\t\tc.Header(\"Remote-Name\", utils.SanitizeHeader(userContext.Name))\n\t\tc.Header(\"Remote-Email\", utils.SanitizeHeader(userContext.Email))\n\n\t\tif userContext.Provider == \"ldap\" {\n\t\t\tc.Header(\"Remote-Groups\", utils.SanitizeHeader(userContext.LdapGroups))\n\t\t} else if userContext.Provider != \"local\" {\n\t\t\tc.Header(\"Remote-Groups\", utils.SanitizeHeader(userContext.OAuthGroups))\n\t\t}\n\n\t\tc.Header(\"Remote-Sub\", utils.SanitizeHeader(userContext.OAuthSub))\n\n\t\tcontroller.setHeaders(c, acls)\n\n\t\tc.JSON(200, gin.H{\n\t\t\t\"status\":  200,\n\t\t\t\"message\": \"Authenticated\",\n\t\t})\n\t\treturn\n\t}\n\n\tif !controller.useFriendlyError(proxyCtx) {\n\t\tc.JSON(401, gin.H{\n\t\t\t\"status\":  401,\n\t\t\t\"message\": \"Unauthorized\",\n\t\t})\n\t\treturn\n\t}\n\n\tqueries, err := query.Values(config.RedirectQuery{\n\t\tRedirectURI: fmt.Sprintf(\"%s://%s%s\", proxyCtx.Proto, proxyCtx.Host, proxyCtx.Path),\n\t})\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to encode redirect URI query\")\n\t\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n\t\treturn\n\t}\n\n\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/login?%s\", controller.config.AppURL, queries.Encode()))\n}\n\nfunc (controller *ProxyController) setHeaders(c *gin.Context, acls config.App) {\n\tc.Header(\"Authorization\", c.Request.Header.Get(\"Authorization\"))\n\n\theaders := utils.ParseHeaders(acls.Response.Headers)\n\n\tfor key, value := range headers {\n\t\ttlog.App.Debug().Str(\"header\", key).Msg(\"Setting header\")\n\t\tc.Header(key, value)\n\t}\n\n\tbasicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)\n\n\tif acls.Response.BasicAuth.Username != \"\" && basicPassword != \"\" {\n\t\ttlog.App.Debug().Str(\"username\", acls.Response.BasicAuth.Username).Msg(\"Setting basic auth header\")\n\t\tc.Header(\"Authorization\", fmt.Sprintf(\"Basic %s\", utils.GetBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))\n\t}\n}\n\nfunc (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) {\n\tif !controller.useFriendlyError(proxyCtx) {\n\t\tc.JSON(500, gin.H{\n\t\t\t\"status\":  500,\n\t\t\t\"message\": \"Internal Server Error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(\"%s/error\", controller.config.AppURL))\n}\n\nfunc (controller *ProxyController) getHeader(c *gin.Context, header string) (string, bool) {\n\tval := c.Request.Header.Get(header)\n\treturn val, strings.TrimSpace(val) != \"\"\n}\n\nfunc (controller *ProxyController) useFriendlyError(proxyCtx ProxyContext) bool {\n\treturn (proxyCtx.Type == ForwardAuth || proxyCtx.Type == ExtAuthz) && proxyCtx.IsBrowser\n}\n\n// Code below is inspired from https://github.com/authelia/authelia/blob/master/internal/handlers/handler_authz.go\n// and thus it may be subject to Apache 2.0 License\nfunc (controller *ProxyController) getForwardAuthContext(c *gin.Context) (ProxyContext, error) {\n\thost, ok := controller.getHeader(c, \"x-forwarded-host\")\n\n\tif !ok {\n\t\treturn ProxyContext{}, errors.New(\"x-forwarded-host not found\")\n\t}\n\n\turi, ok := controller.getHeader(c, \"x-forwarded-uri\")\n\n\tif !ok {\n\t\treturn ProxyContext{}, errors.New(\"x-forwarded-uri not found\")\n\t}\n\n\tproto, ok := controller.getHeader(c, \"x-forwarded-proto\")\n\n\tif !ok {\n\t\treturn ProxyContext{}, errors.New(\"x-forwarded-proto not found\")\n\t}\n\n\t// Normally we should only allow GET for forward auth but since it's a fallback\n\t// for envoy we should allow everything, not a big deal\n\tmethod := c.Request.Method\n\n\treturn ProxyContext{\n\t\tHost:   host,\n\t\tProto:  proto,\n\t\tPath:   uri,\n\t\tMethod: method,\n\t\tType:   ForwardAuth,\n\t}, nil\n}\n\nfunc (controller *ProxyController) getAuthRequestContext(c *gin.Context) (ProxyContext, error) {\n\txOriginalUrl, ok := controller.getHeader(c, \"x-original-url\")\n\n\tif !ok {\n\t\treturn ProxyContext{}, errors.New(\"x-original-url not found\")\n\t}\n\n\turl, err := url.Parse(xOriginalUrl)\n\n\tif err != nil {\n\t\treturn ProxyContext{}, err\n\t}\n\n\thost := url.Host\n\n\tif strings.TrimSpace(host) == \"\" {\n\t\treturn ProxyContext{}, errors.New(\"host not found\")\n\t}\n\n\tproto := url.Scheme\n\n\tif strings.TrimSpace(proto) == \"\" {\n\t\treturn ProxyContext{}, errors.New(\"proto not found\")\n\t}\n\n\tpath := url.Path\n\tmethod := c.Request.Method\n\n\treturn ProxyContext{\n\t\tHost:   host,\n\t\tProto:  proto,\n\t\tPath:   path,\n\t\tMethod: method,\n\t\tType:   AuthRequest,\n\t}, nil\n}\n\nfunc (controller *ProxyController) getExtAuthzContext(c *gin.Context) (ProxyContext, error) {\n\t// We hope for the someone to set the x-forwarded-proto header\n\tproto, ok := controller.getHeader(c, \"x-forwarded-proto\")\n\n\tif !ok {\n\t\treturn ProxyContext{}, errors.New(\"x-forwarded-proto not found\")\n\t}\n\n\t// It sets the host to the original host, not the forwarded host\n\thost := c.Request.Host\n\n\tif strings.TrimSpace(host) == \"\" {\n\t\treturn ProxyContext{}, errors.New(\"host not found\")\n\t}\n\n\t// We get the path from the query string\n\tpath := c.Query(\"path\")\n\n\t// For envoy we need to support every method\n\tmethod := c.Request.Method\n\n\treturn ProxyContext{\n\t\tHost:   host,\n\t\tProto:  proto,\n\t\tPath:   path,\n\t\tMethod: method,\n\t\tType:   ExtAuthz,\n\t}, nil\n}\n\nfunc (controller *ProxyController) determineAuthModules(proxy string) []AuthModuleType {\n\tswitch proxy {\n\tcase \"traefik\", \"caddy\":\n\t\treturn []AuthModuleType{ForwardAuth}\n\tcase \"envoy\":\n\t\treturn []AuthModuleType{ExtAuthz, ForwardAuth}\n\tcase \"nginx\":\n\t\treturn []AuthModuleType{AuthRequest, ForwardAuth}\n\tdefault:\n\t\treturn []AuthModuleType{}\n\t}\n}\n\nfunc (controller *ProxyController) getContextFromAuthModule(c *gin.Context, module AuthModuleType) (ProxyContext, error) {\n\tswitch module {\n\tcase ForwardAuth:\n\t\tctx, err := controller.getForwardAuthContext(c)\n\t\tif err != nil {\n\t\t\treturn ProxyContext{}, err\n\t\t}\n\t\treturn ctx, nil\n\tcase ExtAuthz:\n\t\tctx, err := controller.getExtAuthzContext(c)\n\t\tif err != nil {\n\t\t\treturn ProxyContext{}, err\n\t\t}\n\t\treturn ctx, nil\n\tcase AuthRequest:\n\t\tctx, err := controller.getAuthRequestContext(c)\n\t\tif err != nil {\n\t\t\treturn ProxyContext{}, err\n\t\t}\n\t\treturn ctx, nil\n\t}\n\treturn ProxyContext{}, fmt.Errorf(\"unsupported auth module: %v\", module)\n}\n\nfunc (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext, error) {\n\tvar req Proxy\n\n\terr := c.BindUri(&req)\n\tif err != nil {\n\t\treturn ProxyContext{}, err\n\t}\n\n\ttlog.App.Debug().Msgf(\"Proxy: %v\", req.Proxy)\n\n\tauthModules := controller.determineAuthModules(req.Proxy)\n\n\tif len(authModules) == 0 {\n\t\treturn ProxyContext{}, fmt.Errorf(\"no auth modules supported for proxy: %v\", req.Proxy)\n\t}\n\n\tvar ctx ProxyContext\n\n\tfor _, module := range authModules {\n\t\ttlog.App.Debug().Msgf(\"Trying auth module: %v\", module)\n\t\tctx, err = controller.getContextFromAuthModule(c, module)\n\t\tif err == nil {\n\t\t\ttlog.App.Debug().Msgf(\"Auth module %v succeeded\", module)\n\t\t\tbreak\n\t\t}\n\t\ttlog.App.Debug().Err(err).Msgf(\"Auth module %v failed\", module)\n\t}\n\n\tif err != nil {\n\t\treturn ProxyContext{}, err\n\t}\n\n\t// We don't care if the header is empty, we will just assume it's not a browser\n\tuserAgent, _ := controller.getHeader(c, \"user-agent\")\n\tisBrowser := BrowserUserAgentRegex.MatchString(userAgent)\n\n\tif isBrowser {\n\t\ttlog.App.Debug().Msg(\"Request identified as coming from a browser\")\n\t} else {\n\t\ttlog.App.Debug().Msg(\"Request identified as coming from a non-browser client\")\n\t}\n\n\tctx.IsBrowser = isBrowser\n\treturn ctx, nil\n}\n"
  },
  {
    "path": "internal/controller/proxy_controller_test.go",
    "content": "package controller_test\n\nimport (\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/bootstrap\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"gotest.tools/v3/assert\"\n)\n\nvar loggedInCtx = config.UserContext{\n\tUsername:   \"test\",\n\tName:       \"Test\",\n\tEmail:      \"test@example.com\",\n\tIsLoggedIn: true,\n\tProvider:   \"local\",\n}\n\nfunc setupProxyController(t *testing.T, middlewares []gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {\n\t// Setup\n\tgin.SetMode(gin.TestMode)\n\trouter := gin.Default()\n\n\tif len(middlewares) > 0 {\n\t\tfor _, m := range middlewares {\n\t\t\trouter.Use(m)\n\t\t}\n\t}\n\n\tgroup := router.Group(\"/api\")\n\trecorder := httptest.NewRecorder()\n\n\t// Mock app\n\tapp := bootstrap.NewBootstrapApp(config.Config{})\n\n\t// Database\n\tdb, err := app.SetupDatabase(\":memory:\")\n\n\tassert.NilError(t, err)\n\n\t// Queries\n\tqueries := repository.New(db)\n\n\t// Docker\n\tdockerService := service.NewDockerService()\n\n\tassert.NilError(t, dockerService.Init())\n\n\t// Access controls\n\taccessControlsService := service.NewAccessControlsService(dockerService, map[string]config.App{\n\t\t\"whoami\": {\n\t\t\tPath: config.AppPath{\n\t\t\t\tAllow: \"/allow\",\n\t\t\t},\n\t\t},\n\t})\n\n\tassert.NilError(t, accessControlsService.Init())\n\n\t// Auth service\n\tauthService := service.NewAuthService(service.AuthServiceConfig{\n\t\tUsers: []config.User{\n\t\t\t{\n\t\t\t\tUsername: \"testuser\",\n\t\t\t\tPassword: \"$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.\", // test\n\t\t\t},\n\t\t\t{\n\t\t\t\tUsername:   \"totpuser\",\n\t\t\t\tPassword:   \"$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.\",\n\t\t\t\tTotpSecret: \"foo\",\n\t\t\t},\n\t\t},\n\t\tOauthWhitelist:     []string{},\n\t\tSessionExpiry:      3600,\n\t\tSessionMaxLifetime: 0,\n\t\tSecureCookie:       false,\n\t\tCookieDomain:       \"localhost\",\n\t\tLoginTimeout:       300,\n\t\tLoginMaxRetries:    3,\n\t\tSessionCookieName:  \"tinyauth-session\",\n\t}, dockerService, nil, queries)\n\n\t// Controller\n\tctrl := controller.NewProxyController(controller.ProxyControllerConfig{\n\t\tAppURL: \"http://tinyauth.example.com\",\n\t}, group, accessControlsService, authService)\n\tctrl.SetupRoutes()\n\n\treturn router, recorder\n}\n\n// TODO: Needs tests for context middleware\n\nfunc TestProxyHandler(t *testing.T) {\n\t// Test logged out user traefik/caddy (forward_auth)\n\trouter, recorder := setupProxyController(t, nil)\n\n\treq, err := http.NewRequest(\"GET\", \"/api/auth/traefik\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-forwarded-host\", \"whoami.example.com\")\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\treq.Header.Set(\"x-forwarded-uri\", \"/\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusUnauthorized)\n\n\t// Test logged out user nginx (auth_request)\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/nginx\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-original-url\", \"http://whoami.example.com/\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusUnauthorized)\n\n\t// Test logged out user envoy (ext_authz)\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/envoy?path=/\", nil)\n\tassert.NilError(t, err)\n\n\treq.Host = \"whoami.example.com\"\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusUnauthorized)\n\n\t// Test logged in user traefik/caddy (forward_auth)\n\trouter, recorder = setupProxyController(t, []gin.HandlerFunc{\n\t\tfunc(c *gin.Context) {\n\t\t\tc.Set(\"context\", &loggedInCtx)\n\t\t\tc.Next()\n\t\t},\n\t})\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/traefik\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-forwarded-host\", \"whoami.example.com\")\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\treq.Header.Set(\"x-forwarded-uri\", \"/\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test logged in user nginx (auth_request)\n\trouter, recorder = setupProxyController(t, []gin.HandlerFunc{\n\t\tfunc(c *gin.Context) {\n\t\t\tc.Set(\"context\", &loggedInCtx)\n\t\t\tc.Next()\n\t\t},\n\t})\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/nginx\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-original-url\", \"http://whoami.example.com/\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test logged in user envoy (ext_authz)\n\trouter, recorder = setupProxyController(t, []gin.HandlerFunc{\n\t\tfunc(c *gin.Context) {\n\t\t\tc.Set(\"context\", &loggedInCtx)\n\t\t\tc.Next()\n\t\t},\n\t})\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/envoy?path=/\", nil)\n\tassert.NilError(t, err)\n\n\treq.Host = \"whoami.example.com\"\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test ACL allow caddy/traefik (forward_auth)\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/traefik\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-forwarded-host\", \"whoami.example.com\")\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\treq.Header.Set(\"x-forwarded-uri\", \"/allow\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test ACL allow nginx\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/nginx\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-original-url\", \"http://whoami.example.com/allow\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test ACL allow envoy\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/envoy?path=/allow\", nil)\n\tassert.NilError(t, err)\n\n\treq.Host = \"whoami.example.com\"\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test traefik/caddy (forward_auth) without required headers\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/traefik\", nil)\n\tassert.NilError(t, err)\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusBadRequest)\n\n\t// Test nginx (forward_auth) without required headers\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/nginx\", nil)\n\tassert.NilError(t, err)\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusBadRequest)\n\n\t// Test envoy (forward_auth) without required headers\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/envoy\", nil)\n\tassert.NilError(t, err)\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusBadRequest)\n\n\t// Test nginx (auth_request) with forward_auth fallback with ACLs\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/nginx\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-forwarded-host\", \"whoami.example.com\")\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\treq.Header.Set(\"x-forwarded-uri\", \"/allow\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test envoy (ext_authz) with forward_auth fallback with ACLs\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/envoy\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-forwarded-host\", \"whoami.example.com\")\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\treq.Header.Set(\"x-forwarded-uri\", \"/allow\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n\n\t// Test envoy (ext_authz) with empty path\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/envoy\", nil)\n\tassert.NilError(t, err)\n\n\treq.Host = \"whoami.example.com\"\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusUnauthorized)\n\n\t// Ensure forward_auth fallback works with path (should ignore)\n\trouter, recorder = setupProxyController(t, nil)\n\n\treq, err = http.NewRequest(\"GET\", \"/api/auth/traefik?path=/allow\", nil)\n\tassert.NilError(t, err)\n\n\treq.Header.Set(\"x-forwarded-proto\", \"http\")\n\treq.Header.Set(\"x-forwarded-host\", \"whoami.example.com\")\n\treq.Header.Set(\"x-forwarded-uri\", \"/allow\")\n\n\trouter.ServeHTTP(recorder, req)\n\tassert.Equal(t, recorder.Code, http.StatusOK)\n}\n"
  },
  {
    "path": "internal/controller/resources_controller.go",
    "content": "package controller\n\nimport (\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype ResourcesControllerConfig struct {\n\tPath    string\n\tEnabled bool\n}\n\ntype ResourcesController struct {\n\tconfig     ResourcesControllerConfig\n\trouter     *gin.RouterGroup\n\tfileServer http.Handler\n}\n\nfunc NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {\n\tfileServer := http.StripPrefix(\"/resources\", http.FileServer(http.Dir(config.Path)))\n\n\treturn &ResourcesController{\n\t\tconfig:     config,\n\t\trouter:     router,\n\t\tfileServer: fileServer,\n\t}\n}\n\nfunc (controller *ResourcesController) SetupRoutes() {\n\tcontroller.router.GET(\"/resources/*resource\", controller.resourcesHandler)\n}\n\nfunc (controller *ResourcesController) resourcesHandler(c *gin.Context) {\n\tif controller.config.Path == \"\" {\n\t\tc.JSON(404, gin.H{\n\t\t\t\"status\":  404,\n\t\t\t\"message\": \"Resources not found\",\n\t\t})\n\t\treturn\n\t}\n\tif !controller.config.Enabled {\n\t\tc.JSON(403, gin.H{\n\t\t\t\"status\":  403,\n\t\t\t\"message\": \"Resources are disabled\",\n\t\t})\n\t\treturn\n\t}\n\tcontroller.fileServer.ServeHTTP(c.Writer, c.Request)\n}\n"
  },
  {
    "path": "internal/controller/resources_controller_test.go",
    "content": "package controller_test\n\nimport (\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestResourcesHandler(t *testing.T) {\n\t// Setup\n\tgin.SetMode(gin.TestMode)\n\trouter := gin.New()\n\tgroup := router.Group(\"/\")\n\n\tctrl := controller.NewResourcesController(controller.ResourcesControllerConfig{\n\t\tPath:    \"/tmp/tinyauth\",\n\t\tEnabled: true,\n\t}, group)\n\tctrl.SetupRoutes()\n\n\t// Create test data\n\terr := os.Mkdir(\"/tmp/tinyauth\", 0755)\n\tassert.NilError(t, err)\n\tdefer os.RemoveAll(\"/tmp/tinyauth\")\n\n\tfile, err := os.Create(\"/tmp/tinyauth/test.txt\")\n\tassert.NilError(t, err)\n\n\t_, err = file.WriteString(\"This is a test file.\")\n\tassert.NilError(t, err)\n\tfile.Close()\n\n\t// Test existing file\n\treq := httptest.NewRequest(\"GET\", \"/resources/test.txt\", nil)\n\trecorder := httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\tassert.Equal(t, \"This is a test file.\", recorder.Body.String())\n\n\t// Test non-existing file\n\treq = httptest.NewRequest(\"GET\", \"/resources/nonexistent.txt\", nil)\n\trecorder = httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 404, recorder.Code)\n\n\t// Test directory traversal attack\n\treq = httptest.NewRequest(\"GET\", \"/resources/../etc/passwd\", nil)\n\trecorder = httptest.NewRecorder()\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 404, recorder.Code)\n}\n"
  },
  {
    "path": "internal/controller/user_controller.go",
    "content": "package controller\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pquerna/otp/totp\"\n)\n\ntype LoginRequest struct {\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n}\n\ntype TotpRequest struct {\n\tCode string `json:\"code\"`\n}\n\ntype UserControllerConfig struct {\n\tCookieDomain string\n}\n\ntype UserController struct {\n\tconfig UserControllerConfig\n\trouter *gin.RouterGroup\n\tauth   *service.AuthService\n}\n\nfunc NewUserController(config UserControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *UserController {\n\treturn &UserController{\n\t\tconfig: config,\n\t\trouter: router,\n\t\tauth:   auth,\n\t}\n}\n\nfunc (controller *UserController) SetupRoutes() {\n\tuserGroup := controller.router.Group(\"/user\")\n\tuserGroup.POST(\"/login\", controller.loginHandler)\n\tuserGroup.POST(\"/logout\", controller.logoutHandler)\n\tuserGroup.POST(\"/totp\", controller.totpHandler)\n}\n\nfunc (controller *UserController) loginHandler(c *gin.Context) {\n\tvar req LoginRequest\n\n\terr := c.ShouldBindJSON(&req)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to bind JSON\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"status\":  400,\n\t\t\t\"message\": \"Bad Request\",\n\t\t})\n\t\treturn\n\t}\n\n\ttlog.App.Debug().Str(\"username\", req.Username).Msg(\"Login attempt\")\n\n\tisLocked, remaining := controller.auth.IsAccountLocked(req.Username)\n\n\tif isLocked {\n\t\ttlog.App.Warn().Str(\"username\", req.Username).Msg(\"Account is locked due to too many failed login attempts\")\n\t\ttlog.AuditLoginFailure(c, req.Username, \"username\", \"account locked\")\n\t\tc.Writer.Header().Add(\"x-tinyauth-lock-locked\", \"true\")\n\t\tc.Writer.Header().Add(\"x-tinyauth-lock-reset\", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))\n\t\tc.JSON(429, gin.H{\n\t\t\t\"status\":  429,\n\t\t\t\"message\": fmt.Sprintf(\"Too many failed login attempts. Try again in %d seconds\", remaining),\n\t\t})\n\t\treturn\n\t}\n\n\tuserSearch := controller.auth.SearchUser(req.Username)\n\n\tif userSearch.Type == \"unknown\" {\n\t\ttlog.App.Warn().Str(\"username\", req.Username).Msg(\"User not found\")\n\t\tcontroller.auth.RecordLoginAttempt(req.Username, false)\n\t\ttlog.AuditLoginFailure(c, req.Username, \"username\", \"user not found\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"status\":  401,\n\t\t\t\"message\": \"Unauthorized\",\n\t\t})\n\t\treturn\n\t}\n\n\tif !controller.auth.VerifyUser(userSearch, req.Password) {\n\t\ttlog.App.Warn().Str(\"username\", req.Username).Msg(\"Invalid password\")\n\t\tcontroller.auth.RecordLoginAttempt(req.Username, false)\n\t\ttlog.AuditLoginFailure(c, req.Username, \"username\", \"invalid password\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"status\":  401,\n\t\t\t\"message\": \"Unauthorized\",\n\t\t})\n\t\treturn\n\t}\n\n\ttlog.App.Info().Str(\"username\", req.Username).Msg(\"Login successful\")\n\ttlog.AuditLoginSuccess(c, req.Username, \"username\")\n\n\tcontroller.auth.RecordLoginAttempt(req.Username, true)\n\n\tif userSearch.Type == \"local\" {\n\t\tuser := controller.auth.GetLocalUser(userSearch.Username)\n\n\t\tif user.TotpSecret != \"\" {\n\t\t\ttlog.App.Debug().Str(\"username\", req.Username).Msg(\"User has TOTP enabled, requiring TOTP verification\")\n\n\t\t\terr := controller.auth.CreateSessionCookie(c, &repository.Session{\n\t\t\t\tUsername:    user.Username,\n\t\t\t\tName:        utils.Capitalize(user.Username),\n\t\t\t\tEmail:       utils.CompileUserEmail(user.Username, controller.config.CookieDomain),\n\t\t\t\tProvider:    \"local\",\n\t\t\t\tTotpPending: true,\n\t\t\t})\n\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to create session cookie\")\n\t\t\t\tc.JSON(500, gin.H{\n\t\t\t\t\t\"status\":  500,\n\t\t\t\t\t\"message\": \"Internal Server Error\",\n\t\t\t\t})\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tc.JSON(200, gin.H{\n\t\t\t\t\"status\":      200,\n\t\t\t\t\"message\":     \"TOTP required\",\n\t\t\t\t\"totpPending\": true,\n\t\t\t})\n\t\t\treturn\n\t\t}\n\t}\n\n\tsessionCookie := repository.Session{\n\t\tUsername: req.Username,\n\t\tName:     utils.Capitalize(req.Username),\n\t\tEmail:    utils.CompileUserEmail(req.Username, controller.config.CookieDomain),\n\t\tProvider: \"local\",\n\t}\n\n\tif userSearch.Type == \"ldap\" {\n\t\tsessionCookie.Provider = \"ldap\"\n\t}\n\n\ttlog.App.Trace().Interface(\"session_cookie\", sessionCookie).Msg(\"Creating session cookie\")\n\n\terr = controller.auth.CreateSessionCookie(c, &sessionCookie)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to create session cookie\")\n\t\tc.JSON(500, gin.H{\n\t\t\t\"status\":  500,\n\t\t\t\"message\": \"Internal Server Error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"status\":  200,\n\t\t\"message\": \"Login successful\",\n\t})\n}\n\nfunc (controller *UserController) logoutHandler(c *gin.Context) {\n\ttlog.App.Debug().Msg(\"Logout request received\")\n\n\tcontroller.auth.DeleteSessionCookie(c)\n\n\tcontext, err := utils.GetContext(c)\n\tif err == nil && context.IsLoggedIn {\n\t\ttlog.AuditLogout(c, context.Username, context.Provider)\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"status\":  200,\n\t\t\"message\": \"Logout successful\",\n\t})\n}\n\nfunc (controller *UserController) totpHandler(c *gin.Context) {\n\tvar req TotpRequest\n\n\terr := c.ShouldBindJSON(&req)\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to bind JSON\")\n\t\tc.JSON(400, gin.H{\n\t\t\t\"status\":  400,\n\t\t\t\"message\": \"Bad Request\",\n\t\t})\n\t\treturn\n\t}\n\n\tcontext, err := utils.GetContext(c)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to get user context\")\n\t\tc.JSON(500, gin.H{\n\t\t\t\"status\":  500,\n\t\t\t\"message\": \"Internal Server Error\",\n\t\t})\n\t\treturn\n\t}\n\n\tif !context.TotpPending {\n\t\ttlog.App.Warn().Msg(\"TOTP attempt without a pending TOTP session\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"status\":  401,\n\t\t\t\"message\": \"Unauthorized\",\n\t\t})\n\t\treturn\n\t}\n\n\ttlog.App.Debug().Str(\"username\", context.Username).Msg(\"TOTP verification attempt\")\n\n\tisLocked, remaining := controller.auth.IsAccountLocked(context.Username)\n\n\tif isLocked {\n\t\ttlog.App.Warn().Str(\"username\", context.Username).Msg(\"Account is locked due to too many failed TOTP attempts\")\n\t\tc.Writer.Header().Add(\"x-tinyauth-lock-locked\", \"true\")\n\t\tc.Writer.Header().Add(\"x-tinyauth-lock-reset\", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))\n\t\tc.JSON(429, gin.H{\n\t\t\t\"status\":  429,\n\t\t\t\"message\": fmt.Sprintf(\"Too many failed TOTP attempts. Try again in %d seconds\", remaining),\n\t\t})\n\t\treturn\n\t}\n\n\tuser := controller.auth.GetLocalUser(context.Username)\n\n\tok := totp.Validate(req.Code, user.TotpSecret)\n\n\tif !ok {\n\t\ttlog.App.Warn().Str(\"username\", context.Username).Msg(\"Invalid TOTP code\")\n\t\tcontroller.auth.RecordLoginAttempt(context.Username, false)\n\t\ttlog.AuditLoginFailure(c, context.Username, \"totp\", \"invalid totp code\")\n\t\tc.JSON(401, gin.H{\n\t\t\t\"status\":  401,\n\t\t\t\"message\": \"Unauthorized\",\n\t\t})\n\t\treturn\n\t}\n\n\ttlog.App.Info().Str(\"username\", context.Username).Msg(\"TOTP verification successful\")\n\ttlog.AuditLoginSuccess(c, context.Username, \"totp\")\n\n\tcontroller.auth.RecordLoginAttempt(context.Username, true)\n\n\tsessionCookie := repository.Session{\n\t\tUsername: user.Username,\n\t\tName:     utils.Capitalize(user.Username),\n\t\tEmail:    utils.CompileUserEmail(user.Username, controller.config.CookieDomain),\n\t\tProvider: \"local\",\n\t}\n\n\ttlog.App.Trace().Interface(\"session_cookie\", sessionCookie).Msg(\"Creating session cookie\")\n\n\terr = controller.auth.CreateSessionCookie(c, &sessionCookie)\n\n\tif err != nil {\n\t\ttlog.App.Error().Err(err).Msg(\"Failed to create session cookie\")\n\t\tc.JSON(500, gin.H{\n\t\t\t\"status\":  500,\n\t\t\t\"message\": \"Internal Server Error\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.JSON(200, gin.H{\n\t\t\"status\":  200,\n\t\t\"message\": \"Login successful\",\n\t})\n}\n"
  },
  {
    "path": "internal/controller/user_controller_test.go",
    "content": "package controller_test\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/bootstrap\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/pquerna/otp/totp\"\n\t\"gotest.tools/v3/assert\"\n)\n\nvar cookieValue string\nvar totpSecret = \"6WFZXPEZRK5MZHHYAFW4DAOUYQMCASBJ\"\n\nfunc setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Engine, *httptest.ResponseRecorder) {\n\ttlog.NewSimpleLogger().Init()\n\n\t// Setup\n\tgin.SetMode(gin.TestMode)\n\trouter := gin.Default()\n\n\tif middlewares != nil {\n\t\tfor _, m := range *middlewares {\n\t\t\trouter.Use(m)\n\t\t}\n\t}\n\n\tgroup := router.Group(\"/api\")\n\trecorder := httptest.NewRecorder()\n\n\t// Mock app\n\tapp := bootstrap.NewBootstrapApp(config.Config{})\n\n\t// Database\n\tdb, err := app.SetupDatabase(\":memory:\")\n\n\tassert.NilError(t, err)\n\n\t// Queries\n\tqueries := repository.New(db)\n\n\t// Auth service\n\tauthService := service.NewAuthService(service.AuthServiceConfig{\n\t\tUsers: []config.User{\n\t\t\t{\n\t\t\t\tUsername: \"testuser\",\n\t\t\t\tPassword: \"$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.\", // test\n\t\t\t},\n\t\t\t{\n\t\t\t\tUsername:   \"totpuser\",\n\t\t\t\tPassword:   \"$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.\", // test\n\t\t\t\tTotpSecret: totpSecret,\n\t\t\t},\n\t\t},\n\t\tOauthWhitelist:     []string{},\n\t\tSessionExpiry:      3600,\n\t\tSessionMaxLifetime: 0,\n\t\tSecureCookie:       false,\n\t\tCookieDomain:       \"localhost\",\n\t\tLoginTimeout:       300,\n\t\tLoginMaxRetries:    3,\n\t\tSessionCookieName:  \"tinyauth-session\",\n\t}, nil, nil, queries)\n\n\t// Controller\n\tctrl := controller.NewUserController(controller.UserControllerConfig{\n\t\tCookieDomain: \"localhost\",\n\t}, group, authService)\n\tctrl.SetupRoutes()\n\n\treturn router, recorder\n}\n\nfunc TestLoginHandler(t *testing.T) {\n\t// Setup\n\trouter, recorder := setupUserController(t, nil)\n\n\tloginReq := controller.LoginRequest{\n\t\tUsername: \"testuser\",\n\t\tPassword: \"test\",\n\t}\n\n\tloginReqJson, err := json.Marshal(loginReq)\n\tassert.NilError(t, err)\n\n\t// Test\n\treq := httptest.NewRequest(\"POST\", \"/api/user/login\", strings.NewReader(string(loginReqJson)))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\n\tcookie := recorder.Result().Cookies()[0]\n\n\tassert.Equal(t, \"tinyauth-session\", cookie.Name)\n\tassert.Assert(t, cookie.Value != \"\")\n\n\tcookieValue = cookie.Value\n\n\t// Test invalid credentials\n\tloginReq = controller.LoginRequest{\n\t\tUsername: \"testuser\",\n\t\tPassword: \"invalid\",\n\t}\n\n\tloginReqJson, err = json.Marshal(loginReq)\n\tassert.NilError(t, err)\n\n\trecorder = httptest.NewRecorder()\n\treq = httptest.NewRequest(\"POST\", \"/api/user/login\", strings.NewReader(string(loginReqJson)))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 401, recorder.Code)\n\n\t// Test totp required\n\tloginReq = controller.LoginRequest{\n\t\tUsername: \"totpuser\",\n\t\tPassword: \"test\",\n\t}\n\n\tloginReqJson, err = json.Marshal(loginReq)\n\tassert.NilError(t, err)\n\n\trecorder = httptest.NewRecorder()\n\treq = httptest.NewRequest(\"POST\", \"/api/user/login\", strings.NewReader(string(loginReqJson)))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\n\tloginResJson, err := json.Marshal(map[string]any{\n\t\t\"message\":     \"TOTP required\",\n\t\t\"status\":      200,\n\t\t\"totpPending\": true,\n\t})\n\n\tassert.NilError(t, err)\n\tassert.Equal(t, string(loginResJson), recorder.Body.String())\n\n\t// Test invalid json\n\trecorder = httptest.NewRecorder()\n\treq = httptest.NewRequest(\"POST\", \"/api/user/login\", strings.NewReader(\"{invalid json}\"))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 400, recorder.Code)\n\n\t// Test rate limiting\n\tloginReq = controller.LoginRequest{\n\t\tUsername: \"testuser\",\n\t\tPassword: \"invalid\",\n\t}\n\n\tloginReqJson, err = json.Marshal(loginReq)\n\tassert.NilError(t, err)\n\n\tfor range 5 {\n\t\trecorder = httptest.NewRecorder()\n\t\treq = httptest.NewRequest(\"POST\", \"/api/user/login\", strings.NewReader(string(loginReqJson)))\n\t\trouter.ServeHTTP(recorder, req)\n\t}\n\n\tassert.Equal(t, 429, recorder.Code)\n}\n\nfunc TestLogoutHandler(t *testing.T) {\n\t// Setup\n\trouter, recorder := setupUserController(t, nil)\n\n\t// Test\n\treq := httptest.NewRequest(\"POST\", \"/api/user/logout\", nil)\n\n\treq.AddCookie(&http.Cookie{\n\t\tName:  \"tinyauth-session\",\n\t\tValue: cookieValue,\n\t})\n\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\n\tcookie := recorder.Result().Cookies()[0]\n\n\tassert.Equal(t, \"tinyauth-session\", cookie.Name)\n\tassert.Equal(t, \"\", cookie.Value)\n\tassert.Equal(t, -1, cookie.MaxAge)\n}\n\nfunc TestTotpHandler(t *testing.T) {\n\t// Setup\n\trouter, recorder := setupUserController(t, &[]gin.HandlerFunc{\n\t\tfunc(c *gin.Context) {\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:    \"totpuser\",\n\t\t\t\tName:        \"totpuser\",\n\t\t\t\tEmail:       \"totpuser@example.com\",\n\t\t\t\tIsLoggedIn:  false,\n\t\t\t\tOAuth:       false,\n\t\t\t\tProvider:    \"local\",\n\t\t\t\tTotpPending: true,\n\t\t\t\tOAuthGroups: \"\",\n\t\t\t\tTotpEnabled: true,\n\t\t\t})\n\t\t\tc.Next()\n\t\t},\n\t})\n\n\t// Test\n\tcode, err := totp.GenerateCode(totpSecret, time.Now())\n\n\tassert.NilError(t, err)\n\n\ttotpReq := controller.TotpRequest{\n\t\tCode: code,\n\t}\n\n\ttotpReqJson, err := json.Marshal(totpReq)\n\tassert.NilError(t, err)\n\n\treq := httptest.NewRequest(\"POST\", \"/api/user/totp\", strings.NewReader(string(totpReqJson)))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 200, recorder.Code)\n\n\tcookie := recorder.Result().Cookies()[0]\n\n\tassert.Equal(t, \"tinyauth-session\", cookie.Name)\n\tassert.Assert(t, cookie.Value != \"\")\n\n\t// Test invalid json\n\trecorder = httptest.NewRecorder()\n\treq = httptest.NewRequest(\"POST\", \"/api/user/totp\", strings.NewReader(\"{invalid json}\"))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 400, recorder.Code)\n\n\t// Test rate limiting\n\ttotpReq = controller.TotpRequest{\n\t\tCode: \"000000\",\n\t}\n\n\ttotpReqJson, err = json.Marshal(totpReq)\n\tassert.NilError(t, err)\n\n\tfor range 5 {\n\t\trecorder = httptest.NewRecorder()\n\t\treq = httptest.NewRequest(\"POST\", \"/api/user/totp\", strings.NewReader(string(totpReqJson)))\n\t\trouter.ServeHTTP(recorder, req)\n\t}\n\n\tassert.Equal(t, 429, recorder.Code)\n\n\t// Test invalid code\n\trouter, recorder = setupUserController(t, &[]gin.HandlerFunc{\n\t\tfunc(c *gin.Context) {\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:    \"totpuser\",\n\t\t\t\tName:        \"totpuser\",\n\t\t\t\tEmail:       \"totpuser@example.com\",\n\t\t\t\tIsLoggedIn:  false,\n\t\t\t\tOAuth:       false,\n\t\t\t\tProvider:    \"local\",\n\t\t\t\tTotpPending: true,\n\t\t\t\tOAuthGroups: \"\",\n\t\t\t\tTotpEnabled: true,\n\t\t\t})\n\t\t\tc.Next()\n\t\t},\n\t})\n\n\treq = httptest.NewRequest(\"POST\", \"/api/user/totp\", strings.NewReader(string(totpReqJson)))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 401, recorder.Code)\n\n\t// Test no totp pending\n\trouter, recorder = setupUserController(t, &[]gin.HandlerFunc{\n\t\tfunc(c *gin.Context) {\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:    \"totpuser\",\n\t\t\t\tName:        \"totpuser\",\n\t\t\t\tEmail:       \"totpuser@example.com\",\n\t\t\t\tIsLoggedIn:  false,\n\t\t\t\tOAuth:       false,\n\t\t\t\tProvider:    \"local\",\n\t\t\t\tTotpPending: false,\n\t\t\t\tOAuthGroups: \"\",\n\t\t\t\tTotpEnabled: false,\n\t\t\t})\n\t\t\tc.Next()\n\t\t},\n\t})\n\n\treq = httptest.NewRequest(\"POST\", \"/api/user/totp\", strings.NewReader(string(totpReqJson)))\n\trouter.ServeHTTP(recorder, req)\n\n\tassert.Equal(t, 401, recorder.Code)\n}\n"
  },
  {
    "path": "internal/controller/well_known_controller.go",
    "content": "package controller\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n)\n\ntype OpenIDConnectConfiguration struct {\n\tIssuer                            string   `json:\"issuer\"`\n\tAuthorizationEndpoint             string   `json:\"authorization_endpoint\"`\n\tTokenEndpoint                     string   `json:\"token_endpoint\"`\n\tUserinfoEndpoint                  string   `json:\"userinfo_endpoint\"`\n\tJwksUri                           string   `json:\"jwks_uri\"`\n\tScopesSupported                   []string `json:\"scopes_supported\"`\n\tResponseTypesSupported            []string `json:\"response_types_supported\"`\n\tGrantTypesSupported               []string `json:\"grant_types_supported\"`\n\tSubjectTypesSupported             []string `json:\"subject_types_supported\"`\n\tIDTokenSigningAlgValuesSupported  []string `json:\"id_token_signing_alg_values_supported\"`\n\tTokenEndpointAuthMethodsSupported []string `json:\"token_endpoint_auth_methods_supported\"`\n\tClaimsSupported                   []string `json:\"claims_supported\"`\n\tServiceDocumentation              string   `json:\"service_documentation\"`\n}\n\ntype WellKnownControllerConfig struct{}\n\ntype WellKnownController struct {\n\tconfig WellKnownControllerConfig\n\tengine *gin.Engine\n\toidc   *service.OIDCService\n}\n\nfunc NewWellKnownController(config WellKnownControllerConfig, oidc *service.OIDCService, engine *gin.Engine) *WellKnownController {\n\treturn &WellKnownController{\n\t\tconfig: config,\n\t\toidc:   oidc,\n\t\tengine: engine,\n\t}\n}\n\nfunc (controller *WellKnownController) SetupRoutes() {\n\tcontroller.engine.GET(\"/.well-known/openid-configuration\", controller.OpenIDConnectConfiguration)\n\tcontroller.engine.GET(\"/.well-known/jwks.json\", controller.JWKS)\n}\n\nfunc (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) {\n\tissuer := controller.oidc.GetIssuer()\n\tc.JSON(200, OpenIDConnectConfiguration{\n\t\tIssuer:                            issuer,\n\t\tAuthorizationEndpoint:             fmt.Sprintf(\"%s/authorize\", issuer),\n\t\tTokenEndpoint:                     fmt.Sprintf(\"%s/api/oidc/token\", issuer),\n\t\tUserinfoEndpoint:                  fmt.Sprintf(\"%s/api/oidc/userinfo\", issuer),\n\t\tJwksUri:                           fmt.Sprintf(\"%s/.well-known/jwks.json\", issuer),\n\t\tScopesSupported:                   service.SupportedScopes,\n\t\tResponseTypesSupported:            service.SupportedResponseTypes,\n\t\tGrantTypesSupported:               service.SupportedGrantTypes,\n\t\tSubjectTypesSupported:             []string{\"pairwise\"},\n\t\tIDTokenSigningAlgValuesSupported:  []string{\"RS256\"},\n\t\tTokenEndpointAuthMethodsSupported: []string{\"client_secret_basic\", \"client_secret_post\"},\n\t\tClaimsSupported:                   []string{\"sub\", \"updated_at\", \"name\", \"preferred_username\", \"email\", \"email_verified\", \"groups\"},\n\t\tServiceDocumentation:              \"https://tinyauth.app/docs/guides/oidc\",\n\t})\n}\n\nfunc (controller *WellKnownController) JWKS(c *gin.Context) {\n\tjwks, err := controller.oidc.GetJWK()\n\n\tif err != nil {\n\t\tc.JSON(500, gin.H{\n\t\t\t\"status\":  \"500\",\n\t\t\t\"message\": \"failed to get JWK\",\n\t\t})\n\t\treturn\n\t}\n\n\tc.Header(\"content-type\", \"application/json\")\n\n\tc.Writer.WriteString(`{\"keys\":[`)\n\tc.Writer.Write(jwks)\n\tc.Writer.WriteString(`]}`)\n\n\tc.Status(http.StatusOK)\n}\n"
  },
  {
    "path": "internal/middleware/context_middleware.go",
    "content": "package middleware\n\nimport (\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nvar OIDCIgnorePaths = []string{\"/api/oidc/token\", \"/api/oidc/userinfo\"}\n\ntype ContextMiddlewareConfig struct {\n\tCookieDomain string\n}\n\ntype ContextMiddleware struct {\n\tconfig ContextMiddlewareConfig\n\tauth   *service.AuthService\n\tbroker *service.OAuthBrokerService\n}\n\nfunc NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware {\n\treturn &ContextMiddleware{\n\t\tconfig: config,\n\t\tauth:   auth,\n\t\tbroker: broker,\n\t}\n}\n\nfunc (m *ContextMiddleware) Init() error {\n\treturn nil\n}\n\nfunc (m *ContextMiddleware) Middleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\t// There is no point in trying to get credentials if it's an OIDC endpoint\n\t\tpath := c.Request.URL.Path\n\t\tif slices.Contains(OIDCIgnorePaths, strings.TrimSuffix(path, \"/\")) {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tcookie, err := m.auth.GetSessionCookie(c)\n\n\t\tif err != nil {\n\t\t\ttlog.App.Debug().Err(err).Msg(\"No valid session cookie found\")\n\t\t\tgoto basic\n\t\t}\n\n\t\tif cookie.TotpPending {\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:    cookie.Username,\n\t\t\t\tName:        cookie.Name,\n\t\t\t\tEmail:       cookie.Email,\n\t\t\t\tProvider:    \"local\",\n\t\t\t\tTotpPending: true,\n\t\t\t\tTotpEnabled: true,\n\t\t\t})\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tswitch cookie.Provider {\n\t\tcase \"local\", \"ldap\":\n\t\t\tuserSearch := m.auth.SearchUser(cookie.Username)\n\n\t\t\tif userSearch.Type == \"unknown\" {\n\t\t\t\ttlog.App.Debug().Msg(\"User from session cookie not found\")\n\t\t\t\tm.auth.DeleteSessionCookie(c)\n\t\t\t\tgoto basic\n\t\t\t}\n\n\t\t\tif userSearch.Type != cookie.Provider {\n\t\t\t\ttlog.App.Warn().Msg(\"User type from session cookie does not match user search type\")\n\t\t\t\tm.auth.DeleteSessionCookie(c)\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tvar ldapGroups []string\n\n\t\t\tif cookie.Provider == \"ldap\" {\n\t\t\t\tldapUser, err := m.auth.GetLdapUser(userSearch.Username)\n\n\t\t\t\tif err != nil {\n\t\t\t\t\ttlog.App.Error().Err(err).Msg(\"Error retrieving LDAP user details\")\n\t\t\t\t\tc.Next()\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tldapGroups = ldapUser.Groups\n\t\t\t}\n\n\t\t\tm.auth.RefreshSessionCookie(c)\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:   cookie.Username,\n\t\t\t\tName:       cookie.Name,\n\t\t\t\tEmail:      cookie.Email,\n\t\t\t\tProvider:   cookie.Provider,\n\t\t\t\tIsLoggedIn: true,\n\t\t\t\tLdapGroups: strings.Join(ldapGroups, \",\"),\n\t\t\t})\n\t\t\tc.Next()\n\t\t\treturn\n\t\tdefault:\n\t\t\t_, exists := m.broker.GetService(cookie.Provider)\n\n\t\t\tif !exists {\n\t\t\t\ttlog.App.Debug().Msg(\"OAuth provider from session cookie not found\")\n\t\t\t\tm.auth.DeleteSessionCookie(c)\n\t\t\t\tgoto basic\n\t\t\t}\n\n\t\t\tif !m.auth.IsEmailWhitelisted(cookie.Email) {\n\t\t\t\ttlog.App.Debug().Msg(\"Email from session cookie not whitelisted\")\n\t\t\t\tm.auth.DeleteSessionCookie(c)\n\t\t\t\tgoto basic\n\t\t\t}\n\n\t\t\tm.auth.RefreshSessionCookie(c)\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:    cookie.Username,\n\t\t\t\tName:        cookie.Name,\n\t\t\t\tEmail:       cookie.Email,\n\t\t\t\tProvider:    cookie.Provider,\n\t\t\t\tOAuthGroups: cookie.OAuthGroups,\n\t\t\t\tOAuthName:   cookie.OAuthName,\n\t\t\t\tOAuthSub:    cookie.OAuthSub,\n\t\t\t\tIsLoggedIn:  true,\n\t\t\t\tOAuth:       true,\n\t\t\t})\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\tbasic:\n\t\tbasic := m.auth.GetBasicAuth(c)\n\n\t\tif basic == nil {\n\t\t\ttlog.App.Debug().Msg(\"No basic auth provided\")\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tlocked, remaining := m.auth.IsAccountLocked(basic.Username)\n\n\t\tif locked {\n\t\t\ttlog.App.Debug().Msgf(\"Account for user %s is locked for %d seconds, denying auth\", basic.Username, remaining)\n\t\t\tc.Writer.Header().Add(\"x-tinyauth-lock-locked\", \"true\")\n\t\t\tc.Writer.Header().Add(\"x-tinyauth-lock-reset\", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tuserSearch := m.auth.SearchUser(basic.Username)\n\n\t\tif userSearch.Type == \"unknown\" || userSearch.Type == \"error\" {\n\t\t\tm.auth.RecordLoginAttempt(basic.Username, false)\n\t\t\ttlog.App.Debug().Msg(\"User from basic auth not found\")\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tif !m.auth.VerifyUser(userSearch, basic.Password) {\n\t\t\tm.auth.RecordLoginAttempt(basic.Username, false)\n\t\t\ttlog.App.Debug().Msg(\"Invalid password for basic auth user\")\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tm.auth.RecordLoginAttempt(basic.Username, true)\n\n\t\tswitch userSearch.Type {\n\t\tcase \"local\":\n\t\t\ttlog.App.Debug().Msg(\"Basic auth user is local\")\n\n\t\t\tuser := m.auth.GetLocalUser(basic.Username)\n\n\t\t\tif user.TotpSecret != \"\" {\n\t\t\t\ttlog.App.Debug().Msg(\"User with TOTP not allowed to login via basic auth\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:    user.Username,\n\t\t\t\tName:        utils.Capitalize(user.Username),\n\t\t\t\tEmail:       utils.CompileUserEmail(user.Username, m.config.CookieDomain),\n\t\t\t\tProvider:    \"local\",\n\t\t\t\tIsLoggedIn:  true,\n\t\t\t\tIsBasicAuth: true,\n\t\t\t})\n\t\t\tc.Next()\n\t\t\treturn\n\t\tcase \"ldap\":\n\t\t\ttlog.App.Debug().Msg(\"Basic auth user is LDAP\")\n\n\t\t\tldapUser, err := m.auth.GetLdapUser(basic.Username)\n\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Debug().Err(err).Msg(\"Error retrieving LDAP user details\")\n\t\t\t\tc.Next()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tc.Set(\"context\", &config.UserContext{\n\t\t\t\tUsername:    basic.Username,\n\t\t\t\tName:        utils.Capitalize(basic.Username),\n\t\t\t\tEmail:       utils.CompileUserEmail(basic.Username, m.config.CookieDomain),\n\t\t\t\tProvider:    \"ldap\",\n\t\t\t\tIsLoggedIn:  true,\n\t\t\t\tLdapGroups:  strings.Join(ldapUser.Groups, \",\"),\n\t\t\t\tIsBasicAuth: true,\n\t\t\t})\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/ui_middleware.go",
    "content": "package middleware\n\nimport (\n\t\"fmt\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/assets\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\ntype UIMiddleware struct {\n\tuiFs         fs.FS\n\tuiFileServer http.Handler\n}\n\nfunc NewUIMiddleware() *UIMiddleware {\n\treturn &UIMiddleware{}\n}\n\nfunc (m *UIMiddleware) Init() error {\n\tui, err := fs.Sub(assets.FrontendAssets, \"dist\")\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tm.uiFs = ui\n\tm.uiFileServer = http.FileServerFS(ui)\n\n\treturn nil\n}\n\nfunc (m *UIMiddleware) Middleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tpath := strings.TrimPrefix(c.Request.URL.Path, \"/\")\n\n\t\ttlog.App.Debug().Str(\"path\", path).Msg(\"path\")\n\n\t\tswitch strings.SplitN(path, \"/\", 2)[0] {\n\t\tcase \"api\", \"resources\", \".well-known\":\n\t\t\tc.Next()\n\t\t\treturn\n\t\tdefault:\n\t\t\t_, err := fs.Stat(m.uiFs, path)\n\n\t\t\t// Enough for one authentication flow\n\t\t\tmaxAge := 15 * time.Minute\n\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\tc.Request.URL.Path = \"/\"\n\t\t\t} else if strings.HasPrefix(path, \"assets/\") {\n\t\t\t\t// assets are named with a hash and can be cached for a long time\n\t\t\t\tmaxAge = 30 * 24 * time.Hour\n\t\t\t}\n\n\t\t\tc.Writer.Header().Set(\"Cache-Control\", fmt.Sprintf(\"public, max-age=%d\", int(maxAge.Seconds())))\n\t\t\tm.uiFileServer.ServeHTTP(c.Writer, c.Request)\n\t\t\tc.Abort()\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/middleware/zerolog_middleware.go",
    "content": "package middleware\n\nimport (\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n)\n\nvar (\n\tloggerSkipPathsPrefix = []string{\n\t\t\"GET /api/health\",\n\t\t\"HEAD /api/health\",\n\t\t\"GET /favicon.ico\",\n\t}\n)\n\ntype ZerologMiddleware struct{}\n\nfunc NewZerologMiddleware() *ZerologMiddleware {\n\treturn &ZerologMiddleware{}\n}\n\nfunc (m *ZerologMiddleware) Init() error {\n\treturn nil\n}\n\nfunc (m *ZerologMiddleware) logPath(path string) bool {\n\tfor _, prefix := range loggerSkipPathsPrefix {\n\t\tif strings.HasPrefix(path, prefix) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\nfunc (m *ZerologMiddleware) Middleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\ttStart := time.Now()\n\n\t\tc.Next()\n\n\t\tcode := c.Writer.Status()\n\t\taddress := c.Request.RemoteAddr\n\t\tclientIP := c.ClientIP()\n\t\tmethod := c.Request.Method\n\t\tpath := c.Request.URL.Path\n\n\t\tlatency := time.Since(tStart).String()\n\n\t\tsubLogger := tlog.HTTP.With().Str(\"method\", method).\n\t\t\tStr(\"path\", path).\n\t\t\tStr(\"address\", address).\n\t\t\tStr(\"client_ip\", clientIP).\n\t\t\tInt(\"status\", code).\n\t\t\tStr(\"latency\", latency).Logger()\n\n\t\tif m.logPath(method + \" \" + path) {\n\t\t\tswitch {\n\t\t\tcase code >= 400 && code < 500:\n\t\t\t\tsubLogger.Warn().Msg(\"Client Error\")\n\t\t\tcase code >= 500:\n\t\t\t\tsubLogger.Error().Msg(\"Server Error\")\n\t\t\tdefault:\n\t\t\t\tsubLogger.Info().Msg(\"Request\")\n\t\t\t}\n\t\t} else {\n\t\t\tsubLogger.Debug().Msg(\"Request\")\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/repository/db.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.30.0\n\npackage repository\n\nimport (\n\t\"context\"\n\t\"database/sql\"\n)\n\ntype DBTX interface {\n\tExecContext(context.Context, string, ...interface{}) (sql.Result, error)\n\tPrepareContext(context.Context, string) (*sql.Stmt, error)\n\tQueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)\n\tQueryRowContext(context.Context, string, ...interface{}) *sql.Row\n}\n\nfunc New(db DBTX) *Queries {\n\treturn &Queries{db: db}\n}\n\ntype Queries struct {\n\tdb DBTX\n}\n\nfunc (q *Queries) WithTx(tx *sql.Tx) *Queries {\n\treturn &Queries{\n\t\tdb: tx,\n\t}\n}\n"
  },
  {
    "path": "internal/repository/models.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.30.0\n\npackage repository\n\ntype OidcCode struct {\n\tSub         string\n\tCodeHash    string\n\tScope       string\n\tRedirectURI string\n\tClientID    string\n\tExpiresAt   int64\n\tNonce       string\n}\n\ntype OidcToken struct {\n\tSub                   string\n\tAccessTokenHash       string\n\tRefreshTokenHash      string\n\tScope                 string\n\tClientID              string\n\tTokenExpiresAt        int64\n\tRefreshTokenExpiresAt int64\n\tNonce                 string\n}\n\ntype OidcUserinfo struct {\n\tSub               string\n\tName              string\n\tPreferredUsername string\n\tEmail             string\n\tGroups            string\n\tUpdatedAt         int64\n}\n\ntype Session struct {\n\tUUID        string\n\tUsername    string\n\tEmail       string\n\tName        string\n\tProvider    string\n\tTotpPending bool\n\tOAuthGroups string\n\tExpiry      int64\n\tCreatedAt   int64\n\tOAuthName   string\n\tOAuthSub    string\n}\n"
  },
  {
    "path": "internal/repository/oidc_queries.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.30.0\n// source: oidc_queries.sql\n\npackage repository\n\nimport (\n\t\"context\"\n)\n\nconst createOidcCode = `-- name: CreateOidcCode :one\nINSERT INTO \"oidc_codes\" (\n    \"sub\",\n    \"code_hash\",\n    \"scope\",\n    \"redirect_uri\",\n    \"client_id\",\n    \"expires_at\",\n    \"nonce\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?, ?\n)\nRETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce\n`\n\ntype CreateOidcCodeParams struct {\n\tSub         string\n\tCodeHash    string\n\tScope       string\n\tRedirectURI string\n\tClientID    string\n\tExpiresAt   int64\n\tNonce       string\n}\n\nfunc (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error) {\n\trow := q.db.QueryRowContext(ctx, createOidcCode,\n\t\targ.Sub,\n\t\targ.CodeHash,\n\t\targ.Scope,\n\t\targ.RedirectURI,\n\t\targ.ClientID,\n\t\targ.ExpiresAt,\n\t\targ.Nonce,\n\t)\n\tvar i OidcCode\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.CodeHash,\n\t\t&i.Scope,\n\t\t&i.RedirectURI,\n\t\t&i.ClientID,\n\t\t&i.ExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst createOidcToken = `-- name: CreateOidcToken :one\nINSERT INTO \"oidc_tokens\" (\n    \"sub\",\n    \"access_token_hash\",\n    \"refresh_token_hash\",\n    \"scope\",\n    \"client_id\",\n    \"token_expires_at\",\n    \"refresh_token_expires_at\",\n    \"nonce\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?, ?, ?\n)\nRETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce\n`\n\ntype CreateOidcTokenParams struct {\n\tSub                   string\n\tAccessTokenHash       string\n\tRefreshTokenHash      string\n\tScope                 string\n\tClientID              string\n\tTokenExpiresAt        int64\n\tRefreshTokenExpiresAt int64\n\tNonce                 string\n}\n\nfunc (q *Queries) CreateOidcToken(ctx context.Context, arg CreateOidcTokenParams) (OidcToken, error) {\n\trow := q.db.QueryRowContext(ctx, createOidcToken,\n\t\targ.Sub,\n\t\targ.AccessTokenHash,\n\t\targ.RefreshTokenHash,\n\t\targ.Scope,\n\t\targ.ClientID,\n\t\targ.TokenExpiresAt,\n\t\targ.RefreshTokenExpiresAt,\n\t\targ.Nonce,\n\t)\n\tvar i OidcToken\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.AccessTokenHash,\n\t\t&i.RefreshTokenHash,\n\t\t&i.Scope,\n\t\t&i.ClientID,\n\t\t&i.TokenExpiresAt,\n\t\t&i.RefreshTokenExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst createOidcUserInfo = `-- name: CreateOidcUserInfo :one\nINSERT INTO \"oidc_userinfo\" (\n    \"sub\",\n    \"name\",\n    \"preferred_username\",\n    \"email\",\n    \"groups\",\n    \"updated_at\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?\n)\nRETURNING sub, name, preferred_username, email, \"groups\", updated_at\n`\n\ntype CreateOidcUserInfoParams struct {\n\tSub               string\n\tName              string\n\tPreferredUsername string\n\tEmail             string\n\tGroups            string\n\tUpdatedAt         int64\n}\n\nfunc (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {\n\trow := q.db.QueryRowContext(ctx, createOidcUserInfo,\n\t\targ.Sub,\n\t\targ.Name,\n\t\targ.PreferredUsername,\n\t\targ.Email,\n\t\targ.Groups,\n\t\targ.UpdatedAt,\n\t)\n\tvar i OidcUserinfo\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.Name,\n\t\t&i.PreferredUsername,\n\t\t&i.Email,\n\t\t&i.Groups,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst deleteExpiredOidcCodes = `-- name: DeleteExpiredOidcCodes :many\nDELETE FROM \"oidc_codes\"\nWHERE \"expires_at\" < ?\nRETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce\n`\n\nfunc (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error) {\n\trows, err := q.db.QueryContext(ctx, deleteExpiredOidcCodes, expiresAt)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tvar items []OidcCode\n\tfor rows.Next() {\n\t\tvar i OidcCode\n\t\tif err := rows.Scan(\n\t\t\t&i.Sub,\n\t\t\t&i.CodeHash,\n\t\t\t&i.Scope,\n\t\t\t&i.RedirectURI,\n\t\t\t&i.ClientID,\n\t\t\t&i.ExpiresAt,\n\t\t\t&i.Nonce,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Close(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst deleteExpiredOidcTokens = `-- name: DeleteExpiredOidcTokens :many\nDELETE FROM \"oidc_tokens\"\nWHERE \"token_expires_at\" < ? AND \"refresh_token_expires_at\" < ?\nRETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce\n`\n\ntype DeleteExpiredOidcTokensParams struct {\n\tTokenExpiresAt        int64\n\tRefreshTokenExpiresAt int64\n}\n\nfunc (q *Queries) DeleteExpiredOidcTokens(ctx context.Context, arg DeleteExpiredOidcTokensParams) ([]OidcToken, error) {\n\trows, err := q.db.QueryContext(ctx, deleteExpiredOidcTokens, arg.TokenExpiresAt, arg.RefreshTokenExpiresAt)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer rows.Close()\n\tvar items []OidcToken\n\tfor rows.Next() {\n\t\tvar i OidcToken\n\t\tif err := rows.Scan(\n\t\t\t&i.Sub,\n\t\t\t&i.AccessTokenHash,\n\t\t\t&i.RefreshTokenHash,\n\t\t\t&i.Scope,\n\t\t\t&i.ClientID,\n\t\t\t&i.TokenExpiresAt,\n\t\t\t&i.RefreshTokenExpiresAt,\n\t\t\t&i.Nonce,\n\t\t); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titems = append(items, i)\n\t}\n\tif err := rows.Close(); err != nil {\n\t\treturn nil, err\n\t}\n\tif err := rows.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\treturn items, nil\n}\n\nconst deleteOidcCode = `-- name: DeleteOidcCode :exec\nDELETE FROM \"oidc_codes\"\nWHERE \"code_hash\" = ?\n`\n\nfunc (q *Queries) DeleteOidcCode(ctx context.Context, codeHash string) error {\n\t_, err := q.db.ExecContext(ctx, deleteOidcCode, codeHash)\n\treturn err\n}\n\nconst deleteOidcCodeBySub = `-- name: DeleteOidcCodeBySub :exec\nDELETE FROM \"oidc_codes\"\nWHERE \"sub\" = ?\n`\n\nfunc (q *Queries) DeleteOidcCodeBySub(ctx context.Context, sub string) error {\n\t_, err := q.db.ExecContext(ctx, deleteOidcCodeBySub, sub)\n\treturn err\n}\n\nconst deleteOidcToken = `-- name: DeleteOidcToken :exec\nDELETE FROM \"oidc_tokens\"\nWHERE \"access_token_hash\" = ?\n`\n\nfunc (q *Queries) DeleteOidcToken(ctx context.Context, accessTokenHash string) error {\n\t_, err := q.db.ExecContext(ctx, deleteOidcToken, accessTokenHash)\n\treturn err\n}\n\nconst deleteOidcTokenBySub = `-- name: DeleteOidcTokenBySub :exec\nDELETE FROM \"oidc_tokens\"\nWHERE \"sub\" = ?\n`\n\nfunc (q *Queries) DeleteOidcTokenBySub(ctx context.Context, sub string) error {\n\t_, err := q.db.ExecContext(ctx, deleteOidcTokenBySub, sub)\n\treturn err\n}\n\nconst deleteOidcUserInfo = `-- name: DeleteOidcUserInfo :exec\nDELETE FROM \"oidc_userinfo\"\nWHERE \"sub\" = ?\n`\n\nfunc (q *Queries) DeleteOidcUserInfo(ctx context.Context, sub string) error {\n\t_, err := q.db.ExecContext(ctx, deleteOidcUserInfo, sub)\n\treturn err\n}\n\nconst getOidcCode = `-- name: GetOidcCode :one\nDELETE FROM \"oidc_codes\"\nWHERE \"code_hash\" = ?\nRETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce\n`\n\nfunc (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcCode, codeHash)\n\tvar i OidcCode\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.CodeHash,\n\t\t&i.Scope,\n\t\t&i.RedirectURI,\n\t\t&i.ClientID,\n\t\t&i.ExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst getOidcCodeBySub = `-- name: GetOidcCodeBySub :one\nDELETE FROM \"oidc_codes\"\nWHERE \"sub\" = ?\nRETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce\n`\n\nfunc (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcCodeBySub, sub)\n\tvar i OidcCode\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.CodeHash,\n\t\t&i.Scope,\n\t\t&i.RedirectURI,\n\t\t&i.ClientID,\n\t\t&i.ExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst getOidcCodeBySubUnsafe = `-- name: GetOidcCodeBySubUnsafe :one\nSELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce FROM \"oidc_codes\"\nWHERE \"sub\" = ?\n`\n\nfunc (q *Queries) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (OidcCode, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcCodeBySubUnsafe, sub)\n\tvar i OidcCode\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.CodeHash,\n\t\t&i.Scope,\n\t\t&i.RedirectURI,\n\t\t&i.ClientID,\n\t\t&i.ExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst getOidcCodeUnsafe = `-- name: GetOidcCodeUnsafe :one\nSELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce FROM \"oidc_codes\"\nWHERE \"code_hash\" = ?\n`\n\nfunc (q *Queries) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (OidcCode, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcCodeUnsafe, codeHash)\n\tvar i OidcCode\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.CodeHash,\n\t\t&i.Scope,\n\t\t&i.RedirectURI,\n\t\t&i.ClientID,\n\t\t&i.ExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst getOidcToken = `-- name: GetOidcToken :one\nSELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM \"oidc_tokens\"\nWHERE \"access_token_hash\" = ?\n`\n\nfunc (q *Queries) GetOidcToken(ctx context.Context, accessTokenHash string) (OidcToken, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcToken, accessTokenHash)\n\tvar i OidcToken\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.AccessTokenHash,\n\t\t&i.RefreshTokenHash,\n\t\t&i.Scope,\n\t\t&i.ClientID,\n\t\t&i.TokenExpiresAt,\n\t\t&i.RefreshTokenExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst getOidcTokenByRefreshToken = `-- name: GetOidcTokenByRefreshToken :one\nSELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM \"oidc_tokens\"\nWHERE \"refresh_token_hash\" = ?\n`\n\nfunc (q *Queries) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (OidcToken, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcTokenByRefreshToken, refreshTokenHash)\n\tvar i OidcToken\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.AccessTokenHash,\n\t\t&i.RefreshTokenHash,\n\t\t&i.Scope,\n\t\t&i.ClientID,\n\t\t&i.TokenExpiresAt,\n\t\t&i.RefreshTokenExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst getOidcTokenBySub = `-- name: GetOidcTokenBySub :one\nSELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM \"oidc_tokens\"\nWHERE \"sub\" = ?\n`\n\nfunc (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcTokenBySub, sub)\n\tvar i OidcToken\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.AccessTokenHash,\n\t\t&i.RefreshTokenHash,\n\t\t&i.Scope,\n\t\t&i.ClientID,\n\t\t&i.TokenExpiresAt,\n\t\t&i.RefreshTokenExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n\nconst getOidcUserInfo = `-- name: GetOidcUserInfo :one\nSELECT sub, name, preferred_username, email, \"groups\", updated_at FROM \"oidc_userinfo\"\nWHERE \"sub\" = ?\n`\n\nfunc (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo, error) {\n\trow := q.db.QueryRowContext(ctx, getOidcUserInfo, sub)\n\tvar i OidcUserinfo\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.Name,\n\t\t&i.PreferredUsername,\n\t\t&i.Email,\n\t\t&i.Groups,\n\t\t&i.UpdatedAt,\n\t)\n\treturn i, err\n}\n\nconst updateOidcTokenByRefreshToken = `-- name: UpdateOidcTokenByRefreshToken :one\nUPDATE \"oidc_tokens\" SET\n    \"access_token_hash\" = ?,\n    \"refresh_token_hash\" = ?,\n    \"token_expires_at\" = ?,\n    \"refresh_token_expires_at\" = ?\nWHERE \"refresh_token_hash\" = ?\nRETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce\n`\n\ntype UpdateOidcTokenByRefreshTokenParams struct {\n\tAccessTokenHash       string\n\tRefreshTokenHash      string\n\tTokenExpiresAt        int64\n\tRefreshTokenExpiresAt int64\n\tRefreshTokenHash_2    string\n}\n\nfunc (q *Queries) UpdateOidcTokenByRefreshToken(ctx context.Context, arg UpdateOidcTokenByRefreshTokenParams) (OidcToken, error) {\n\trow := q.db.QueryRowContext(ctx, updateOidcTokenByRefreshToken,\n\t\targ.AccessTokenHash,\n\t\targ.RefreshTokenHash,\n\t\targ.TokenExpiresAt,\n\t\targ.RefreshTokenExpiresAt,\n\t\targ.RefreshTokenHash_2,\n\t)\n\tvar i OidcToken\n\terr := row.Scan(\n\t\t&i.Sub,\n\t\t&i.AccessTokenHash,\n\t\t&i.RefreshTokenHash,\n\t\t&i.Scope,\n\t\t&i.ClientID,\n\t\t&i.TokenExpiresAt,\n\t\t&i.RefreshTokenExpiresAt,\n\t\t&i.Nonce,\n\t)\n\treturn i, err\n}\n"
  },
  {
    "path": "internal/repository/session_queries.sql.go",
    "content": "// Code generated by sqlc. DO NOT EDIT.\n// versions:\n//   sqlc v1.30.0\n// source: session_queries.sql\n\npackage repository\n\nimport (\n\t\"context\"\n)\n\nconst createSession = `-- name: CreateSession :one\nINSERT INTO \"sessions\" (\n    \"uuid\",\n    \"username\",\n    \"email\",\n    \"name\",\n    \"provider\",\n    \"totp_pending\",\n    \"oauth_groups\",\n    \"expiry\",\n    \"created_at\",\n    \"oauth_name\",\n    \"oauth_sub\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\n)\nRETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub\n`\n\ntype CreateSessionParams struct {\n\tUUID        string\n\tUsername    string\n\tEmail       string\n\tName        string\n\tProvider    string\n\tTotpPending bool\n\tOAuthGroups string\n\tExpiry      int64\n\tCreatedAt   int64\n\tOAuthName   string\n\tOAuthSub    string\n}\n\nfunc (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {\n\trow := q.db.QueryRowContext(ctx, createSession,\n\t\targ.UUID,\n\t\targ.Username,\n\t\targ.Email,\n\t\targ.Name,\n\t\targ.Provider,\n\t\targ.TotpPending,\n\t\targ.OAuthGroups,\n\t\targ.Expiry,\n\t\targ.CreatedAt,\n\t\targ.OAuthName,\n\t\targ.OAuthSub,\n\t)\n\tvar i Session\n\terr := row.Scan(\n\t\t&i.UUID,\n\t\t&i.Username,\n\t\t&i.Email,\n\t\t&i.Name,\n\t\t&i.Provider,\n\t\t&i.TotpPending,\n\t\t&i.OAuthGroups,\n\t\t&i.Expiry,\n\t\t&i.CreatedAt,\n\t\t&i.OAuthName,\n\t\t&i.OAuthSub,\n\t)\n\treturn i, err\n}\n\nconst deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec\nDELETE FROM \"sessions\"\nWHERE \"expiry\" < ?\n`\n\nfunc (q *Queries) DeleteExpiredSessions(ctx context.Context, expiry int64) error {\n\t_, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiry)\n\treturn err\n}\n\nconst deleteSession = `-- name: DeleteSession :exec\nDELETE FROM \"sessions\"\nWHERE \"uuid\" = ?\n`\n\nfunc (q *Queries) DeleteSession(ctx context.Context, uuid string) error {\n\t_, err := q.db.ExecContext(ctx, deleteSession, uuid)\n\treturn err\n}\n\nconst getSession = `-- name: GetSession :one\nSELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub FROM \"sessions\"\nWHERE \"uuid\" = ?\n`\n\nfunc (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) {\n\trow := q.db.QueryRowContext(ctx, getSession, uuid)\n\tvar i Session\n\terr := row.Scan(\n\t\t&i.UUID,\n\t\t&i.Username,\n\t\t&i.Email,\n\t\t&i.Name,\n\t\t&i.Provider,\n\t\t&i.TotpPending,\n\t\t&i.OAuthGroups,\n\t\t&i.Expiry,\n\t\t&i.CreatedAt,\n\t\t&i.OAuthName,\n\t\t&i.OAuthSub,\n\t)\n\treturn i, err\n}\n\nconst updateSession = `-- name: UpdateSession :one\nUPDATE \"sessions\" SET\n    \"username\" = ?,\n    \"email\" = ?,\n    \"name\" = ?,\n    \"provider\" = ?,\n    \"totp_pending\" = ?,\n    \"oauth_groups\" = ?,\n    \"expiry\" = ?,\n    \"oauth_name\" = ?,\n    \"oauth_sub\" = ?\nWHERE \"uuid\" = ?\nRETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub\n`\n\ntype UpdateSessionParams struct {\n\tUsername    string\n\tEmail       string\n\tName        string\n\tProvider    string\n\tTotpPending bool\n\tOAuthGroups string\n\tExpiry      int64\n\tOAuthName   string\n\tOAuthSub    string\n\tUUID        string\n}\n\nfunc (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {\n\trow := q.db.QueryRowContext(ctx, updateSession,\n\t\targ.Username,\n\t\targ.Email,\n\t\targ.Name,\n\t\targ.Provider,\n\t\targ.TotpPending,\n\t\targ.OAuthGroups,\n\t\targ.Expiry,\n\t\targ.OAuthName,\n\t\targ.OAuthSub,\n\t\targ.UUID,\n\t)\n\tvar i Session\n\terr := row.Scan(\n\t\t&i.UUID,\n\t\t&i.Username,\n\t\t&i.Email,\n\t\t&i.Name,\n\t\t&i.Provider,\n\t\t&i.TotpPending,\n\t\t&i.OAuthGroups,\n\t\t&i.Expiry,\n\t\t&i.CreatedAt,\n\t\t&i.OAuthName,\n\t\t&i.OAuthSub,\n\t)\n\treturn i, err\n}\n"
  },
  {
    "path": "internal/service/access_controls_service.go",
    "content": "package service\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n)\n\ntype AccessControlsService struct {\n\tdocker *DockerService\n\tstatic map[string]config.App\n}\n\nfunc NewAccessControlsService(docker *DockerService, static map[string]config.App) *AccessControlsService {\n\treturn &AccessControlsService{\n\t\tdocker: docker,\n\t\tstatic: static,\n\t}\n}\n\nfunc (acls *AccessControlsService) Init() error {\n\treturn nil // No initialization needed\n}\n\nfunc (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {\n\tfor app, config := range acls.static {\n\t\tif config.Config.Domain == domain {\n\t\t\ttlog.App.Debug().Str(\"name\", app).Msg(\"Found matching container by domain\")\n\t\t\treturn config, nil\n\t\t}\n\n\t\tif strings.SplitN(domain, \".\", 2)[0] == app {\n\t\t\ttlog.App.Debug().Str(\"name\", app).Msg(\"Found matching container by app name\")\n\t\t\treturn config, nil\n\t\t}\n\t}\n\treturn config.App{}, errors.New(\"no results\")\n}\n\nfunc (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {\n\t// First check in the static config\n\tapp, err := acls.lookupStaticACLs(domain)\n\n\tif err == nil {\n\t\ttlog.App.Debug().Msg(\"Using ACls from static configuration\")\n\t\treturn app, nil\n\t}\n\n\t// Fallback to Docker labels\n\ttlog.App.Debug().Msg(\"Falling back to Docker labels for ACLs\")\n\treturn acls.docker.GetLabels(domain)\n}\n"
  },
  {
    "path": "internal/service/auth_service.go",
    "content": "package service\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/google/uuid\"\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\ntype LdapGroupsCache struct {\n\tGroups  []string\n\tExpires time.Time\n}\n\ntype LoginAttempt struct {\n\tFailedAttempts int\n\tLastAttempt    time.Time\n\tLockedUntil    time.Time\n}\n\ntype AuthServiceConfig struct {\n\tUsers              []config.User\n\tOauthWhitelist     []string\n\tSessionExpiry      int\n\tSessionMaxLifetime int\n\tSecureCookie       bool\n\tCookieDomain       string\n\tLoginTimeout       int\n\tLoginMaxRetries    int\n\tSessionCookieName  string\n\tIP                 config.IPConfig\n\tLDAPGroupsCacheTTL int\n}\n\ntype AuthService struct {\n\tconfig          AuthServiceConfig\n\tdocker          *DockerService\n\tloginAttempts   map[string]*LoginAttempt\n\tldapGroupsCache map[string]*LdapGroupsCache\n\tloginMutex      sync.RWMutex\n\tldapGroupsMutex sync.RWMutex\n\tldap            *LdapService\n\tqueries         *repository.Queries\n}\n\nfunc NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService {\n\treturn &AuthService{\n\t\tconfig:          config,\n\t\tdocker:          docker,\n\t\tloginAttempts:   make(map[string]*LoginAttempt),\n\t\tldapGroupsCache: make(map[string]*LdapGroupsCache),\n\t\tldap:            ldap,\n\t\tqueries:         queries,\n\t}\n}\n\nfunc (auth *AuthService) Init() error {\n\treturn nil\n}\n\nfunc (auth *AuthService) SearchUser(username string) config.UserSearch {\n\tif auth.GetLocalUser(username).Username != \"\" {\n\t\treturn config.UserSearch{\n\t\t\tUsername: username,\n\t\t\tType:     \"local\",\n\t\t}\n\t}\n\n\tif auth.ldap.IsConfigured() {\n\t\tuserDN, err := auth.ldap.GetUserDN(username)\n\n\t\tif err != nil {\n\t\t\ttlog.App.Warn().Err(err).Str(\"username\", username).Msg(\"Failed to search for user in LDAP\")\n\t\t\treturn config.UserSearch{\n\t\t\t\tType: \"unknown\",\n\t\t\t}\n\t\t}\n\n\t\treturn config.UserSearch{\n\t\t\tUsername: userDN,\n\t\t\tType:     \"ldap\",\n\t\t}\n\t}\n\n\treturn config.UserSearch{\n\t\tType: \"unknown\",\n\t}\n}\n\nfunc (auth *AuthService) VerifyUser(search config.UserSearch, password string) bool {\n\tswitch search.Type {\n\tcase \"local\":\n\t\tuser := auth.GetLocalUser(search.Username)\n\t\treturn auth.CheckPassword(user, password)\n\tcase \"ldap\":\n\t\tif auth.ldap.IsConfigured() {\n\t\t\terr := auth.ldap.Bind(search.Username, password)\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Warn().Err(err).Str(\"username\", search.Username).Msg(\"Failed to bind to LDAP\")\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\terr = auth.ldap.BindService(true)\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to rebind with service account after user authentication\")\n\t\t\t\treturn false\n\t\t\t}\n\n\t\t\treturn true\n\t\t}\n\tdefault:\n\t\ttlog.App.Debug().Str(\"type\", search.Type).Msg(\"Unknown user type for authentication\")\n\t\treturn false\n\t}\n\n\ttlog.App.Warn().Str(\"username\", search.Username).Msg(\"User authentication failed\")\n\treturn false\n}\n\nfunc (auth *AuthService) GetLocalUser(username string) config.User {\n\tfor _, user := range auth.config.Users {\n\t\tif user.Username == username {\n\t\t\treturn user\n\t\t}\n\t}\n\n\ttlog.App.Warn().Str(\"username\", username).Msg(\"Local user not found\")\n\treturn config.User{}\n}\n\nfunc (auth *AuthService) GetLdapUser(userDN string) (config.LdapUser, error) {\n\tif !auth.ldap.IsConfigured() {\n\t\treturn config.LdapUser{}, errors.New(\"LDAP service not initialized\")\n\t}\n\n\tauth.ldapGroupsMutex.RLock()\n\tentry, exists := auth.ldapGroupsCache[userDN]\n\tauth.ldapGroupsMutex.RUnlock()\n\n\tif exists && time.Now().Before(entry.Expires) {\n\t\treturn config.LdapUser{\n\t\t\tDN:     userDN,\n\t\t\tGroups: entry.Groups,\n\t\t}, nil\n\t}\n\n\tgroups, err := auth.ldap.GetUserGroups(userDN)\n\n\tif err != nil {\n\t\treturn config.LdapUser{}, err\n\t}\n\n\tauth.ldapGroupsMutex.Lock()\n\tauth.ldapGroupsCache[userDN] = &LdapGroupsCache{\n\t\tGroups:  groups,\n\t\tExpires: time.Now().Add(time.Duration(auth.config.LDAPGroupsCacheTTL) * time.Second),\n\t}\n\tauth.ldapGroupsMutex.Unlock()\n\n\treturn config.LdapUser{\n\t\tDN:     userDN,\n\t\tGroups: groups,\n\t}, nil\n}\n\nfunc (auth *AuthService) CheckPassword(user config.User, password string) bool {\n\treturn bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil\n}\n\nfunc (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {\n\tauth.loginMutex.RLock()\n\tdefer auth.loginMutex.RUnlock()\n\n\tif auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {\n\t\treturn false, 0\n\t}\n\n\tattempt, exists := auth.loginAttempts[identifier]\n\tif !exists {\n\t\treturn false, 0\n\t}\n\n\tif attempt.LockedUntil.After(time.Now()) {\n\t\tremaining := int(time.Until(attempt.LockedUntil).Seconds())\n\t\treturn true, remaining\n\t}\n\n\treturn false, 0\n}\n\nfunc (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {\n\tif auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {\n\t\treturn\n\t}\n\n\tauth.loginMutex.Lock()\n\tdefer auth.loginMutex.Unlock()\n\n\tattempt, exists := auth.loginAttempts[identifier]\n\tif !exists {\n\t\tattempt = &LoginAttempt{}\n\t\tauth.loginAttempts[identifier] = attempt\n\t}\n\n\tattempt.LastAttempt = time.Now()\n\n\tif success {\n\t\tattempt.FailedAttempts = 0\n\t\tattempt.LockedUntil = time.Time{} // Reset lock time\n\t\treturn\n\t}\n\n\tattempt.FailedAttempts++\n\n\tif attempt.FailedAttempts >= auth.config.LoginMaxRetries {\n\t\tattempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second)\n\t\ttlog.App.Warn().Str(\"identifier\", identifier).Int(\"timeout\", auth.config.LoginTimeout).Msg(\"Account locked due to too many failed login attempts\")\n\t}\n}\n\nfunc (auth *AuthService) IsEmailWhitelisted(email string) bool {\n\treturn utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, \",\"), email)\n}\n\nfunc (auth *AuthService) CreateSessionCookie(c *gin.Context, data *repository.Session) error {\n\tuuid, err := uuid.NewRandom()\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar expiry int\n\n\tif data.TotpPending {\n\t\texpiry = 3600\n\t} else {\n\t\texpiry = auth.config.SessionExpiry\n\t}\n\n\tsession := repository.CreateSessionParams{\n\t\tUUID:        uuid.String(),\n\t\tUsername:    data.Username,\n\t\tEmail:       data.Email,\n\t\tName:        data.Name,\n\t\tProvider:    data.Provider,\n\t\tTotpPending: data.TotpPending,\n\t\tOAuthGroups: data.OAuthGroups,\n\t\tExpiry:      time.Now().Add(time.Duration(expiry) * time.Second).Unix(),\n\t\tCreatedAt:   time.Now().Unix(),\n\t\tOAuthName:   data.OAuthName,\n\t\tOAuthSub:    data.OAuthSub,\n\t}\n\n\t_, err = auth.queries.CreateSession(c, session)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, \"/\", fmt.Sprintf(\".%s\", auth.config.CookieDomain), auth.config.SecureCookie, true)\n\n\treturn nil\n}\n\nfunc (auth *AuthService) RefreshSessionCookie(c *gin.Context) error {\n\tcookie, err := c.Cookie(auth.config.SessionCookieName)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tsession, err := auth.queries.GetSession(c, cookie)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcurrentTime := time.Now().Unix()\n\n\tvar refreshThreshold int64\n\n\tif auth.config.SessionExpiry <= int(time.Hour.Seconds()) {\n\t\trefreshThreshold = int64(auth.config.SessionExpiry / 2)\n\t} else {\n\t\trefreshThreshold = int64(time.Hour.Seconds())\n\t}\n\n\tif session.Expiry-currentTime > refreshThreshold {\n\t\treturn nil\n\t}\n\n\tnewExpiry := session.Expiry + refreshThreshold\n\n\t_, err = auth.queries.UpdateSession(c, repository.UpdateSessionParams{\n\t\tUsername:    session.Username,\n\t\tEmail:       session.Email,\n\t\tName:        session.Name,\n\t\tProvider:    session.Provider,\n\t\tTotpPending: session.TotpPending,\n\t\tOAuthGroups: session.OAuthGroups,\n\t\tExpiry:      newExpiry,\n\t\tOAuthName:   session.OAuthName,\n\t\tOAuthSub:    session.OAuthSub,\n\t\tUUID:        session.UUID,\n\t})\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.SetCookie(auth.config.SessionCookieName, cookie, int(newExpiry-currentTime), \"/\", fmt.Sprintf(\".%s\", auth.config.CookieDomain), auth.config.SecureCookie, true)\n\ttlog.App.Trace().Str(\"username\", session.Username).Msg(\"Session cookie refreshed\")\n\n\treturn nil\n}\n\nfunc (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {\n\tcookie, err := c.Cookie(auth.config.SessionCookieName)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = auth.queries.DeleteSession(c, cookie)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.SetCookie(auth.config.SessionCookieName, \"\", -1, \"/\", fmt.Sprintf(\".%s\", auth.config.CookieDomain), auth.config.SecureCookie, true)\n\n\treturn nil\n}\n\nfunc (auth *AuthService) GetSessionCookie(c *gin.Context) (repository.Session, error) {\n\tcookie, err := c.Cookie(auth.config.SessionCookieName)\n\n\tif err != nil {\n\t\treturn repository.Session{}, err\n\t}\n\n\tsession, err := auth.queries.GetSession(c, cookie)\n\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn repository.Session{}, fmt.Errorf(\"session not found\")\n\t\t}\n\t\treturn repository.Session{}, err\n\t}\n\n\tcurrentTime := time.Now().Unix()\n\n\tif auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 {\n\t\tif currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) {\n\t\t\terr = auth.queries.DeleteSession(c, cookie)\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to delete session exceeding max lifetime\")\n\t\t\t}\n\t\t\treturn repository.Session{}, fmt.Errorf(\"session expired due to max lifetime exceeded\")\n\t\t}\n\t}\n\n\tif currentTime > session.Expiry {\n\t\terr = auth.queries.DeleteSession(c, cookie)\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msg(\"Failed to delete expired session\")\n\t\t}\n\t\treturn repository.Session{}, fmt.Errorf(\"session expired\")\n\t}\n\n\treturn repository.Session{\n\t\tUUID:        session.UUID,\n\t\tUsername:    session.Username,\n\t\tEmail:       session.Email,\n\t\tName:        session.Name,\n\t\tProvider:    session.Provider,\n\t\tTotpPending: session.TotpPending,\n\t\tOAuthGroups: session.OAuthGroups,\n\t\tOAuthName:   session.OAuthName,\n\t\tOAuthSub:    session.OAuthSub,\n\t}, nil\n}\n\nfunc (auth *AuthService) LocalAuthConfigured() bool {\n\treturn len(auth.config.Users) > 0\n}\n\nfunc (auth *AuthService) LdapAuthConfigured() bool {\n\treturn auth.ldap.IsConfigured()\n}\n\nfunc (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {\n\tif context.OAuth {\n\t\ttlog.App.Debug().Msg(\"Checking OAuth whitelist\")\n\t\treturn utils.CheckFilter(acls.OAuth.Whitelist, context.Email)\n\t}\n\n\tif acls.Users.Block != \"\" {\n\t\ttlog.App.Debug().Msg(\"Checking blocked users\")\n\t\tif utils.CheckFilter(acls.Users.Block, context.Username) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\ttlog.App.Debug().Msg(\"Checking users\")\n\treturn utils.CheckFilter(acls.Users.Allow, context.Username)\n}\n\nfunc (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {\n\tif requiredGroups == \"\" {\n\t\treturn true\n\t}\n\n\tfor id := range config.OverrideProviders {\n\t\tif context.Provider == id {\n\t\t\ttlog.App.Info().Str(\"provider\", id).Msg(\"OAuth groups not supported for this provider\")\n\t\t\treturn true\n\t\t}\n\t}\n\n\tfor userGroup := range strings.SplitSeq(context.OAuthGroups, \",\") {\n\t\tif utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {\n\t\t\ttlog.App.Trace().Str(\"group\", userGroup).Str(\"required\", requiredGroups).Msg(\"User group matched\")\n\t\t\treturn true\n\t\t}\n\t}\n\n\ttlog.App.Debug().Msg(\"No groups matched\")\n\treturn false\n}\n\nfunc (auth *AuthService) IsInLdapGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {\n\tif requiredGroups == \"\" {\n\t\treturn true\n\t}\n\n\tfor userGroup := range strings.SplitSeq(context.LdapGroups, \",\") {\n\t\tif utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {\n\t\t\ttlog.App.Trace().Str(\"group\", userGroup).Str(\"required\", requiredGroups).Msg(\"User group matched\")\n\t\t\treturn true\n\t\t}\n\t}\n\n\ttlog.App.Debug().Msg(\"No groups matched\")\n\treturn false\n}\n\nfunc (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) {\n\t// Check for block list\n\tif path.Block != \"\" {\n\t\tregex, err := regexp.Compile(path.Block)\n\n\t\tif err != nil {\n\t\t\treturn true, err\n\t\t}\n\n\t\tif !regex.MatchString(uri) {\n\t\t\treturn false, nil\n\t\t}\n\t}\n\n\t// Check for allow list\n\tif path.Allow != \"\" {\n\t\tregex, err := regexp.Compile(path.Allow)\n\n\t\tif err != nil {\n\t\t\treturn true, err\n\t\t}\n\n\t\tif regex.MatchString(uri) {\n\t\t\treturn false, nil\n\t\t}\n\t}\n\n\treturn true, nil\n}\n\nfunc (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User {\n\tusername, password, ok := c.Request.BasicAuth()\n\tif !ok {\n\t\ttlog.App.Debug().Msg(\"No basic auth provided\")\n\t\treturn nil\n\t}\n\treturn &config.User{\n\t\tUsername: username,\n\t\tPassword: password,\n\t}\n}\n\nfunc (auth *AuthService) CheckIP(acls config.AppIP, ip string) bool {\n\t// Merge the global and app IP filter\n\tblockedIps := append(auth.config.IP.Block, acls.Block...)\n\tallowedIPs := append(auth.config.IP.Allow, acls.Allow...)\n\n\tfor _, blocked := range blockedIps {\n\t\tres, err := utils.FilterIP(blocked, ip)\n\t\tif err != nil {\n\t\t\ttlog.App.Warn().Err(err).Str(\"item\", blocked).Msg(\"Invalid IP/CIDR in block list\")\n\t\t\tcontinue\n\t\t}\n\t\tif res {\n\t\t\ttlog.App.Debug().Str(\"ip\", ip).Str(\"item\", blocked).Msg(\"IP is in blocked list, denying access\")\n\t\t\treturn false\n\t\t}\n\t}\n\n\tfor _, allowed := range allowedIPs {\n\t\tres, err := utils.FilterIP(allowed, ip)\n\t\tif err != nil {\n\t\t\ttlog.App.Warn().Err(err).Str(\"item\", allowed).Msg(\"Invalid IP/CIDR in allow list\")\n\t\t\tcontinue\n\t\t}\n\t\tif res {\n\t\t\ttlog.App.Debug().Str(\"ip\", ip).Str(\"item\", allowed).Msg(\"IP is in allowed list, allowing access\")\n\t\t\treturn true\n\t\t}\n\t}\n\n\tif len(allowedIPs) > 0 {\n\t\ttlog.App.Debug().Str(\"ip\", ip).Msg(\"IP not in allow list, denying access\")\n\t\treturn false\n\t}\n\n\ttlog.App.Debug().Str(\"ip\", ip).Msg(\"IP not in allow or block list, allowing by default\")\n\treturn true\n}\n\nfunc (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool {\n\tfor _, bypassed := range acls.Bypass {\n\t\tres, err := utils.FilterIP(bypassed, ip)\n\t\tif err != nil {\n\t\t\ttlog.App.Warn().Err(err).Str(\"item\", bypassed).Msg(\"Invalid IP/CIDR in bypass list\")\n\t\t\tcontinue\n\t\t}\n\t\tif res {\n\t\t\ttlog.App.Debug().Str(\"ip\", ip).Str(\"item\", bypassed).Msg(\"IP is in bypass list, allowing access\")\n\t\t\treturn true\n\t\t}\n\t}\n\n\ttlog.App.Debug().Str(\"ip\", ip).Msg(\"IP not in bypass list, continuing with authentication\")\n\treturn false\n}\n"
  },
  {
    "path": "internal/service/docker_service.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/decoders\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\tcontainer \"github.com/docker/docker/api/types/container\"\n\t\"github.com/docker/docker/client\"\n)\n\ntype DockerService struct {\n\tclient      *client.Client\n\tcontext     context.Context\n\tisConnected bool\n}\n\nfunc NewDockerService() *DockerService {\n\treturn &DockerService{}\n}\n\nfunc (docker *DockerService) Init() error {\n\tclient, err := client.NewClientWithOpts(client.FromEnv)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tctx := context.Background()\n\tclient.NegotiateAPIVersion(ctx)\n\n\tdocker.client = client\n\tdocker.context = ctx\n\n\t_, err = docker.client.Ping(docker.context)\n\n\tif err != nil {\n\t\ttlog.App.Debug().Err(err).Msg(\"Docker not connected\")\n\t\tdocker.isConnected = false\n\t\tdocker.client = nil\n\t\tdocker.context = nil\n\t\treturn nil\n\t}\n\n\tdocker.isConnected = true\n\ttlog.App.Debug().Msg(\"Docker connected\")\n\n\treturn nil\n}\n\nfunc (docker *DockerService) getContainers() ([]container.Summary, error) {\n\tcontainers, err := docker.client.ContainerList(docker.context, container.ListOptions{})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn containers, nil\n}\n\nfunc (docker *DockerService) inspectContainer(containerId string) (container.InspectResponse, error) {\n\tinspect, err := docker.client.ContainerInspect(docker.context, containerId)\n\tif err != nil {\n\t\treturn container.InspectResponse{}, err\n\t}\n\treturn inspect, nil\n}\n\nfunc (docker *DockerService) GetLabels(appDomain string) (config.App, error) {\n\tif !docker.isConnected {\n\t\ttlog.App.Debug().Msg(\"Docker not connected, returning empty labels\")\n\t\treturn config.App{}, nil\n\t}\n\n\tcontainers, err := docker.getContainers()\n\tif err != nil {\n\t\treturn config.App{}, err\n\t}\n\n\tfor _, ctr := range containers {\n\t\tinspect, err := docker.inspectContainer(ctr.ID)\n\t\tif err != nil {\n\t\t\treturn config.App{}, err\n\t\t}\n\n\t\tlabels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels, \"apps\")\n\t\tif err != nil {\n\t\t\treturn config.App{}, err\n\t\t}\n\n\t\tfor appName, appLabels := range labels.Apps {\n\t\t\tif appLabels.Config.Domain == appDomain {\n\t\t\t\ttlog.App.Debug().Str(\"id\", inspect.ID).Str(\"name\", inspect.Name).Msg(\"Found matching container by domain\")\n\t\t\t\treturn appLabels, nil\n\t\t\t}\n\n\t\t\tif strings.SplitN(appDomain, \".\", 2)[0] == appName {\n\t\t\t\ttlog.App.Debug().Str(\"id\", inspect.ID).Str(\"name\", inspect.Name).Msg(\"Found matching container by app name\")\n\t\t\t\treturn appLabels, nil\n\t\t\t}\n\t\t}\n\t}\n\n\ttlog.App.Debug().Msg(\"No matching container found, returning empty labels\")\n\treturn config.App{}, nil\n}\n"
  },
  {
    "path": "internal/service/generic_oauth_service.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"golang.org/x/oauth2\"\n)\n\ntype GenericOAuthService struct {\n\tconfig             oauth2.Config\n\tcontext            context.Context\n\ttoken              *oauth2.Token\n\tverifier           string\n\tinsecureSkipVerify bool\n\tuserinfoUrl        string\n\tname               string\n}\n\nfunc NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService {\n\treturn &GenericOAuthService{\n\t\tconfig: oauth2.Config{\n\t\t\tClientID:     config.ClientID,\n\t\t\tClientSecret: config.ClientSecret,\n\t\t\tRedirectURL:  config.RedirectURL,\n\t\t\tScopes:       config.Scopes,\n\t\t\tEndpoint: oauth2.Endpoint{\n\t\t\t\tAuthURL:  config.AuthURL,\n\t\t\t\tTokenURL: config.TokenURL,\n\t\t\t},\n\t\t},\n\t\tinsecureSkipVerify: config.Insecure,\n\t\tuserinfoUrl:        config.UserinfoURL,\n\t\tname:               config.Name,\n\t}\n}\n\nfunc (generic *GenericOAuthService) Init() error {\n\ttransport := &http.Transport{\n\t\tTLSClientConfig: &tls.Config{\n\t\t\tInsecureSkipVerify: generic.insecureSkipVerify,\n\t\t\tMinVersion:         tls.VersionTLS12,\n\t\t},\n\t}\n\n\thttpClient := &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   30 * time.Second,\n\t}\n\n\tctx := context.Background()\n\n\tctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)\n\n\tgeneric.context = ctx\n\treturn nil\n}\n\nfunc (generic *GenericOAuthService) GenerateState() string {\n\tb := make([]byte, 128)\n\t_, err := rand.Read(b)\n\tif err != nil {\n\t\treturn base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, \"state-%d\", time.Now().UnixNano()))\n\t}\n\tstate := base64.RawURLEncoding.EncodeToString(b)\n\treturn state\n}\n\nfunc (generic *GenericOAuthService) GenerateVerifier() string {\n\tverifier := oauth2.GenerateVerifier()\n\tgeneric.verifier = verifier\n\treturn verifier\n}\n\nfunc (generic *GenericOAuthService) GetAuthURL(state string) string {\n\treturn generic.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(generic.verifier))\n}\n\nfunc (generic *GenericOAuthService) VerifyCode(code string) error {\n\ttoken, err := generic.config.Exchange(generic.context, code, oauth2.VerifierOption(generic.verifier))\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgeneric.token = token\n\treturn nil\n}\n\nfunc (generic *GenericOAuthService) Userinfo() (config.Claims, error) {\n\tvar user config.Claims\n\n\tclient := generic.config.Client(generic.context, generic.token)\n\n\tres, err := client.Get(generic.userinfoUrl)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\tdefer res.Body.Close()\n\n\tif res.StatusCode < 200 || res.StatusCode >= 300 {\n\t\treturn user, fmt.Errorf(\"request failed with status: %s\", res.Status)\n\t}\n\n\tbody, err := io.ReadAll(res.Body)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\ttlog.App.Trace().Str(\"body\", string(body)).Msg(\"Userinfo response body\")\n\n\terr = json.Unmarshal(body, &user)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\treturn user, nil\n}\n\nfunc (generic *GenericOAuthService) GetName() string {\n\treturn generic.name\n}\n"
  },
  {
    "path": "internal/service/github_oauth_service.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/endpoints\"\n)\n\nvar GithubOAuthScopes = []string{\"user:email\", \"read:user\"}\n\ntype GithubEmailResponse []struct {\n\tEmail   string `json:\"email\"`\n\tPrimary bool   `json:\"primary\"`\n}\n\ntype GithubUserInfoResponse struct {\n\tLogin string `json:\"login\"`\n\tName  string `json:\"name\"`\n\tID    int    `json:\"id\"`\n}\n\ntype GithubOAuthService struct {\n\tconfig   oauth2.Config\n\tcontext  context.Context\n\ttoken    *oauth2.Token\n\tverifier string\n\tname     string\n}\n\nfunc NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService {\n\treturn &GithubOAuthService{\n\t\tconfig: oauth2.Config{\n\t\t\tClientID:     config.ClientID,\n\t\t\tClientSecret: config.ClientSecret,\n\t\t\tRedirectURL:  config.RedirectURL,\n\t\t\tScopes:       GithubOAuthScopes,\n\t\t\tEndpoint:     endpoints.GitHub,\n\t\t},\n\t\tname: config.Name,\n\t}\n}\n\nfunc (github *GithubOAuthService) Init() error {\n\thttpClient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t}\n\tctx := context.Background()\n\tctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)\n\tgithub.context = ctx\n\treturn nil\n}\n\nfunc (github *GithubOAuthService) GenerateState() string {\n\tb := make([]byte, 128)\n\t_, err := rand.Read(b)\n\tif err != nil {\n\t\treturn base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, \"state-%d\", time.Now().UnixNano()))\n\t}\n\tstate := base64.RawURLEncoding.EncodeToString(b)\n\treturn state\n}\n\nfunc (github *GithubOAuthService) GenerateVerifier() string {\n\tverifier := oauth2.GenerateVerifier()\n\tgithub.verifier = verifier\n\treturn verifier\n}\n\nfunc (github *GithubOAuthService) GetAuthURL(state string) string {\n\treturn github.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.verifier))\n}\n\nfunc (github *GithubOAuthService) VerifyCode(code string) error {\n\ttoken, err := github.config.Exchange(github.context, code, oauth2.VerifierOption(github.verifier))\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgithub.token = token\n\treturn nil\n}\n\nfunc (github *GithubOAuthService) Userinfo() (config.Claims, error) {\n\tvar user config.Claims\n\n\tclient := github.config.Client(github.context, github.token)\n\n\treq, err := http.NewRequest(\"GET\", \"https://api.github.com/user\", nil)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\treq.Header.Set(\"Accept\", \"application/vnd.github+json\")\n\n\tres, err := client.Do(req)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\tdefer res.Body.Close()\n\n\tif res.StatusCode < 200 || res.StatusCode >= 300 {\n\t\treturn user, fmt.Errorf(\"request failed with status: %s\", res.Status)\n\t}\n\n\tbody, err := io.ReadAll(res.Body)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\tvar userInfo GithubUserInfoResponse\n\n\terr = json.Unmarshal(body, &userInfo)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\treq, err = http.NewRequest(\"GET\", \"https://api.github.com/user/emails\", nil)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\treq.Header.Set(\"Accept\", \"application/vnd.github+json\")\n\n\tres, err = client.Do(req)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\tdefer res.Body.Close()\n\n\tif res.StatusCode < 200 || res.StatusCode >= 300 {\n\t\treturn user, fmt.Errorf(\"request failed with status: %s\", res.Status)\n\t}\n\n\tbody, err = io.ReadAll(res.Body)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\tvar emails GithubEmailResponse\n\n\terr = json.Unmarshal(body, &emails)\n\tif err != nil {\n\t\treturn user, err\n\t}\n\n\tfor _, email := range emails {\n\t\tif email.Primary {\n\t\t\tuser.Email = email.Email\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif len(emails) == 0 {\n\t\treturn user, errors.New(\"no emails found\")\n\t}\n\n\t// Use first available email if no primary email was found\n\tif user.Email == \"\" {\n\t\tuser.Email = emails[0].Email\n\t}\n\n\tuser.PreferredUsername = userInfo.Login\n\tuser.Name = userInfo.Name\n\tuser.Sub = strconv.Itoa(userInfo.ID)\n\n\treturn user, nil\n}\n\nfunc (github *GithubOAuthService) GetName() string {\n\treturn github.name\n}\n"
  },
  {
    "path": "internal/service/google_oauth_service.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\n\t\"golang.org/x/oauth2\"\n\t\"golang.org/x/oauth2/endpoints\"\n)\n\nvar GoogleOAuthScopes = []string{\"openid\", \"email\", \"profile\"}\n\ntype GoogleOAuthService struct {\n\tconfig   oauth2.Config\n\tcontext  context.Context\n\ttoken    *oauth2.Token\n\tverifier string\n\tname     string\n}\n\nfunc NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService {\n\treturn &GoogleOAuthService{\n\t\tconfig: oauth2.Config{\n\t\t\tClientID:     config.ClientID,\n\t\t\tClientSecret: config.ClientSecret,\n\t\t\tRedirectURL:  config.RedirectURL,\n\t\t\tScopes:       GoogleOAuthScopes,\n\t\t\tEndpoint:     endpoints.Google,\n\t\t},\n\t\tname: config.Name,\n\t}\n}\n\nfunc (google *GoogleOAuthService) Init() error {\n\thttpClient := &http.Client{\n\t\tTimeout: 30 * time.Second,\n\t}\n\tctx := context.Background()\n\tctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)\n\tgoogle.context = ctx\n\treturn nil\n}\n\nfunc (oauth *GoogleOAuthService) GenerateState() string {\n\tb := make([]byte, 128)\n\t_, err := rand.Read(b)\n\tif err != nil {\n\t\treturn base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, \"state-%d\", time.Now().UnixNano()))\n\t}\n\tstate := base64.RawURLEncoding.EncodeToString(b)\n\treturn state\n}\n\nfunc (google *GoogleOAuthService) GenerateVerifier() string {\n\tverifier := oauth2.GenerateVerifier()\n\tgoogle.verifier = verifier\n\treturn verifier\n}\n\nfunc (google *GoogleOAuthService) GetAuthURL(state string) string {\n\treturn google.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(google.verifier))\n}\n\nfunc (google *GoogleOAuthService) VerifyCode(code string) error {\n\ttoken, err := google.config.Exchange(google.context, code, oauth2.VerifierOption(google.verifier))\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tgoogle.token = token\n\treturn nil\n}\n\nfunc (google *GoogleOAuthService) Userinfo() (config.Claims, error) {\n\tvar user config.Claims\n\n\tclient := google.config.Client(google.context, google.token)\n\n\tres, err := client.Get(\"https://openidconnect.googleapis.com/v1/userinfo\")\n\tif err != nil {\n\t\treturn config.Claims{}, err\n\t}\n\tdefer res.Body.Close()\n\n\tif res.StatusCode < 200 || res.StatusCode >= 300 {\n\t\treturn user, fmt.Errorf(\"request failed with status: %s\", res.Status)\n\t}\n\n\tbody, err := io.ReadAll(res.Body)\n\tif err != nil {\n\t\treturn config.Claims{}, err\n\t}\n\n\terr = json.Unmarshal(body, &user)\n\tif err != nil {\n\t\treturn config.Claims{}, err\n\t}\n\n\tuser.PreferredUsername = strings.SplitN(user.Email, \"@\", 2)[0]\n\n\treturn user, nil\n}\n\nfunc (google *GoogleOAuthService) GetName() string {\n\treturn google.name\n}\n"
  },
  {
    "path": "internal/service/ldap_service.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/cenkalti/backoff/v5\"\n\tldapgo \"github.com/go-ldap/ldap/v3\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n)\n\ntype LdapServiceConfig struct {\n\tAddress      string\n\tBindDN       string\n\tBindPassword string\n\tBaseDN       string\n\tInsecure     bool\n\tSearchFilter string\n\tAuthCert     string\n\tAuthKey      string\n}\n\ntype LdapService struct {\n\tconfig       LdapServiceConfig\n\tconn         *ldapgo.Conn\n\tmutex        sync.RWMutex\n\tcert         *tls.Certificate\n\tisConfigured bool\n}\n\nfunc NewLdapService(config LdapServiceConfig) *LdapService {\n\treturn &LdapService{\n\t\tconfig: config,\n\t}\n}\n\nfunc (ldap *LdapService) IsConfigured() bool {\n\treturn ldap.isConfigured\n}\n\nfunc (ldap *LdapService) Unconfigure() error {\n\tif !ldap.isConfigured {\n\t\treturn nil\n\t}\n\n\tif ldap.conn != nil {\n\t\tif err := ldap.conn.Close(); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to close LDAP connection: %w\", err)\n\t\t}\n\t}\n\n\tldap.isConfigured = false\n\treturn nil\n}\n\nfunc (ldap *LdapService) Init() error {\n\tif ldap.config.Address == \"\" {\n\t\tldap.isConfigured = false\n\t\treturn nil\n\t}\n\n\tldap.isConfigured = true\n\n\t// Check whether authentication with client certificate is possible\n\tif ldap.config.AuthCert != \"\" && ldap.config.AuthKey != \"\" {\n\t\tcert, err := tls.LoadX509KeyPair(ldap.config.AuthCert, ldap.config.AuthKey)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to initialize LDAP with mTLS authentication: %w\", err)\n\t\t}\n\t\tldap.cert = &cert\n\t\ttlog.App.Info().Msg(\"Using LDAP with mTLS authentication\")\n\n\t\t// TODO: Add optional extra CA certificates, instead of `InsecureSkipVerify`\n\t\t/*\n\t\t\tcaCert, _ := ioutil.ReadFile(*caFile)\n\t\t\tcaCertPool := x509.NewCertPool()\n\t\t\tcaCertPool.AppendCertsFromPEM(caCert)\n\t\t\ttlsConfig := &tls.Config{\n\t\t\t\t\t\t...\n\t\t\tRootCAs:      caCertPool,\n\t\t\t}\n\t\t*/\n\t}\n\t_, err := ldap.connect()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to connect to LDAP server: %w\", err)\n\t}\n\n\tgo func() {\n\t\tfor range time.Tick(time.Duration(5) * time.Minute) {\n\t\t\terr := ldap.heartbeat()\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Error().Err(err).Msg(\"LDAP connection heartbeat failed\")\n\t\t\t\tif reconnectErr := ldap.reconnect(); reconnectErr != nil {\n\t\t\t\t\ttlog.App.Error().Err(reconnectErr).Msg(\"Failed to reconnect to LDAP server\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttlog.App.Info().Msg(\"Successfully reconnected to LDAP server\")\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (ldap *LdapService) connect() (*ldapgo.Conn, error) {\n\tldap.mutex.Lock()\n\tdefer ldap.mutex.Unlock()\n\n\tvar conn *ldapgo.Conn\n\tvar err error\n\n\t// TODO: There's also STARTTLS (or SASL)-based mTLS authentication\n\t// scenario, where we first connect to plain text port (389) and\n\t// continue with a STARTTLS negotiation:\n\t// 1. conn = ldap.DialURL(\"ldap://ldap.example.com:389\")\n\t// 2. conn.StartTLS(tlsConfig)\n\t// 3. conn.externalBind()\n\tif ldap.cert != nil {\n\t\tconn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{\n\t\t\tMinVersion:   tls.VersionTLS12,\n\t\t\tCertificates: []tls.Certificate{*ldap.cert},\n\t\t}))\n\t} else {\n\t\tconn, err = ldapgo.DialURL(ldap.config.Address, ldapgo.DialWithTLSConfig(&tls.Config{\n\t\t\tInsecureSkipVerify: ldap.config.Insecure,\n\t\t\tMinVersion:         tls.VersionTLS12,\n\t\t}))\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tldap.conn = conn\n\n\terr = ldap.BindService(false)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn ldap.conn, nil\n}\n\nfunc (ldap *LdapService) GetUserDN(username string) (string, error) {\n\t// Escape the username to prevent LDAP injection\n\tescapedUsername := ldapgo.EscapeFilter(username)\n\tfilter := fmt.Sprintf(ldap.config.SearchFilter, escapedUsername)\n\n\tsearchRequest := ldapgo.NewSearchRequest(\n\t\tldap.config.BaseDN,\n\t\tldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,\n\t\tfilter,\n\t\t[]string{\"dn\"},\n\t\tnil,\n\t)\n\n\tldap.mutex.Lock()\n\tdefer ldap.mutex.Unlock()\n\n\tsearchResult, err := ldap.conn.Search(searchRequest)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif len(searchResult.Entries) != 1 {\n\t\treturn \"\", fmt.Errorf(\"multiple or no entries found for user %s\", username)\n\t}\n\n\tuserDN := searchResult.Entries[0].DN\n\treturn userDN, nil\n}\n\nfunc (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {\n\tescapedUserDN := ldapgo.EscapeFilter(userDN)\n\n\tsearchRequest := ldapgo.NewSearchRequest(\n\t\tldap.config.BaseDN,\n\t\tldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,\n\t\tfmt.Sprintf(\"(&(objectclass=groupOfUniqueNames)(uniquemember=%s))\", escapedUserDN),\n\t\t[]string{\"dn\"},\n\t\tnil,\n\t)\n\n\tldap.mutex.Lock()\n\tdefer ldap.mutex.Unlock()\n\n\tsearchResult, err := ldap.conn.Search(searchRequest)\n\tif err != nil {\n\t\treturn []string{}, err\n\t}\n\n\tgroupDNs := []string{}\n\n\tfor _, entry := range searchResult.Entries {\n\t\tgroupDNs = append(groupDNs, entry.DN)\n\t}\n\n\tgroups := []string{}\n\n\t// I guess it should work for most ldap providers\n\tfor _, dn := range groupDNs {\n\t\trdnParts, err := ldapgo.ParseDN(dn)\n\t\tif err != nil {\n\t\t\treturn []string{}, err\n\t\t}\n\t\tif len(rdnParts.RDNs) == 0 || len(rdnParts.RDNs[0].Attributes) == 0 {\n\t\t\treturn []string{}, fmt.Errorf(\"invalid DN format: %s\", dn)\n\t\t}\n\t\tgroups = append(groups, rdnParts.RDNs[0].Attributes[0].Value)\n\t}\n\n\treturn groups, nil\n}\n\nfunc (ldap *LdapService) BindService(rebind bool) error {\n\t// Locks must not be used for initial binding attempt\n\tif rebind {\n\t\tldap.mutex.Lock()\n\t\tdefer ldap.mutex.Unlock()\n\t}\n\n\tif ldap.cert != nil {\n\t\treturn ldap.conn.ExternalBind()\n\t}\n\treturn ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)\n}\n\nfunc (ldap *LdapService) Bind(userDN string, password string) error {\n\tldap.mutex.Lock()\n\tdefer ldap.mutex.Unlock()\n\terr := ldap.conn.Bind(userDN, password)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\nfunc (ldap *LdapService) heartbeat() error {\n\ttlog.App.Debug().Msg(\"Performing LDAP connection heartbeat\")\n\n\tsearchRequest := ldapgo.NewSearchRequest(\n\t\t\"\",\n\t\tldapgo.ScopeBaseObject, ldapgo.NeverDerefAliases, 0, 0, false,\n\t\t\"(objectClass=*)\",\n\t\t[]string{},\n\t\tnil,\n\t)\n\n\tldap.mutex.Lock()\n\tdefer ldap.mutex.Unlock()\n\t_, err := ldap.conn.Search(searchRequest)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// No error means the connection is alive\n\treturn nil\n}\n\nfunc (ldap *LdapService) reconnect() error {\n\ttlog.App.Info().Msg(\"Reconnecting to LDAP server\")\n\n\texp := backoff.NewExponentialBackOff()\n\texp.InitialInterval = 500 * time.Millisecond\n\texp.RandomizationFactor = 0.1\n\texp.Multiplier = 1.5\n\texp.Reset()\n\n\toperation := func() (*ldapgo.Conn, error) {\n\t\tldap.conn.Close()\n\t\tconn, err := ldap.connect()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn conn, nil\n\t}\n\n\t_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/service/oauth_broker_service.go",
    "content": "package service\n\nimport (\n\t\"errors\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"golang.org/x/exp/slices\"\n)\n\ntype OAuthService interface {\n\tInit() error\n\tGenerateState() string\n\tGenerateVerifier() string\n\tGetAuthURL(state string) string\n\tVerifyCode(code string) error\n\tUserinfo() (config.Claims, error)\n\tGetName() string\n}\n\ntype OAuthBrokerService struct {\n\tservices map[string]OAuthService\n\tconfigs  map[string]config.OAuthServiceConfig\n}\n\nfunc NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService {\n\treturn &OAuthBrokerService{\n\t\tservices: make(map[string]OAuthService),\n\t\tconfigs:  configs,\n\t}\n}\n\nfunc (broker *OAuthBrokerService) Init() error {\n\tfor name, cfg := range broker.configs {\n\t\tswitch name {\n\t\tcase \"github\":\n\t\t\tservice := NewGithubOAuthService(cfg)\n\t\t\tbroker.services[name] = service\n\t\tcase \"google\":\n\t\t\tservice := NewGoogleOAuthService(cfg)\n\t\t\tbroker.services[name] = service\n\t\tdefault:\n\t\t\tservice := NewGenericOAuthService(cfg)\n\t\t\tbroker.services[name] = service\n\t\t}\n\t}\n\n\tfor name, service := range broker.services {\n\t\terr := service.Init()\n\t\tif err != nil {\n\t\t\ttlog.App.Error().Err(err).Msgf(\"Failed to initialize OAuth service: %s\", name)\n\t\t\treturn err\n\t\t}\n\t\ttlog.App.Info().Str(\"service\", name).Msg(\"Initialized OAuth service\")\n\t}\n\n\treturn nil\n}\n\nfunc (broker *OAuthBrokerService) GetConfiguredServices() []string {\n\tservices := make([]string, 0, len(broker.services))\n\tfor name := range broker.services {\n\t\tservices = append(services, name)\n\t}\n\tslices.Sort(services)\n\treturn services\n}\n\nfunc (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) {\n\tservice, exists := broker.services[name]\n\treturn service, exists\n}\n\nfunc (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) {\n\toauthService, exists := broker.services[service]\n\tif !exists {\n\t\treturn config.Claims{}, errors.New(\"oauth service not found\")\n\t}\n\treturn oauthService.Userinfo()\n}\n"
  },
  {
    "path": "internal/service/oidc_service.go",
    "content": "package service\n\nimport (\n\t\"context\"\n\t\"crypto\"\n\t\"crypto/rand\"\n\t\"crypto/rsa\"\n\t\"crypto/sha256\"\n\t\"crypto/x509\"\n\t\"database/sql\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"encoding/pem\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/go-jose/go-jose/v4\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\t\"golang.org/x/exp/slices\"\n)\n\nvar (\n\tSupportedScopes        = []string{\"openid\", \"profile\", \"email\", \"groups\"}\n\tSupportedResponseTypes = []string{\"code\"}\n\tSupportedGrantTypes    = []string{\"authorization_code\", \"refresh_token\"}\n)\n\nvar (\n\tErrCodeExpired   = errors.New(\"code_expired\")\n\tErrCodeNotFound  = errors.New(\"code_not_found\")\n\tErrTokenNotFound = errors.New(\"token_not_found\")\n\tErrTokenExpired  = errors.New(\"token_expired\")\n\tErrInvalidClient = errors.New(\"invalid_client\")\n)\n\ntype ClaimSet struct {\n\tIss               string   `json:\"iss\"`\n\tAud               string   `json:\"aud\"`\n\tSub               string   `json:\"sub\"`\n\tIat               int64    `json:\"iat\"`\n\tExp               int64    `json:\"exp\"`\n\tName              string   `json:\"name,omitempty\"`\n\tEmail             string   `json:\"email,omitempty\"`\n\tEmailVerified     bool     `json:\"email_verified,omitempty\"`\n\tPreferredUsername string   `json:\"preferred_username,omitempty\"`\n\tGroups            []string `json:\"groups,omitempty\"`\n\tNonce             string   `json:\"nonce,omitempty\"`\n}\n\ntype UserinfoResponse struct {\n\tSub               string   `json:\"sub\"`\n\tName              string   `json:\"name,omitempty\"`\n\tEmail             string   `json:\"email,omitempty\"`\n\tPreferredUsername string   `json:\"preferred_username,omitempty\"`\n\tGroups            []string `json:\"groups,omitempty\"`\n\tEmailVerified     bool     `json:\"email_verified,omitempty\"`\n\tUpdatedAt         int64    `json:\"updated_at\"`\n}\n\ntype TokenResponse struct {\n\tAccessToken  string `json:\"access_token\"`\n\tRefreshToken string `json:\"refresh_token\"`\n\tTokenType    string `json:\"token_type\"`\n\tExpiresIn    int64  `json:\"expires_in\"`\n\tIDToken      string `json:\"id_token\"`\n\tScope        string `json:\"scope\"`\n}\n\ntype AuthorizeRequest struct {\n\tScope        string `json:\"scope\" binding:\"required\"`\n\tResponseType string `json:\"response_type\" binding:\"required\"`\n\tClientID     string `json:\"client_id\" binding:\"required\"`\n\tRedirectURI  string `json:\"redirect_uri\" binding:\"required\"`\n\tState        string `json:\"state\"`\n\tNonce        string `json:\"nonce\"`\n}\n\ntype OIDCServiceConfig struct {\n\tClients        map[string]config.OIDCClientConfig\n\tPrivateKeyPath string\n\tPublicKeyPath  string\n\tIssuer         string\n\tSessionExpiry  int\n}\n\ntype OIDCService struct {\n\tconfig       OIDCServiceConfig\n\tqueries      *repository.Queries\n\tclients      map[string]config.OIDCClientConfig\n\tprivateKey   *rsa.PrivateKey\n\tpublicKey    crypto.PublicKey\n\tissuer       string\n\tisConfigured bool\n}\n\nfunc NewOIDCService(config OIDCServiceConfig, queries *repository.Queries) *OIDCService {\n\treturn &OIDCService{\n\t\tconfig:  config,\n\t\tqueries: queries,\n\t}\n}\n\nfunc (service *OIDCService) IsConfigured() bool {\n\treturn service.isConfigured\n}\n\nfunc (service *OIDCService) Init() error {\n\t// If not configured, skip init\n\tif len(service.config.Clients) == 0 {\n\t\tservice.isConfigured = false\n\t\treturn nil\n\t}\n\n\tservice.isConfigured = true\n\n\t// Ensure issuer is https\n\tuissuer, err := url.Parse(service.config.Issuer)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif uissuer.Scheme != \"https\" {\n\t\treturn errors.New(\"issuer must be https\")\n\t}\n\n\tservice.issuer = fmt.Sprintf(\"%s://%s\", uissuer.Scheme, uissuer.Host)\n\n\t// Create/load private and public keys\n\tif strings.TrimSpace(service.config.PrivateKeyPath) == \"\" ||\n\t\tstrings.TrimSpace(service.config.PublicKeyPath) == \"\" {\n\t\treturn errors.New(\"private key path and public key path are required\")\n\t}\n\n\tvar privateKey *rsa.PrivateKey\n\n\tfprivateKey, err := os.ReadFile(service.config.PrivateKeyPath)\n\n\tif err != nil && !errors.Is(err, os.ErrNotExist) {\n\t\treturn err\n\t}\n\n\tif errors.Is(err, os.ErrNotExist) {\n\t\tprivateKey, err = rsa.GenerateKey(rand.Reader, 2048)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tder := x509.MarshalPKCS1PrivateKey(privateKey)\n\t\tif der == nil {\n\t\t\treturn errors.New(\"failed to marshal private key\")\n\t\t}\n\t\tencoded := pem.EncodeToMemory(&pem.Block{\n\t\t\tType:  \"RSA PRIVATE KEY\",\n\t\t\tBytes: der,\n\t\t})\n\t\ttlog.App.Trace().Str(\"type\", \"RSA PRIVATE KEY\").Msg(\"Generated private RSA key\")\n\t\terr = os.WriteFile(service.config.PrivateKeyPath, encoded, 0600)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tservice.privateKey = privateKey\n\t} else {\n\t\tblock, _ := pem.Decode(fprivateKey)\n\t\tif block == nil {\n\t\t\treturn errors.New(\"failed to decode private key\")\n\t\t}\n\t\ttlog.App.Trace().Str(\"type\", block.Type).Msg(\"Loaded private key\")\n\t\tprivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tservice.privateKey = privateKey\n\t}\n\n\tfpublicKey, err := os.ReadFile(service.config.PublicKeyPath)\n\n\tif err != nil && !errors.Is(err, os.ErrNotExist) {\n\t\treturn err\n\t}\n\n\tif errors.Is(err, os.ErrNotExist) {\n\t\tpublicKey := service.privateKey.Public()\n\t\tder := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey))\n\t\tif der == nil {\n\t\t\treturn errors.New(\"failed to marshal public key\")\n\t\t}\n\t\tencoded := pem.EncodeToMemory(&pem.Block{\n\t\t\tType:  \"RSA PUBLIC KEY\",\n\t\t\tBytes: der,\n\t\t})\n\t\ttlog.App.Trace().Str(\"type\", \"RSA PUBLIC KEY\").Msg(\"Generated public RSA key\")\n\t\terr = os.WriteFile(service.config.PublicKeyPath, encoded, 0644)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tservice.publicKey = publicKey\n\t} else {\n\t\tblock, _ := pem.Decode(fpublicKey)\n\t\tif block == nil {\n\t\t\treturn errors.New(\"failed to decode public key\")\n\t\t}\n\t\ttlog.App.Trace().Str(\"type\", block.Type).Msg(\"Loaded public key\")\n\t\tswitch block.Type {\n\t\tcase \"RSA PUBLIC KEY\":\n\t\t\tpublicKey, err := x509.ParsePKCS1PublicKey(block.Bytes)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tservice.publicKey = publicKey\n\t\tcase \"PUBLIC KEY\":\n\t\t\tpublicKey, err := x509.ParsePKIXPublicKey(block.Bytes)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tservice.publicKey = publicKey.(crypto.PublicKey)\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"unsupported public key type: %s\", block.Type)\n\t\t}\n\t}\n\n\t// We will reorganize the client into a map with the client ID as the key\n\tservice.clients = make(map[string]config.OIDCClientConfig)\n\n\tfor id, client := range service.config.Clients {\n\t\tclient.ID = id\n\t\tif client.Name == \"\" {\n\t\t\tclient.Name = utils.Capitalize(client.ID)\n\t\t}\n\t\tservice.clients[client.ClientID] = client\n\t}\n\n\t// Load the client secrets from files if they exist\n\tfor id, client := range service.clients {\n\t\tsecret := utils.GetSecret(client.ClientSecret, client.ClientSecretFile)\n\t\tif secret != \"\" {\n\t\t\tclient.ClientSecret = secret\n\t\t}\n\t\tclient.ClientSecretFile = \"\"\n\t\tservice.clients[id] = client\n\t\ttlog.App.Info().Str(\"id\", client.ID).Msg(\"Registered OIDC client\")\n\t}\n\n\treturn nil\n}\n\nfunc (service *OIDCService) GetIssuer() string {\n\treturn service.issuer\n}\n\nfunc (service *OIDCService) GetClient(id string) (config.OIDCClientConfig, bool) {\n\tclient, ok := service.clients[id]\n\treturn client, ok\n}\n\nfunc (service *OIDCService) ValidateAuthorizeParams(req AuthorizeRequest) error {\n\t// Validate client ID\n\tclient, ok := service.GetClient(req.ClientID)\n\tif !ok {\n\t\treturn errors.New(\"access_denied\")\n\t}\n\n\t// Scopes\n\tscopes := strings.Split(req.Scope, \" \")\n\n\tif len(scopes) == 0 || strings.TrimSpace(req.Scope) == \"\" {\n\t\treturn errors.New(\"invalid_scope\")\n\t}\n\n\tfor _, scope := range scopes {\n\t\tif strings.TrimSpace(scope) == \"\" {\n\t\t\treturn errors.New(\"invalid_scope\")\n\t\t}\n\t\tif !slices.Contains(SupportedScopes, scope) {\n\t\t\ttlog.App.Warn().Str(\"scope\", scope).Msg(\"Unsupported OIDC scope, will be ignored\")\n\t\t}\n\t}\n\n\t// Response type\n\tif !slices.Contains(SupportedResponseTypes, req.ResponseType) {\n\t\treturn errors.New(\"unsupported_response_type\")\n\t}\n\n\t// Redirect URI\n\tif !slices.Contains(client.TrustedRedirectURIs, req.RedirectURI) {\n\t\treturn errors.New(\"invalid_request_uri\")\n\t}\n\n\treturn nil\n}\n\nfunc (service *OIDCService) filterScopes(scopes []string) []string {\n\treturn utils.Filter(scopes, func(scope string) bool {\n\t\treturn slices.Contains(SupportedScopes, scope)\n\t})\n}\n\nfunc (service *OIDCService) StoreCode(c *gin.Context, sub string, code string, req AuthorizeRequest) error {\n\t// Fixed 10 minutes\n\texpiresAt := time.Now().Add(time.Minute * time.Duration(10)).Unix()\n\n\t// Insert the code into the database\n\t_, err := service.queries.CreateOidcCode(c, repository.CreateOidcCodeParams{\n\t\tSub:      sub,\n\t\tCodeHash: service.Hash(code),\n\t\t// Here it's safe to split and trust the output since, we validated the scopes before\n\t\tScope:       strings.Join(service.filterScopes(strings.Split(req.Scope, \" \")), \",\"),\n\t\tRedirectURI: req.RedirectURI,\n\t\tClientID:    req.ClientID,\n\t\tExpiresAt:   expiresAt,\n\t\tNonce:       req.Nonce,\n\t})\n\n\treturn err\n}\n\nfunc (service *OIDCService) StoreUserinfo(c *gin.Context, sub string, userContext config.UserContext, req AuthorizeRequest) error {\n\tuserInfoParams := repository.CreateOidcUserInfoParams{\n\t\tSub:               sub,\n\t\tName:              userContext.Name,\n\t\tEmail:             userContext.Email,\n\t\tPreferredUsername: userContext.Username,\n\t\tUpdatedAt:         time.Now().Unix(),\n\t}\n\n\t// Tinyauth will pass through the groups it got from an LDAP or an OIDC server\n\tif userContext.Provider == \"ldap\" {\n\t\tuserInfoParams.Groups = userContext.LdapGroups\n\t}\n\n\tif userContext.OAuth && len(userContext.OAuthGroups) > 0 {\n\t\tuserInfoParams.Groups = userContext.OAuthGroups\n\t}\n\n\t_, err := service.queries.CreateOidcUserInfo(c, userInfoParams)\n\n\treturn err\n}\n\nfunc (service *OIDCService) ValidateGrantType(grantType string) error {\n\tif !slices.Contains(SupportedGrantTypes, grantType) {\n\t\treturn errors.New(\"unsupported_grant_type\")\n\t}\n\n\treturn nil\n}\n\nfunc (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, clientId string) (repository.OidcCode, error) {\n\toidcCode, err := service.queries.GetOidcCode(c, codeHash)\n\n\tif err != nil {\n\t\tif errors.Is(err, sql.ErrNoRows) {\n\t\t\treturn repository.OidcCode{}, ErrCodeNotFound\n\t\t}\n\t\treturn repository.OidcCode{}, err\n\t}\n\n\tif time.Now().Unix() > oidcCode.ExpiresAt {\n\t\terr = service.queries.DeleteOidcCode(c, codeHash)\n\t\tif err != nil {\n\t\t\treturn repository.OidcCode{}, err\n\t\t}\n\t\terr = service.DeleteUserinfo(c, oidcCode.Sub)\n\t\tif err != nil {\n\t\t\treturn repository.OidcCode{}, err\n\t\t}\n\t\treturn repository.OidcCode{}, ErrCodeExpired\n\t}\n\n\tif oidcCode.ClientID != clientId {\n\t\treturn repository.OidcCode{}, ErrInvalidClient\n\t}\n\n\treturn oidcCode, nil\n}\n\nfunc (service *OIDCService) generateIDToken(client config.OIDCClientConfig, user repository.OidcUserinfo, scope string, nonce string) (string, error) {\n\tcreatedAt := time.Now().Unix()\n\texpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()\n\n\thasher := sha256.New()\n\n\tder := x509.MarshalPKCS1PublicKey(&service.privateKey.PublicKey)\n\n\tif der == nil {\n\t\treturn \"\", errors.New(\"failed to marshal public key\")\n\t}\n\n\thasher.Write(der)\n\n\tsigner, err := jose.NewSigner(jose.SigningKey{\n\t\tAlgorithm: jose.RS256,\n\t\tKey:       service.privateKey,\n\t}, &jose.SignerOptions{\n\t\tExtraHeaders: map[jose.HeaderKey]any{\n\t\t\t\"typ\": \"jwt\",\n\t\t\t\"jku\": fmt.Sprintf(\"%s/.well-known/jwks.json\", service.issuer),\n\t\t\t\"kid\": base64.URLEncoding.EncodeToString(hasher.Sum(nil)),\n\t\t},\n\t})\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tuserInfo := service.CompileUserinfo(user, scope)\n\n\tclaims := ClaimSet{\n\t\tIss:               service.issuer,\n\t\tAud:               client.ClientID,\n\t\tSub:               user.Sub,\n\t\tIat:               createdAt,\n\t\tExp:               expiresAt,\n\t\tName:              userInfo.Name,\n\t\tEmail:             userInfo.Email,\n\t\tEmailVerified:     userInfo.EmailVerified,\n\t\tPreferredUsername: userInfo.PreferredUsername,\n\t\tGroups:            userInfo.Groups,\n\t\tNonce:             nonce,\n\t}\n\n\tpayload, err := json.Marshal(claims)\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tobject, err := signer.Sign(payload)\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\ttoken, err := object.CompactSerialize()\n\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn token, nil\n}\n\nfunc (service *OIDCService) GenerateAccessToken(c *gin.Context, client config.OIDCClientConfig, codeEntry repository.OidcCode) (TokenResponse, error) {\n\tuser, err := service.GetUserinfo(c, codeEntry.Sub)\n\n\tif err != nil {\n\t\treturn TokenResponse{}, err\n\t}\n\n\tidToken, err := service.generateIDToken(client, user, codeEntry.Scope, codeEntry.Nonce)\n\n\tif err != nil {\n\t\treturn TokenResponse{}, err\n\t}\n\n\taccessToken := utils.GenerateString(32)\n\trefreshToken := utils.GenerateString(32)\n\n\ttokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()\n\n\t// Refresh token lives double the time of an access token but can't be used to access userinfo\n\trefrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()\n\n\ttokenResponse := TokenResponse{\n\t\tAccessToken:  accessToken,\n\t\tRefreshToken: refreshToken,\n\t\tTokenType:    \"Bearer\",\n\t\tExpiresIn:    int64(service.config.SessionExpiry),\n\t\tIDToken:      idToken,\n\t\tScope:        strings.ReplaceAll(codeEntry.Scope, \",\", \" \"),\n\t}\n\n\t_, err = service.queries.CreateOidcToken(c, repository.CreateOidcTokenParams{\n\t\tSub:                   codeEntry.Sub,\n\t\tAccessTokenHash:       service.Hash(accessToken),\n\t\tRefreshTokenHash:      service.Hash(refreshToken),\n\t\tClientID:              client.ClientID,\n\t\tScope:                 codeEntry.Scope,\n\t\tTokenExpiresAt:        tokenExpiresAt,\n\t\tRefreshTokenExpiresAt: refrshTokenExpiresAt,\n\t\tNonce:                 codeEntry.Nonce,\n\t})\n\n\tif err != nil {\n\t\treturn TokenResponse{}, err\n\t}\n\n\treturn tokenResponse, nil\n}\n\nfunc (service *OIDCService) RefreshAccessToken(c *gin.Context, refreshToken string, reqClientId string) (TokenResponse, error) {\n\tentry, err := service.queries.GetOidcTokenByRefreshToken(c, service.Hash(refreshToken))\n\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn TokenResponse{}, ErrTokenNotFound\n\t\t}\n\t\treturn TokenResponse{}, err\n\t}\n\n\tif entry.RefreshTokenExpiresAt < time.Now().Unix() {\n\t\treturn TokenResponse{}, ErrTokenExpired\n\t}\n\n\t// Ensure the client ID in the request matches the client ID in the token\n\tif entry.ClientID != reqClientId {\n\t\treturn TokenResponse{}, ErrInvalidClient\n\t}\n\n\tuser, err := service.GetUserinfo(c, entry.Sub)\n\n\tif err != nil {\n\t\treturn TokenResponse{}, err\n\t}\n\n\tidToken, err := service.generateIDToken(config.OIDCClientConfig{\n\t\tClientID: entry.ClientID,\n\t}, user, entry.Scope, entry.Nonce)\n\n\tif err != nil {\n\t\treturn TokenResponse{}, err\n\t}\n\n\taccessToken := utils.GenerateString(32)\n\tnewRefreshToken := utils.GenerateString(32)\n\n\ttokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry) * time.Second).Unix()\n\trefrshTokenExpiresAt := time.Now().Add(time.Duration(service.config.SessionExpiry*2) * time.Second).Unix()\n\n\ttokenResponse := TokenResponse{\n\t\tAccessToken:  accessToken,\n\t\tRefreshToken: newRefreshToken,\n\t\tTokenType:    \"Bearer\",\n\t\tExpiresIn:    int64(service.config.SessionExpiry),\n\t\tIDToken:      idToken,\n\t\tScope:        strings.ReplaceAll(entry.Scope, \",\", \" \"),\n\t}\n\n\t_, err = service.queries.UpdateOidcTokenByRefreshToken(c, repository.UpdateOidcTokenByRefreshTokenParams{\n\t\tAccessTokenHash:       service.Hash(accessToken),\n\t\tRefreshTokenHash:      service.Hash(newRefreshToken),\n\t\tTokenExpiresAt:        tokenExpiresAt,\n\t\tRefreshTokenExpiresAt: refrshTokenExpiresAt,\n\t\tRefreshTokenHash_2:    service.Hash(refreshToken), // that's the selector, it's not stored in the db\n\t})\n\n\tif err != nil {\n\t\treturn TokenResponse{}, err\n\t}\n\n\treturn tokenResponse, nil\n}\n\nfunc (service *OIDCService) DeleteCodeEntry(c *gin.Context, codeHash string) error {\n\treturn service.queries.DeleteOidcCode(c, codeHash)\n}\n\nfunc (service *OIDCService) DeleteUserinfo(c *gin.Context, sub string) error {\n\treturn service.queries.DeleteOidcUserInfo(c, sub)\n}\n\nfunc (service *OIDCService) DeleteToken(c *gin.Context, tokenHash string) error {\n\treturn service.queries.DeleteOidcToken(c, tokenHash)\n}\n\nfunc (service *OIDCService) GetAccessToken(c *gin.Context, tokenHash string) (repository.OidcToken, error) {\n\tentry, err := service.queries.GetOidcToken(c, tokenHash)\n\n\tif err != nil {\n\t\tif err == sql.ErrNoRows {\n\t\t\treturn repository.OidcToken{}, ErrTokenNotFound\n\t\t}\n\t\treturn repository.OidcToken{}, err\n\t}\n\n\tif entry.TokenExpiresAt < time.Now().Unix() {\n\t\t// If refresh token is expired, delete the token and userinfo since there is no way for the client to access anything anymore\n\t\tif entry.RefreshTokenExpiresAt < time.Now().Unix() {\n\t\t\terr := service.DeleteToken(c, tokenHash)\n\t\t\tif err != nil {\n\t\t\t\treturn repository.OidcToken{}, err\n\t\t\t}\n\t\t\terr = service.DeleteUserinfo(c, entry.Sub)\n\t\t\tif err != nil {\n\t\t\t\treturn repository.OidcToken{}, err\n\t\t\t}\n\t\t}\n\t\treturn repository.OidcToken{}, ErrTokenExpired\n\t}\n\n\treturn entry, nil\n}\n\nfunc (service *OIDCService) GetUserinfo(c *gin.Context, sub string) (repository.OidcUserinfo, error) {\n\treturn service.queries.GetOidcUserInfo(c, sub)\n}\n\nfunc (service *OIDCService) CompileUserinfo(user repository.OidcUserinfo, scope string) UserinfoResponse {\n\tscopes := strings.Split(scope, \",\") // split by comma since it's a db entry\n\tuserInfo := UserinfoResponse{\n\t\tSub:       user.Sub,\n\t\tUpdatedAt: user.UpdatedAt,\n\t}\n\n\tif slices.Contains(scopes, \"profile\") {\n\t\tuserInfo.Name = user.Name\n\t\tuserInfo.PreferredUsername = user.PreferredUsername\n\t}\n\n\tif slices.Contains(scopes, \"email\") {\n\t\tuserInfo.Email = user.Email\n\t\t// We can set this as a configuration option in the future but for now it's a good idea to assume it's true\n\t\tuserInfo.EmailVerified = true\n\t}\n\n\tif slices.Contains(scopes, \"groups\") {\n\t\tif user.Groups != \"\" {\n\t\t\tuserInfo.Groups = strings.Split(user.Groups, \",\")\n\t\t} else {\n\t\t\tuserInfo.Groups = []string{}\n\t\t}\n\t}\n\n\treturn userInfo\n}\n\nfunc (service *OIDCService) Hash(token string) string {\n\thasher := sha256.New()\n\thasher.Write([]byte(token))\n\treturn fmt.Sprintf(\"%x\", hasher.Sum(nil))\n}\n\nfunc (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) error {\n\terr := service.queries.DeleteOidcCodeBySub(ctx, sub)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn err\n\t}\n\terr = service.queries.DeleteOidcTokenBySub(ctx, sub)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn err\n\t}\n\terr = service.queries.DeleteOidcUserInfo(ctx, sub)\n\tif err != nil && !errors.Is(err, sql.ErrNoRows) {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// Cleanup routine - Resource heavy due to the linked tables\nfunc (service *OIDCService) Cleanup() {\n\t// We need a context for the routine\n\tctx := context.Background()\n\n\tticker := time.NewTicker(time.Duration(30) * time.Minute)\n\tdefer ticker.Stop()\n\n\tfor range ticker.C {\n\t\tcurrentTime := time.Now().Unix()\n\n\t\t// For the OIDC tokens, if they are expired we delete the userinfo and codes\n\t\texpiredTokens, err := service.queries.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{\n\t\t\tTokenExpiresAt:        currentTime,\n\t\t\tRefreshTokenExpiresAt: currentTime,\n\t\t})\n\n\t\tif err != nil {\n\t\t\ttlog.App.Warn().Err(err).Msg(\"Failed to delete expired tokens\")\n\t\t}\n\n\t\tfor _, expiredToken := range expiredTokens {\n\t\t\terr := service.DeleteOldSession(ctx, expiredToken.Sub)\n\t\t\tif err != nil {\n\t\t\t\ttlog.App.Warn().Err(err).Msg(\"Failed to delete old session\")\n\t\t\t}\n\t\t}\n\n\t\t// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything\n\t\texpiredCodes, err := service.queries.DeleteExpiredOidcCodes(ctx, currentTime)\n\n\t\tif err != nil {\n\t\t\ttlog.App.Warn().Err(err).Msg(\"Failed to delete expired codes\")\n\t\t}\n\n\t\tfor _, expiredCode := range expiredCodes {\n\t\t\ttoken, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)\n\n\t\t\tif err != nil {\n\t\t\t\tif err == sql.ErrNoRows {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\ttlog.App.Warn().Err(err).Msg(\"Failed to get OIDC token by sub\")\n\t\t\t}\n\n\t\t\tif token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {\n\t\t\t\terr := service.DeleteOldSession(ctx, expiredCode.Sub)\n\t\t\t\tif err != nil {\n\t\t\t\t\ttlog.App.Warn().Err(err).Msg(\"Failed to delete session\")\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (service *OIDCService) GetJWK() ([]byte, error) {\n\thasher := sha256.New()\n\n\tder := x509.MarshalPKCS1PublicKey(&service.privateKey.PublicKey)\n\n\tif der == nil {\n\t\treturn nil, errors.New(\"failed to marshal public key\")\n\t}\n\n\thasher.Write(der)\n\n\tjwk := jose.JSONWebKey{\n\t\tKey:       service.privateKey,\n\t\tAlgorithm: string(jose.RS256),\n\t\tUse:       \"sig\",\n\t\tKeyID:     base64.URLEncoding.EncodeToString(hasher.Sum(nil)),\n\t}\n\n\treturn jwk.Public().MarshalJSON()\n}\n"
  },
  {
    "path": "internal/utils/app_utils.go",
    "content": "package utils\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/weppos/publicsuffix-go/publicsuffix\"\n)\n\n// Get cookie domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com)\nfunc GetCookieDomain(u string) (string, error) {\n\tparsed, err := url.Parse(u)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\thost := parsed.Hostname()\n\n\tif netIP := net.ParseIP(host); netIP != nil {\n\t\treturn \"\", errors.New(\"IP addresses not allowed\")\n\t}\n\n\tparts := strings.Split(host, \".\")\n\n\tif len(parts) < 3 {\n\t\treturn \"\", errors.New(\"invalid app url, must be at least second level domain\")\n\t}\n\n\tdomain := strings.Join(parts[1:], \".\")\n\n\t_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil)\n\n\tif err != nil {\n\t\treturn \"\", errors.New(\"domain in public suffix list, cannot set cookies\")\n\t}\n\n\treturn domain, nil\n}\n\nfunc ParseFileToLine(content string) string {\n\tlines := strings.Split(content, \"\\n\")\n\tusers := make([]string, 0)\n\n\tfor _, line := range lines {\n\t\tif strings.TrimSpace(line) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tusers = append(users, strings.TrimSpace(line))\n\t}\n\n\treturn strings.Join(users, \",\")\n}\n\nfunc Filter[T any](slice []T, test func(T) bool) (res []T) {\n\tres = make([]T, 0)\n\tfor _, value := range slice {\n\t\tif test(value) {\n\t\t\tres = append(res, value)\n\t\t}\n\t}\n\treturn res\n}\n\nfunc GetContext(c *gin.Context) (config.UserContext, error) {\n\tuserContextValue, exists := c.Get(\"context\")\n\n\tif !exists {\n\t\treturn config.UserContext{}, errors.New(\"no user context in request\")\n\t}\n\n\tuserContext, ok := userContextValue.(*config.UserContext)\n\n\tif !ok {\n\t\treturn config.UserContext{}, errors.New(\"invalid user context in request\")\n\t}\n\n\treturn *userContext, nil\n}\n\nfunc IsRedirectSafe(redirectURL string, domain string) bool {\n\tif redirectURL == \"\" {\n\t\treturn false\n\t}\n\n\tparsed, err := url.Parse(redirectURL)\n\n\tif err != nil {\n\t\treturn false\n\t}\n\n\thostname := parsed.Hostname()\n\n\tif strings.HasSuffix(hostname, fmt.Sprintf(\".%s\", domain)) {\n\t\treturn true\n\t}\n\n\treturn hostname == domain\n}\n"
  },
  {
    "path": "internal/utils/app_utils_test.go",
    "content": "package utils_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestGetRootDomain(t *testing.T) {\n\t// Normal case\n\tdomain := \"http://sub.tinyauth.app\"\n\texpected := \"tinyauth.app\"\n\tresult, err := utils.GetCookieDomain(domain)\n\tassert.NilError(t, err)\n\tassert.Equal(t, expected, result)\n\n\t// Domain with multiple subdomains\n\tdomain = \"http://b.c.tinyauth.app\"\n\texpected = \"c.tinyauth.app\"\n\tresult, err = utils.GetCookieDomain(domain)\n\tassert.NilError(t, err)\n\tassert.Equal(t, expected, result)\n\n\t// Domain with no subdomain\n\tdomain = \"http://tinyauth.app\"\n\texpected = \"tinyauth.app\"\n\t_, err = utils.GetCookieDomain(domain)\n\tassert.Error(t, err, \"invalid app url, must be at least second level domain\")\n\n\t// Invalid domain (only TLD)\n\tdomain = \"com\"\n\t_, err = utils.GetCookieDomain(domain)\n\tassert.ErrorContains(t, err, \"invalid app url, must be at least second level domain\")\n\n\t// IP address\n\tdomain = \"http://10.10.10.10\"\n\t_, err = utils.GetCookieDomain(domain)\n\tassert.ErrorContains(t, err, \"IP addresses not allowed\")\n\n\t// Invalid URL\n\tdomain = \"http://[::1]:namedport\"\n\t_, err = utils.GetCookieDomain(domain)\n\tassert.ErrorContains(t, err, \"parse \\\"http://[::1]:namedport\\\": invalid port \\\":namedport\\\" after host\")\n\n\t// URL with scheme and path\n\tdomain = \"https://sub.tinyauth.app/path\"\n\texpected = \"tinyauth.app\"\n\tresult, err = utils.GetCookieDomain(domain)\n\tassert.NilError(t, err)\n\tassert.Equal(t, expected, result)\n\n\t// URL with port\n\tdomain = \"http://sub.tinyauth.app:8080\"\n\texpected = \"tinyauth.app\"\n\tresult, err = utils.GetCookieDomain(domain)\n\tassert.NilError(t, err)\n\tassert.Equal(t, expected, result)\n\n\t// Domain managed by ICANN\n\tdomain = \"http://example.co.uk\"\n\t_, err = utils.GetCookieDomain(domain)\n\tassert.Error(t, err, \"domain in public suffix list, cannot set cookies\")\n}\n\nfunc TestParseFileToLine(t *testing.T) {\n\t// Normal case\n\tcontent := \"user1\\nuser2\\nuser3\"\n\texpected := \"user1,user2,user3\"\n\tresult := utils.ParseFileToLine(content)\n\tassert.Equal(t, expected, result)\n\n\t// Case with empty lines and spaces\n\tcontent = \" user1 \\n\\n user2 \\n user3 \\n\"\n\texpected = \"user1,user2,user3\"\n\tresult = utils.ParseFileToLine(content)\n\tassert.Equal(t, expected, result)\n\n\t// Case with only empty lines\n\tcontent = \"\\n\\n\\n\"\n\texpected = \"\"\n\tresult = utils.ParseFileToLine(content)\n\tassert.Equal(t, expected, result)\n\n\t// Case with single user\n\tcontent = \"singleuser\"\n\texpected = \"singleuser\"\n\tresult = utils.ParseFileToLine(content)\n\tassert.Equal(t, expected, result)\n\n\t// Case with trailing newline\n\tcontent = \"user1\\nuser2\\n\"\n\texpected = \"user1,user2\"\n\tresult = utils.ParseFileToLine(content)\n\tassert.Equal(t, expected, result)\n}\n\nfunc TestFilter(t *testing.T) {\n\t// Normal case\n\tslice := []int{1, 2, 3, 4, 5}\n\ttestFunc := func(n int) bool { return n%2 == 0 }\n\texpected := []int{2, 4}\n\tresult := utils.Filter(slice, testFunc)\n\tassert.DeepEqual(t, expected, result)\n\n\t// Case with no matches\n\tslice = []int{1, 3, 5}\n\ttestFunc = func(n int) bool { return n%2 == 0 }\n\texpected = []int{}\n\tresult = utils.Filter(slice, testFunc)\n\tassert.DeepEqual(t, expected, result)\n\n\t// Case with all matches\n\tslice = []int{2, 4, 6}\n\ttestFunc = func(n int) bool { return n%2 == 0 }\n\texpected = []int{2, 4, 6}\n\tresult = utils.Filter(slice, testFunc)\n\tassert.DeepEqual(t, expected, result)\n\n\t// Case with empty slice\n\tslice = []int{}\n\ttestFunc = func(n int) bool { return n%2 == 0 }\n\texpected = []int{}\n\tresult = utils.Filter(slice, testFunc)\n\tassert.DeepEqual(t, expected, result)\n\n\t// Case with different type (string)\n\tsliceStr := []string{\"apple\", \"banana\", \"cherry\"}\n\ttestFuncStr := func(s string) bool { return len(s) > 5 }\n\texpectedStr := []string{\"banana\", \"cherry\"}\n\tresultStr := utils.Filter(sliceStr, testFuncStr)\n\tassert.DeepEqual(t, expectedStr, resultStr)\n}\n\nfunc TestGetContext(t *testing.T) {\n\t// Setup\n\tgin.SetMode(gin.TestMode)\n\tc, _ := gin.CreateTestContext(nil)\n\n\t// Normal case\n\tc.Set(\"context\", &config.UserContext{Username: \"testuser\"})\n\tresult, err := utils.GetContext(c)\n\tassert.NilError(t, err)\n\tassert.Equal(t, \"testuser\", result.Username)\n\n\t// Case with no context\n\tc.Set(\"context\", nil)\n\t_, err = utils.GetContext(c)\n\tassert.Error(t, err, \"invalid user context in request\")\n\n\t// Case with invalid context type\n\tc.Set(\"context\", \"invalid type\")\n\t_, err = utils.GetContext(c)\n\tassert.Error(t, err, \"invalid user context in request\")\n}\n\nfunc TestIsRedirectSafe(t *testing.T) {\n\t// Setup\n\tdomain := \"example.com\"\n\n\t// Case with no subdomain\n\tredirectURL := \"http://example.com/welcome\"\n\tresult := utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, true, result)\n\n\t// Case with different domain\n\tredirectURL = \"http://malicious.com/phishing\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, false, result)\n\n\t// Case with subdomain\n\tredirectURL = \"http://sub.example.com/page\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, true, result)\n\n\t// Case with sub-subdomain\n\tredirectURL = \"http://a.b.example.com/home\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, true, result)\n\n\t// Case with empty redirect URL\n\tredirectURL = \"\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, false, result)\n\n\t// Case with invalid URL\n\tredirectURL = \"http://[::1]:namedport\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, false, result)\n\n\t// Case with URL having port\n\tredirectURL = \"http://sub.example.com:8080/page\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, true, result)\n\n\t// Case with URL having different subdomain\n\tredirectURL = \"http://another.example.com/page\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, true, result)\n\n\t// Case with URL having different TLD\n\tredirectURL = \"http://example.org/page\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, false, result)\n\n\t// Case with malicious domain\n\tredirectURL = \"https://malicious-example.com/yoyo\"\n\tresult = utils.IsRedirectSafe(redirectURL, domain)\n\tassert.Equal(t, false, result)\n}\n"
  },
  {
    "path": "internal/utils/decoders/label_decoder.go",
    "content": "package decoders\n\nimport (\n\t\"github.com/traefik/paerser/parser\"\n)\n\nfunc DecodeLabels[T any](labels map[string]string, root string) (T, error) {\n\tvar labelsDecoded T\n\n\terr := parser.Decode(labels, &labelsDecoded, \"tinyauth\", \"tinyauth.\"+root)\n\n\tif err != nil {\n\t\treturn labelsDecoded, err\n\t}\n\n\treturn labelsDecoded, nil\n}\n"
  },
  {
    "path": "internal/utils/decoders/label_decoder_test.go",
    "content": "package decoders_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/decoders\"\n\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestDecodeLabels(t *testing.T) {\n\t// Variables\n\texpected := config.Apps{\n\t\tApps: map[string]config.App{\n\t\t\t\"foo\": {\n\t\t\t\tConfig: config.AppConfig{\n\t\t\t\t\tDomain: \"example.com\",\n\t\t\t\t},\n\t\t\t\tUsers: config.AppUsers{\n\t\t\t\t\tAllow: \"user1,user2\",\n\t\t\t\t\tBlock: \"user3\",\n\t\t\t\t},\n\t\t\t\tOAuth: config.AppOAuth{\n\t\t\t\t\tWhitelist: \"somebody@example.com\",\n\t\t\t\t\tGroups:    \"group3\",\n\t\t\t\t},\n\t\t\t\tIP: config.AppIP{\n\t\t\t\t\tAllow:  []string{\"10.71.0.1/24\", \"10.71.0.2\"},\n\t\t\t\t\tBlock:  []string{\"10.10.10.10\", \"10.0.0.0/24\"},\n\t\t\t\t\tBypass: []string{\"192.168.1.1\"},\n\t\t\t\t},\n\t\t\t\tResponse: config.AppResponse{\n\t\t\t\t\tHeaders: []string{\"X-Foo=Bar\", \"X-Baz=Qux\"},\n\t\t\t\t\tBasicAuth: config.AppBasicAuth{\n\t\t\t\t\t\tUsername:     \"admin\",\n\t\t\t\t\t\tPassword:     \"password\",\n\t\t\t\t\t\tPasswordFile: \"/path/to/passwordfile\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tPath: config.AppPath{\n\t\t\t\t\tAllow: \"/public\",\n\t\t\t\t\tBlock: \"/private\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\ttest := map[string]string{\n\t\t\"tinyauth.apps.foo.config.domain\":                   \"example.com\",\n\t\t\"tinyauth.apps.foo.users.allow\":                     \"user1,user2\",\n\t\t\"tinyauth.apps.foo.users.block\":                     \"user3\",\n\t\t\"tinyauth.apps.foo.oauth.whitelist\":                 \"somebody@example.com\",\n\t\t\"tinyauth.apps.foo.oauth.groups\":                    \"group3\",\n\t\t\"tinyauth.apps.foo.ip.allow\":                        \"10.71.0.1/24,10.71.0.2\",\n\t\t\"tinyauth.apps.foo.ip.block\":                        \"10.10.10.10,10.0.0.0/24\",\n\t\t\"tinyauth.apps.foo.ip.bypass\":                       \"192.168.1.1\",\n\t\t\"tinyauth.apps.foo.response.headers\":                \"X-Foo=Bar,X-Baz=Qux\",\n\t\t\"tinyauth.apps.foo.response.basicauth.username\":     \"admin\",\n\t\t\"tinyauth.apps.foo.response.basicauth.password\":     \"password\",\n\t\t\"tinyauth.apps.foo.response.basicauth.passwordfile\": \"/path/to/passwordfile\",\n\t\t\"tinyauth.apps.foo.path.allow\":                      \"/public\",\n\t\t\"tinyauth.apps.foo.path.block\":                      \"/private\",\n\t}\n\n\t// Test\n\tresult, err := decoders.DecodeLabels[config.Apps](test, \"apps\")\n\tassert.NilError(t, err)\n\tassert.DeepEqual(t, expected, result)\n}\n"
  },
  {
    "path": "internal/utils/fs_utils.go",
    "content": "package utils\n\nimport \"os\"\n\nfunc ReadFile(file string) (string, error) {\n\t_, err := os.Stat(file)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tdata, err := os.ReadFile(file)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn string(data), nil\n}\n"
  },
  {
    "path": "internal/utils/fs_utils_test.go",
    "content": "package utils\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestReadFile(t *testing.T) {\n\t// Setup\n\tfile, err := os.Create(\"/tmp/tinyauth_test_file\")\n\tassert.NilError(t, err)\n\n\t_, err = file.WriteString(\"file content\\n\")\n\tassert.NilError(t, err)\n\n\terr = file.Close()\n\tassert.NilError(t, err)\n\tdefer os.Remove(\"/tmp/tinyauth_test_file\")\n\n\t// Normal case\n\tcontent, err := ReadFile(\"/tmp/tinyauth_test_file\")\n\tassert.NilError(t, err)\n\tassert.Equal(t, \"file content\\n\", content)\n\n\t// Non-existing file\n\tcontent, err = ReadFile(\"/tmp/non_existing_file\")\n\tassert.ErrorContains(t, err, \"no such file or directory\")\n\tassert.Equal(t, \"\", content)\n}\n"
  },
  {
    "path": "internal/utils/label_utils.go",
    "content": "package utils\n\nimport (\n\t\"net/http\"\n\t\"strings\"\n)\n\nfunc ParseHeaders(headers []string) map[string]string {\n\theaderMap := make(map[string]string)\n\tfor _, header := range headers {\n\t\tsplit := strings.SplitN(header, \"=\", 2)\n\t\tif len(split) != 2 || strings.TrimSpace(split[0]) == \"\" || strings.TrimSpace(split[1]) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tkey := SanitizeHeader(strings.TrimSpace(split[0]))\n\t\tif strings.ContainsAny(key, \" \\t\") {\n\t\t\tcontinue\n\t\t}\n\t\tkey = http.CanonicalHeaderKey(key)\n\t\tvalue := SanitizeHeader(strings.TrimSpace(split[1]))\n\t\theaderMap[key] = value\n\t}\n\treturn headerMap\n}\n\nfunc SanitizeHeader(header string) string {\n\treturn strings.Map(func(r rune) rune {\n\t\t// Allow only printable ASCII characters (32-126) and safe whitespace (space, tab)\n\t\tif r == ' ' || r == '\\t' || (r >= 32 && r <= 126) {\n\t\t\treturn r\n\t\t}\n\t\treturn -1\n\t}, header)\n}\n"
  },
  {
    "path": "internal/utils/label_utils_test.go",
    "content": "package utils_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestParseHeaders(t *testing.T) {\n\t// Normal case\n\theaders := []string{\n\t\t\"X-Custom-Header=Value\",\n\t\t\"Another-Header=AnotherValue\",\n\t}\n\texpected := map[string]string{\n\t\t\"X-Custom-Header\": \"Value\",\n\t\t\"Another-Header\":  \"AnotherValue\",\n\t}\n\tassert.DeepEqual(t, expected, utils.ParseHeaders(headers))\n\n\t// Case insensitivity and trimming\n\theaders = []string{\n\t\t\"  x-custom-header =  Value  \",\n\t\t\"ANOTHER-HEADER=AnotherValue\",\n\t}\n\texpected = map[string]string{\n\t\t\"X-Custom-Header\": \"Value\",\n\t\t\"Another-Header\":  \"AnotherValue\",\n\t}\n\tassert.DeepEqual(t, expected, utils.ParseHeaders(headers))\n\n\t// Invalid headers (missing '=', empty key/value)\n\theaders = []string{\n\t\t\"InvalidHeader\",\n\t\t\"=NoKey\",\n\t\t\"NoValue=\",\n\t\t\"   =   \",\n\t}\n\texpected = map[string]string{}\n\tassert.DeepEqual(t, expected, utils.ParseHeaders(headers))\n\n\t// Headers with unsafe characters\n\theaders = []string{\n\t\t\"X-Custom-Header=Val\\x00ue\",       // Null byte\n\t\t\"Another-Header=Anoth\\x7FerValue\", // DEL character\n\t\t\"Good-Header=GoodValue\",\n\t}\n\texpected = map[string]string{\n\t\t\"X-Custom-Header\": \"Value\",\n\t\t\"Another-Header\":  \"AnotherValue\",\n\t\t\"Good-Header\":     \"GoodValue\",\n\t}\n\tassert.DeepEqual(t, expected, utils.ParseHeaders(headers))\n\n\t// Header with spaces in key (should be ignored)\n\theaders = []string{\n\t\t\"X Custom Header=Value\",\n\t\t\"Valid-Header=ValidValue\",\n\t}\n\texpected = map[string]string{\n\t\t\"Valid-Header\": \"ValidValue\",\n\t}\n\tassert.DeepEqual(t, expected, utils.ParseHeaders(headers))\n}\n\nfunc TestSanitizeHeader(t *testing.T) {\n\t// Normal case\n\theader := \"X-Custom-Header\"\n\texpected := \"X-Custom-Header\"\n\tassert.Equal(t, expected, utils.SanitizeHeader(header))\n\n\t// Header with unsafe characters\n\theader = \"X-Cust\\x00om-Hea\\x7Fder\" // Null byte and DEL character\n\texpected = \"X-Custom-Header\"\n\tassert.Equal(t, expected, utils.SanitizeHeader(header))\n\n\t// Header with only unsafe characters\n\theader = \"\\x00\\x01\\x02\\x7F\"\n\texpected = \"\"\n\tassert.Equal(t, expected, utils.SanitizeHeader(header))\n\n\t// Header with spaces and tabs (should be preserved)\n\theader = \"X Custom\\tHeader\"\n\texpected = \"X Custom\\tHeader\"\n\tassert.Equal(t, expected, utils.SanitizeHeader(header))\n}\n"
  },
  {
    "path": "internal/utils/loaders/loader_env.go",
    "content": "package loaders\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\n\t\"github.com/traefik/paerser/cli\"\n\t\"github.com/traefik/paerser/env\"\n)\n\ntype EnvLoader struct{}\n\nfunc (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {\n\tvars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration)\n\tif len(vars) == 0 {\n\t\treturn false, nil\n\t}\n\n\tif err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil {\n\t\treturn false, fmt.Errorf(\"failed to decode configuration from environment variables: %w\", err)\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/utils/loaders/loader_file.go",
    "content": "package loaders\n\nimport (\n\t\"os\"\n\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/traefik/paerser/cli\"\n\t\"github.com/traefik/paerser/file\"\n\t\"github.com/traefik/paerser/flag\"\n)\n\ntype FileLoader struct{}\n\nfunc (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {\n\tflags, err := flag.Parse(args, cmd.Configuration)\n\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\t// I guess we are using traefik as the root name (we can't change it)\n\tconfigFileFlag := \"traefik.experimental.configfile\"\n\tenvVar := \"TINYAUTH_EXPERIMENTAL_CONFIGFILE\"\n\n\tif _, ok := flags[configFileFlag]; !ok {\n\t\tif value := os.Getenv(envVar); value != \"\" {\n\t\t\tflags[configFileFlag] = value\n\t\t} else {\n\t\t\treturn false, nil\n\t\t}\n\t}\n\n\tlog.Warn().Msg(\"Using experimental file config loader, this feature is experimental and may change or be removed in future releases\")\n\n\terr = file.Decode(flags[configFileFlag], cmd.Configuration)\n\n\tif err != nil {\n\t\treturn false, err\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/utils/loaders/loader_flag.go",
    "content": "package loaders\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/traefik/paerser/cli\"\n\t\"github.com/traefik/paerser/flag\"\n)\n\ntype FlagLoader struct{}\n\nfunc (*FlagLoader) Load(args []string, cmd *cli.Command) (bool, error) {\n\tif len(args) == 0 {\n\t\treturn false, nil\n\t}\n\n\tif err := flag.Decode(args, cmd.Configuration); err != nil {\n\t\treturn false, fmt.Errorf(\"failed to decode configuration from flags: %w\", err)\n\t}\n\n\treturn true, nil\n}\n"
  },
  {
    "path": "internal/utils/security_utils.go",
    "content": "package utils\n\nimport (\n\t\"crypto/rand\"\n\t\"encoding/base64\"\n\t\"errors\"\n\t\"net\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/google/uuid\"\n)\n\nfunc GetSecret(conf string, file string) string {\n\tif conf == \"\" && file == \"\" {\n\t\treturn \"\"\n\t}\n\n\tif conf != \"\" {\n\t\treturn conf\n\t}\n\n\tcontents, err := ReadFile(file)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\n\treturn ParseSecretFile(contents)\n}\n\nfunc ParseSecretFile(contents string) string {\n\tlines := strings.Split(contents, \"\\n\")\n\n\tfor _, line := range lines {\n\t\tif strings.TrimSpace(line) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\treturn strings.TrimSpace(line)\n\t}\n\n\treturn \"\"\n}\n\nfunc GetBasicAuth(username string, password string) string {\n\tauth := username + \":\" + password\n\treturn base64.StdEncoding.EncodeToString([]byte(auth))\n}\n\nfunc FilterIP(filter string, ip string) (bool, error) {\n\tipAddr := net.ParseIP(ip)\n\n\tif ipAddr == nil {\n\t\treturn false, errors.New(\"invalid IP address\")\n\t}\n\n\tfilter = strings.Replace(filter, \"-\", \"/\", -1)\n\n\tif strings.Contains(filter, \"/\") {\n\t\t_, cidr, err := net.ParseCIDR(filter)\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\t\treturn cidr.Contains(ipAddr), nil\n\t}\n\n\tipFilter := net.ParseIP(filter)\n\tif ipFilter == nil {\n\t\treturn false, errors.New(\"invalid IP address in filter\")\n\t}\n\n\tif ipFilter.Equal(ipAddr) {\n\t\treturn true, nil\n\t}\n\n\treturn false, nil\n}\n\nfunc CheckFilter(filter string, str string) bool {\n\tif len(strings.TrimSpace(filter)) == 0 {\n\t\treturn true\n\t}\n\n\tif strings.HasPrefix(filter, \"/\") && strings.HasSuffix(filter, \"/\") {\n\t\tre, err := regexp.Compile(filter[1 : len(filter)-1])\n\t\tif err != nil {\n\t\t\treturn false\n\t\t}\n\n\t\tif re.MatchString(strings.TrimSpace(str)) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\tfilterSplit := strings.Split(filter, \",\")\n\n\tfor _, item := range filterSplit {\n\t\tif strings.TrimSpace(item) == strings.TrimSpace(str) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc GenerateUUID(str string) string {\n\tuuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str))\n\treturn uuid.String()\n}\n\nfunc GenerateString(length int) string {\n\tsrc := make([]byte, length)\n\trand.Read(src)\n\treturn base64.RawURLEncoding.EncodeToString(src)[:length]\n}\n"
  },
  {
    "path": "internal/utils/security_utils_test.go",
    "content": "package utils_test\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestGetSecret(t *testing.T) {\n\t// Setup\n\tfile, err := os.Create(\"/tmp/tinyauth_test_secret\")\n\tassert.NilError(t, err)\n\n\t_, err = file.WriteString(\"       secret       \\n\")\n\tassert.NilError(t, err)\n\n\terr = file.Close()\n\tassert.NilError(t, err)\n\tdefer os.Remove(\"/tmp/tinyauth_test_secret\")\n\n\t// Get from config\n\tassert.Equal(t, \"mysecret\", utils.GetSecret(\"mysecret\", \"\"))\n\n\t// Get from file\n\tassert.Equal(t, \"secret\", utils.GetSecret(\"\", \"/tmp/tinyauth_test_secret\"))\n\n\t// Get from both (config should take precedence)\n\tassert.Equal(t, \"mysecret\", utils.GetSecret(\"mysecret\", \"/tmp/tinyauth_test_secret\"))\n\n\t// Get from none\n\tassert.Equal(t, \"\", utils.GetSecret(\"\", \"\"))\n\n\t// Get from non-existing file\n\tassert.Equal(t, \"\", utils.GetSecret(\"\", \"/tmp/non_existing_file\"))\n}\n\nfunc TestParseSecretFile(t *testing.T) {\n\t// Normal case\n\tcontent := \"   mysecret   \\n\"\n\tassert.Equal(t, \"mysecret\", utils.ParseSecretFile(content))\n\n\t// Multiple lines (should take the first non-empty line)\n\tcontent = \"\\n\\n   firstsecret   \\nsecondsecret\\n\"\n\tassert.Equal(t, \"firstsecret\", utils.ParseSecretFile(content))\n\n\t// All empty lines\n\tcontent = \"\\n   \\n  \\n\"\n\tassert.Equal(t, \"\", utils.ParseSecretFile(content))\n\n\t// Empty content\n\tcontent = \"\"\n\tassert.Equal(t, \"\", utils.ParseSecretFile(content))\n}\n\nfunc TestGetBasicAuth(t *testing.T) {\n\t// Normal case\n\tusername := \"user\"\n\tpassword := \"pass\"\n\texpected := \"dXNlcjpwYXNz\" // base64 of \"user:pass\"\n\tassert.Equal(t, expected, utils.GetBasicAuth(username, password))\n\n\t// Empty username\n\tusername = \"\"\n\tpassword = \"pass\"\n\texpected = \"OnBhc3M=\" // base64 of \":pass\"\n\tassert.Equal(t, expected, utils.GetBasicAuth(username, password))\n\n\t// Empty password\n\tusername = \"user\"\n\tpassword = \"\"\n\texpected = \"dXNlcjo=\" // base64 of \"user:\"\n\tassert.Equal(t, expected, utils.GetBasicAuth(username, password))\n}\n\nfunc TestFilterIP(t *testing.T) {\n\t// Exact match IPv4\n\tok, err := utils.FilterIP(\"10.10.0.1\", \"10.10.0.1\")\n\tassert.NilError(t, err)\n\tassert.Equal(t, true, ok)\n\n\t// Non-match IPv4\n\tok, err = utils.FilterIP(\"10.10.0.1\", \"10.10.0.2\")\n\tassert.NilError(t, err)\n\tassert.Equal(t, false, ok)\n\n\t// CIDR match IPv4\n\tok, err = utils.FilterIP(\"10.10.0.0/24\", \"10.10.0.2\")\n\tassert.NilError(t, err)\n\tassert.Equal(t, true, ok)\n\n\t// CIDR match IPv4 with '-' instead of '/'\n\tok, err = utils.FilterIP(\"10.10.10.0-24\", \"10.10.10.5\")\n\tassert.NilError(t, err)\n\tassert.Equal(t, true, ok)\n\n\t// CIDR non-match IPv4\n\tok, err = utils.FilterIP(\"10.10.0.0/24\", \"10.5.0.1\")\n\tassert.NilError(t, err)\n\tassert.Equal(t, false, ok)\n\n\t// Invalid CIDR\n\tok, err = utils.FilterIP(\"10.10.0.0/222\", \"10.0.0.1\")\n\tassert.ErrorContains(t, err, \"invalid CIDR address\")\n\tassert.Equal(t, false, ok)\n\n\t// Invalid IP in filter\n\tok, err = utils.FilterIP(\"invalid_ip\", \"10.5.5.5\")\n\tassert.ErrorContains(t, err, \"invalid IP address in filter\")\n\tassert.Equal(t, false, ok)\n\n\t// Invalid IP to check\n\tok, err = utils.FilterIP(\"10.10.10.10\", \"invalid_ip\")\n\tassert.ErrorContains(t, err, \"invalid IP address\")\n\tassert.Equal(t, false, ok)\n}\n\nfunc TestCheckFilter(t *testing.T) {\n\t// Empty filter\n\tassert.Equal(t, true, utils.CheckFilter(\"\", \"anystring\"))\n\n\t// Exact match\n\tassert.Equal(t, true, utils.CheckFilter(\"hello\", \"hello\"))\n\n\t// Regex match\n\tassert.Equal(t, true, utils.CheckFilter(\"/^h.*o$/\", \"hello\"))\n\n\t// Invalid regex\n\tassert.Equal(t, false, utils.CheckFilter(\"/[unclosed\", \"test\"))\n\n\t// Comma-separated values\n\tassert.Equal(t, true, utils.CheckFilter(\"apple, banana, cherry\", \"banana\"))\n\n\t// No match\n\tassert.Equal(t, false, utils.CheckFilter(\"apple, banana, cherry\", \"grape\"))\n}\n\nfunc TestGenerateUUID(t *testing.T) {\n\t// Consistent output for same input\n\tid1 := utils.GenerateUUID(\"teststring\")\n\tid2 := utils.GenerateUUID(\"teststring\")\n\tassert.Equal(t, id1, id2)\n\n\t// Different output for different input\n\tid3 := utils.GenerateUUID(\"differentstring\")\n\tassert.Assert(t, id1 != id3)\n}\n"
  },
  {
    "path": "internal/utils/string_utils.go",
    "content": "package utils\n\nimport (\n\t\"strings\"\n)\n\nfunc Capitalize(str string) string {\n\tif len(str) == 0 {\n\t\treturn \"\"\n\t}\n\treturn strings.ToUpper(string([]rune(str)[0])) + string([]rune(str)[1:])\n}\n\nfunc CoalesceToString(value any) string {\n\tswitch v := value.(type) {\n\tcase []any:\n\t\tstrs := make([]string, 0, len(v))\n\t\tfor _, item := range v {\n\t\t\tif str, ok := item.(string); ok {\n\t\t\t\tstrs = append(strs, str)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\treturn strings.Join(strs, \",\")\n\tcase string:\n\t\treturn v\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n"
  },
  {
    "path": "internal/utils/string_utils_test.go",
    "content": "package utils_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestCapitalize(t *testing.T) {\n\t// Test empty string\n\tassert.Equal(t, \"\", utils.Capitalize(\"\"))\n\n\t// Test single character\n\tassert.Equal(t, \"A\", utils.Capitalize(\"a\"))\n\n\t// Test multiple characters\n\tassert.Equal(t, \"Hello\", utils.Capitalize(\"hello\"))\n\n\t// Test already capitalized\n\tassert.Equal(t, \"World\", utils.Capitalize(\"World\"))\n\n\t// Test non-alphabetic first character\n\tassert.Equal(t, \"1number\", utils.Capitalize(\"1number\"))\n\n\t// Test Unicode characters\n\tassert.Equal(t, \"Γειά\", utils.Capitalize(\"γειά\"))\n\tassert.Equal(t, \"Привет\", utils.Capitalize(\"привет\"))\n\n}\n\nfunc TestCoalesceToString(t *testing.T) {\n\t// Test with []any containing strings\n\tassert.Equal(t, \"a,b,c\", utils.CoalesceToString([]any{\"a\", \"b\", \"c\"}))\n\n\t// Test with []any containing mixed types\n\tassert.Equal(t, \"a,c\", utils.CoalesceToString([]any{\"a\", 1, \"c\", true}))\n\n\t// Test with []any containing no strings\n\tassert.Equal(t, \"\", utils.CoalesceToString([]any{1, 2, 3}))\n\n\t// Test with string input\n\tassert.Equal(t, \"hello\", utils.CoalesceToString(\"hello\"))\n\n\t// Test with non-string, non-[]any input\n\tassert.Equal(t, \"\", utils.CoalesceToString(123))\n\n\t// Test with nil input\n\tassert.Equal(t, \"\", utils.CoalesceToString(nil))\n}\n\nfunc TestCompileUserEmail(t *testing.T) {\n\t// Test with valid email\n\tassert.Equal(t, \"user@example.com\", utils.CompileUserEmail(\"user@example.com\", \"example.com\"))\n\n\t// Test with invalid email\n\tassert.Equal(t, \"user@example.com\", utils.CompileUserEmail(\"user\", \"example.com\"))\n}\n"
  },
  {
    "path": "internal/utils/tlog/log_audit.go",
    "content": "package tlog\n\nimport \"github.com/gin-gonic/gin\"\n\n// functions here use CallerSkipFrame to ensure correct caller info is logged\n\nfunc AuditLoginSuccess(c *gin.Context, username, provider string) {\n\tAudit.Info().\n\t\tCallerSkipFrame(1).\n\t\tStr(\"event\", \"login\").\n\t\tStr(\"result\", \"success\").\n\t\tStr(\"username\", username).\n\t\tStr(\"provider\", provider).\n\t\tStr(\"ip\", c.ClientIP()).\n\t\tSend()\n}\n\nfunc AuditLoginFailure(c *gin.Context, username, provider string, reason string) {\n\tAudit.Warn().\n\t\tCallerSkipFrame(1).\n\t\tStr(\"event\", \"login\").\n\t\tStr(\"result\", \"failure\").\n\t\tStr(\"username\", username).\n\t\tStr(\"provider\", provider).\n\t\tStr(\"ip\", c.ClientIP()).\n\t\tStr(\"reason\", reason).\n\t\tSend()\n}\n\nfunc AuditLogout(c *gin.Context, username, provider string) {\n\tAudit.Info().\n\t\tCallerSkipFrame(1).\n\t\tStr(\"event\", \"logout\").\n\t\tStr(\"result\", \"success\").\n\t\tStr(\"username\", username).\n\t\tStr(\"provider\", provider).\n\t\tStr(\"ip\", c.ClientIP()).\n\t\tSend()\n}\n"
  },
  {
    "path": "internal/utils/tlog/log_wrapper.go",
    "content": "package tlog\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/rs/zerolog\"\n\t\"github.com/rs/zerolog/log\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n)\n\ntype Logger struct {\n\tAudit zerolog.Logger\n\tHTTP  zerolog.Logger\n\tApp   zerolog.Logger\n}\n\nvar (\n\tAudit zerolog.Logger\n\tHTTP  zerolog.Logger\n\tApp   zerolog.Logger\n)\n\nfunc NewLogger(cfg config.LogConfig) *Logger {\n\tbaseLogger := log.With().\n\t\tTimestamp().\n\t\tCaller().\n\t\tLogger().\n\t\tLevel(parseLogLevel(cfg.Level))\n\n\tif !cfg.Json {\n\t\tbaseLogger = baseLogger.Output(zerolog.ConsoleWriter{\n\t\t\tOut:        os.Stderr,\n\t\t\tTimeFormat: time.RFC3339,\n\t\t})\n\t}\n\n\treturn &Logger{\n\t\tAudit: createLogger(\"audit\", cfg.Streams.Audit, baseLogger),\n\t\tHTTP:  createLogger(\"http\", cfg.Streams.HTTP, baseLogger),\n\t\tApp:   createLogger(\"app\", cfg.Streams.App, baseLogger),\n\t}\n}\n\nfunc NewSimpleLogger() *Logger {\n\treturn NewLogger(config.LogConfig{\n\t\tLevel: \"info\",\n\t\tJson:  false,\n\t\tStreams: config.LogStreams{\n\t\t\tHTTP:  config.LogStreamConfig{Enabled: true},\n\t\t\tApp:   config.LogStreamConfig{Enabled: true},\n\t\t\tAudit: config.LogStreamConfig{Enabled: false},\n\t\t},\n\t})\n}\n\nfunc (l *Logger) Init() {\n\tAudit = l.Audit\n\tHTTP = l.HTTP\n\tApp = l.App\n}\n\nfunc createLogger(component string, streamCfg config.LogStreamConfig, baseLogger zerolog.Logger) zerolog.Logger {\n\tif !streamCfg.Enabled {\n\t\treturn zerolog.Nop()\n\t}\n\tsubLogger := baseLogger.With().Str(\"log_stream\", component).Logger()\n\t// override level if specified, otherwise use base level\n\tif streamCfg.Level != \"\" {\n\t\tsubLogger = subLogger.Level(parseLogLevel(streamCfg.Level))\n\t}\n\treturn subLogger\n}\n\nfunc parseLogLevel(level string) zerolog.Level {\n\tif level == \"\" {\n\t\treturn zerolog.InfoLevel\n\t}\n\tparsedLevel, err := zerolog.ParseLevel(strings.ToLower(level))\n\tif err != nil {\n\t\tlog.Warn().Err(err).Str(\"level\", level).Msg(\"Invalid log level, defaulting to info\")\n\t\tparsedLevel = zerolog.InfoLevel\n\t}\n\treturn parsedLevel\n}\n"
  },
  {
    "path": "internal/utils/tlog/log_wrapper_test.go",
    "content": "package tlog_test\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\n\t\"github.com/rs/zerolog\"\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestNewLogger(t *testing.T) {\n\tcfg := config.LogConfig{\n\t\tLevel: \"debug\",\n\t\tJson:  true,\n\t\tStreams: config.LogStreams{\n\t\t\tHTTP:  config.LogStreamConfig{Enabled: true, Level: \"info\"},\n\t\t\tApp:   config.LogStreamConfig{Enabled: true, Level: \"\"},\n\t\t\tAudit: config.LogStreamConfig{Enabled: false, Level: \"\"},\n\t\t},\n\t}\n\n\tlogger := tlog.NewLogger(cfg)\n\n\tassert.Assert(t, logger != nil)\n\tassert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)\n\tassert.Assert(t, logger.App.GetLevel() == zerolog.DebugLevel)\n\tassert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)\n}\n\nfunc TestNewSimpleLogger(t *testing.T) {\n\tlogger := tlog.NewSimpleLogger()\n\tassert.Assert(t, logger != nil)\n\tassert.Assert(t, logger.HTTP.GetLevel() == zerolog.InfoLevel)\n\tassert.Assert(t, logger.App.GetLevel() == zerolog.InfoLevel)\n\tassert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)\n}\n\nfunc TestLoggerInit(t *testing.T) {\n\tlogger := tlog.NewSimpleLogger()\n\tlogger.Init()\n\n\tassert.Assert(t, tlog.App.GetLevel() != zerolog.Disabled)\n}\n\nfunc TestLoggerWithDisabledStreams(t *testing.T) {\n\tcfg := config.LogConfig{\n\t\tLevel: \"info\",\n\t\tJson:  false,\n\t\tStreams: config.LogStreams{\n\t\t\tHTTP:  config.LogStreamConfig{Enabled: false},\n\t\t\tApp:   config.LogStreamConfig{Enabled: false},\n\t\t\tAudit: config.LogStreamConfig{Enabled: false},\n\t\t},\n\t}\n\n\tlogger := tlog.NewLogger(cfg)\n\n\tassert.Assert(t, logger.HTTP.GetLevel() == zerolog.Disabled)\n\tassert.Assert(t, logger.App.GetLevel() == zerolog.Disabled)\n\tassert.Assert(t, logger.Audit.GetLevel() == zerolog.Disabled)\n}\n\nfunc TestLogStreamField(t *testing.T) {\n\tvar buf bytes.Buffer\n\n\tcfg := config.LogConfig{\n\t\tLevel: \"info\",\n\t\tJson:  true,\n\t\tStreams: config.LogStreams{\n\t\t\tHTTP:  config.LogStreamConfig{Enabled: true},\n\t\t\tApp:   config.LogStreamConfig{Enabled: true},\n\t\t\tAudit: config.LogStreamConfig{Enabled: true},\n\t\t},\n\t}\n\n\tlogger := tlog.NewLogger(cfg)\n\n\t// Override output for HTTP logger to capture output\n\tlogger.HTTP = logger.HTTP.Output(&buf)\n\n\tlogger.HTTP.Info().Msg(\"test message\")\n\n\tvar logEntry map[string]interface{}\n\terr := json.Unmarshal(buf.Bytes(), &logEntry)\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, \"http\", logEntry[\"log_stream\"])\n\tassert.Equal(t, \"test message\", logEntry[\"message\"])\n}\n"
  },
  {
    "path": "internal/utils/user_utils.go",
    "content": "package utils\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/mail\"\n\t\"strings\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n)\n\nfunc ParseUsers(usersStr []string) ([]config.User, error) {\n\tvar users []config.User\n\n\tif len(usersStr) == 0 {\n\t\treturn []config.User{}, nil\n\t}\n\n\tfor _, user := range usersStr {\n\t\tif strings.TrimSpace(user) == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tparsed, err := ParseUser(strings.TrimSpace(user))\n\t\tif err != nil {\n\t\t\treturn []config.User{}, err\n\t\t}\n\t\tusers = append(users, parsed)\n\t}\n\n\treturn users, nil\n}\n\nfunc GetUsers(usersCfg []string, usersPath string) ([]config.User, error) {\n\tvar usersStr []string\n\n\tif len(usersCfg) == 0 && usersPath == \"\" {\n\t\treturn []config.User{}, nil\n\t}\n\n\tif len(usersCfg) > 0 {\n\t\tusersStr = append(usersStr, usersCfg...)\n\t}\n\n\tif usersPath != \"\" {\n\t\tcontents, err := ReadFile(usersPath)\n\n\t\tif err != nil {\n\t\t\treturn []config.User{}, err\n\t\t}\n\n\t\tlines := strings.SplitSeq(contents, \"\\n\")\n\n\t\tfor line := range lines {\n\t\t\tlineTrimmed := strings.TrimSpace(line)\n\t\t\tif lineTrimmed == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tusersStr = append(usersStr, lineTrimmed)\n\t\t}\n\t}\n\n\treturn ParseUsers(usersStr)\n}\n\nfunc ParseUser(userStr string) (config.User, error) {\n\tif strings.Contains(userStr, \"$$\") {\n\t\tuserStr = strings.ReplaceAll(userStr, \"$$\", \"$\")\n\t}\n\n\tparts := strings.SplitN(userStr, \":\", 4)\n\n\tif len(parts) < 2 || len(parts) > 3 {\n\t\treturn config.User{}, errors.New(\"invalid user format\")\n\t}\n\n\tfor i, part := range parts {\n\t\ttrimmed := strings.TrimSpace(part)\n\t\tif trimmed == \"\" {\n\t\t\treturn config.User{}, errors.New(\"invalid user format\")\n\t\t}\n\t\tparts[i] = trimmed\n\t}\n\n\tuser := config.User{\n\t\tUsername: parts[0],\n\t\tPassword: parts[1],\n\t}\n\n\tif len(parts) == 3 {\n\t\tuser.TotpSecret = parts[2]\n\t}\n\n\treturn user, nil\n}\n\nfunc CompileUserEmail(username string, domain string) string {\n\t_, err := mail.ParseAddress(username)\n\n\tif err != nil {\n\t\treturn fmt.Sprintf(\"%s@%s\", strings.ToLower(username), domain)\n\t}\n\n\treturn username\n}\n"
  },
  {
    "path": "internal/utils/user_utils_test.go",
    "content": "package utils_test\n\nimport (\n\t\"os\"\n\t\"testing\"\n\n\t\"github.com/steveiliop56/tinyauth/internal/utils\"\n\n\t\"gotest.tools/v3/assert\"\n)\n\nfunc TestGetUsers(t *testing.T) {\n\t// Setup\n\tfile, err := os.Create(\"/tmp/tinyauth_users_test.txt\")\n\tassert.NilError(t, err)\n\n\t_, err = file.WriteString(\"      user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G        \\n         user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G                    \") // Spacing is on purpose\n\tassert.NilError(t, err)\n\n\terr = file.Close()\n\tassert.NilError(t, err)\n\tdefer os.Remove(\"/tmp/tinyauth_users_test.txt\")\n\n\t// Test file\n\tusers, err := utils.GetUsers([]string{}, \"/tmp/tinyauth_users_test.txt\")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, 2, len(users))\n\n\tassert.Equal(t, \"user1\", users[0].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[0].Password)\n\tassert.Equal(t, \"user2\", users[1].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[1].Password)\n\n\t// Test config\n\tusers, err = utils.GetUsers([]string{\"user3:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", \"user4:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\"}, \"\")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, 2, len(users))\n\n\tassert.Equal(t, \"user3\", users[0].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[0].Password)\n\tassert.Equal(t, \"user4\", users[1].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[1].Password)\n\n\t// Test both\n\tusers, err = utils.GetUsers([]string{\"user5:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\"}, \"/tmp/tinyauth_users_test.txt\")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, 3, len(users))\n\n\tassert.Equal(t, \"user5\", users[0].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[0].Password)\n\tassert.Equal(t, \"user1\", users[1].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[1].Password)\n\tassert.Equal(t, \"user2\", users[2].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[2].Password)\n\n\t// Test empty\n\tusers, err = utils.GetUsers([]string{}, \"\")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, 0, len(users))\n\n\t// Test non-existent file\n\tusers, err = utils.GetUsers([]string{}, \"/tmp/non_existent_file.txt\")\n\n\tassert.ErrorContains(t, err, \"no such file or directory\")\n\n\tassert.Equal(t, 0, len(users))\n}\n\nfunc TestParseUsers(t *testing.T) {\n\t// Valid users\n\tusers, err := utils.ParseUsers([]string{\"user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", \"user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF\"}) // user2 has TOTP\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, 2, len(users))\n\n\tassert.Equal(t, \"user1\", users[0].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[0].Password)\n\tassert.Equal(t, \"\", users[0].TotpSecret)\n\tassert.Equal(t, \"user2\", users[1].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[1].Password)\n\tassert.Equal(t, \"ABCDEF\", users[1].TotpSecret)\n\n\t// Valid weirdly spaced users\n\tusers, err = utils.ParseUsers([]string{\"      user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G        \", \"         user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF                    \"}) // Spacing is on purpose\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, 2, len(users))\n\n\tassert.Equal(t, \"user1\", users[0].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[0].Password)\n\tassert.Equal(t, \"\", users[0].TotpSecret)\n\tassert.Equal(t, \"user2\", users[1].Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", users[1].Password)\n\tassert.Equal(t, \"ABCDEF\", users[1].TotpSecret)\n}\n\nfunc TestParseUser(t *testing.T) {\n\t// Valid user without TOTP\n\tuser, err := utils.ParseUser(\"user1:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, \"user1\", user.Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", user.Password)\n\tassert.Equal(t, \"\", user.TotpSecret)\n\n\t// Valid user with TOTP\n\tuser, err = utils.ParseUser(\"user2:$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G:ABCDEF\")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, \"user2\", user.Username)\n\tassert.Equal(t, \"$2a$10$Mz5xhkfSJUtPWkzCd/TdaePh9CaXc5QcGII5wIMPLSR46eTwma30G\", user.Password)\n\tassert.Equal(t, \"ABCDEF\", user.TotpSecret)\n\n\t// Valid user with $$ in password\n\tuser, err = utils.ParseUser(\"user3:pa$$word123\")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, \"user3\", user.Username)\n\tassert.Equal(t, \"pa$word123\", user.Password)\n\tassert.Equal(t, \"\", user.TotpSecret)\n\n\t// User with spaces\n\tuser, err = utils.ParseUser(\"   user4   :   password123   :   TOTPSECRET   \")\n\n\tassert.NilError(t, err)\n\n\tassert.Equal(t, \"user4\", user.Username)\n\tassert.Equal(t, \"password123\", user.Password)\n\tassert.Equal(t, \"TOTPSECRET\", user.TotpSecret)\n\n\t// Invalid users\n\t_, err = utils.ParseUser(\"user1\") // Missing password\n\tassert.ErrorContains(t, err, \"invalid user format\")\n\n\t_, err = utils.ParseUser(\"user1:\")\n\tassert.ErrorContains(t, err, \"invalid user format\")\n\n\t_, err = utils.ParseUser(\":password123\")\n\tassert.ErrorContains(t, err, \"invalid user format\")\n\n\t_, err = utils.ParseUser(\"user1:password123:ABC:EXTRA\") // Too many parts\n\tassert.ErrorContains(t, err, \"invalid user format\")\n\n\t_, err = utils.ParseUser(\"user1::ABC\")\n\tassert.ErrorContains(t, err, \"invalid user format\")\n\n\t_, err = utils.ParseUser(\":password123:ABC\")\n\tassert.ErrorContains(t, err, \"invalid user format\")\n\n\t_, err = utils.ParseUser(\"   :   :   \")\n\tassert.ErrorContains(t, err, \"invalid user format\")\n}\n"
  },
  {
    "path": "patches/nested_maps.diff",
    "content": "diff --git a/env/env_test.go b/env/env_test.go\nindex 7045569..365dc00 100644\n--- a/env/env_test.go\n+++ b/env/env_test.go\n@@ -166,6 +166,38 @@ func TestDecode(t *testing.T) {\n \t\t\t\tFoo: &struct{ Field string }{},\n \t\t\t},\n \t\t},\n+\t\t{\n+\t\t\tdesc:    \"map under the root key\",\n+\t\t\tenviron: []string{\"TRAEFIK_FOO_BAR_FOOBAR_BARFOO=foo\"},\n+\t\t\telement: &struct {\n+\t\t\t\tFoo map[string]struct {\n+\t\t\t\t\tFoobar struct {\n+\t\t\t\t\t\tBarfoo string\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t}{},\n+\t\t\texpected: &struct {\n+\t\t\t\tFoo map[string]struct {\n+\t\t\t\t\tFoobar struct {\n+\t\t\t\t\t\tBarfoo string\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t}{\n+\t\t\t\tFoo: map[string]struct {\n+\t\t\t\t\tFoobar struct {\n+\t\t\t\t\t\tBarfoo string\n+\t\t\t\t\t}\n+\t\t\t\t}{\n+\t\t\t\t\t\"bar\": {\n+\t\t\t\t\t\tFoobar: struct {\n+\t\t\t\t\t\t\tBarfoo string\n+\t\t\t\t\t\t}{\n+\t\t\t\t\t\t\tBarfoo: \"foo\",\n+\t\t\t\t\t\t},\n+\t\t\t\t\t},\n+\t\t\t\t},\n+\t\t\t},\n+\t\t},\n \t}\n \n \tfor _, test := range testCases {\ndiff --git a/parser/nodes_metadata.go b/parser/nodes_metadata.go\nindex 36946c1..0279705 100644\n--- a/parser/nodes_metadata.go\n+++ b/parser/nodes_metadata.go\n@@ -75,8 +75,13 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {\n \tnode.Kind = fType.Kind()\n \tnode.Tag = field.Tag\n \n-\tif fType.Kind() == reflect.Struct || fType.Kind() == reflect.Pointer && fType.Elem().Kind() == reflect.Struct ||\n-\t\tfType.Kind() == reflect.Map {\n+\tif node.Kind == reflect.String && len(node.Children) > 0 {\n+\t\tfType = reflect.TypeOf(struct{}{})\n+\t\tnode.Kind = reflect.Struct\n+\t}\n+\n+\tif node.Kind == reflect.Struct || node.Kind == reflect.Pointer && fType.Elem().Kind() == reflect.Struct ||\n+\t\tnode.Kind == reflect.Map {\n \t\tif len(node.Children) == 0 && !(field.Tag.Get(m.TagName) == TagLabelAllowEmpty || field.Tag.Get(m.TagName) == \"-\") {\n \t\t\treturn fmt.Errorf(\"%s cannot be a standalone element (type %s)\", node.Name, fType)\n \t\t}\n@@ -90,11 +95,11 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {\n \t\treturn nil\n \t}\n \n-\tif fType.Kind() == reflect.Struct || fType.Kind() == reflect.Pointer && fType.Elem().Kind() == reflect.Struct {\n+\tif node.Kind == reflect.Struct || node.Kind == reflect.Pointer && fType.Elem().Kind() == reflect.Struct {\n \t\treturn m.browseChildren(fType, node)\n \t}\n \n-\tif fType.Kind() == reflect.Map {\n+\tif node.Kind == reflect.Map {\n \t\tif fType.Elem().Kind() == reflect.Interface {\n \t\t\taddRawValue(node)\n \t\t\treturn nil\n@@ -115,7 +120,7 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {\n \t\treturn nil\n \t}\n \n-\tif fType.Kind() == reflect.Slice {\n+\tif node.Kind == reflect.Slice {\n \t\tif m.AllowSliceAsStruct && field.Tag.Get(TagLabelSliceAsStruct) != \"\" {\n \t\t\treturn m.browseChildren(fType.Elem(), node)\n \t\t}\n@@ -129,7 +134,7 @@ func (m metadata) add(rootType reflect.Type, node *Node) error {\n \t\treturn nil\n \t}\n \n-\treturn fmt.Errorf(\"invalid node %s: %v\", node.Name, fType.Kind())\n+\treturn fmt.Errorf(\"invalid node %s: %v\", node.Name, node.Kind)\n }\n \n func (m metadata) findTypedField(rType reflect.Type, node *Node) (reflect.StructField, error) {\n"
  },
  {
    "path": "sql/oidc_queries.sql",
    "content": "-- name: CreateOidcCode :one\nINSERT INTO \"oidc_codes\" (\n    \"sub\",\n    \"code_hash\",\n    \"scope\",\n    \"redirect_uri\",\n    \"client_id\",\n    \"expires_at\",\n    \"nonce\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?, ?\n)\nRETURNING *;\n\n-- name: GetOidcCodeUnsafe :one\nSELECT * FROM \"oidc_codes\"\nWHERE \"code_hash\" = ?;\n\n-- name: GetOidcCode :one\nDELETE FROM \"oidc_codes\"\nWHERE \"code_hash\" = ?\nRETURNING *;\n\n-- name: GetOidcCodeBySubUnsafe :one\nSELECT * FROM \"oidc_codes\"\nWHERE \"sub\" = ?;\n\n-- name: GetOidcCodeBySub :one\nDELETE FROM \"oidc_codes\"\nWHERE \"sub\" = ?\nRETURNING *;\n\n-- name: DeleteOidcCode :exec\nDELETE FROM \"oidc_codes\"\nWHERE \"code_hash\" = ?;\n\n-- name: DeleteOidcCodeBySub :exec\nDELETE FROM \"oidc_codes\"\nWHERE \"sub\" = ?;\n\n-- name: CreateOidcToken :one\nINSERT INTO \"oidc_tokens\" (\n    \"sub\",\n    \"access_token_hash\",\n    \"refresh_token_hash\",\n    \"scope\",\n    \"client_id\",\n    \"token_expires_at\",\n    \"refresh_token_expires_at\",\n    \"nonce\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?, ?, ?\n)\nRETURNING *;\n\n-- name: UpdateOidcTokenByRefreshToken :one\nUPDATE \"oidc_tokens\" SET\n    \"access_token_hash\" = ?,\n    \"refresh_token_hash\" = ?,\n    \"token_expires_at\" = ?,\n    \"refresh_token_expires_at\" = ?\nWHERE \"refresh_token_hash\" = ?\nRETURNING *;\n\n-- name: GetOidcToken :one\nSELECT * FROM \"oidc_tokens\"\nWHERE \"access_token_hash\" = ?;\n\n-- name: GetOidcTokenByRefreshToken :one\nSELECT * FROM \"oidc_tokens\"\nWHERE \"refresh_token_hash\" = ?;\n\n-- name: GetOidcTokenBySub :one\nSELECT * FROM \"oidc_tokens\"\nWHERE \"sub\" = ?;\n\n-- name: DeleteOidcToken :exec\nDELETE FROM \"oidc_tokens\"\nWHERE \"access_token_hash\" = ?;\n\n-- name: DeleteOidcTokenBySub :exec\nDELETE FROM \"oidc_tokens\"\nWHERE \"sub\" = ?;\n\n-- name: CreateOidcUserInfo :one\nINSERT INTO \"oidc_userinfo\" (\n    \"sub\",\n    \"name\",\n    \"preferred_username\",\n    \"email\",\n    \"groups\",\n    \"updated_at\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?\n)\nRETURNING *;\n\n-- name: GetOidcUserInfo :one\nSELECT * FROM \"oidc_userinfo\"\nWHERE \"sub\" = ?;\n\n-- name: DeleteOidcUserInfo :exec\nDELETE FROM \"oidc_userinfo\"\nWHERE \"sub\" = ?;\n\n-- name: DeleteExpiredOidcCodes :many\nDELETE FROM \"oidc_codes\"\nWHERE \"expires_at\" < ?\nRETURNING *;\n\n-- name: DeleteExpiredOidcTokens :many\nDELETE FROM \"oidc_tokens\"\nWHERE \"token_expires_at\" < ? AND \"refresh_token_expires_at\" < ?\nRETURNING *;\n"
  },
  {
    "path": "sql/oidc_schemas.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"oidc_codes\" (\n    \"sub\" TEXT NOT NULL UNIQUE,\n    \"code_hash\" TEXT NOT NULL PRIMARY KEY UNIQUE,\n    \"scope\" TEXT NOT NULL,\n    \"redirect_uri\" TEXT NOT NULL,\n    \"client_id\" TEXT NOT NULL,\n    \"expires_at\" INTEGER NOT NULL,\n    \"nonce\" TEXT DEFAULT \"\"\n);\n\nCREATE TABLE IF NOT EXISTS \"oidc_tokens\" (\n    \"sub\" TEXT NOT NULL UNIQUE,\n    \"access_token_hash\" TEXT NOT NULL PRIMARY KEY UNIQUE,\n    \"refresh_token_hash\" TEXT NOT NULL,\n    \"scope\" TEXT NOT NULL,\n    \"client_id\" TEXT NOT NULL,\n    \"token_expires_at\" INTEGER NOT NULL,\n    \"refresh_token_expires_at\" INTEGER NOT NULL,\n    \"nonce\" TEXT DEFAULT \"\"\n);\n\nCREATE TABLE IF NOT EXISTS \"oidc_userinfo\" (\n    \"sub\" TEXT NOT NULL UNIQUE PRIMARY KEY,\n    \"name\" TEXT NOT NULL,\n    \"preferred_username\" TEXT NOT NULL,\n    \"email\" TEXT NOT NULL,\n    \"groups\" TEXT NOT NULL,\n    \"updated_at\" INTEGER NOT NULL\n);\n"
  },
  {
    "path": "sql/session_queries.sql",
    "content": "-- name: CreateSession :one\nINSERT INTO \"sessions\" (\n    \"uuid\",\n    \"username\",\n    \"email\",\n    \"name\",\n    \"provider\",\n    \"totp_pending\",\n    \"oauth_groups\",\n    \"expiry\",\n    \"created_at\",\n    \"oauth_name\",\n    \"oauth_sub\"\n) VALUES (\n    ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?\n)\nRETURNING *;\n\n-- name: GetSession :one\nSELECT * FROM \"sessions\"\nWHERE \"uuid\" = ?;\n\n-- name: DeleteSession :exec\nDELETE FROM \"sessions\"\nWHERE \"uuid\" = ?;\n\n-- name: UpdateSession :one\nUPDATE \"sessions\" SET\n    \"username\" = ?,\n    \"email\" = ?,\n    \"name\" = ?,\n    \"provider\" = ?,\n    \"totp_pending\" = ?,\n    \"oauth_groups\" = ?,\n    \"expiry\" = ?,\n    \"oauth_name\" = ?,\n    \"oauth_sub\" = ?\nWHERE \"uuid\" = ?\nRETURNING *;\n\n-- name: DeleteExpiredSessions :exec\nDELETE FROM \"sessions\"\nWHERE \"expiry\" < ?;\n"
  },
  {
    "path": "sql/session_schemas.sql",
    "content": "CREATE TABLE IF NOT EXISTS \"sessions\" (\n    \"uuid\" TEXT NOT NULL PRIMARY KEY UNIQUE,\n    \"username\" TEXT NOT NULL,\n    \"email\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"provider\" TEXT NOT NULL,\n    \"totp_pending\" BOOLEAN NOT NULL,\n    \"oauth_groups\" TEXT NULL,\n    \"expiry\" INTEGER NOT NULL,\n    \"created_at\" INTEGER NOT NULL,\n    \"oauth_name\" TEXT NULL,\n    \"oauth_sub\" TEXT NULL\n);\n"
  },
  {
    "path": "sqlc.yml",
    "content": "version: \"2\"\nsql:\n  - engine: \"sqlite\"\n    queries: \"sql/*_queries.sql\"\n    schema: \"sql/*_schemas.sql\"\n    gen:\n      go:\n        package: \"repository\"\n        out: \"internal/repository\"\n        rename:\n          uuid: \"UUID\"\n          oauth_groups: \"OAuthGroups\"\n          oauth_name: \"OAuthName\"\n          oauth_sub: \"OAuthSub\"\n          redirect_uri: \"RedirectURI\"\n        overrides:\n          - column: \"sessions.oauth_groups\"\n            go_type: \"string\"\n          - column: \"sessions.oauth_name\"\n            go_type: \"string\"\n          - column: \"sessions.oauth_sub\"\n            go_type: \"string\"\n          - column: \"sessions.ldap_groups\"\n            go_type: \"string\"\n          - column: \"oidc_codes.nonce\"\n            go_type: \"string\"\n          - column: \"oidc_tokens.nonce\"\n            go_type: \"string\"\n"
  }
]