mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-11-04 08:05:42 +00:00 
			
		
		
		
	Compare commits
	
		
			41 Commits
		
	
	
		
			v4.0.0-bet
			...
			2ea921f3ca
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					2ea921f3ca | ||
| 
						 | 
					473109b36a | ||
| 
						 | 
					f628d1f0b3 | ||
| 
						 | 
					a9c1bf8865 | ||
| 
						 | 
					81136eeb42 | ||
| 
						 | 
					8ee331a564 | ||
| 
						 | 
					0996711f08 | ||
| 
						 | 
					64222b6d15 | ||
| 
						 | 
					1b87ed9b99 | ||
| 
						 | 
					dc67be2ba0 | ||
| 
						 | 
					9b76a84ee2 | ||
| 
						 | 
					ed20d2cf51 | ||
| 
						 | 
					fc7e395e66 | ||
| 
						 | 
					b940d681c3 | ||
| 
						 | 
					a1ec4a69cf | ||
| 
						 | 
					4047cea451 | ||
| 
						 | 
					5a4855c12c | ||
| 
						 | 
					05d4dbd68e | ||
| 
						 | 
					ae8347fd28 | ||
| 
						 | 
					76f2014444 | ||
| 
						 | 
					5b7bda3378 | ||
| 
						 | 
					e878516130 | ||
| 
						 | 
					e5f1df03c4 | ||
| 
						 | 
					c77da30d87 | ||
| 
						 | 
					287c6f975f | ||
| 
						 | 
					0255e954f7 | ||
| 
						 | 
					c5d70d7c93 | ||
| 
						 | 
					adffb4ac0a | ||
| 
						 | 
					cbe31d442d | ||
| 
						 | 
					4a530eebc9 | ||
| 
						 | 
					9ba1695274 | ||
| 
						 | 
					c337ba5b31 | ||
| 
						 | 
					bbf8112995 | ||
| 
						 | 
					103285855e | ||
| 
						 | 
					2cc6b6bdbb | ||
| 
						 | 
					adb1a9bee5 | ||
| 
						 | 
					1ee0cee171 | ||
| 
						 | 
					720f387908 | ||
| 
						 | 
					a629430a88 | ||
| 
						 | 
					f0a48cc91c | ||
| 
						 | 
					2f8fa39a9b | 
							
								
								
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							@@ -23,7 +23,7 @@ jobs:
 | 
				
			|||||||
      - name: Install frontend dependencies
 | 
					      - name: Install frontend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cd frontend
 | 
					          cd frontend
 | 
				
			||||||
          bun install
 | 
					          bun install --frozen-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: Set version
 | 
					      - name: Set version
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										173
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										173
									
								
								.github/workflows/nightly.yml
									
									
									
									
										vendored
									
									
								
							@@ -66,7 +66,7 @@ jobs:
 | 
				
			|||||||
      - name: Install frontend dependencies
 | 
					      - name: Install frontend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cd frontend
 | 
					          cd frontend
 | 
				
			||||||
          bun install
 | 
					          bun install --frozen-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: Install backend dependencies
 | 
					      - name: Install backend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
@@ -112,7 +112,7 @@ jobs:
 | 
				
			|||||||
      - name: Install frontend dependencies
 | 
					      - name: Install frontend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cd frontend
 | 
					          cd frontend
 | 
				
			||||||
          bun install
 | 
					          bun install --frozen-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: Install backend dependencies
 | 
					      - name: Install backend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
@@ -171,6 +171,9 @@ jobs:
 | 
				
			|||||||
          labels: ${{ steps.meta.outputs.labels }}
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          build-args: |
 | 
					          build-args: |
 | 
				
			||||||
            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
@@ -190,6 +193,65 @@ jobs:
 | 
				
			|||||||
          if-no-files-found: error
 | 
					          if-no-files-found: error
 | 
				
			||||||
          retention-days: 1
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  image-build-distroless:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - create-release
 | 
				
			||||||
 | 
					      - generate-metadata
 | 
				
			||||||
 | 
					      - image-build
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Checkout
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          ref: nightly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Docker meta
 | 
				
			||||||
 | 
					        id: meta
 | 
				
			||||||
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Build and push
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
 | 
					        id: build
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          platforms: linux/amd64
 | 
				
			||||||
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          file: Dockerfile.distroless
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					          build-args: |
 | 
				
			||||||
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
 | 
					            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Export digest
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          mkdir -p ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					          digest="${{ steps.build.outputs.digest }}"
 | 
				
			||||||
 | 
					          touch "${{ runner.temp }}/digests/${digest#sha256:}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Upload digest
 | 
				
			||||||
 | 
					        uses: actions/upload-artifact@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          name: digests-distroless-linux-amd64
 | 
				
			||||||
 | 
					          path: ${{ runner.temp }}/digests/*
 | 
				
			||||||
 | 
					          if-no-files-found: error
 | 
				
			||||||
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  image-build-arm:
 | 
					  image-build-arm:
 | 
				
			||||||
    runs-on: ubuntu-24.04-arm
 | 
					    runs-on: ubuntu-24.04-arm
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
@@ -217,10 +279,6 @@ jobs:
 | 
				
			|||||||
      - name: Set up Docker Buildx
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
        uses: docker/setup-buildx-action@v3
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: Set version
 | 
					 | 
				
			||||||
        run: |
 | 
					 | 
				
			||||||
          echo nightly > internal/assets/version
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      - name: Build and push
 | 
					      - name: Build and push
 | 
				
			||||||
        uses: docker/build-push-action@v6
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
        id: build
 | 
					        id: build
 | 
				
			||||||
@@ -229,6 +287,9 @@ jobs:
 | 
				
			|||||||
          labels: ${{ steps.meta.outputs.labels }}
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          build-args: |
 | 
					          build-args: |
 | 
				
			||||||
            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
@@ -248,6 +309,65 @@ jobs:
 | 
				
			|||||||
          if-no-files-found: error
 | 
					          if-no-files-found: error
 | 
				
			||||||
          retention-days: 1
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  image-build-arm-distroless:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-24.04-arm
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - create-release
 | 
				
			||||||
 | 
					      - generate-metadata
 | 
				
			||||||
 | 
					      - image-build-arm
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Checkout
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          ref: nightly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Docker meta
 | 
				
			||||||
 | 
					        id: meta
 | 
				
			||||||
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Build and push
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
 | 
					        id: build
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          platforms: linux/arm64
 | 
				
			||||||
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          file: Dockerfile.distroless
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					          build-args: |
 | 
				
			||||||
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
 | 
					            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Export digest
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          mkdir -p ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					          digest="${{ steps.build.outputs.digest }}"
 | 
				
			||||||
 | 
					          touch "${{ runner.temp }}/digests/${digest#sha256:}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Upload digest
 | 
				
			||||||
 | 
					        uses: actions/upload-artifact@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          name: digests-distroless-linux-arm64
 | 
				
			||||||
 | 
					          path: ${{ runner.temp }}/digests/*
 | 
				
			||||||
 | 
					          if-no-files-found: error
 | 
				
			||||||
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  image-merge:
 | 
					  image-merge:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
@@ -276,6 +396,8 @@ jobs:
 | 
				
			|||||||
        uses: docker/metadata-action@v5
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          flavor: |
 | 
				
			||||||
 | 
					            latest=false
 | 
				
			||||||
          tags: |
 | 
					          tags: |
 | 
				
			||||||
            type=raw,nightly
 | 
					            type=raw,nightly
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -285,6 +407,45 @@ jobs:
 | 
				
			|||||||
          docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
 | 
					          docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
 | 
				
			||||||
            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)
 | 
					            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  image-merge-distroless:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - image-build-distroless
 | 
				
			||||||
 | 
					      - image-build-arm-distroless
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Download digests
 | 
				
			||||||
 | 
					        uses: actions/download-artifact@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					          pattern: digests-distroless-*
 | 
				
			||||||
 | 
					          merge-multiple: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Docker meta
 | 
				
			||||||
 | 
					        id: meta
 | 
				
			||||||
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          flavor: |
 | 
				
			||||||
 | 
					            latest=false
 | 
				
			||||||
 | 
					          tags: |
 | 
				
			||||||
 | 
					            type=raw,nightly-distroless
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Create manifest list and push
 | 
				
			||||||
 | 
					        working-directory: ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
 | 
				
			||||||
 | 
					            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  update-release:
 | 
					  update-release:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										174
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										174
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@@ -44,7 +44,7 @@ jobs:
 | 
				
			|||||||
      - name: Install frontend dependencies
 | 
					      - name: Install frontend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cd frontend
 | 
					          cd frontend
 | 
				
			||||||
          bun install
 | 
					          bun install --frozen-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: Install backend dependencies
 | 
					      - name: Install backend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
@@ -87,7 +87,7 @@ jobs:
 | 
				
			|||||||
      - name: Install frontend dependencies
 | 
					      - name: Install frontend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
          cd frontend
 | 
					          cd frontend
 | 
				
			||||||
          bun install
 | 
					          bun install --frozen-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: Install backend dependencies
 | 
					      - name: Install backend dependencies
 | 
				
			||||||
        run: |
 | 
					        run: |
 | 
				
			||||||
@@ -143,6 +143,9 @@ jobs:
 | 
				
			|||||||
          labels: ${{ steps.meta.outputs.labels }}
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          build-args: |
 | 
					          build-args: |
 | 
				
			||||||
            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
@@ -162,6 +165,62 @@ jobs:
 | 
				
			|||||||
          if-no-files-found: error
 | 
					          if-no-files-found: error
 | 
				
			||||||
          retention-days: 1
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  image-build-distroless:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - generate-metadata
 | 
				
			||||||
 | 
					      - image-build
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Checkout
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Docker meta
 | 
				
			||||||
 | 
					        id: meta
 | 
				
			||||||
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Build and push
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
 | 
					        id: build
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          platforms: linux/amd64
 | 
				
			||||||
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          file: Dockerfile.distroless
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					          build-args: |
 | 
				
			||||||
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
 | 
					            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Export digest
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          mkdir -p ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					          digest="${{ steps.build.outputs.digest }}"
 | 
				
			||||||
 | 
					          touch "${{ runner.temp }}/digests/${digest#sha256:}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Upload digest
 | 
				
			||||||
 | 
					        uses: actions/upload-artifact@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          name: digests-distroless-linux-amd64
 | 
				
			||||||
 | 
					          path: ${{ runner.temp }}/digests/*
 | 
				
			||||||
 | 
					          if-no-files-found: error
 | 
				
			||||||
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  image-build-arm:
 | 
					  image-build-arm:
 | 
				
			||||||
    runs-on: ubuntu-24.04-arm
 | 
					    runs-on: ubuntu-24.04-arm
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
@@ -194,6 +253,9 @@ jobs:
 | 
				
			|||||||
          labels: ${{ steps.meta.outputs.labels }}
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
          build-args: |
 | 
					          build-args: |
 | 
				
			||||||
            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
@@ -213,6 +275,62 @@ jobs:
 | 
				
			|||||||
          if-no-files-found: error
 | 
					          if-no-files-found: error
 | 
				
			||||||
          retention-days: 1
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  image-build-arm-distroless:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-24.04-arm
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - generate-metadata
 | 
				
			||||||
 | 
					      - image-build-arm
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Checkout
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Docker meta
 | 
				
			||||||
 | 
					        id: meta
 | 
				
			||||||
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Build and push
 | 
				
			||||||
 | 
					        uses: docker/build-push-action@v6
 | 
				
			||||||
 | 
					        id: build
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          platforms: linux/arm64
 | 
				
			||||||
 | 
					          labels: ${{ steps.meta.outputs.labels }}
 | 
				
			||||||
 | 
					          tags: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
 | 
				
			||||||
 | 
					          file: Dockerfile.distroless
 | 
				
			||||||
 | 
					          cache-from: type=gha
 | 
				
			||||||
 | 
					          cache-to: type=gha,mode=max
 | 
				
			||||||
 | 
					          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					          build-args: |
 | 
				
			||||||
 | 
					            VERSION=${{ needs.generate-metadata.outputs.VERSION }}
 | 
				
			||||||
 | 
					            COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
 | 
				
			||||||
 | 
					            BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Export digest
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          mkdir -p ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					          digest="${{ steps.build.outputs.digest }}"
 | 
				
			||||||
 | 
					          touch "${{ runner.temp }}/digests/${digest#sha256:}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Upload digest
 | 
				
			||||||
 | 
					        uses: actions/upload-artifact@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          name: digests-distroless-linux-arm64
 | 
				
			||||||
 | 
					          path: ${{ runner.temp }}/digests/*
 | 
				
			||||||
 | 
					          if-no-files-found: error
 | 
				
			||||||
 | 
					          retention-days: 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  image-merge:
 | 
					  image-merge:
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
    needs:
 | 
					    needs:
 | 
				
			||||||
@@ -241,10 +359,56 @@ jobs:
 | 
				
			|||||||
        uses: docker/metadata-action@v5
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          flavor: |
 | 
				
			||||||
 | 
					            latest=true
 | 
				
			||||||
 | 
					            prefix=v,onlatest=false
 | 
				
			||||||
          tags: |
 | 
					          tags: |
 | 
				
			||||||
            type=semver,pattern={{version}},prefix=v
 | 
					            type=semver,pattern={{version}}
 | 
				
			||||||
            type=semver,pattern={{major}},prefix=v
 | 
					            type=semver,pattern={{major}}
 | 
				
			||||||
            type=semver,pattern={{major}}.{{minor}},prefix=v
 | 
					            type=semver,pattern={{major}}.{{minor}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Create manifest list and push
 | 
				
			||||||
 | 
					        working-directory: ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
 | 
				
			||||||
 | 
					            $(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  image-merge-distroless:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    needs:
 | 
				
			||||||
 | 
					      - image-build-distroless
 | 
				
			||||||
 | 
					      - image-build-arm-distroless
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Download digests
 | 
				
			||||||
 | 
					        uses: actions/download-artifact@v4
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          path: ${{ runner.temp }}/digests
 | 
				
			||||||
 | 
					          pattern: digests-distroless-*
 | 
				
			||||||
 | 
					          merge-multiple: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Login to GitHub Container Registry
 | 
				
			||||||
 | 
					        uses: docker/login-action@v3
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          registry: ghcr.io
 | 
				
			||||||
 | 
					          username: ${{ github.repository_owner }}
 | 
				
			||||||
 | 
					          password: ${{ secrets.GITHUB_TOKEN }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Set up Docker Buildx
 | 
				
			||||||
 | 
					        uses: docker/setup-buildx-action@v3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Docker meta
 | 
				
			||||||
 | 
					        id: meta
 | 
				
			||||||
 | 
					        uses: docker/metadata-action@v5
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          images: ghcr.io/${{ github.repository_owner }}/tinyauth
 | 
				
			||||||
 | 
					          flavor: |
 | 
				
			||||||
 | 
					            latest=false
 | 
				
			||||||
 | 
					            prefix=v,onlatest=false
 | 
				
			||||||
 | 
					            suffix=-distroless,onlatest=false
 | 
				
			||||||
 | 
					          tags: |
 | 
				
			||||||
 | 
					            type=semver,pattern={{version}}
 | 
				
			||||||
 | 
					            type=semver,pattern={{major}}
 | 
				
			||||||
 | 
					            type=semver,pattern={{major}}.{{minor}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - name: Create manifest list and push
 | 
					      - name: Create manifest list and push
 | 
				
			||||||
        working-directory: ${{ runner.temp }}/digests
 | 
					        working-directory: ${{ runner.temp }}/digests
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										16
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -1,12 +1,12 @@
 | 
				
			|||||||
# Site builder
 | 
					# Site builder
 | 
				
			||||||
FROM oven/bun:1.2.23-alpine AS frontend-builder
 | 
					FROM oven/bun:1.3.0-alpine AS frontend-builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
WORKDIR /frontend
 | 
					WORKDIR /frontend
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./frontend/package.json ./
 | 
					COPY ./frontend/package.json ./
 | 
				
			||||||
COPY ./frontend/bun.lock ./
 | 
					COPY ./frontend/bun.lock ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN bun install
 | 
					RUN bun install --frozen-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY ./frontend/public ./public
 | 
					COPY ./frontend/public ./public
 | 
				
			||||||
COPY ./frontend/src ./src
 | 
					COPY ./frontend/src ./src
 | 
				
			||||||
@@ -45,12 +45,18 @@ FROM alpine:3.22 AS runner
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
WORKDIR /tinyauth
 | 
					WORKDIR /tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN apk add --no-cache curl
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
COPY --from=builder /tinyauth/tinyauth ./
 | 
					COPY --from=builder /tinyauth/tinyauth ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN mkdir -p /data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
EXPOSE 3000
 | 
					EXPOSE 3000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
VOLUME ["/data"]
 | 
					VOLUME ["/data"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ENTRYPOINT ["./tinyauth"]
 | 
					ENV GIN_MODE=release
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENV PATH=$PATH:/tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENTRYPOINT ["tinyauth"]
 | 
				
			||||||
							
								
								
									
										65
									
								
								Dockerfile.distroless
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								Dockerfile.distroless
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
				
			|||||||
 | 
					# Site builder
 | 
				
			||||||
 | 
					FROM oven/bun:1.3.0-alpine AS frontend-builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR /frontend
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY ./frontend/package.json ./
 | 
				
			||||||
 | 
					COPY ./frontend/bun.lock ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN bun install --frozen-lockfile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY ./frontend/public ./public
 | 
				
			||||||
 | 
					COPY ./frontend/src ./src
 | 
				
			||||||
 | 
					COPY ./frontend/eslint.config.js ./
 | 
				
			||||||
 | 
					COPY ./frontend/index.html ./
 | 
				
			||||||
 | 
					COPY ./frontend/tsconfig.json ./
 | 
				
			||||||
 | 
					COPY ./frontend/tsconfig.app.json ./
 | 
				
			||||||
 | 
					COPY ./frontend/tsconfig.node.json ./
 | 
				
			||||||
 | 
					COPY ./frontend/vite.config.ts ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN bun run build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Builder
 | 
				
			||||||
 | 
					FROM golang:1.25-alpine3.21 AS builder
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ARG VERSION
 | 
				
			||||||
 | 
					ARG COMMIT_HASH
 | 
				
			||||||
 | 
					ARG BUILD_TIMESTAMP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR /tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY go.mod ./
 | 
				
			||||||
 | 
					COPY go.sum ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN go mod download
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY ./main.go ./
 | 
				
			||||||
 | 
					COPY ./cmd ./cmd
 | 
				
			||||||
 | 
					COPY ./internal ./internal
 | 
				
			||||||
 | 
					COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN mkdir -p data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" 
 | 
				
			||||||
 | 
					 
 | 
				
			||||||
 | 
					# Runner
 | 
				
			||||||
 | 
					FROM gcr.io/distroless/static-debian12:latest AS runner
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					WORKDIR /tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					COPY --from=builder /tinyauth/tinyauth ./
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Since it's distroless, we need to copy the data directory from the builder stage
 | 
				
			||||||
 | 
					COPY --from=builder /tinyauth/data /data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					EXPOSE 3000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					VOLUME ["/data"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENV GIN_MODE=release
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENV PATH=$PATH:/tinyauth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["tinyauth", "healthcheck"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENTRYPOINT ["tinyauth"]
 | 
				
			||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
<div align="center">
 | 
					<div align="center">
 | 
				
			||||||
    <img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
 | 
					    <img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
 | 
				
			||||||
    <h1>Tinyauth</h1>
 | 
					    <h1>Tinyauth</h1>
 | 
				
			||||||
    <p>The easiest way to secure your apps with a login screen.</p>
 | 
					    <p>The simplest way to protect your apps with a login screen.</p>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div align="center">
 | 
					<div align="center">
 | 
				
			||||||
@@ -14,7 +14,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<br />
 | 
					<br />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github and any provider to all of your docker apps. It supports all the popular proxies like Traefik, Nginx and Caddy.
 | 
					Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github or any other provider to all of your apps. It supports all the popular proxies like Traefik, Nginx and Caddy.
 | 
				
			||||||
 | 
					
 | 
				
			||||||

 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,7 +23,7 @@ Tinyauth is a simple authentication middleware that adds a simple login screen o
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
## Getting Started
 | 
					## Getting Started
 | 
				
			||||||
 | 
					
 | 
				
			||||||
You can easily get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities.
 | 
					You can easily get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Demo
 | 
					## Demo
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -53,7 +53,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
A big thank you to the following people for providing me with more coffee:
 | 
					A big thank you to the following people for providing me with more coffee:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>  <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>  <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>  <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>  <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a>  <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>  <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a>  <!-- sponsors -->
 | 
					<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>  <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>  <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>  <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>  <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a>  <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>  <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a>  <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a>  <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a>  <!-- sponsors -->
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Acknowledgements
 | 
					## Acknowledgements
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2
									
								
								air.toml
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								air.toml
									
									
									
									
									
								
							@@ -2,7 +2,7 @@ root = "/tinyauth"
 | 
				
			|||||||
tmp_dir = "tmp"
 | 
					tmp_dir = "tmp"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[build]
 | 
					[build]
 | 
				
			||||||
pre_cmd = ["mkdir -p internal/assets/dist", "echo 'backend running' > internal/assets/dist/index.html", "go install github.com/go-delve/delve/cmd/dlv@v1.25.0"]
 | 
					pre_cmd = ["mkdir -p internal/assets/dist", "mkdir -p /data", "echo 'backend running' > internal/assets/dist/index.html", "go install github.com/go-delve/delve/cmd/dlv@v1.25.0"]
 | 
				
			||||||
cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ."
 | 
					cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ."
 | 
				
			||||||
bin = "/go/bin/dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false"
 | 
					bin = "/go/bin/dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false"
 | 
				
			||||||
include_ext = ["go"]
 | 
					include_ext = ["go"]
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										99
									
								
								cmd/create.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								cmd/create.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
				
			|||||||
 | 
					package cmd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"errors"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/charmbracelet/huh"
 | 
				
			||||||
 | 
						"github.com/rs/zerolog"
 | 
				
			||||||
 | 
						"github.com/rs/zerolog/log"
 | 
				
			||||||
 | 
						"github.com/spf13/cobra"
 | 
				
			||||||
 | 
						"golang.org/x/crypto/bcrypt"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type createUserCmd struct {
 | 
				
			||||||
 | 
						root *cobra.Command
 | 
				
			||||||
 | 
						cmd  *cobra.Command
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						interactive bool
 | 
				
			||||||
 | 
						docker      bool
 | 
				
			||||||
 | 
						username    string
 | 
				
			||||||
 | 
						password    string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func newCreateUserCmd(root *cobra.Command) *createUserCmd {
 | 
				
			||||||
 | 
						return &createUserCmd{
 | 
				
			||||||
 | 
							root: root,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *createUserCmd) Register() {
 | 
				
			||||||
 | 
						c.cmd = &cobra.Command{
 | 
				
			||||||
 | 
							Use:   "create",
 | 
				
			||||||
 | 
							Short: "Create a user",
 | 
				
			||||||
 | 
							Long:  `Create a user either interactively or by passing flags.`,
 | 
				
			||||||
 | 
							Run:   c.run,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Create a user interactively")
 | 
				
			||||||
 | 
						c.cmd.Flags().BoolVar(&c.docker, "docker", false, "Format output for docker")
 | 
				
			||||||
 | 
						c.cmd.Flags().StringVar(&c.username, "username", "", "Username")
 | 
				
			||||||
 | 
						c.cmd.Flags().StringVar(&c.password, "password", "", "Password")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.root != nil {
 | 
				
			||||||
 | 
							c.root.AddCommand(c.cmd)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *createUserCmd) GetCmd() *cobra.Command {
 | 
				
			||||||
 | 
						return c.cmd
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *createUserCmd) run(cmd *cobra.Command, args []string) {
 | 
				
			||||||
 | 
						log.Logger = log.Level(zerolog.InfoLevel)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.interactive {
 | 
				
			||||||
 | 
							form := huh.NewForm(
 | 
				
			||||||
 | 
								huh.NewGroup(
 | 
				
			||||||
 | 
									huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error {
 | 
				
			||||||
 | 
										if s == "" {
 | 
				
			||||||
 | 
											return errors.New("username cannot be empty")
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return nil
 | 
				
			||||||
 | 
									})),
 | 
				
			||||||
 | 
									huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error {
 | 
				
			||||||
 | 
										if s == "" {
 | 
				
			||||||
 | 
											return errors.New("password cannot be empty")
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return nil
 | 
				
			||||||
 | 
									})),
 | 
				
			||||||
 | 
									huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&c.docker),
 | 
				
			||||||
 | 
								),
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
							var baseTheme *huh.Theme = huh.ThemeBase()
 | 
				
			||||||
 | 
							err := form.WithTheme(baseTheme).Run()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								log.Fatal().Err(err).Msg("Form failed")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.username == "" || c.password == "" {
 | 
				
			||||||
 | 
							log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Str("username", c.username).Msg("Creating user")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						passwd, err := bcrypt.GenerateFromPassword([]byte(c.password), bcrypt.DefaultCost)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to hash password")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// If docker format is enabled, escape the dollar sign
 | 
				
			||||||
 | 
						passwdStr := string(passwd)
 | 
				
			||||||
 | 
						if c.docker {
 | 
				
			||||||
 | 
							passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Str("user", fmt.Sprintf("%s:%s", c.username, passwdStr)).Msg("User created")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										120
									
								
								cmd/generate.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								cmd/generate.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,120 @@
 | 
				
			|||||||
 | 
					package cmd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"errors"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
						"tinyauth/internal/utils"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/charmbracelet/huh"
 | 
				
			||||||
 | 
						"github.com/mdp/qrterminal/v3"
 | 
				
			||||||
 | 
						"github.com/pquerna/otp/totp"
 | 
				
			||||||
 | 
						"github.com/rs/zerolog"
 | 
				
			||||||
 | 
						"github.com/rs/zerolog/log"
 | 
				
			||||||
 | 
						"github.com/spf13/cobra"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type generateTotpCmd struct {
 | 
				
			||||||
 | 
						root *cobra.Command
 | 
				
			||||||
 | 
						cmd  *cobra.Command
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						interactive bool
 | 
				
			||||||
 | 
						user        string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func newGenerateTotpCmd(root *cobra.Command) *generateTotpCmd {
 | 
				
			||||||
 | 
						return &generateTotpCmd{
 | 
				
			||||||
 | 
							root: root,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *generateTotpCmd) Register() {
 | 
				
			||||||
 | 
						c.cmd = &cobra.Command{
 | 
				
			||||||
 | 
							Use:   "generate",
 | 
				
			||||||
 | 
							Short: "Generate a totp secret",
 | 
				
			||||||
 | 
							Long:  `Generate a totp secret for a user either interactively or by passing flags.`,
 | 
				
			||||||
 | 
							Run:   c.run,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Run in interactive mode")
 | 
				
			||||||
 | 
						c.cmd.Flags().StringVar(&c.user, "user", "", "Your current user (username:hash)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.root != nil {
 | 
				
			||||||
 | 
							c.root.AddCommand(c.cmd)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *generateTotpCmd) GetCmd() *cobra.Command {
 | 
				
			||||||
 | 
						return c.cmd
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *generateTotpCmd) run(cmd *cobra.Command, args []string) {
 | 
				
			||||||
 | 
						log.Logger = log.Level(zerolog.InfoLevel)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.interactive {
 | 
				
			||||||
 | 
							form := huh.NewForm(
 | 
				
			||||||
 | 
								huh.NewGroup(
 | 
				
			||||||
 | 
									huh.NewInput().Title("Current user (username:hash)").Value(&c.user).Validate((func(s string) error {
 | 
				
			||||||
 | 
										if s == "" {
 | 
				
			||||||
 | 
											return errors.New("user cannot be empty")
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return nil
 | 
				
			||||||
 | 
									})),
 | 
				
			||||||
 | 
								),
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
							var baseTheme *huh.Theme = huh.ThemeBase()
 | 
				
			||||||
 | 
							err := form.WithTheme(baseTheme).Run()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								log.Fatal().Err(err).Msg("Form failed")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := utils.ParseUser(c.user)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to parse user")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						docker := false
 | 
				
			||||||
 | 
						if strings.Contains(c.user, "$$") {
 | 
				
			||||||
 | 
							docker = true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if user.TotpSecret != "" {
 | 
				
			||||||
 | 
							log.Fatal().Msg("User already has a TOTP secret")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						key, err := totp.Generate(totp.GenerateOpts{
 | 
				
			||||||
 | 
							Issuer:      "Tinyauth",
 | 
				
			||||||
 | 
							AccountName: user.Username,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to generate TOTP secret")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						secret := key.Secret()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Str("secret", secret).Msg("Generated TOTP secret")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Msg("Generated QR code")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						config := qrterminal.Config{
 | 
				
			||||||
 | 
							Level:     qrterminal.L,
 | 
				
			||||||
 | 
							Writer:    os.Stdout,
 | 
				
			||||||
 | 
							BlackChar: qrterminal.BLACK,
 | 
				
			||||||
 | 
							WhiteChar: qrterminal.WHITE,
 | 
				
			||||||
 | 
							QuietZone: 2,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						qrterminal.GenerateWithConfig(key.URL(), config)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user.TotpSecret = secret
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// If using docker escape re-escape it
 | 
				
			||||||
 | 
						if docker {
 | 
				
			||||||
 | 
							user.Password = strings.ReplaceAll(user.Password, "$", "$$")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										112
									
								
								cmd/healthcheck.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								cmd/healthcheck.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,112 @@
 | 
				
			|||||||
 | 
					package cmd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"errors"
 | 
				
			||||||
 | 
						"io"
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/rs/zerolog"
 | 
				
			||||||
 | 
						"github.com/rs/zerolog/log"
 | 
				
			||||||
 | 
						"github.com/spf13/cobra"
 | 
				
			||||||
 | 
						"github.com/spf13/viper"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type healthzResponse struct {
 | 
				
			||||||
 | 
						Status  string `json:"status"`
 | 
				
			||||||
 | 
						Message string `json:"message"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type healthcheckCmd struct {
 | 
				
			||||||
 | 
						root *cobra.Command
 | 
				
			||||||
 | 
						cmd  *cobra.Command
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						viper *viper.Viper
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func newHealthcheckCmd(root *cobra.Command) *healthcheckCmd {
 | 
				
			||||||
 | 
						return &healthcheckCmd{
 | 
				
			||||||
 | 
							root:  root,
 | 
				
			||||||
 | 
							viper: viper.New(),
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *healthcheckCmd) Register() {
 | 
				
			||||||
 | 
						c.cmd = &cobra.Command{
 | 
				
			||||||
 | 
							Use:   "healthcheck [app-url]",
 | 
				
			||||||
 | 
							Short: "Perform a health check",
 | 
				
			||||||
 | 
							Long:  `Use the health check endpoint to verify that Tinyauth is running and it's healthy.`,
 | 
				
			||||||
 | 
							Run:   c.run,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.viper.AutomaticEnv()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.root != nil {
 | 
				
			||||||
 | 
							c.root.AddCommand(c.cmd)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *healthcheckCmd) GetCmd() *cobra.Command {
 | 
				
			||||||
 | 
						return c.cmd
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *healthcheckCmd) run(cmd *cobra.Command, args []string) {
 | 
				
			||||||
 | 
						log.Logger = log.Level(zerolog.InfoLevel)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var appUrl string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						port := c.viper.GetString("PORT")
 | 
				
			||||||
 | 
						address := c.viper.GetString("ADDRESS")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if port == "" {
 | 
				
			||||||
 | 
							port = "3000"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if address == "" {
 | 
				
			||||||
 | 
							address = "127.0.0.1"
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						appUrl = "http://" + address + ":" + port
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if len(args) > 0 {
 | 
				
			||||||
 | 
							appUrl = args[0]
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Str("app_url", appUrl).Msg("Performing health check")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						client := http.Client{}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to create request")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						resp, err := client.Do(req)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to perform request")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if resp.StatusCode != http.StatusOK {
 | 
				
			||||||
 | 
							log.Fatal().Err(errors.New("service is not healthy")).Msgf("Service is not healthy. Status code: %d", resp.StatusCode)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						defer resp.Body.Close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var healthResp healthzResponse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						body, err := io.ReadAll(resp.Body)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to read response")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err = json.Unmarshal(body, &healthResp)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to decode response")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										146
									
								
								cmd/root.go
									
									
									
									
									
								
							
							
						
						
									
										146
									
								
								cmd/root.go
									
									
									
									
									
								
							@@ -2,8 +2,6 @@ package cmd
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	totpCmd "tinyauth/cmd/totp"
 | 
					 | 
				
			||||||
	userCmd "tinyauth/cmd/user"
 | 
					 | 
				
			||||||
	"tinyauth/internal/bootstrap"
 | 
						"tinyauth/internal/bootstrap"
 | 
				
			||||||
	"tinyauth/internal/config"
 | 
						"tinyauth/internal/config"
 | 
				
			||||||
	"tinyauth/internal/utils"
 | 
						"tinyauth/internal/utils"
 | 
				
			||||||
@@ -15,55 +13,28 @@ import (
 | 
				
			|||||||
	"github.com/spf13/viper"
 | 
						"github.com/spf13/viper"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var rootCmd = &cobra.Command{
 | 
					type rootCmd struct {
 | 
				
			||||||
 | 
						root *cobra.Command
 | 
				
			||||||
 | 
						cmd  *cobra.Command
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						viper *viper.Viper
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func newRootCmd() *rootCmd {
 | 
				
			||||||
 | 
						return &rootCmd{
 | 
				
			||||||
 | 
							viper: viper.New(),
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *rootCmd) Register() {
 | 
				
			||||||
 | 
						c.cmd = &cobra.Command{
 | 
				
			||||||
		Use:   "tinyauth",
 | 
							Use:   "tinyauth",
 | 
				
			||||||
	Short: "The simplest way to protect your apps with a login screen.",
 | 
							Short: "The simplest way to protect your apps with a login screen",
 | 
				
			||||||
	Long:  `Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic OAuth provider to all of your docker apps.`,
 | 
							Long:  `Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github or any other provider to all of your docker apps.`,
 | 
				
			||||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
							Run:   c.run,
 | 
				
			||||||
		var conf config.Config
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		err := viper.Unmarshal(&conf)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Err(err).Msg("Failed to parse config")
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Validate config
 | 
						c.viper.AutomaticEnv()
 | 
				
			||||||
		v := validator.New()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		err = v.Struct(conf)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Err(err).Msg("Invalid config")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
 | 
					 | 
				
			||||||
		log.Info().Str("version", strings.TrimSpace(config.Version)).Msg("Starting tinyauth")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Create bootstrap app
 | 
					 | 
				
			||||||
		app := bootstrap.NewBootstrapApp(conf)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Run
 | 
					 | 
				
			||||||
		err = app.Setup()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Err(err).Msg("Failed to setup app")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func Execute() {
 | 
					 | 
				
			||||||
	rootCmd.FParseErrWhitelist.UnknownFlags = true
 | 
					 | 
				
			||||||
	err := rootCmd.Execute()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		log.Fatal().Err(err).Msg("Failed to execute command")
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func init() {
 | 
					 | 
				
			||||||
	rootCmd.AddCommand(userCmd.UserCmd())
 | 
					 | 
				
			||||||
	rootCmd.AddCommand(totpCmd.TotpCmd())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	viper.AutomaticEnv()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	configOptions := []struct {
 | 
						configOptions := []struct {
 | 
				
			||||||
		name        string
 | 
							name        string
 | 
				
			||||||
@@ -101,17 +72,86 @@ func init() {
 | 
				
			|||||||
	for _, opt := range configOptions {
 | 
						for _, opt := range configOptions {
 | 
				
			||||||
		switch v := opt.defaultVal.(type) {
 | 
							switch v := opt.defaultVal.(type) {
 | 
				
			||||||
		case bool:
 | 
							case bool:
 | 
				
			||||||
			rootCmd.Flags().Bool(opt.name, v, opt.description)
 | 
								c.cmd.Flags().Bool(opt.name, v, opt.description)
 | 
				
			||||||
		case int:
 | 
							case int:
 | 
				
			||||||
			rootCmd.Flags().Int(opt.name, v, opt.description)
 | 
								c.cmd.Flags().Int(opt.name, v, opt.description)
 | 
				
			||||||
		case string:
 | 
							case string:
 | 
				
			||||||
			rootCmd.Flags().String(opt.name, v, opt.description)
 | 
								c.cmd.Flags().String(opt.name, v, opt.description)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Create uppercase env var name
 | 
							// Create uppercase env var name
 | 
				
			||||||
		envVar := strings.ReplaceAll(strings.ToUpper(opt.name), "-", "_")
 | 
							envVar := strings.ReplaceAll(strings.ToUpper(opt.name), "-", "_")
 | 
				
			||||||
		viper.BindEnv(opt.name, envVar)
 | 
							c.viper.BindEnv(opt.name, envVar)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	viper.BindPFlags(rootCmd.Flags())
 | 
						c.viper.BindPFlags(c.cmd.Flags())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.root != nil {
 | 
				
			||||||
 | 
							c.root.AddCommand(c.cmd)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *rootCmd) GetCmd() *cobra.Command {
 | 
				
			||||||
 | 
						return c.cmd
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *rootCmd) run(cmd *cobra.Command, args []string) {
 | 
				
			||||||
 | 
						var conf config.Config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err := c.viper.Unmarshal(&conf)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to parse config")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						v := validator.New()
 | 
				
			||||||
 | 
						err = v.Struct(conf)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Invalid config")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel)))
 | 
				
			||||||
 | 
						log.Info().Str("version", strings.TrimSpace(config.Version)).Msg("Starting Tinyauth")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if log.Logger.GetLevel() == zerolog.TraceLevel {
 | 
				
			||||||
 | 
							log.Warn().Msg("Log level set to trace, this will log sensitive information!")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						app := bootstrap.NewBootstrapApp(conf)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err = app.Setup()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to setup app")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func Run() {
 | 
				
			||||||
 | 
						rootCmd := newRootCmd()
 | 
				
			||||||
 | 
						rootCmd.Register()
 | 
				
			||||||
 | 
						root := rootCmd.GetCmd()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						userCmd := &cobra.Command{
 | 
				
			||||||
 | 
							Use:   "user",
 | 
				
			||||||
 | 
							Short: "User utilities",
 | 
				
			||||||
 | 
							Long:  `Utilities for creating and verifying tinyauth compatible users.`,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						totpCmd := &cobra.Command{
 | 
				
			||||||
 | 
							Use:   "totp",
 | 
				
			||||||
 | 
							Short: "Totp utilities",
 | 
				
			||||||
 | 
							Long:  `Utilities for creating and verifying totp codes.`,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						newCreateUserCmd(userCmd).Register()
 | 
				
			||||||
 | 
						newVerifyUserCmd(userCmd).Register()
 | 
				
			||||||
 | 
						newGenerateTotpCmd(totpCmd).Register()
 | 
				
			||||||
 | 
						newVersionCmd(root).Register()
 | 
				
			||||||
 | 
						newHealthcheckCmd(root).Register()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						root.AddCommand(userCmd)
 | 
				
			||||||
 | 
						root.AddCommand(totpCmd)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err := root.Execute()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to execute root command")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,99 +0,0 @@
 | 
				
			|||||||
package generate
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import (
 | 
					 | 
				
			||||||
	"errors"
 | 
					 | 
				
			||||||
	"fmt"
 | 
					 | 
				
			||||||
	"os"
 | 
					 | 
				
			||||||
	"strings"
 | 
					 | 
				
			||||||
	"tinyauth/internal/utils"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	"github.com/charmbracelet/huh"
 | 
					 | 
				
			||||||
	"github.com/mdp/qrterminal/v3"
 | 
					 | 
				
			||||||
	"github.com/pquerna/otp/totp"
 | 
					 | 
				
			||||||
	"github.com/rs/zerolog"
 | 
					 | 
				
			||||||
	"github.com/rs/zerolog/log"
 | 
					 | 
				
			||||||
	"github.com/spf13/cobra"
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var interactive bool
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// Input user
 | 
					 | 
				
			||||||
var iUser string
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var GenerateCmd = &cobra.Command{
 | 
					 | 
				
			||||||
	Use:   "generate",
 | 
					 | 
				
			||||||
	Short: "Generate a totp secret",
 | 
					 | 
				
			||||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
					 | 
				
			||||||
		log.Logger = log.Level(zerolog.InfoLevel)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if interactive {
 | 
					 | 
				
			||||||
			form := huh.NewForm(
 | 
					 | 
				
			||||||
				huh.NewGroup(
 | 
					 | 
				
			||||||
					huh.NewInput().Title("Current username:hash").Value(&iUser).Validate((func(s string) error {
 | 
					 | 
				
			||||||
						if s == "" {
 | 
					 | 
				
			||||||
							return errors.New("user cannot be empty")
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						return nil
 | 
					 | 
				
			||||||
					})),
 | 
					 | 
				
			||||||
				),
 | 
					 | 
				
			||||||
			)
 | 
					 | 
				
			||||||
			var baseTheme *huh.Theme = huh.ThemeBase()
 | 
					 | 
				
			||||||
			err := form.WithTheme(baseTheme).Run()
 | 
					 | 
				
			||||||
			if err != nil {
 | 
					 | 
				
			||||||
				log.Fatal().Err(err).Msg("Form failed")
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		user, err := utils.ParseUser(iUser)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Err(err).Msg("Failed to parse user")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		dockerEscape := false
 | 
					 | 
				
			||||||
		if strings.Contains(iUser, "$$") {
 | 
					 | 
				
			||||||
			dockerEscape = true
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if user.TotpSecret != "" {
 | 
					 | 
				
			||||||
			log.Fatal().Msg("User already has a totp secret")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		key, err := totp.Generate(totp.GenerateOpts{
 | 
					 | 
				
			||||||
			Issuer:      "Tinyauth",
 | 
					 | 
				
			||||||
			AccountName: user.Username,
 | 
					 | 
				
			||||||
		})
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Err(err).Msg("Failed to generate totp secret")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		secret := key.Secret()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		log.Info().Str("secret", secret).Msg("Generated totp secret")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		log.Info().Msg("Generated QR code")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		config := qrterminal.Config{
 | 
					 | 
				
			||||||
			Level:     qrterminal.L,
 | 
					 | 
				
			||||||
			Writer:    os.Stdout,
 | 
					 | 
				
			||||||
			BlackChar: qrterminal.BLACK,
 | 
					 | 
				
			||||||
			WhiteChar: qrterminal.WHITE,
 | 
					 | 
				
			||||||
			QuietZone: 2,
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		qrterminal.GenerateWithConfig(key.URL(), config)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		user.TotpSecret = secret
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// If using docker escape re-escape it
 | 
					 | 
				
			||||||
		if dockerEscape {
 | 
					 | 
				
			||||||
			user.Password = strings.ReplaceAll(user.Password, "$", "$$")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		log.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TotpSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func init() {
 | 
					 | 
				
			||||||
	GenerateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Run in interactive mode")
 | 
					 | 
				
			||||||
	GenerateCmd.Flags().StringVar(&iUser, "user", "", "Your current username:hash")
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,17 +0,0 @@
 | 
				
			|||||||
package cmd
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import (
 | 
					 | 
				
			||||||
	"tinyauth/cmd/totp/generate"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	"github.com/spf13/cobra"
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func TotpCmd() *cobra.Command {
 | 
					 | 
				
			||||||
	totpCmd := &cobra.Command{
 | 
					 | 
				
			||||||
		Use:   "totp",
 | 
					 | 
				
			||||||
		Short: "Totp utilities",
 | 
					 | 
				
			||||||
		Long:  `Utilities for creating and verifying totp codes.`,
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	totpCmd.AddCommand(generate.GenerateCmd)
 | 
					 | 
				
			||||||
	return totpCmd
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,80 +0,0 @@
 | 
				
			|||||||
package create
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import (
 | 
					 | 
				
			||||||
	"errors"
 | 
					 | 
				
			||||||
	"fmt"
 | 
					 | 
				
			||||||
	"strings"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	"github.com/charmbracelet/huh"
 | 
					 | 
				
			||||||
	"github.com/rs/zerolog"
 | 
					 | 
				
			||||||
	"github.com/rs/zerolog/log"
 | 
					 | 
				
			||||||
	"github.com/spf13/cobra"
 | 
					 | 
				
			||||||
	"golang.org/x/crypto/bcrypt"
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var interactive bool
 | 
					 | 
				
			||||||
var docker bool
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// i stands for input
 | 
					 | 
				
			||||||
var iUsername string
 | 
					 | 
				
			||||||
var iPassword string
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var CreateCmd = &cobra.Command{
 | 
					 | 
				
			||||||
	Use:   "create",
 | 
					 | 
				
			||||||
	Short: "Create a user",
 | 
					 | 
				
			||||||
	Long:  `Create a user either interactively or by passing flags.`,
 | 
					 | 
				
			||||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
					 | 
				
			||||||
		log.Logger = log.Level(zerolog.InfoLevel)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if interactive {
 | 
					 | 
				
			||||||
			form := huh.NewForm(
 | 
					 | 
				
			||||||
				huh.NewGroup(
 | 
					 | 
				
			||||||
					huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error {
 | 
					 | 
				
			||||||
						if s == "" {
 | 
					 | 
				
			||||||
							return errors.New("username cannot be empty")
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						return nil
 | 
					 | 
				
			||||||
					})),
 | 
					 | 
				
			||||||
					huh.NewInput().Title("Password").Value(&iPassword).Validate((func(s string) error {
 | 
					 | 
				
			||||||
						if s == "" {
 | 
					 | 
				
			||||||
							return errors.New("password cannot be empty")
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						return nil
 | 
					 | 
				
			||||||
					})),
 | 
					 | 
				
			||||||
					huh.NewSelect[bool]().Title("Format the output for docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&docker),
 | 
					 | 
				
			||||||
				),
 | 
					 | 
				
			||||||
			)
 | 
					 | 
				
			||||||
			var baseTheme *huh.Theme = huh.ThemeBase()
 | 
					 | 
				
			||||||
			err := form.WithTheme(baseTheme).Run()
 | 
					 | 
				
			||||||
			if err != nil {
 | 
					 | 
				
			||||||
				log.Fatal().Err(err).Msg("Form failed")
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if iUsername == "" || iPassword == "" {
 | 
					 | 
				
			||||||
			log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		log.Info().Str("username", iUsername).Str("password", iPassword).Bool("docker", docker).Msg("Creating user")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		password, err := bcrypt.GenerateFromPassword([]byte(iPassword), bcrypt.DefaultCost)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Err(err).Msg("Failed to hash password")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// If docker format is enabled, escape the dollar sign
 | 
					 | 
				
			||||||
		passwordString := string(password)
 | 
					 | 
				
			||||||
		if docker {
 | 
					 | 
				
			||||||
			passwordString = strings.ReplaceAll(passwordString, "$", "$$")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		log.Info().Str("user", fmt.Sprintf("%s:%s", iUsername, passwordString)).Msg("User created")
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func init() {
 | 
					 | 
				
			||||||
	CreateCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
 | 
					 | 
				
			||||||
	CreateCmd.Flags().BoolVar(&docker, "docker", false, "Format output for docker")
 | 
					 | 
				
			||||||
	CreateCmd.Flags().StringVar(&iUsername, "username", "", "Username")
 | 
					 | 
				
			||||||
	CreateCmd.Flags().StringVar(&iPassword, "password", "", "Password")
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,19 +0,0 @@
 | 
				
			|||||||
package cmd
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import (
 | 
					 | 
				
			||||||
	"tinyauth/cmd/user/create"
 | 
					 | 
				
			||||||
	"tinyauth/cmd/user/verify"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	"github.com/spf13/cobra"
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func UserCmd() *cobra.Command {
 | 
					 | 
				
			||||||
	userCmd := &cobra.Command{
 | 
					 | 
				
			||||||
		Use:   "user",
 | 
					 | 
				
			||||||
		Short: "User utilities",
 | 
					 | 
				
			||||||
		Long:  `Utilities for creating and verifying tinyauth compatible users.`,
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	userCmd.AddCommand(create.CreateCmd)
 | 
					 | 
				
			||||||
	userCmd.AddCommand(verify.VerifyCmd)
 | 
					 | 
				
			||||||
	return userCmd
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,101 +0,0 @@
 | 
				
			|||||||
package verify
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import (
 | 
					 | 
				
			||||||
	"errors"
 | 
					 | 
				
			||||||
	"tinyauth/internal/utils"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	"github.com/charmbracelet/huh"
 | 
					 | 
				
			||||||
	"github.com/pquerna/otp/totp"
 | 
					 | 
				
			||||||
	"github.com/rs/zerolog"
 | 
					 | 
				
			||||||
	"github.com/rs/zerolog/log"
 | 
					 | 
				
			||||||
	"github.com/spf13/cobra"
 | 
					 | 
				
			||||||
	"golang.org/x/crypto/bcrypt"
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var interactive bool
 | 
					 | 
				
			||||||
var docker bool
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// i stands for input
 | 
					 | 
				
			||||||
var iUsername string
 | 
					 | 
				
			||||||
var iPassword string
 | 
					 | 
				
			||||||
var iTotp string
 | 
					 | 
				
			||||||
var iUser string
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
var VerifyCmd = &cobra.Command{
 | 
					 | 
				
			||||||
	Use:   "verify",
 | 
					 | 
				
			||||||
	Short: "Verify a user is set up correctly",
 | 
					 | 
				
			||||||
	Long:  `Verify a user is set up correctly meaning that it has a correct username, password and totp code.`,
 | 
					 | 
				
			||||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
					 | 
				
			||||||
		log.Logger = log.Level(zerolog.InfoLevel)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if interactive {
 | 
					 | 
				
			||||||
			form := huh.NewForm(
 | 
					 | 
				
			||||||
				huh.NewGroup(
 | 
					 | 
				
			||||||
					huh.NewInput().Title("User (username:hash:totp)").Value(&iUser).Validate((func(s string) error {
 | 
					 | 
				
			||||||
						if s == "" {
 | 
					 | 
				
			||||||
							return errors.New("user cannot be empty")
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						return nil
 | 
					 | 
				
			||||||
					})),
 | 
					 | 
				
			||||||
					huh.NewInput().Title("Username").Value(&iUsername).Validate((func(s string) error {
 | 
					 | 
				
			||||||
						if s == "" {
 | 
					 | 
				
			||||||
							return errors.New("username cannot be empty")
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						return nil
 | 
					 | 
				
			||||||
					})),
 | 
					 | 
				
			||||||
					huh.NewInput().Title("Password").Value(&iPassword).Validate((func(s string) error {
 | 
					 | 
				
			||||||
						if s == "" {
 | 
					 | 
				
			||||||
							return errors.New("password cannot be empty")
 | 
					 | 
				
			||||||
						}
 | 
					 | 
				
			||||||
						return nil
 | 
					 | 
				
			||||||
					})),
 | 
					 | 
				
			||||||
					huh.NewInput().Title("Totp Code (if setup)").Value(&iTotp),
 | 
					 | 
				
			||||||
				),
 | 
					 | 
				
			||||||
			)
 | 
					 | 
				
			||||||
			var baseTheme *huh.Theme = huh.ThemeBase()
 | 
					 | 
				
			||||||
			err := form.WithTheme(baseTheme).Run()
 | 
					 | 
				
			||||||
			if err != nil {
 | 
					 | 
				
			||||||
				log.Fatal().Err(err).Msg("Form failed")
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		user, err := utils.ParseUser(iUser)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Err(err).Msg("Failed to parse user")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if user.Username != iUsername {
 | 
					 | 
				
			||||||
			log.Fatal().Msg("Username is incorrect")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(iPassword))
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			log.Fatal().Msg("Password is incorrect")
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if user.TotpSecret == "" {
 | 
					 | 
				
			||||||
			if iTotp != "" {
 | 
					 | 
				
			||||||
				log.Warn().Msg("User does not have 2fa secret")
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			log.Info().Msg("User verified")
 | 
					 | 
				
			||||||
			return
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		ok := totp.Validate(iTotp, user.TotpSecret)
 | 
					 | 
				
			||||||
		if !ok {
 | 
					 | 
				
			||||||
			log.Fatal().Msg("Totp code incorrect")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		log.Info().Msg("User verified")
 | 
					 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func init() {
 | 
					 | 
				
			||||||
	VerifyCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, "Create a user interactively")
 | 
					 | 
				
			||||||
	VerifyCmd.Flags().BoolVar(&docker, "docker", false, "Is the user formatted for docker?")
 | 
					 | 
				
			||||||
	VerifyCmd.Flags().StringVar(&iUsername, "username", "", "Username")
 | 
					 | 
				
			||||||
	VerifyCmd.Flags().StringVar(&iPassword, "password", "", "Password")
 | 
					 | 
				
			||||||
	VerifyCmd.Flags().StringVar(&iTotp, "totp", "", "Totp code")
 | 
					 | 
				
			||||||
	VerifyCmd.Flags().StringVar(&iUser, "user", "", "Hash (username:hash:totp combination)")
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										118
									
								
								cmd/verify.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								cmd/verify.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,118 @@
 | 
				
			|||||||
 | 
					package cmd
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"errors"
 | 
				
			||||||
 | 
						"tinyauth/internal/utils"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/charmbracelet/huh"
 | 
				
			||||||
 | 
						"github.com/pquerna/otp/totp"
 | 
				
			||||||
 | 
						"github.com/rs/zerolog"
 | 
				
			||||||
 | 
						"github.com/rs/zerolog/log"
 | 
				
			||||||
 | 
						"github.com/spf13/cobra"
 | 
				
			||||||
 | 
						"golang.org/x/crypto/bcrypt"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type verifyUserCmd struct {
 | 
				
			||||||
 | 
						root *cobra.Command
 | 
				
			||||||
 | 
						cmd  *cobra.Command
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						interactive bool
 | 
				
			||||||
 | 
						username    string
 | 
				
			||||||
 | 
						password    string
 | 
				
			||||||
 | 
						totp        string
 | 
				
			||||||
 | 
						user        string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func newVerifyUserCmd(root *cobra.Command) *verifyUserCmd {
 | 
				
			||||||
 | 
						return &verifyUserCmd{
 | 
				
			||||||
 | 
							root: root,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *verifyUserCmd) Register() {
 | 
				
			||||||
 | 
						c.cmd = &cobra.Command{
 | 
				
			||||||
 | 
							Use:   "verify",
 | 
				
			||||||
 | 
							Short: "Verify a user is set up correctly",
 | 
				
			||||||
 | 
							Long:  `Verify a user is set up correctly meaning that it has a correct username, password and TOTP code.`,
 | 
				
			||||||
 | 
							Run:   c.run,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Validate a user interactively")
 | 
				
			||||||
 | 
						c.cmd.Flags().StringVar(&c.username, "username", "", "Username")
 | 
				
			||||||
 | 
						c.cmd.Flags().StringVar(&c.password, "password", "", "Password")
 | 
				
			||||||
 | 
						c.cmd.Flags().StringVar(&c.totp, "totp", "", "TOTP code")
 | 
				
			||||||
 | 
						c.cmd.Flags().StringVar(&c.user, "user", "", "Hash (username:hash:totp)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.root != nil {
 | 
				
			||||||
 | 
							c.root.AddCommand(c.cmd)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *verifyUserCmd) GetCmd() *cobra.Command {
 | 
				
			||||||
 | 
						return c.cmd
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *verifyUserCmd) run(cmd *cobra.Command, args []string) {
 | 
				
			||||||
 | 
						log.Logger = log.Level(zerolog.InfoLevel)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.interactive {
 | 
				
			||||||
 | 
							form := huh.NewForm(
 | 
				
			||||||
 | 
								huh.NewGroup(
 | 
				
			||||||
 | 
									huh.NewInput().Title("User (username:hash:totp)").Value(&c.user).Validate((func(s string) error {
 | 
				
			||||||
 | 
										if s == "" {
 | 
				
			||||||
 | 
											return errors.New("user cannot be empty")
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return nil
 | 
				
			||||||
 | 
									})),
 | 
				
			||||||
 | 
									huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error {
 | 
				
			||||||
 | 
										if s == "" {
 | 
				
			||||||
 | 
											return errors.New("username cannot be empty")
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return nil
 | 
				
			||||||
 | 
									})),
 | 
				
			||||||
 | 
									huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error {
 | 
				
			||||||
 | 
										if s == "" {
 | 
				
			||||||
 | 
											return errors.New("password cannot be empty")
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
										return nil
 | 
				
			||||||
 | 
									})),
 | 
				
			||||||
 | 
									huh.NewInput().Title("TOTP Code (optional)").Value(&c.totp),
 | 
				
			||||||
 | 
								),
 | 
				
			||||||
 | 
							)
 | 
				
			||||||
 | 
							var baseTheme *huh.Theme = huh.ThemeBase()
 | 
				
			||||||
 | 
							err := form.WithTheme(baseTheme).Run()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								log.Fatal().Err(err).Msg("Form failed")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						user, err := utils.ParseUser(c.user)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Err(err).Msg("Failed to parse user")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if user.Username != c.username {
 | 
				
			||||||
 | 
							log.Fatal().Msg("Username is incorrect")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(c.password))
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal().Msg("Password is incorrect")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if user.TotpSecret == "" {
 | 
				
			||||||
 | 
							if c.totp != "" {
 | 
				
			||||||
 | 
								log.Warn().Msg("User does not have TOTP secret")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							log.Info().Msg("User verified")
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						ok := totp.Validate(c.totp, user.TotpSecret)
 | 
				
			||||||
 | 
						if !ok {
 | 
				
			||||||
 | 
							log.Fatal().Msg("TOTP code incorrect")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Info().Msg("User verified")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -7,17 +7,36 @@ import (
 | 
				
			|||||||
	"github.com/spf13/cobra"
 | 
						"github.com/spf13/cobra"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var versionCmd = &cobra.Command{
 | 
					type versionCmd struct {
 | 
				
			||||||
 | 
						root *cobra.Command
 | 
				
			||||||
 | 
						cmd  *cobra.Command
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func newVersionCmd(root *cobra.Command) *versionCmd {
 | 
				
			||||||
 | 
						return &versionCmd{
 | 
				
			||||||
 | 
							root: root,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *versionCmd) Register() {
 | 
				
			||||||
 | 
						c.cmd = &cobra.Command{
 | 
				
			||||||
		Use:   "version",
 | 
							Use:   "version",
 | 
				
			||||||
		Short: "Print the version number of Tinyauth",
 | 
							Short: "Print the version number of Tinyauth",
 | 
				
			||||||
	Long:  `All software has versions. This is Tinyauth's`,
 | 
							Long:  `All software has versions. This is Tinyauth's.`,
 | 
				
			||||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
							Run:   c.run,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if c.root != nil {
 | 
				
			||||||
 | 
							c.root.AddCommand(c.cmd)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *versionCmd) GetCmd() *cobra.Command {
 | 
				
			||||||
 | 
						return c.cmd
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *versionCmd) run(cmd *cobra.Command, args []string) {
 | 
				
			||||||
	fmt.Printf("Version: %s\n", config.Version)
 | 
						fmt.Printf("Version: %s\n", config.Version)
 | 
				
			||||||
	fmt.Printf("Commit Hash: %s\n", config.CommitHash)
 | 
						fmt.Printf("Commit Hash: %s\n", config.CommitHash)
 | 
				
			||||||
	fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
 | 
						fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp)
 | 
				
			||||||
	},
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func init() {
 | 
					 | 
				
			||||||
	rootCmd.AddCommand(versionCmd)
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,39 +14,39 @@
 | 
				
			|||||||
        "axios": "^1.12.2",
 | 
					        "axios": "^1.12.2",
 | 
				
			||||||
        "class-variance-authority": "^0.7.1",
 | 
					        "class-variance-authority": "^0.7.1",
 | 
				
			||||||
        "clsx": "^2.1.1",
 | 
					        "clsx": "^2.1.1",
 | 
				
			||||||
        "i18next": "^25.5.3",
 | 
					        "i18next": "^25.6.0",
 | 
				
			||||||
        "i18next-browser-languagedetector": "^8.2.0",
 | 
					        "i18next-browser-languagedetector": "^8.2.0",
 | 
				
			||||||
        "i18next-resources-to-backend": "^1.2.1",
 | 
					        "i18next-resources-to-backend": "^1.2.1",
 | 
				
			||||||
        "input-otp": "^1.4.2",
 | 
					        "input-otp": "^1.4.2",
 | 
				
			||||||
        "lucide-react": "^0.544.0",
 | 
					        "lucide-react": "^0.545.0",
 | 
				
			||||||
        "next-themes": "^0.4.6",
 | 
					        "next-themes": "^0.4.6",
 | 
				
			||||||
        "react": "^19.2.0",
 | 
					        "react": "^19.2.0",
 | 
				
			||||||
        "react-dom": "^19.2.0",
 | 
					        "react-dom": "^19.2.0",
 | 
				
			||||||
        "react-hook-form": "^7.63.0",
 | 
					        "react-hook-form": "^7.65.0",
 | 
				
			||||||
        "react-i18next": "^15.7.3",
 | 
					        "react-i18next": "^16.0.0",
 | 
				
			||||||
        "react-markdown": "^10.1.0",
 | 
					        "react-markdown": "^10.1.0",
 | 
				
			||||||
        "react-router": "^7.9.3",
 | 
					        "react-router": "^7.9.4",
 | 
				
			||||||
        "sonner": "^2.0.7",
 | 
					        "sonner": "^2.0.7",
 | 
				
			||||||
        "tailwind-merge": "^3.3.1",
 | 
					        "tailwind-merge": "^3.3.1",
 | 
				
			||||||
        "tailwindcss": "^4.1.14",
 | 
					        "tailwindcss": "^4.1.14",
 | 
				
			||||||
        "zod": "^4.1.11",
 | 
					        "zod": "^4.1.12",
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "devDependencies": {
 | 
					      "devDependencies": {
 | 
				
			||||||
        "@eslint/js": "^9.36.0",
 | 
					        "@eslint/js": "^9.37.0",
 | 
				
			||||||
        "@tanstack/eslint-plugin-query": "^5.91.0",
 | 
					        "@tanstack/eslint-plugin-query": "^5.91.0",
 | 
				
			||||||
        "@types/node": "^24.6.2",
 | 
					        "@types/node": "^24.7.2",
 | 
				
			||||||
        "@types/react": "^19.2.0",
 | 
					        "@types/react": "^19.2.2",
 | 
				
			||||||
        "@types/react-dom": "^19.2.0",
 | 
					        "@types/react-dom": "^19.2.1",
 | 
				
			||||||
        "@vitejs/plugin-react": "^5.0.4",
 | 
					        "@vitejs/plugin-react": "^5.0.4",
 | 
				
			||||||
        "eslint": "^9.36.0",
 | 
					        "eslint": "^9.37.0",
 | 
				
			||||||
        "eslint-plugin-react-hooks": "^5.2.0",
 | 
					        "eslint-plugin-react-hooks": "^7.0.0",
 | 
				
			||||||
        "eslint-plugin-react-refresh": "^0.4.23",
 | 
					        "eslint-plugin-react-refresh": "^0.4.23",
 | 
				
			||||||
        "globals": "^16.4.0",
 | 
					        "globals": "^16.4.0",
 | 
				
			||||||
        "prettier": "3.6.2",
 | 
					        "prettier": "3.6.2",
 | 
				
			||||||
        "tw-animate-css": "^1.4.0",
 | 
					        "tw-animate-css": "^1.4.0",
 | 
				
			||||||
        "typescript": "~5.9.3",
 | 
					        "typescript": "~5.9.3",
 | 
				
			||||||
        "typescript-eslint": "^8.45.0",
 | 
					        "typescript-eslint": "^8.46.0",
 | 
				
			||||||
        "vite": "^7.1.8",
 | 
					        "vite": "^7.1.9",
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
@@ -147,17 +147,17 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
 | 
					    "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@eslint/config-helpers": ["@eslint/config-helpers@0.3.1", "", {}, "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="],
 | 
					    "@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="],
 | 
					    "@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
 | 
					    "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@eslint/js": ["@eslint/js@9.36.0", "", {}, "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw=="],
 | 
					    "@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
 | 
					    "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
 | 
					    "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="],
 | 
					    "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -355,33 +355,33 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
 | 
					    "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],
 | 
					    "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
 | 
					    "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="],
 | 
					    "@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
 | 
					    "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.45.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/type-utils": "8.45.0", "@typescript-eslint/utils": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg=="],
 | 
					    "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/type-utils": "8.46.0", "@typescript-eslint/utils": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/parser": ["@typescript-eslint/parser@8.45.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ=="],
 | 
					    "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", "@typescript-eslint/typescript-estree": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.45.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.45.0", "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg=="],
 | 
					    "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.0", "@typescript-eslint/types": "^8.46.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0" } }, "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA=="],
 | 
					    "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0" } }, "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.45.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w=="],
 | 
					    "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A=="],
 | 
					    "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "@typescript-eslint/typescript-estree": "8.46.0", "@typescript-eslint/utils": "8.46.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/types": ["@typescript-eslint/types@8.45.0", "", {}, "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA=="],
 | 
					    "@typescript-eslint/types": ["@typescript-eslint/types@8.45.0", "", {}, "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.45.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.45.0", "@typescript-eslint/tsconfig-utils": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA=="],
 | 
					    "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.0", "@typescript-eslint/tsconfig-utils": "8.46.0", "@typescript-eslint/types": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/utils": ["@typescript-eslint/utils@8.45.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg=="],
 | 
					    "@typescript-eslint/utils": ["@typescript-eslint/utils@8.45.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag=="],
 | 
					    "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
 | 
					    "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -491,9 +491,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
 | 
					    "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "eslint": ["eslint@9.36.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.36.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ=="],
 | 
					    "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
 | 
					    "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.0", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.22.4 || ^4.0.0", "zod-validation-error": "^3.0.3 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.23", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA=="],
 | 
					    "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.23", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -575,11 +575,15 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
 | 
					    "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
 | 
					    "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
 | 
					    "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "i18next": ["i18next@25.5.3", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg=="],
 | 
					    "i18next": ["i18next@25.6.0", "", { "dependencies": { "@babel/runtime": "^7.27.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
 | 
					    "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -663,7 +667,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
 | 
					    "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
 | 
					    "lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
 | 
					    "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -787,9 +791,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
 | 
					    "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "react-hook-form": ["react-hook-form@7.63.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA=="],
 | 
					    "react-hook-form": ["react-hook-form@7.65.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "react-i18next": ["react-i18next@15.7.3", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.4.1", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw=="],
 | 
					    "react-i18next": ["react-i18next@16.0.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 25.5.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
 | 
					    "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -799,7 +803,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
 | 
					    "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "react-router": ["react-router@7.9.3", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg=="],
 | 
					    "react-router": ["react-router@7.9.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
 | 
					    "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -867,9 +871,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
 | 
					    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "typescript-eslint": ["typescript-eslint@8.45.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", "@typescript-eslint/parser": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg=="],
 | 
					    "typescript-eslint": ["typescript-eslint@8.46.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.0", "@typescript-eslint/parser": "8.46.0", "@typescript-eslint/typescript-estree": "8.46.0", "@typescript-eslint/utils": "8.46.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="],
 | 
					    "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
 | 
					    "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -895,7 +899,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
 | 
					    "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "vite": ["vite@7.1.8", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ=="],
 | 
					    "vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
 | 
					    "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -907,7 +911,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
 | 
					    "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
 | 
					    "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
 | 
					    "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -965,12 +971,34 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
 | 
					    "@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0" } }, "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", "@typescript-eslint/typescript-estree": "8.46.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
 | 
					    "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0" } }, "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/type-utils/@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", "@typescript-eslint/typescript-estree": "8.46.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
 | 
					    "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
 | 
					    "@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.45.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.45.0", "@typescript-eslint/tsconfig-utils": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
 | 
					    "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
 | 
					    "hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
 | 
				
			||||||
@@ -985,6 +1013,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
 | 
					    "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", "@typescript-eslint/typescript-estree": "8.46.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
 | 
					    "@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.2", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw=="],
 | 
					    "@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.2", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw=="],
 | 
				
			||||||
@@ -999,12 +1029,34 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
 | 
					    "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/type-utils/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0" } }, "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
 | 
					    "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.45.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.45.0", "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.45.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.0", "", { "dependencies": { "@typescript-eslint/types": "8.46.0", "@typescript-eslint/visitor-keys": "8.46.0" } }, "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.46.0", "", {}, "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
 | 
					    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
 | 
					    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    "@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
 | 
					    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
 | 
					    "@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,7 @@
 | 
				
			|||||||
    <link rel="shortcut icon" href="/favicon.ico" />
 | 
					    <link rel="shortcut icon" href="/favicon.ico" />
 | 
				
			||||||
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
 | 
					    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
 | 
				
			||||||
    <meta name="apple-mobile-web-app-title" content="Tinyauth" />
 | 
					    <meta name="apple-mobile-web-app-title" content="Tinyauth" />
 | 
				
			||||||
    <meta name="robots" content="none" />
 | 
					    <meta name="robots" content="nofollow, noindex" />
 | 
				
			||||||
    <link rel="manifest" href="/site.webmanifest" />
 | 
					    <link rel="manifest" href="/site.webmanifest" />
 | 
				
			||||||
    <title>Tinyauth</title>
 | 
					    <title>Tinyauth</title>
 | 
				
			||||||
  </head>
 | 
					  </head>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,38 +20,38 @@
 | 
				
			|||||||
    "axios": "^1.12.2",
 | 
					    "axios": "^1.12.2",
 | 
				
			||||||
    "class-variance-authority": "^0.7.1",
 | 
					    "class-variance-authority": "^0.7.1",
 | 
				
			||||||
    "clsx": "^2.1.1",
 | 
					    "clsx": "^2.1.1",
 | 
				
			||||||
    "i18next": "^25.5.3",
 | 
					    "i18next": "^25.6.0",
 | 
				
			||||||
    "i18next-browser-languagedetector": "^8.2.0",
 | 
					    "i18next-browser-languagedetector": "^8.2.0",
 | 
				
			||||||
    "i18next-resources-to-backend": "^1.2.1",
 | 
					    "i18next-resources-to-backend": "^1.2.1",
 | 
				
			||||||
    "input-otp": "^1.4.2",
 | 
					    "input-otp": "^1.4.2",
 | 
				
			||||||
    "lucide-react": "^0.544.0",
 | 
					    "lucide-react": "^0.545.0",
 | 
				
			||||||
    "next-themes": "^0.4.6",
 | 
					    "next-themes": "^0.4.6",
 | 
				
			||||||
    "react": "^19.2.0",
 | 
					    "react": "^19.2.0",
 | 
				
			||||||
    "react-dom": "^19.2.0",
 | 
					    "react-dom": "^19.2.0",
 | 
				
			||||||
    "react-hook-form": "^7.63.0",
 | 
					    "react-hook-form": "^7.65.0",
 | 
				
			||||||
    "react-i18next": "^15.7.3",
 | 
					    "react-i18next": "^16.0.0",
 | 
				
			||||||
    "react-markdown": "^10.1.0",
 | 
					    "react-markdown": "^10.1.0",
 | 
				
			||||||
    "react-router": "^7.9.3",
 | 
					    "react-router": "^7.9.4",
 | 
				
			||||||
    "sonner": "^2.0.7",
 | 
					    "sonner": "^2.0.7",
 | 
				
			||||||
    "tailwind-merge": "^3.3.1",
 | 
					    "tailwind-merge": "^3.3.1",
 | 
				
			||||||
    "tailwindcss": "^4.1.14",
 | 
					    "tailwindcss": "^4.1.14",
 | 
				
			||||||
    "zod": "^4.1.11"
 | 
					    "zod": "^4.1.12"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@eslint/js": "^9.36.0",
 | 
					    "@eslint/js": "^9.37.0",
 | 
				
			||||||
    "@tanstack/eslint-plugin-query": "^5.91.0",
 | 
					    "@tanstack/eslint-plugin-query": "^5.91.0",
 | 
				
			||||||
    "@types/node": "^24.6.2",
 | 
					    "@types/node": "^24.7.2",
 | 
				
			||||||
    "@types/react": "^19.2.0",
 | 
					    "@types/react": "^19.2.2",
 | 
				
			||||||
    "@types/react-dom": "^19.2.0",
 | 
					    "@types/react-dom": "^19.2.1",
 | 
				
			||||||
    "@vitejs/plugin-react": "^5.0.4",
 | 
					    "@vitejs/plugin-react": "^5.0.4",
 | 
				
			||||||
    "eslint": "^9.36.0",
 | 
					    "eslint": "^9.37.0",
 | 
				
			||||||
    "eslint-plugin-react-hooks": "^5.2.0",
 | 
					    "eslint-plugin-react-hooks": "^7.0.0",
 | 
				
			||||||
    "eslint-plugin-react-refresh": "^0.4.23",
 | 
					    "eslint-plugin-react-refresh": "^0.4.23",
 | 
				
			||||||
    "globals": "^16.4.0",
 | 
					    "globals": "^16.4.0",
 | 
				
			||||||
    "prettier": "3.6.2",
 | 
					    "prettier": "3.6.2",
 | 
				
			||||||
    "tw-animate-css": "^1.4.0",
 | 
					    "tw-animate-css": "^1.4.0",
 | 
				
			||||||
    "typescript": "~5.9.3",
 | 
					    "typescript": "~5.9.3",
 | 
				
			||||||
    "typescript-eslint": "^8.45.0",
 | 
					    "typescript-eslint": "^8.46.0",
 | 
				
			||||||
    "vite": "^7.1.8"
 | 
					    "vite": "^7.1.9"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -1,11 +1,15 @@
 | 
				
			|||||||
import { useAppContext } from "@/context/app-context";
 | 
					import { useAppContext } from "@/context/app-context";
 | 
				
			||||||
import { LanguageSelector } from "../language/language";
 | 
					import { LanguageSelector } from "../language/language";
 | 
				
			||||||
import { Outlet } from "react-router";
 | 
					import { Outlet } from "react-router";
 | 
				
			||||||
import { useCallback, useState } from "react";
 | 
					import { useCallback, useEffect, useState } from "react";
 | 
				
			||||||
import { DomainWarning } from "../domain-warning/domain-warning";
 | 
					import { DomainWarning } from "../domain-warning/domain-warning";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
 | 
					const BaseLayout = ({ children }: { children: React.ReactNode }) => {
 | 
				
			||||||
  const { backgroundImage } = useAppContext();
 | 
					  const { backgroundImage, title } = useAppContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    document.title = title;
 | 
				
			||||||
 | 
					  }, [title]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div
 | 
					    <div
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										24
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								go.mod
									
									
									
									
									
								
							@@ -8,7 +8,7 @@ require (
 | 
				
			|||||||
	github.com/cenkalti/backoff/v5 v5.0.3
 | 
						github.com/cenkalti/backoff/v5 v5.0.3
 | 
				
			||||||
	github.com/gin-gonic/gin v1.11.0
 | 
						github.com/gin-gonic/gin v1.11.0
 | 
				
			||||||
	github.com/glebarez/sqlite v1.11.0
 | 
						github.com/glebarez/sqlite v1.11.0
 | 
				
			||||||
	github.com/go-playground/validator/v10 v10.27.0
 | 
						github.com/go-playground/validator/v10 v10.28.0
 | 
				
			||||||
	github.com/golang-migrate/migrate/v4 v4.19.0
 | 
						github.com/golang-migrate/migrate/v4 v4.19.0
 | 
				
			||||||
	github.com/google/go-querystring v1.1.0
 | 
						github.com/google/go-querystring v1.1.0
 | 
				
			||||||
	github.com/google/uuid v1.6.0
 | 
						github.com/google/uuid v1.6.0
 | 
				
			||||||
@@ -18,7 +18,7 @@ require (
 | 
				
			|||||||
	github.com/spf13/viper v1.21.0
 | 
						github.com/spf13/viper v1.21.0
 | 
				
			||||||
	github.com/traefik/paerser v0.2.2
 | 
						github.com/traefik/paerser v0.2.2
 | 
				
			||||||
	github.com/weppos/publicsuffix-go v0.50.0
 | 
						github.com/weppos/publicsuffix-go v0.50.0
 | 
				
			||||||
	golang.org/x/crypto v0.42.0
 | 
						golang.org/x/crypto v0.43.0
 | 
				
			||||||
	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
 | 
						golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
 | 
				
			||||||
	gorm.io/gorm v1.31.0
 | 
						gorm.io/gorm v1.31.0
 | 
				
			||||||
	gotest.tools/v3 v3.5.2
 | 
						gotest.tools/v3 v3.5.2
 | 
				
			||||||
@@ -45,7 +45,7 @@ require (
 | 
				
			|||||||
	github.com/moby/term v0.5.2 // indirect
 | 
						github.com/moby/term v0.5.2 // indirect
 | 
				
			||||||
	github.com/ncruces/go-strftime v0.1.9 // indirect
 | 
						github.com/ncruces/go-strftime v0.1.9 // indirect
 | 
				
			||||||
	github.com/quic-go/qpack v0.5.1 // indirect
 | 
						github.com/quic-go/qpack v0.5.1 // indirect
 | 
				
			||||||
	github.com/quic-go/quic-go v0.54.0 // indirect
 | 
						github.com/quic-go/quic-go v0.54.1 // indirect
 | 
				
			||||||
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 | 
						github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 | 
				
			||||||
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 | 
						github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 | 
				
			||||||
	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 | 
						go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 | 
				
			||||||
@@ -53,9 +53,9 @@ require (
 | 
				
			|||||||
	go.opentelemetry.io/otel/sdk v1.34.0 // indirect
 | 
						go.opentelemetry.io/otel/sdk v1.34.0 // indirect
 | 
				
			||||||
	go.uber.org/mock v0.5.0 // indirect
 | 
						go.uber.org/mock v0.5.0 // indirect
 | 
				
			||||||
	go.yaml.in/yaml/v3 v3.0.4 // indirect
 | 
						go.yaml.in/yaml/v3 v3.0.4 // indirect
 | 
				
			||||||
	golang.org/x/mod v0.27.0 // indirect
 | 
						golang.org/x/mod v0.28.0 // indirect
 | 
				
			||||||
	golang.org/x/term v0.35.0 // indirect
 | 
						golang.org/x/term v0.36.0 // indirect
 | 
				
			||||||
	golang.org/x/tools v0.36.0 // indirect
 | 
						golang.org/x/tools v0.37.0 // indirect
 | 
				
			||||||
	modernc.org/libc v1.66.3 // indirect
 | 
						modernc.org/libc v1.66.3 // indirect
 | 
				
			||||||
	modernc.org/mathutil v1.7.1 // indirect
 | 
						modernc.org/mathutil v1.7.1 // indirect
 | 
				
			||||||
	modernc.org/memory v1.11.0 // indirect
 | 
						modernc.org/memory v1.11.0 // indirect
 | 
				
			||||||
@@ -80,14 +80,14 @@ require (
 | 
				
			|||||||
	github.com/charmbracelet/x/term v0.2.1 // indirect
 | 
						github.com/charmbracelet/x/term v0.2.1 // indirect
 | 
				
			||||||
	github.com/cloudwego/base64x v0.1.6 // indirect
 | 
						github.com/cloudwego/base64x v0.1.6 // indirect
 | 
				
			||||||
	github.com/distribution/reference v0.6.0 // indirect
 | 
						github.com/distribution/reference v0.6.0 // indirect
 | 
				
			||||||
	github.com/docker/docker v28.5.0+incompatible
 | 
						github.com/docker/docker v28.5.1+incompatible
 | 
				
			||||||
	github.com/docker/go-connections v0.5.0 // indirect
 | 
						github.com/docker/go-connections v0.5.0 // indirect
 | 
				
			||||||
	github.com/docker/go-units v0.5.0 // indirect
 | 
						github.com/docker/go-units v0.5.0 // indirect
 | 
				
			||||||
	github.com/dustin/go-humanize v1.0.1 // indirect
 | 
						github.com/dustin/go-humanize v1.0.1 // indirect
 | 
				
			||||||
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 | 
						github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 | 
				
			||||||
	github.com/felixge/httpsnoop v1.0.4 // indirect
 | 
						github.com/felixge/httpsnoop v1.0.4 // indirect
 | 
				
			||||||
	github.com/fsnotify/fsnotify v1.9.0 // indirect
 | 
						github.com/fsnotify/fsnotify v1.9.0 // indirect
 | 
				
			||||||
	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 | 
						github.com/gabriel-vasile/mimetype v1.4.10 // indirect
 | 
				
			||||||
	github.com/gin-contrib/sse v1.1.0 // indirect
 | 
						github.com/gin-contrib/sse v1.1.0 // indirect
 | 
				
			||||||
	github.com/go-ldap/ldap/v3 v3.4.12
 | 
						github.com/go-ldap/ldap/v3 v3.4.12
 | 
				
			||||||
	github.com/go-logr/logr v1.4.3 // indirect
 | 
						github.com/go-logr/logr v1.4.3 // indirect
 | 
				
			||||||
@@ -130,10 +130,10 @@ require (
 | 
				
			|||||||
	go.opentelemetry.io/otel/metric v1.37.0 // indirect
 | 
						go.opentelemetry.io/otel/metric v1.37.0 // indirect
 | 
				
			||||||
	go.opentelemetry.io/otel/trace v1.37.0 // indirect
 | 
						go.opentelemetry.io/otel/trace v1.37.0 // indirect
 | 
				
			||||||
	golang.org/x/arch v0.20.0 // indirect
 | 
						golang.org/x/arch v0.20.0 // indirect
 | 
				
			||||||
	golang.org/x/net v0.44.0 // indirect
 | 
						golang.org/x/net v0.45.0 // indirect
 | 
				
			||||||
	golang.org/x/oauth2 v0.31.0
 | 
						golang.org/x/oauth2 v0.32.0
 | 
				
			||||||
	golang.org/x/sync v0.17.0 // indirect
 | 
						golang.org/x/sync v0.17.0 // indirect
 | 
				
			||||||
	golang.org/x/sys v0.36.0 // indirect
 | 
						golang.org/x/sys v0.37.0 // indirect
 | 
				
			||||||
	golang.org/x/text v0.29.0 // indirect
 | 
						golang.org/x/text v0.30.0 // indirect
 | 
				
			||||||
	google.golang.org/protobuf v1.36.9 // indirect
 | 
						google.golang.org/protobuf v1.36.9 // indirect
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										48
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								go.sum
									
									
									
									
									
								
							@@ -72,8 +72,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 | 
				
			|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
					github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
				
			||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 | 
					github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 | 
				
			||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 | 
					github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 | 
				
			||||||
github.com/docker/docker v28.5.0+incompatible h1:ZdSQoRUE9XxhFI/B8YLvhnEFMmYN9Pp8Egd2qcaFk1E=
 | 
					github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
 | 
				
			||||||
github.com/docker/docker v28.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
					github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
				
			||||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
 | 
					github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
 | 
				
			||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
 | 
					github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
 | 
				
			||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 | 
					github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 | 
				
			||||||
@@ -88,8 +88,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
 | 
				
			|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 | 
					github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 | 
				
			||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
 | 
					github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
 | 
				
			||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 | 
					github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 | 
				
			||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 | 
					github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
 | 
				
			||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
 | 
					github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 | 
				
			||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
 | 
					github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
 | 
				
			||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
 | 
					github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
 | 
				
			||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
 | 
					github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
 | 
				
			||||||
@@ -113,8 +113,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 | 
				
			|||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 | 
					github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 | 
				
			||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 | 
					github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 | 
				
			||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 | 
					github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 | 
				
			||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
 | 
					github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
 | 
				
			||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 | 
					github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
 | 
				
			||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
 | 
					github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
 | 
				
			||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 | 
					github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 | 
				
			||||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
 | 
					github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
 | 
				
			||||||
@@ -229,8 +229,8 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
 | 
				
			|||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 | 
					github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 | 
				
			||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
 | 
					github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
 | 
				
			||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
 | 
					github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
 | 
				
			||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
 | 
					github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg=
 | 
				
			||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
 | 
					github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
 | 
				
			||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 | 
					github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 | 
				
			||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 | 
					github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 | 
				
			||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 | 
					github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 | 
				
			||||||
@@ -304,32 +304,32 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 | 
				
			|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 | 
					go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 | 
				
			||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
 | 
					golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
 | 
				
			||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
 | 
					golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
 | 
				
			||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
 | 
					golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
 | 
				
			||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
 | 
					golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
 | 
				
			||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
 | 
					golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
 | 
				
			||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
 | 
					golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
 | 
				
			||||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
 | 
					golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
 | 
				
			||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
 | 
					golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
 | 
				
			||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
 | 
					golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
 | 
				
			||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
 | 
					golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
 | 
				
			||||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
 | 
					golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
 | 
				
			||||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 | 
					golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 | 
				
			||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
 | 
					golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
 | 
				
			||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 | 
					golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 | 
				
			||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
					golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
				
			||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
					golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
				
			||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
					golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
				
			||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
					golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
				
			||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
 | 
					golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
 | 
				
			||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 | 
					golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 | 
				
			||||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
 | 
					golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
 | 
				
			||||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
 | 
					golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
 | 
				
			||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
 | 
					golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
 | 
				
			||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
 | 
					golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 | 
				
			||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 | 
					golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 | 
				
			||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 | 
					golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 | 
				
			||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
 | 
					golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
 | 
				
			||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
 | 
					golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 | 
				
			||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
					golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
				
			||||||
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
 | 
					google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
 | 
				
			||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
 | 
					google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,6 +7,7 @@ import (
 | 
				
			|||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"net/url"
 | 
						"net/url"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
 | 
						"sort"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
	"tinyauth/internal/config"
 | 
						"tinyauth/internal/config"
 | 
				
			||||||
@@ -74,6 +75,15 @@ func (app *BootstrapApp) Setup() error {
 | 
				
			|||||||
	csrfCookieName := fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
 | 
						csrfCookieName := fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId)
 | 
				
			||||||
	redirectCookieName := fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
 | 
						redirectCookieName := fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Dumps
 | 
				
			||||||
 | 
						log.Trace().Interface("config", app.config).Msg("Config dump")
 | 
				
			||||||
 | 
						log.Trace().Interface("users", users).Msg("Users dump")
 | 
				
			||||||
 | 
						log.Trace().Interface("oauthProviders", oauthProviders).Msg("OAuth providers dump")
 | 
				
			||||||
 | 
						log.Trace().Str("cookieDomain", cookieDomain).Msg("Cookie domain")
 | 
				
			||||||
 | 
						log.Trace().Str("sessionCookieName", sessionCookieName).Msg("Session cookie name")
 | 
				
			||||||
 | 
						log.Trace().Str("csrfCookieName", csrfCookieName).Msg("CSRF cookie name")
 | 
				
			||||||
 | 
						log.Trace().Str("redirectCookieName", redirectCookieName).Msg("Redirect cookie name")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create configs
 | 
						// Create configs
 | 
				
			||||||
	authConfig := service.AuthServiceConfig{
 | 
						authConfig := service.AuthServiceConfig{
 | 
				
			||||||
		Users:             users,
 | 
							Users:             users,
 | 
				
			||||||
@@ -150,18 +160,6 @@ func (app *BootstrapApp) Setup() error {
 | 
				
			|||||||
	configuredProviders := make([]controller.Provider, 0)
 | 
						configuredProviders := make([]controller.Provider, 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	for id, provider := range oauthProviders {
 | 
						for id, provider := range oauthProviders {
 | 
				
			||||||
		if id == "" {
 | 
					 | 
				
			||||||
			continue
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if provider.Name == "" {
 | 
					 | 
				
			||||||
			if name, ok := config.OverrideProviders[id]; ok {
 | 
					 | 
				
			||||||
				provider.Name = name
 | 
					 | 
				
			||||||
			} else {
 | 
					 | 
				
			||||||
				provider.Name = utils.Capitalize(id)
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		configuredProviders = append(configuredProviders, controller.Provider{
 | 
							configuredProviders = append(configuredProviders, controller.Provider{
 | 
				
			||||||
			Name:  provider.Name,
 | 
								Name:  provider.Name,
 | 
				
			||||||
			ID:    id,
 | 
								ID:    id,
 | 
				
			||||||
@@ -169,6 +167,10 @@ func (app *BootstrapApp) Setup() error {
 | 
				
			|||||||
		})
 | 
							})
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						sort.Slice(configuredProviders, func(i, j int) bool {
 | 
				
			||||||
 | 
							return configuredProviders[i].Name < configuredProviders[j].Name
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if authService.UserAuthConfigured() || ldapService != nil {
 | 
						if authService.UserAuthConfigured() || ldapService != nil {
 | 
				
			||||||
		configuredProviders = append(configuredProviders, controller.Provider{
 | 
							configuredProviders = append(configuredProviders, controller.Provider{
 | 
				
			||||||
			Name:  "Username",
 | 
								Name:  "Username",
 | 
				
			||||||
@@ -184,11 +186,8 @@ func (app *BootstrapApp) Setup() error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Create engine
 | 
						// Create engine
 | 
				
			||||||
	if config.Version != "development" {
 | 
					 | 
				
			||||||
		gin.SetMode(gin.ReleaseMode)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	engine := gin.New()
 | 
						engine := gin.New()
 | 
				
			||||||
 | 
						engine.Use(gin.Recovery())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if len(app.config.TrustedProxies) > 0 {
 | 
						if len(app.config.TrustedProxies) > 0 {
 | 
				
			||||||
		err := engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ","))
 | 
							err := engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ","))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,8 +13,8 @@ func NewHealthController(router *gin.RouterGroup) *HealthController {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (controller *HealthController) SetupRoutes() {
 | 
					func (controller *HealthController) SetupRoutes() {
 | 
				
			||||||
	controller.router.GET("/health", controller.healthHandler)
 | 
						controller.router.GET("/healthz", controller.healthHandler)
 | 
				
			||||||
	controller.router.HEAD("/health", controller.healthHandler)
 | 
						controller.router.HEAD("/healthz", controller.healthHandler)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (controller *HealthController) healthHandler(c *gin.Context) {
 | 
					func (controller *HealthController) healthHandler(c *gin.Context) {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -162,7 +162,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	var name string
 | 
						var name string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.Name != "" {
 | 
						if strings.TrimSpace(user.Name) != "" {
 | 
				
			||||||
		log.Debug().Msg("Using name from OAuth provider")
 | 
							log.Debug().Msg("Using name from OAuth provider")
 | 
				
			||||||
		name = user.Name
 | 
							name = user.Name
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
@@ -172,7 +172,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	var username string
 | 
						var username string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if user.PreferredUsername != "" {
 | 
						if strings.TrimSpace(user.PreferredUsername) != "" {
 | 
				
			||||||
		log.Debug().Msg("Using preferred username from OAuth provider")
 | 
							log.Debug().Msg("Using preferred username from OAuth provider")
 | 
				
			||||||
		username = user.PreferredUsername
 | 
							username = user.PreferredUsername
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -84,6 +84,8 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
 | 
				
			|||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Trace().Interface("labels", labels).Msg("Labels for resource")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	clientIP := c.ClientIP()
 | 
						clientIP := c.ClientIP()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if controller.auth.IsBypassedIP(labels.IP, clientIP) {
 | 
						if controller.auth.IsBypassedIP(labels.IP, clientIP) {
 | 
				
			||||||
@@ -150,6 +152,8 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
 | 
				
			|||||||
		userContext = context
 | 
							userContext = context
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Trace().Interface("context", userContext).Msg("User context from request")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if userContext.Provider == "basic" && userContext.TotpEnabled {
 | 
						if userContext.Provider == "basic" && userContext.TotpEnabled {
 | 
				
			||||||
		log.Debug().Msg("User has TOTP enabled, denying basic auth access")
 | 
							log.Debug().Msg("User has TOTP enabled, denying basic auth access")
 | 
				
			||||||
		userContext.IsLoggedIn = false
 | 
							userContext.IsLoggedIn = false
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,10 +1,12 @@
 | 
				
			|||||||
package middleware
 | 
					package middleware
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
	"io/fs"
 | 
						"io/fs"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"os"
 | 
						"os"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
	"tinyauth/internal/assets"
 | 
						"tinyauth/internal/assets"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/gin-gonic/gin"
 | 
						"github.com/gin-gonic/gin"
 | 
				
			||||||
@@ -27,14 +29,16 @@ func (m *UIMiddleware) Init() error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	m.uiFs = ui
 | 
						m.uiFs = ui
 | 
				
			||||||
	m.uiFileServer = http.FileServer(http.FS(ui))
 | 
						m.uiFileServer = http.FileServerFS(ui)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *UIMiddleware) Middleware() gin.HandlerFunc {
 | 
					func (m *UIMiddleware) Middleware() gin.HandlerFunc {
 | 
				
			||||||
	return func(c *gin.Context) {
 | 
						return func(c *gin.Context) {
 | 
				
			||||||
		switch strings.Split(c.Request.URL.Path, "/")[1] {
 | 
							path := strings.TrimPrefix(c.Request.URL.Path, "/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							switch strings.SplitN(path, "/", 2)[0] {
 | 
				
			||||||
		case "api":
 | 
							case "api":
 | 
				
			||||||
			c.Next()
 | 
								c.Next()
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
@@ -42,12 +46,19 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
 | 
				
			|||||||
			c.Next()
 | 
								c.Next()
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		default:
 | 
							default:
 | 
				
			||||||
			_, err := fs.Stat(m.uiFs, strings.TrimPrefix(c.Request.URL.Path, "/"))
 | 
								_, err := fs.Stat(m.uiFs, path)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Enough for one authentication flow
 | 
				
			||||||
 | 
								maxAge := 15 * time.Minute
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if os.IsNotExist(err) {
 | 
								if os.IsNotExist(err) {
 | 
				
			||||||
				c.Request.URL.Path = "/"
 | 
									c.Request.URL.Path = "/"
 | 
				
			||||||
 | 
								} else if strings.HasPrefix(path, "assets/") {
 | 
				
			||||||
 | 
									// assets are named with a hash and can be cached for a long time
 | 
				
			||||||
 | 
									maxAge = 30 * 24 * time.Hour
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								c.Writer.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(maxAge.Seconds())))
 | 
				
			||||||
			m.uiFileServer.ServeHTTP(c.Writer, c.Request)
 | 
								m.uiFileServer.ServeHTTP(c.Writer, c.Request)
 | 
				
			||||||
			c.Abort()
 | 
								c.Abort()
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -318,6 +318,7 @@ func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserConte
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") {
 | 
						for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") {
 | 
				
			||||||
		if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
 | 
							if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
 | 
				
			||||||
 | 
								log.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
 | 
				
			||||||
			return true
 | 
								return true
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,6 +14,7 @@ import (
 | 
				
			|||||||
type DockerService struct {
 | 
					type DockerService struct {
 | 
				
			||||||
	client      *client.Client
 | 
						client      *client.Client
 | 
				
			||||||
	context     context.Context
 | 
						context     context.Context
 | 
				
			||||||
 | 
						isConnected bool
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func NewDockerService() *DockerService {
 | 
					func NewDockerService() *DockerService {
 | 
				
			||||||
@@ -31,10 +32,24 @@ func (docker *DockerService) Init() error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	docker.client = client
 | 
						docker.client = client
 | 
				
			||||||
	docker.context = ctx
 | 
						docker.context = ctx
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						_, err = docker.client.Ping(docker.context)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Debug().Err(err).Msg("Docker not connected")
 | 
				
			||||||
 | 
							docker.isConnected = false
 | 
				
			||||||
 | 
							docker.client = nil
 | 
				
			||||||
 | 
							docker.context = nil
 | 
				
			||||||
 | 
							return nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						docker.isConnected = true
 | 
				
			||||||
 | 
						log.Debug().Msg("Docker connected")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (docker *DockerService) GetContainers() ([]container.Summary, error) {
 | 
					func (docker *DockerService) getContainers() ([]container.Summary, error) {
 | 
				
			||||||
	containers, err := docker.client.ContainerList(docker.context, container.ListOptions{})
 | 
						containers, err := docker.client.ContainerList(docker.context, container.ListOptions{})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
@@ -42,7 +57,7 @@ func (docker *DockerService) GetContainers() ([]container.Summary, error) {
 | 
				
			|||||||
	return containers, nil
 | 
						return containers, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (docker *DockerService) InspectContainer(containerId string) (container.InspectResponse, error) {
 | 
					func (docker *DockerService) inspectContainer(containerId string) (container.InspectResponse, error) {
 | 
				
			||||||
	inspect, err := docker.client.ContainerInspect(docker.context, containerId)
 | 
						inspect, err := docker.client.ContainerInspect(docker.context, containerId)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return container.InspectResponse{}, err
 | 
							return container.InspectResponse{}, err
 | 
				
			||||||
@@ -50,45 +65,36 @@ func (docker *DockerService) InspectContainer(containerId string) (container.Ins
 | 
				
			|||||||
	return inspect, nil
 | 
						return inspect, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (docker *DockerService) DockerConnected() bool {
 | 
					 | 
				
			||||||
	_, err := docker.client.Ping(docker.context)
 | 
					 | 
				
			||||||
	return err == nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
 | 
					func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
 | 
				
			||||||
	isConnected := docker.DockerConnected()
 | 
						if !docker.isConnected {
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if !isConnected {
 | 
					 | 
				
			||||||
		log.Debug().Msg("Docker not connected, returning empty labels")
 | 
							log.Debug().Msg("Docker not connected, returning empty labels")
 | 
				
			||||||
		return config.App{}, nil
 | 
							return config.App{}, nil
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	containers, err := docker.GetContainers()
 | 
						containers, err := docker.getContainers()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return config.App{}, err
 | 
							return config.App{}, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	for _, ctr := range containers {
 | 
						for _, ctr := range containers {
 | 
				
			||||||
		inspect, err := docker.InspectContainer(ctr.ID)
 | 
							inspect, err := docker.inspectContainer(ctr.ID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Warn().Str("id", ctr.ID).Err(err).Msg("Error inspecting container, skipping")
 | 
								return config.App{}, err
 | 
				
			||||||
			continue
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		labels, err := decoders.DecodeLabels(inspect.Config.Labels)
 | 
							labels, err := decoders.DecodeLabels(inspect.Config.Labels)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			log.Warn().Str("id", ctr.ID).Err(err).Msg("Error getting container labels, skipping")
 | 
								return config.App{}, err
 | 
				
			||||||
			continue
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		for appName, appLabels := range labels.Apps {
 | 
							for appName, appLabels := range labels.Apps {
 | 
				
			||||||
			if appLabels.Config.Domain == appDomain {
 | 
								if appLabels.Config.Domain == appDomain {
 | 
				
			||||||
				log.Debug().Str("id", inspect.ID).Msg("Found matching container by domain")
 | 
									log.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
 | 
				
			||||||
				return appLabels, nil
 | 
									return appLabels, nil
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			if strings.TrimPrefix(inspect.Name, "/") == appName {
 | 
								if strings.SplitN(appDomain, ".", 2)[0] == appName {
 | 
				
			||||||
				log.Debug().Str("id", inspect.ID).Msg("Found matching container by app name")
 | 
									log.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
 | 
				
			||||||
				return appLabels, nil
 | 
									return appLabels, nil
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,6 +12,7 @@ import (
 | 
				
			|||||||
	"time"
 | 
						"time"
 | 
				
			||||||
	"tinyauth/internal/config"
 | 
						"tinyauth/internal/config"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/rs/zerolog/log"
 | 
				
			||||||
	"golang.org/x/oauth2"
 | 
						"golang.org/x/oauth2"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -110,6 +111,8 @@ func (generic *GenericOAuthService) Userinfo() (config.Claims, error) {
 | 
				
			|||||||
		return user, err
 | 
							return user, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						log.Trace().Str("body", string(body)).Msg("Userinfo response body")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	err = json.Unmarshal(body, &user)
 | 
						err = json.Unmarshal(body, &user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return user, err
 | 
							return user, err
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -50,7 +50,7 @@ func (broker *OAuthBrokerService) Init() error {
 | 
				
			|||||||
			log.Error().Err(err).Msgf("Failed to initialize OAuth service: %T", name)
 | 
								log.Error().Err(err).Msgf("Failed to initialize OAuth service: %T", name)
 | 
				
			||||||
			return err
 | 
								return err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		log.Info().Str("service", service.GetName()).Msg("Initialized OAuth service")
 | 
							log.Info().Str("service", name).Msg("Initialized OAuth service")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -100,17 +100,17 @@ func IsRedirectSafe(redirectURL string, domain string) bool {
 | 
				
			|||||||
		return false
 | 
							return false
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	cookieDomain, err := GetCookieDomain(redirectURL)
 | 
						host := parsedURL.Hostname()
 | 
				
			||||||
 | 
						if host == domain {
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						cookieDomain, err := GetCookieDomain(redirectURL)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return false
 | 
							return false
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if cookieDomain != domain {
 | 
						return cookieDomain == domain
 | 
				
			||||||
		return false
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	return true
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func GetLogLevel(level string) zerolog.Level {
 | 
					func GetLogLevel(level string) zerolog.Level {
 | 
				
			||||||
@@ -184,7 +184,6 @@ func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[st
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// If we have google/github providers and no redirect URL then set a default
 | 
						// If we have google/github providers and no redirect URL then set a default
 | 
				
			||||||
 | 
					 | 
				
			||||||
	for id := range config.OverrideProviders {
 | 
						for id := range config.OverrideProviders {
 | 
				
			||||||
		if provider, exists := providers[id]; exists {
 | 
							if provider, exists := providers[id]; exists {
 | 
				
			||||||
			if provider.RedirectURL == "" {
 | 
								if provider.RedirectURL == "" {
 | 
				
			||||||
@@ -194,6 +193,18 @@ func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[st
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set names
 | 
				
			||||||
 | 
						for id, provider := range providers {
 | 
				
			||||||
 | 
							if provider.Name == "" {
 | 
				
			||||||
 | 
								if name, ok := config.OverrideProviders[id]; ok {
 | 
				
			||||||
 | 
									provider.Name = name
 | 
				
			||||||
 | 
								} else {
 | 
				
			||||||
 | 
									provider.Name = Capitalize(id)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							providers[id] = provider
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Return combined providers
 | 
						// Return combined providers
 | 
				
			||||||
	return providers, nil
 | 
						return providers, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -164,7 +164,7 @@ func TestIsRedirectSafe(t *testing.T) {
 | 
				
			|||||||
	// Case with no subdomain
 | 
						// Case with no subdomain
 | 
				
			||||||
	redirectURL := "http://example.com/welcome"
 | 
						redirectURL := "http://example.com/welcome"
 | 
				
			||||||
	result := utils.IsRedirectSafe(redirectURL, domain)
 | 
						result := utils.IsRedirectSafe(redirectURL, domain)
 | 
				
			||||||
	assert.Equal(t, false, result)
 | 
						assert.Equal(t, true, result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Case with different domain
 | 
						// Case with different domain
 | 
				
			||||||
	redirectURL = "http://malicious.com/phishing"
 | 
						redirectURL = "http://malicious.com/phishing"
 | 
				
			||||||
@@ -202,6 +202,41 @@ func TestIsRedirectSafe(t *testing.T) {
 | 
				
			|||||||
	assert.Equal(t, false, result)
 | 
						assert.Equal(t, false, result)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func TestIsRedirectSafeMultiLevel(t *testing.T) {
 | 
				
			||||||
 | 
						// Setup
 | 
				
			||||||
 | 
						cookieDomain := "tinyauth.example.com"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Case with 3rd level domain
 | 
				
			||||||
 | 
						redirectURL := "http://tinyauth.example.com/welcome"
 | 
				
			||||||
 | 
						result := utils.IsRedirectSafe(redirectURL, cookieDomain)
 | 
				
			||||||
 | 
						assert.Equal(t, true, result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Case with root domain
 | 
				
			||||||
 | 
						redirectURL = "http://example.com/unsafe"
 | 
				
			||||||
 | 
						result = utils.IsRedirectSafe(redirectURL, cookieDomain)
 | 
				
			||||||
 | 
						assert.Equal(t, false, result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Case with 4th level domain
 | 
				
			||||||
 | 
						redirectURL = "http://auth.tinyauth.example.com/post-login"
 | 
				
			||||||
 | 
						result = utils.IsRedirectSafe(redirectURL, cookieDomain)
 | 
				
			||||||
 | 
						assert.Equal(t, true, result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Case with 5th level domain (should be unsafe)
 | 
				
			||||||
 | 
						redirectURL = "http://x.auth.tinyauth.example.com/deep"
 | 
				
			||||||
 | 
						result = utils.IsRedirectSafe(redirectURL, cookieDomain)
 | 
				
			||||||
 | 
						assert.Equal(t, false, result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Case with different subdomain
 | 
				
			||||||
 | 
						redirectURL = "http://auth.tinyauth.example.net/attack"
 | 
				
			||||||
 | 
						result = utils.IsRedirectSafe(redirectURL, cookieDomain)
 | 
				
			||||||
 | 
						assert.Equal(t, false, result)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Case with malformed URL
 | 
				
			||||||
 | 
						redirectURL = "http://[::1]:namedport"
 | 
				
			||||||
 | 
						result = utils.IsRedirectSafe(redirectURL, cookieDomain)
 | 
				
			||||||
 | 
						assert.Equal(t, false, result)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func TestGetOAuthProvidersConfig(t *testing.T) {
 | 
					func TestGetOAuthProvidersConfig(t *testing.T) {
 | 
				
			||||||
	env := []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET=client1-secret"}
 | 
						env := []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET=client1-secret"}
 | 
				
			||||||
	args := []string{"/tinyauth/tinyauth", "--providers-client2-client-id=client2-id", "--providers-client2-client-secret=client2-secret"}
 | 
						args := []string{"/tinyauth/tinyauth", "--providers-client2-client-id=client2-id", "--providers-client2-client-secret=client2-secret"}
 | 
				
			||||||
@@ -210,10 +245,12 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
 | 
				
			|||||||
		"client1": {
 | 
							"client1": {
 | 
				
			||||||
			ClientID:     "client1-id",
 | 
								ClientID:     "client1-id",
 | 
				
			||||||
			ClientSecret: "client1-secret",
 | 
								ClientSecret: "client1-secret",
 | 
				
			||||||
 | 
								Name:         "Client1",
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
		"client2": {
 | 
							"client2": {
 | 
				
			||||||
			ClientID:     "client2-id",
 | 
								ClientID:     "client2-id",
 | 
				
			||||||
			ClientSecret: "client2-secret",
 | 
								ClientSecret: "client2-secret",
 | 
				
			||||||
 | 
								Name:         "Client2",
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -247,6 +284,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
 | 
				
			|||||||
		"client1": {
 | 
							"client1": {
 | 
				
			||||||
			ClientID:     "client1-id",
 | 
								ClientID:     "client1-id",
 | 
				
			||||||
			ClientSecret: "file content",
 | 
								ClientSecret: "file content",
 | 
				
			||||||
 | 
								Name:         "Client1",
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -262,6 +300,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) {
 | 
				
			|||||||
			ClientID:     "google-id",
 | 
								ClientID:     "google-id",
 | 
				
			||||||
			ClientSecret: "google-secret",
 | 
								ClientSecret: "google-secret",
 | 
				
			||||||
			RedirectURL:  "http://app.url/api/oauth/callback/google",
 | 
								RedirectURL:  "http://app.url/api/oauth/callback/google",
 | 
				
			||||||
 | 
								Name:         "Google",
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										2
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								main.go
									
									
									
									
									
								
							@@ -11,5 +11,5 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
func main() {
 | 
					func main() {
 | 
				
			||||||
	log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Caller().Logger()
 | 
						log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Caller().Logger()
 | 
				
			||||||
	cmd.Execute()
 | 
						cmd.Run()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user