Compare commits

...

29 Commits

Author SHA1 Message Date
Elias Schneider
232c13b5ca release: 0.20.0 2024-12-12 17:21:58 +01:00
Elias Schneider
9d20a98dbb feat: add support for Postgres database provider (#79) 2024-12-12 17:21:28 +01:00
Elias Schneider
e9d83dd6c3 docs: add ghcr.io Docker image to docker-compose.yml 2024-12-12 17:18:25 +01:00
Elias Schneider
3006bc9ef7 docs: add callback url to proxy-services.md 2024-12-03 20:35:47 +01:00
Elias Schneider
ae1e2f5e77 release: 0.19.0 2024-11-29 23:17:26 +01:00
soup
edce3d3371 feat(geolite): add Tailscale IP detection with CGNAT range check (#77)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2024-11-29 23:17:08 +01:00
Elias Schneider
9a8ec15678 docs: add demo link 2024-11-29 20:24:26 +01:00
Elias Schneider
62cdab2b59 release: 0.18.0 2024-11-28 12:34:15 +01:00
Elias Schneider
f2bfc73158 fix: email save toast shows two times 2024-11-28 12:28:39 +01:00
Elias Schneider
a9f4dada32 feat: allow empty user and password in SMTP configuration 2024-11-28 12:14:03 +01:00
Elias Schneider
f9fa2c6706 feat: add option to disable TLS for email sending 2024-11-28 12:13:23 +01:00
Elias Schneider
7d6b1d19e9 docs: add PUID and PGID to .env.example 2024-11-26 21:03:53 +01:00
Elias Schneider
31a6b57ec1 docs: improve MAXMIND_LICENSE_KEY documentation in readme 2024-11-26 20:45:34 +01:00
Elias Schneider
f11ed44733 release: 0.17.0 2024-11-26 20:35:54 +01:00
Elias Schneider
541481721f Merge remote-tracking branch 'origin/main' 2024-11-26 20:20:03 +01:00
Chris Danis
0e95e9c56f fix: don't try to create a new user if the Docker user is not root (#71) 2024-11-26 20:19:40 +01:00
Elias Schneider
fcf08a4d89 feat!: add option to specify the Max Mind license key for the Geolite2 db 2024-11-26 20:14:31 +01:00
Elias Schneider
0b4101ccce docs: fix OAuth2 proxy link in readme 2024-11-24 18:59:07 +01:00
Elias Schneider
27ea1fc2d3 release: 0.16.0 2024-11-24 18:55:51 +01:00
Alexander Lehmann
f637a89f57 feat: improve error message for invalid callback url 2024-11-24 18:54:46 +01:00
Elias Schneider
058084ed64 feat: add health check 2024-11-24 18:53:32 +01:00
Elias Schneider
9370292fe5 release: 0.15.0 2024-11-21 18:46:15 +01:00
Elias Schneider
46eef1fcb7 chore: make Docker image run without root user (#67) 2024-11-21 18:44:43 +01:00
Elias Schneider
e784093342 fix: mobile layout overflow on application configuration page 2024-11-21 18:41:21 +01:00
Elias Schneider
653d948f73 feat: add option to skip TLS certificate check and ability to send test email 2024-11-21 18:24:01 +01:00
Elias Schneider
a1302ef7bf refactor: move checkboxes with label in seperate component 2024-11-21 14:28:23 +01:00
Elias Schneider
5f44fef85f ci/cd: add Docker image to ghcr.io and add Docker metadata action 2024-11-21 13:11:08 +01:00
Elias Schneider
3613ac261c feat: add PKCE support 2024-11-17 17:13:38 +01:00
Elias Schneider
760c8e83bb docs: add info that PKCE isn't implemented yet 2024-11-15 11:20:28 +01:00
78 changed files with 1220 additions and 328 deletions

View File

@@ -1,2 +1,6 @@
# See the README for more information: https://github.com/stonith404/pocket-id?tab=readme-ov-file#environment-variables
PUBLIC_APP_URL=http://localhost PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000
PGID=1000

View File

@@ -1,2 +0,0 @@
APP_ENV=test
PUBLIC_APP_URL=http://localhost

View File

@@ -11,20 +11,35 @@ jobs:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
${{ github.repository }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Docker registry - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: Download GeoLite2 City database - name: 'Login to GitHub Container Registry'
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
@@ -32,6 +47,7 @@ jobs:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: stonith404/pocket-id:latest,stonith404/pocket-id:${{ github.ref_name }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

View File

@@ -5,25 +5,43 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
jobs: jobs:
build-and-test: build:
timeout-minutes: 20 timeout-minutes: 20
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export
uses: docker/build-push-action@v6
with:
tags: stonith404/pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
test-sqlite:
runs-on: ubuntu-latest
needs: build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: lts/*
cache: 'npm' cache: "npm"
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Create dummy GeoLite2 City database - name: Download Docker image artifact
run: touch ./backend/GeoLite2-City.mmdb uses: actions/download-artifact@v4
with:
- name: Build Docker Image name: docker-image
run: docker build -t stonith404/pocket-id . path: /tmp
- name: Load Docker Image
- name: Run Docker Container run: docker load -i /tmp/docker-image.tar
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend working-directory: ./frontend
@@ -33,6 +51,13 @@ jobs:
working-directory: ./frontend working-directory: ./frontend
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB
run: |
docker run -d --name pocket-id-sqlite \
-p 80:80 \
-e APP_ENV=test \
stonith404/pocket-id:test
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
@@ -40,7 +65,80 @@ jobs:
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: playwright-report name: playwright-report-sqlite
path: frontend/tests/.report
include-hidden-files: true
retention-days: 15
test-postgres:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker Image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
- name: Install Playwright Browsers
working-directory: ./frontend
run: npx playwright install --with-deps chromium
- name: Create Docker network
run: docker network create pocket-id-network
- name: Start Postgres DB
run: |
docker run -d --name pocket-id-db \
--network pocket-id-network \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=pocket-id \
-p 5432:5432 \
postgres:17
- name: Wait for Postgres to start
run: |
for i in {1..10}; do
if docker exec pocket-id-db pg_isready -U postgres; then
echo "Postgres is ready"
break
fi
echo "Waiting for Postgres..."
sleep 2
done
- name: Run Docker Container with Postgres DB
run: |
docker run -d --name pocket-id-postgres \
--network pocket-id-network \
-p 80:80 \
-e APP_ENV=test \
-e DB_PROVIDER=postgres \
-e POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
stonith404/pocket-id:test
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-postgres
path: frontend/tests/.report path: frontend/tests/.report
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15

View File

@@ -1 +1 @@
0.14.0 0.20.0

View File

@@ -1,3 +1,67 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)
### Features
* add support for Postgres database provider ([#79](https://github.com/stonith404/pocket-id/issues/79)) ([9d20a98](https://github.com/stonith404/pocket-id/commit/9d20a98dbbc322fa6f0644e8b31e6b97769887ce))
## [](https://github.com/stonith404/pocket-id/compare/v0.18.0...v) (2024-11-29)
### Features
* **geolite:** add Tailscale IP detection with CGNAT range check ([#77](https://github.com/stonith404/pocket-id/issues/77)) ([edce3d3](https://github.com/stonith404/pocket-id/commit/edce3d337129c9c6e8a60df2122745984ba0f3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.17.0...v) (2024-11-28)
### Features
* add option to disable TLS for email sending ([f9fa2c6](https://github.com/stonith404/pocket-id/commit/f9fa2c6706a8bf949fe5efd6664dec8c80e18659))
* allow empty user and password in SMTP configuration ([a9f4dad](https://github.com/stonith404/pocket-id/commit/a9f4dada321841d3611b15775307228b34e7793f))
### Bug Fixes
* email save toast shows two times ([f2bfc73](https://github.com/stonith404/pocket-id/commit/f2bfc731585ad7424eb8c4c41c18368fc0f75ffc))
## [](https://github.com/stonith404/pocket-id/compare/v0.16.0...v) (2024-11-26)
### ⚠ BREAKING CHANGES
* add option to specify the Max Mind license key for the Geolite2 db
### Features
* add option to specify the Max Mind license key for the Geolite2 db ([fcf08a4](https://github.com/stonith404/pocket-id/commit/fcf08a4d898160426442bd80830f4431988f4313))
### Bug Fixes
* don't try to create a new user if the Docker user is not root ([#71](https://github.com/stonith404/pocket-id/issues/71)) ([0e95e9c](https://github.com/stonith404/pocket-id/commit/0e95e9c56f4c3f84982f508fdb6894ba747952b4))
## [](https://github.com/stonith404/pocket-id/compare/v0.15.0...v) (2024-11-24)
### Features
* add health check ([058084e](https://github.com/stonith404/pocket-id/commit/058084ed64816b12108e25bf04af988fc97772ed))
* improve error message for invalid callback url ([f637a89](https://github.com/stonith404/pocket-id/commit/f637a89f579aefb8dc3c3c16a27ef0bc453dfe40))
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
### Features
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
### Bug Fixes
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11) ## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)

View File

@@ -21,7 +21,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
# Stage 3: Production Image # Stage 3: Production Image
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache caddy # Delete default node user
RUN deluser --remove-home node
RUN apk add --no-cache caddy curl su-exec
COPY ./reverse-proxy /etc/caddy/ COPY ./reverse-proxy /etc/caddy/
WORKDIR /app WORKDIR /app
@@ -31,7 +34,6 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/GeoLite2-City.mmdb ./backend/GeoLite2-City.mmdb
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images COPY --from=backend-builder /app/backend/images ./backend/images
@@ -41,5 +43,5 @@ RUN chmod +x ./scripts/*.sh
EXPOSE 80 EXPOSE 80
ENV APP_ENV=production ENV APP_ENV=production
# Use a shell form to run both the frontend and backend ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
CMD ["sh", "./scripts/docker-entrypoint.sh"] CMD ["sh", "./scripts/docker/entrypoint.sh"]

View File

@@ -2,6 +2,8 @@
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services. Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
→ Try out the [Demo](https://pocket-id.eliasschneider.com)
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/> <img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases. The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
@@ -11,7 +13,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
## Setup ## Setup
> [!WARNING] > [!WARNING]
> Pocket ID is in its early stages and may contain bugs. > Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
### Before you start ### Before you start
@@ -68,10 +70,6 @@ Required tools:
cd .. cd ..
pm2 start pocket-id-backend --name pocket-id-backend pm2 start pocket-id-backend --name pocket-id-backend
# Optional: Download the GeoLite2 city database.
# If not downloaded the ip location in the audit log will be empty.
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
# Start the frontend # Start the frontend
cd ../frontend cd ../frontend
npm install npm install
@@ -97,7 +95,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID ## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC. As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information. See the [guide](docs/proxy-services.md) for more information.
@@ -130,9 +128,6 @@ docker compose up -d
cd .. cd ..
pm2 start pocket-id-backend --name pocket-id-backend pm2 start pocket-id-backend --name pocket-id-backend
# Optional: Update the GeoLite2 city database
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
# Start the frontend # Start the frontend
cd ../frontend cd ../frontend
npm install npm install
@@ -146,16 +141,21 @@ docker compose up -d
## Environment variables ## Environment variables
| Variable | Default Value | Recommended to change | Description | | Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------- | ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. | | `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. | | `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. | | `MAXMIND_LICENSE_KEY` | `-` | yes | License Key for the GeoLite2 Database. The license key is required to retrieve the geographical location of IP addresses in the audit log. If the key is not provided, IP locations will be marked as "unknown." You can obtain a license key for free [here](https://www.maxmind.com/en/geolite2/signup). |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. | | `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. | | `DB_PROVIDER` | `sqlite` | no | The database provider you want to use. Currently `sqlite` and `postgres` are supported. |
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. | | `SQLITE_DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. This gets ignored if you didn't set `DB_PROVIDER` to `sqlite`. |
| `PORT` | `3000` | no | The port on which the frontend should listen. | | `POSTGRES_CONNECTION_STRING` | `-` | no | The connection string to your Postgres database. This gets ignored if you didn't set `DB_PROVIDER` to `postgres`. A connection string can look like this: `postgresql://user:password@host:5432/pocket-id`. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. | | `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
| `GEOLITE_DB_PATH` | `data/GeoLite2-City.mmdb` | no | The path where the GeoLite2 database should be stored. |
| `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
| `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
## Contribute ## Contribute

View File

@@ -1,6 +1,8 @@
APP_ENV=production APP_ENV=production
PUBLIC_APP_URL=http://localhost PUBLIC_APP_URL=http://localhost
DB_PATH=data/pocket-id.db DB_PROVIDER=sqlite
SQLITE_DB_PATH=data/pocket-id.db
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
UPLOAD_PATH=data/uploads UPLOAD_PATH=data/uploads
PORT=8080 PORT=8080
HOST=localhost HOST=localhost

View File

@@ -0,0 +1,11 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<p>This is a test email.</p>
</div>
{{ end -}}

View File

@@ -0,0 +1,3 @@
{{ define "base" -}}
This is a test email.
{{ end -}}

View File

@@ -5,7 +5,6 @@ go 1.23.1
require ( require (
github.com/caarlos0/env/v11 v11.2.2 github.com/caarlos0/env/v11 v11.2.2
github.com/fxamacker/cbor/v2 v2.7.0 github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.12.1 github.com/go-co-op/gocron/v2 v2.12.1
github.com/go-playground/validator/v10 v10.22.1 github.com/go-playground/validator/v10 v10.22.1
@@ -18,6 +17,7 @@ require (
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
golang.org/x/time v0.6.0 golang.org/x/time v0.6.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.6 gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )
@@ -36,6 +36,10 @@ require (
github.com/google/go-tpm v0.9.1 // indirect github.com/google/go-tpm v0.9.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect
@@ -43,6 +47,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.23 // indirect github.com/mattn/go-sqlite3 v1.14.23 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -57,6 +62,7 @@ require (
golang.org/x/arch v0.10.0 // indirect golang.org/x/arch v0.10.0 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
golang.org/x/net v0.29.0 // indirect golang.org/x/net v0.29.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect

View File

@@ -1,3 +1,7 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU= github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
@@ -13,18 +17,32 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw= github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w= github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -39,6 +57,8 @@ github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc= github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y= github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
@@ -55,6 +75,14 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -85,16 +113,28 @@ github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNG
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc= github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0= github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -118,6 +158,14 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -130,6 +178,8 @@ golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWB
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
@@ -146,6 +196,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=

View File

@@ -2,9 +2,13 @@ package bootstrap
import ( import (
"errors" "errors"
"fmt"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -19,15 +23,29 @@ func newDatabase() (db *gorm.DB) {
log.Fatalf("failed to connect to database: %v", err) log.Fatalf("failed to connect to database: %v", err)
} }
sqlDb, err := db.DB() sqlDb, err := db.DB()
sqlDb.SetMaxOpenConns(1)
if err != nil { if err != nil {
log.Fatalf("failed to get sql.DB: %v", err) log.Fatalf("failed to get sql.DB: %v", err)
} }
driver, err := sqlite3.WithInstance(sqlDb, &sqlite3.Config{}) // Choose the correct driver for the database provider
var driver database.Driver
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
log.Fatalf("failed to create migration driver: %v", err)
}
// Run migrations
m, err := migrate.NewWithDatabaseInstance( m, err := migrate.NewWithDatabaseInstance(
"file://migrations", "file://migrations/"+string(common.EnvConfig.DbProvider),
"postgres", driver) "pocket-id", driver,
)
if err != nil { if err != nil {
log.Fatalf("failed to create migration instance: %v", err) log.Fatalf("failed to create migration instance: %v", err)
} }
@@ -41,15 +59,20 @@ func newDatabase() (db *gorm.DB) {
} }
func connectDatabase() (db *gorm.DB, err error) { func connectDatabase() (db *gorm.DB, err error) {
dbPath := common.EnvConfig.DBPath var dialector gorm.Dialector
// Use in-memory database for testing // Choose the correct database provider
if common.EnvConfig.AppEnv == "test" { switch common.EnvConfig.DbProvider {
dbPath = "file::memory:?cache=shared" case common.DbProviderSqlite:
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
case common.DbProviderPostgres:
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
} }
for i := 1; i <= 3; i++ { for i := 1; i <= 3; i++ {
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{ db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true, TranslateError: true,
Logger: getLogger(), Logger: getLogger(),
}) })

View File

@@ -30,12 +30,13 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Initialize services // Initialize services
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath) templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
emailService, err := service.NewEmailService(appConfigService, templateDir) emailService, err := service.NewEmailService(appConfigService, db, templateDir)
if err != nil { if err != nil {
log.Fatalf("Unable to create email service: %s", err) log.Fatalf("Unable to create email service: %s", err)
} }
auditLogService := service.NewAuditLogService(db, appConfigService, emailService) geoLiteService := service.NewGeoLiteService()
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService, auditLogService) userService := service.NewUserService(db, jwtService, auditLogService)
@@ -58,7 +59,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService) controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)

View File

@@ -6,28 +6,55 @@ import (
"log" "log"
) )
type DbProvider string
const (
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
)
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"` AppURL string `env:"PUBLIC_APP_URL"`
DBPath string `env:"DB_PATH"` DbProvider DbProvider `env:"DB_PROVIDER"`
UploadPath string `env:"UPLOAD_PATH"` SqliteDBPath string `env:"SQLITE_DB_PATH"`
Port string `env:"BACKEND_PORT"` PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
Host string `env:"HOST"` UploadPath string `env:"UPLOAD_PATH"`
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"` Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
} }
var EnvConfig = &EnvConfigSchema{ var EnvConfig = &EnvConfigSchema{
AppEnv: "production", AppEnv: "production",
DBPath: "data/pocket-id.db", DbProvider: "sqlite",
UploadPath: "data/uploads", SqliteDBPath: "data/pocket-id.db",
AppURL: "http://localhost", PostgresConnectionString: "",
Port: "8080", UploadPath: "data/uploads",
Host: "localhost", AppURL: "http://localhost",
EmailTemplatesPath: "./email-templates", Port: "8080",
Host: "localhost",
EmailTemplatesPath: "./email-templates",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
} }
func init() { func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil { if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Validate the environment variables
if EnvConfig.DbProvider != DbProviderSqlite && EnvConfig.DbProvider != DbProviderPostgres {
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
}
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable")
}
} }

View File

@@ -58,7 +58,7 @@ func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{} type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL" } func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL, it might be necessary for an admin to fix this" }
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 } func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{} type FileTypeNotSupportedError struct{}
@@ -102,7 +102,7 @@ func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyR
type ClientIdOrSecretNotProvidedError struct{} type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string { func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id and secret not provided" return "Client id or secret not provided"
} }
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest } func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
@@ -146,3 +146,17 @@ func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account" return "You are not allowed to edit your account"
} }
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden } func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {
return "Invalid code verifier"
}
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingCodeChallengeError struct{}
func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }

View File

@@ -14,10 +14,13 @@ import (
func NewAppConfigController( func NewAppConfigController(
group *gin.RouterGroup, group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware, jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService) { appConfigService *service.AppConfigService,
emailService *service.EmailService,
) {
acc := &AppConfigController{ acc := &AppConfigController{
appConfigService: appConfigService, appConfigService: appConfigService,
emailService: emailService,
} }
group.GET("/application-configuration", acc.listAppConfigHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
@@ -29,10 +32,13 @@ func NewAppConfigController(
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler) group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler) group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler) group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
} }
type AppConfigController struct { type AppConfigController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
emailService *service.EmailService
} }
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
@@ -175,3 +181,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -2,7 +2,6 @@ package controller
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
@@ -80,7 +79,10 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
} }
func (oc *OidcController) createTokensHandler(c *gin.Context) { func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcIdTokenDto // Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil { if err := c.ShouldBind(&input); err != nil {
c.Error(err) c.Error(err)
@@ -91,16 +93,11 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
clientSecret := input.ClientSecret clientSecret := input.ClientSecret
// Client id and secret can also be passed over the Authorization header // Client id and secret can also be passed over the Authorization header
if clientID == "" || clientSecret == "" { if clientID == "" && clientSecret == "" {
var ok bool clientID, clientSecret, _ = c.Request.BasicAuth()
clientID, clientSecret, ok = c.Request.BasicAuth()
if !ok {
c.Error(&common.ClientIdOrSecretNotProvidedError{})
return
}
} }
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret) idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -91,9 +91,7 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return return
} }
userID := c.GetString("userID") user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -22,4 +22,6 @@ type AppConfigUpdateDto struct {
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"` SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"` SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
} }

View File

@@ -9,19 +9,23 @@ type PublicOidcClientDto struct {
type OidcClientDto struct { type OidcClientDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
CreatedBy UserDto `json:"createdBy"` CreatedBy UserDto `json:"createdBy"`
} }
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"` CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
IsPublic bool `json:"isPublic"`
} }
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"` ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"` Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
} }
type AuthorizeOidcClientResponseDto struct { type AuthorizeOidcClientResponseDto struct {
@@ -29,9 +33,10 @@ type AuthorizeOidcClientResponseDto struct {
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL"`
} }
type OidcIdTokenDto struct { type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"` GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"` Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"` ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"` ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
} }

View File

@@ -1,11 +1,8 @@
package middleware package middleware
import ( import (
"github.com/stonith404/pocket-id/backend/internal/common"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
) )
type CorsMiddleware struct{} type CorsMiddleware struct{}
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
} }
func (m *CorsMiddleware) Add() gin.HandlerFunc { func (m *CorsMiddleware) Add() gin.HandlerFunc {
return cors.New(cors.Config{ return func(c *gin.Context) {
AllowOrigins: []string{common.EnvConfig.AppURL}, // Allow all origins for the token endpoint
AllowMethods: []string{"*"}, if c.FullPath() == "/api/oidc/token" {
AllowHeaders: []string{"*"}, c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
MaxAge: 12 * time.Hour, } else {
}) c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
} }

View File

@@ -19,10 +19,12 @@ type AppConfig struct {
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable
EmailEnabled AppConfigVariable EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable SmtpHost AppConfigVariable
SmtpPort AppConfigVariable SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
} }

View File

@@ -20,10 +20,12 @@ type UserAuthorizedOidcClient struct {
type OidcAuthorizationCode struct { type OidcAuthorizationCode struct {
Base Base
Code string Code string
Scope string Scope string
Nonce string Nonce string
ExpiresAt datatype.DateTime CodeChallenge *string
CodeChallengeMethodSha256 *bool
ExpiresAt datatype.DateTime
UserID string UserID string
User User User User
@@ -39,6 +41,7 @@ type OidcClient struct {
CallbackURLs CallbackURLs CallbackURLs CallbackURLs
ImageType *string ImageType *string
HasLogo bool `gorm:"-"` HasLogo bool `gorm:"-"`
IsPublic bool
CreatedByID string CreatedByID string
CreatedBy User CreatedBy User

View File

@@ -2,6 +2,7 @@ package datatype
import ( import (
"database/sql/driver" "database/sql/driver"
"github.com/stonith404/pocket-id/backend/internal/common"
"time" "time"
) )
@@ -14,7 +15,11 @@ func (date *DateTime) Scan(value interface{}) (err error) {
} }
func (date DateTime) Value() (driver.Value, error) { func (date DateTime) Value() (driver.Value, error) {
return time.Time(date).Unix(), nil if common.EnvConfig.DbProvider == common.DbProviderSqlite {
return time.Time(date).Unix(), nil
} else {
return time.Time(date), nil
}
} }
func (date DateTime) UTC() time.Time { func (date DateTime) UTC() time.Time {

View File

@@ -33,7 +33,7 @@ func (u User) WebAuthnCredentials() []webauthn.Credential {
for i, credential := range u.Credentials { for i, credential := range u.Credentials {
credentials[i] = webauthn.Credential{ credentials[i] = webauthn.Credential{
ID: []byte(credential.CredentialID), ID: credential.CredentialID,
AttestationType: credential.AttestationType, AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey, PublicKey: credential.PublicKey,
Transport: credential.Transport, Transport: credential.Transport,
@@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors return descriptors
} }
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
type OneTimeAccessToken struct { type OneTimeAccessToken struct {
Base Base
Token string Token string

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"time" "time"
) )
@@ -12,7 +13,7 @@ type WebauthnSession struct {
Base Base
Challenge string Challenge string
ExpiresAt time.Time ExpiresAt datatype.DateTime
UserVerification string UserVerification string
} }
@@ -20,7 +21,7 @@ type WebauthnCredential struct {
Base Base
Name string Name string
CredentialID string CredentialID []byte
PublicKey []byte PublicKey []byte
AttestationType string AttestationType string
Transport AuthenticatorTransportList Transport AuthenticatorTransportList

View File

@@ -95,6 +95,16 @@ var defaultDbConfig = model.AppConfig{
Key: "smtpPassword", Key: "smtpPassword",
Type: "string", Type: "string",
}, },
SmtpTls: model.AppConfigVariable{
Key: "smtpTls",
Type: "bool",
DefaultValue: "true",
},
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
} }
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {

View File

@@ -2,28 +2,27 @@ package service
import ( import (
userAgentParser "github.com/mileusna/useragent" userAgentParser "github.com/mileusna/useragent"
"github.com/oschwald/maxminddb-golang/v2"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/utils/email" "github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" "gorm.io/gorm"
"log" "log"
"net/netip"
) )
type AuditLogService struct { type AuditLogService struct {
db *gorm.DB db *gorm.DB
appConfigService *AppConfigService appConfigService *AppConfigService
emailService *EmailService emailService *EmailService
geoliteService *GeoLiteService
} }
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService { func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService} return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
} }
// Create creates a new audit log entry in the database // Create creates a new audit log entry in the database
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog { func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
country, city, err := s.GetIpLocation(ipAddress) country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil { if err != nil {
log.Printf("Failed to get IP location: %v\n", err) log.Printf("Failed to get IP location: %v\n", err)
} }
@@ -97,29 +96,3 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent) ua := userAgentParser.Parse(userAgent)
return ua.Name + " on " + ua.OS + " " + ua.OSVersion return ua.Name + " on " + ua.OS + " " + ua.OSVersion
} }
func (s *AuditLogService) GetIpLocation(ipAddress string) (country, city string, err error) {
db, err := maxminddb.Open("GeoLite2-City.mmdb")
if err != nil {
return "", "", err
}
defer db.Close()
addr := netip.MustParseAddr(ipAddress)
var record struct {
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Country struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
}
err = db.Lookup(addr).Decode(&record)
if err != nil {
return "", "", err
}
return record.Country.Names["en"], record.City.Names["en"], nil
}

View File

@@ -2,14 +2,18 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils/email" "github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
htemplate "html/template" htemplate "html/template"
"io/fs" "io/fs"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
"net"
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
ttemplate "text/template" ttemplate "text/template"
@@ -17,11 +21,12 @@ import (
type EmailService struct { type EmailService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
db *gorm.DB
htmlTemplates map[string]*htemplate.Template htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template textTemplates map[string]*ttemplate.Template
} }
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) { func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths) htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err) return nil, fmt.Errorf("prepare html templates: %w", err)
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
return &EmailService{ return &EmailService{
appConfigService: appConfigService, appConfigService: appConfigService,
db: db,
htmlTemplates: htmlTemplates, htmlTemplates: htmlTemplates,
textTemplates: textTemplates, textTemplates: textTemplates,
}, nil }, nil
} }
func (srv *EmailService) SendTestEmail() error {
var user model.User
if err := srv.db.First(&user).Error; err != nil {
return err
}
return SendEmail(srv,
email.Address{
Email: user.Email,
Name: user.FullName(),
}, TestTemplate, nil)
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set // Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" { if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
@@ -71,26 +90,108 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
) )
c.Body(body) c.Body(body)
// Set up the authentication information. // Set up the TLS configuration
auth := smtp.PlainAuth("", tlsConfig := &tls.Config{
srv.appConfigService.DbConfig.SmtpUser.Value, InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
srv.appConfigService.DbConfig.SmtpPassword.Value, ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
srv.appConfigService.DbConfig.SmtpHost.Value,
)
// Send the email
err = smtp.SendMail(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
auth,
srv.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail.Email},
[]byte(c.String()),
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
} }
// Connect to the SMTP server
port := srv.appConfigService.DbConfig.SmtpPort.Value
smtpAddress := srv.appConfigService.DbConfig.SmtpHost.Value + ":" + port
var client *smtp.Client
if srv.appConfigService.DbConfig.SmtpTls.Value == "false" {
client, err = smtp.Dial(smtpAddress)
} else if port == "465" {
client, err = srv.connectToSmtpServerUsingImplicitTLS(
smtpAddress,
tlsConfig,
)
} else {
client, err = srv.connectToSmtpServerUsingStartTLS(
smtpAddress,
tlsConfig,
)
}
defer client.Quit()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
// Set up the authentication if user or password are set
if smtpUser != "" || smtpPassword != "" {
auth := smtp.PlainAuth("",
srv.appConfigService.DbConfig.SmtpUser.Value,
srv.appConfigService.DbConfig.SmtpPassword.Value,
srv.appConfigService.DbConfig.SmtpHost.Value,
)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate SMTP client: %w", err)
}
}
// Send the email
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
return fmt.Errorf("send email content: %w", err)
}
return nil
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, nil
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.StartTLS(tlsConfig); err != nil {
return nil, fmt.Errorf("failed to start TLS: %w", err)
}
return client, nil
}
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(toEmail.Email); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data: %w", err)
}
_, err = w.Write([]byte(c.String()))
if err != nil {
return fmt.Errorf("failed to write email data: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil return nil
} }

View File

@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
}, },
} }
var TestTemplate = email.Template[struct{}]{
Path: "test",
Title: func(data *email.TemplateData[struct{}]) string {
return "Test email"
},
}
type NewLoginTemplateData struct { type NewLoginTemplateData struct {
IPAddress string IPAddress string
Country string Country string
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
} }
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}

View File

@@ -0,0 +1,152 @@
package service
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"time"
"github.com/oschwald/maxminddb-golang/v2"
"github.com/stonith404/pocket-id/backend/internal/common"
)
type GeoLiteService struct{}
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
func NewGeoLiteService() *GeoLiteService {
service := &GeoLiteService{}
go func() {
if err := service.updateDatabase(); err != nil {
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
}
}()
return service
}
// GetLocationByIP returns the country and city of the given IP address.
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
// Check if IP is in Tailscale's CGNAT range (100.64.0.0/10)
if ip := net.ParseIP(ipAddress); ip != nil {
if ip.To4() != nil && ip.To4()[0] == 100 && ip.To4()[1] >= 64 && ip.To4()[1] <= 127 {
return "Internal Network", "Tailscale", nil
}
}
db, err := maxminddb.Open(common.EnvConfig.GeoLiteDBPath)
if err != nil {
return "", "", err
}
defer db.Close()
addr := netip.MustParseAddr(ipAddress)
var record struct {
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Country struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
}
err = db.Lookup(addr).Decode(&record)
if err != nil {
return "", "", err
}
return record.Country.Names["en"], record.City.Names["en"], nil
}
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) updateDatabase() error {
if s.isDatabaseUpToDate() {
log.Println("GeoLite2 City database is up-to-date.")
return nil
}
log.Println("Updating GeoLite2 City database...")
// Download and extract the database
downloadUrl := fmt.Sprintf(
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
common.EnvConfig.MaxMindLicenseKey,
)
// Download the database tar.gz file
resp, err := http.Get(downloadUrl)
if err != nil {
return fmt.Errorf("failed to download database: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download database, received HTTP %d", resp.StatusCode)
}
// Extract the database file directly to the target path
if err := s.extractDatabase(resp.Body); err != nil {
return fmt.Errorf("failed to extract database: %w", err)
}
log.Println("GeoLite2 City database successfully updated.")
return nil
}
// isDatabaseUpToDate checks if the database file is older than 14 days.
func (s *GeoLiteService) isDatabaseUpToDate() bool {
info, err := os.Stat(common.EnvConfig.GeoLiteDBPath)
if err != nil {
// If the file doesn't exist, treat it as not up-to-date
return false
}
return time.Since(info.ModTime()) < 14*24*time.Hour
}
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
gzr, err := gzip.NewReader(reader)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzr.Close()
tarReader := tar.NewReader(gzr)
// Iterate over the files in the tar archive
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar archive: %w", err)
}
// Check if the file is the GeoLite2-City.mmdb file
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
outFile, err := os.Create(common.EnvConfig.GeoLiteDBPath)
if err != nil {
return fmt.Errorf("failed to create target database file: %w", err)
}
defer outFile.Close()
// Write the file contents directly to the target location
if _, err := io.Copy(outFile, tarReader); err != nil {
return fmt.Errorf("failed to write database file: %w", err)
}
return nil
}
}
return errors.New("GeoLite2-City.mmdb not found in archive")
}

View File

@@ -1,6 +1,8 @@
package service package service
import ( import (
"crypto/sha256"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
@@ -39,16 +41,20 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID) s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
if userAuthorizedOIDCClient.Scope != input.Scope { if userAuthorizedOIDCClient.Scope != input.Scope {
return "", "", &common.OidcMissingAuthorizationError{} return "", "", &common.OidcMissingAuthorizationError{}
} }
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL) callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -64,7 +70,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
return "", "", err return "", "", err
} }
callbackURL, err := getCallbackURL(client, input.CallbackURL) if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -83,7 +93,7 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
} }
} }
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -93,31 +103,41 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
return code, callbackURL, nil return code, callbackURL, nil
} }
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) { func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
if grantType != "authorization_code" { if grantType != "authorization_code" {
return "", "", &common.OidcGrantTypeNotSupportedError{} return "", "", &common.OidcGrantTypeNotSupportedError{}
} }
if clientID == "" || clientSecret == "" {
return "", "", &common.OidcMissingClientCredentialsError{}
}
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return "", "", err return "", "", err
} }
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)) // Verify the client secret if the client is not public
if err != nil { if !client.IsPublic {
return "", "", &common.OidcClientSecretInvalidError{} if clientID == "" || clientSecret == "" {
return "", "", &common.OidcMissingClientCredentialsError{}
}
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
return "", "", &common.OidcClientSecretInvalidError{}
}
} }
var authorizationCodeMetaData model.OidcAuthorizationCode var authorizationCodeMetaData model.OidcAuthorizationCode
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
if err != nil { if err != nil {
return "", "", &common.OidcInvalidAuthorizationCodeError{} return "", "", &common.OidcInvalidAuthorizationCodeError{}
} }
// If the client is public, the code verifier must match the code challenge
if client.IsPublic {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", &common.OidcInvalidCodeVerifierError{}
}
}
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", &common.OidcInvalidAuthorizationCodeError{} return "", "", &common.OidcInvalidAuthorizationCodeError{}
} }
@@ -186,6 +206,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
client.Name = input.Name client.Name = input.Name
client.CallbackURLs = input.CallbackURLs client.CallbackURLs = input.CallbackURLs
client.IsPublic = input.IsPublic
if err := s.db.Save(&client).Error; err != nil { if err := s.db.Save(&client).Error; err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
@@ -331,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
profileClaims := map[string]interface{}{ profileClaims := map[string]interface{}{
"given_name": user.FirstName, "given_name": user.FirstName,
"family_name": user.LastName, "family_name": user.LastName,
"name": user.FirstName + " " + user.LastName, "name": user.FullName(),
"preferred_username": user.Username, "preferred_username": user.Username,
} }
@@ -358,19 +379,23 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
return claims, nil return claims, nil
} }
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string) (string, error) { func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32) randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil { if err != nil {
return "", err return "", err
} }
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
oidcAuthorizationCode := model.OidcAuthorizationCode{ oidcAuthorizationCode := model.OidcAuthorizationCode{
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)), ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
Code: randomString, Code: randomString,
ClientID: clientID, ClientID: clientID,
UserID: userID, UserID: userID,
Scope: scope, Scope: scope,
Nonce: nonce, Nonce: nonce,
CodeChallenge: &codeChallenge,
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
} }
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil { if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
@@ -380,7 +405,23 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
return randomString, nil return randomString, nil
} }
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) { func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge
}
// Compute SHA-256 hash of the codeVerifier
h := sha256.New()
h.Write([]byte(codeVerifier))
codeVerifierHash := h.Sum(nil)
// Base64 URL encode the verifier hash
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
return encodedVerifierHash == codeChallenge
}
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
if inputCallbackURL == "" { if inputCallbackURL == "" {
return client.CallbackURLs[0], nil return client.CallbackURLs[0], nil
} }

View File

@@ -60,7 +60,7 @@ func (s *TestService) SeedDatabase() error {
userGroups := []model.UserGroup{ userGroups := []model.UserGroup{
{ {
Base: model.Base{ Base: model.Base{
ID: "4110f814-56f1-4b28-8998-752b69bc97c0e", ID: "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
}, },
Name: "developers", Name: "developers",
FriendlyName: "Developers", FriendlyName: "Developers",
@@ -146,7 +146,7 @@ func (s *TestService) SeedDatabase() error {
webauthnCredentials := []model.WebauthnCredential{ webauthnCredentials := []model.WebauthnCredential{
{ {
Name: "Passkey 1", Name: "Passkey 1",
CredentialID: "test-credential-1", CredentialID: []byte("test-credential-1"),
PublicKey: publicKey1, PublicKey: publicKey1,
AttestationType: "none", AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal}, Transport: model.AuthenticatorTransportList{protocol.Internal},
@@ -154,7 +154,7 @@ func (s *TestService) SeedDatabase() error {
}, },
{ {
Name: "Passkey 2", Name: "Passkey 2",
CredentialID: "test-credential-2", CredentialID: []byte("test-credential-2"),
PublicKey: publicKey2, PublicKey: publicKey2,
AttestationType: "none", AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal}, Transport: model.AuthenticatorTransportList{protocol.Internal},
@@ -169,7 +169,7 @@ func (s *TestService) SeedDatabase() error {
webauthnSession := model.WebauthnSession{ webauthnSession := model.WebauthnSession{
Challenge: "challenge", Challenge: "challenge",
ExpiresAt: time.Now().Add(1 * time.Hour), ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserVerification: "preferred", UserVerification: "preferred",
} }
if err := tx.Create(&webauthnSession).Error; err != nil { if err := tx.Create(&webauthnSession).Error; err != nil {
@@ -183,13 +183,29 @@ func (s *TestService) SeedDatabase() error {
func (s *TestService) ResetDatabase() error { func (s *TestService) ResetDatabase() error {
err := s.db.Transaction(func(tx *gorm.DB) error { err := s.db.Transaction(func(tx *gorm.DB) error {
var tables []string var tables []string
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
return err switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
// Query to get all tables for SQLite
if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil {
return err
}
case common.DbProviderPostgres:
// Query to get all tables for PostgreSQL
if err := tx.Raw(`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public' AND tablename != 'schema_migrations';
`).Scan(&tables).Error; err != nil {
return err
}
default:
return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
} }
// Delete all rows from all tables // Delete all rows from all tables
for _, table := range tables { for _, table := range tables {
if err := tx.Exec("DELETE FROM " + table).Error; err != nil { if err := tx.Exec(fmt.Sprintf("DELETE FROM %s;", table)).Error; err != nil {
return err return err
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
"net/http" "net/http"
@@ -55,7 +56,7 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
} }
sessionToStore := &model.WebauthnSession{ sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires, ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge, Challenge: session.Challenge,
UserVerification: string(session.UserVerification), UserVerification: string(session.UserVerification),
} }
@@ -79,7 +80,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
session := webauthn.SessionData{ session := webauthn.SessionData{
Challenge: storedSession.Challenge, Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt, Expires: storedSession.ExpiresAt.ToTime(),
UserID: []byte(userID), UserID: []byte(userID),
} }
@@ -95,7 +96,7 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
credentialToStore := model.WebauthnCredential{ credentialToStore := model.WebauthnCredential{
Name: "New Passkey", Name: "New Passkey",
CredentialID: string(credential.ID), CredentialID: credential.ID,
AttestationType: credential.AttestationType, AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey, PublicKey: credential.PublicKey,
Transport: credential.Transport, Transport: credential.Transport,
@@ -117,7 +118,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
} }
sessionToStore := &model.WebauthnSession{ sessionToStore := &model.WebauthnSession{
ExpiresAt: session.Expires, ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge, Challenge: session.Challenge,
UserVerification: string(session.UserVerification), UserVerification: string(session.UserVerification),
} }
@@ -133,7 +134,7 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil }, nil
} }
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) { func (s *WebAuthnService) VerifyLogin(sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil { if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return model.User{}, "", err return model.User{}, "", err
@@ -141,7 +142,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
session := webauthn.SessionData{ session := webauthn.SessionData{
Challenge: storedSession.Challenge, Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt, Expires: storedSession.ExpiresAt.ToTime(),
} }
var user *model.User var user *model.User
@@ -156,10 +157,6 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
return model.User{}, "", err return model.User{}, "", err
} }
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(*user) token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil { if err != nil {
return model.User{}, "", err return model.User{}, "", err

View File

@@ -0,0 +1,126 @@
CREATE TABLE app_config_variables
(
key VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
type VARCHAR(20) NOT NULL,
is_public BOOLEAN DEFAULT FALSE NOT NULL,
is_internal BOOLEAN DEFAULT FALSE NOT NULL,
default_value TEXT
);
CREATE TABLE user_groups
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
friendly_name VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE users
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
first_name VARCHAR(100),
last_name VARCHAR(100),
is_admin BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE TABLE audit_logs
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
event VARCHAR(100) NOT NULL,
ip_address INET NOT NULL,
data JSONB NOT NULL,
user_id UUID REFERENCES users ON DELETE SET NULL,
user_agent TEXT,
country VARCHAR(100),
city VARCHAR(100)
);
CREATE TABLE custom_claims
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
user_id UUID REFERENCES users ON DELETE CASCADE,
user_group_id UUID REFERENCES user_groups ON DELETE CASCADE,
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
);
CREATE TABLE oidc_authorization_codes
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
code VARCHAR(255) NOT NULL UNIQUE,
scope TEXT NOT NULL,
nonce VARCHAR(255),
expires_at TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
client_id UUID NOT NULL,
code_challenge VARCHAR(255),
code_challenge_method_sha256 BOOLEAN
);
CREATE TABLE oidc_clients
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
name VARCHAR(255),
secret TEXT,
callback_urls JSONB,
image_type VARCHAR(10),
created_by_id UUID REFERENCES users ON DELETE SET NULL,
is_public BOOLEAN DEFAULT FALSE
);
CREATE TABLE one_time_access_tokens
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE TABLE user_authorized_oidc_clients
(
scope VARCHAR(255),
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
PRIMARY KEY (user_id, client_id)
);
CREATE TABLE user_groups_users
(
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
user_group_id UUID NOT NULL REFERENCES user_groups ON DELETE CASCADE,
PRIMARY KEY (user_id, user_group_id)
);
CREATE TABLE webauthn_credentials
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
name VARCHAR(255) NOT NULL,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
attestation_type VARCHAR(20) NOT NULL,
transport JSONB NOT NULL,
user_id UUID REFERENCES users ON DELETE CASCADE,
backup_eligible BOOLEAN DEFAULT FALSE NOT NULL,
backup_state BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE TABLE webauthn_sessions
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
challenge VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_verification VARCHAR(255) NOT NULL
);

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge;
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256;
ALTER TABLE oidc_clients DROP COLUMN is_public;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT;
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC;
ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE;

View File

@@ -1,9 +1,16 @@
services: services:
pocket-id: pocket-id:
image: stonith404/pocket-id:latest image: stonith404/pocket-id # or ghcr.io/stonith404/pocket-id
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
ports: ports:
- 3000:80 - 3000:80
volumes: volumes:
- "./data:/app/backend/data" - "./data:/app/backend/data"
# Optional healthcheck
healthcheck:
test: "curl -f http://localhost/health"
interval: 1m30s
timeout: 5s
retries: 2
start_period: 10s

View File

@@ -1,12 +1,12 @@
# Proxy Services through Pocket ID # Proxy Services through Pocket ID
The goal of Pocket ID is to stay simple. Because of that we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC. This guide will show you how to set up OAuth2 Proxy with Pocket ID. The goal of Pocket ID is to stay simple. Because of that we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/) to add authentication to your services that don't support OIDC. This guide will show you how to set up OAuth2 Proxy with Pocket ID.
## Docker Setup ## Docker Setup
#### 1. Add OAuth2 proxy to the service that should be proxied. #### 1. Add OAuth2 proxy to the service that should be proxied.
To configure OAuth2 Proxy with Pocket ID, you have to add the following service to the service that should be proxied. E.g., [Uptime Kuma](https://github.com/louislam/uptime-kuma) should be proxied, you can add the following service to the `docker-compose.yml` of Uptime Kuma: To configure OAuth2 Proxy with Pocket ID, you have to add the following service to the service that should be proxied. E.g., if [Uptime Kuma](https://github.com/louislam/uptime-kuma) should be proxied, you can add the following service to the `docker-compose.yml` of Uptime Kuma:
```yaml ```yaml
# Example with Uptime Kuma # Example with Uptime Kuma
@@ -23,7 +23,7 @@ oauth2-proxy:
#### 2. Create a new OIDC client in Pocket ID. #### 2. Create a new OIDC client in Pocket ID.
Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. After adding the client, you will obtain the client ID and client secret. Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. Now enter `https://<domain-of-proxied-service>/oauth2/callback` as the callback URL. After adding the client, you will obtain the client ID and client secret, which you will need in the next step.
#### 3. Create a configuration file for OAuth2 Proxy. #### 3. Create a configuration file for OAuth2 Proxy.

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.14.0", "version": "0.20.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000",

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { Checkbox } from './ui/checkbox';
import { Label } from './ui/label';
let {
id,
checked = $bindable(),
label,
description
}: { id: string; checked: boolean; label: string; description?: string } = $props();
</script>
<div class="items-top mt-5 flex space-x-2">
<Checkbox {id} bind:checked />
<div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm font-medium leading-none">
{label}
</Label>
{#if description}
<p class="text-muted-foreground text-[0.8rem]">
{description}
</p>
{/if}
</div>
</div>

View File

@@ -53,6 +53,10 @@ export default class AppConfigService extends APIService {
await this.api.put(`/application-configuration/background-image`, formData); await this.api.put(`/application-configuration/background-image`, formData);
} }
async sendTestEmail() {
await this.api.post('/application-configuration/test-email');
}
async getVersionInformation() { async getVersionInformation() {
const response = ( const response = (
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest') await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')

View File

@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service'; import APIService from './api-service';
class OidcService extends APIService { class OidcService extends APIService {
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) { async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
const res = await this.api.post('/oidc/authorize', { const res = await this.api.post('/oidc/authorize', {
scope, scope,
nonce, nonce,
callbackURL, callbackURL,
clientId clientId,
codeChallenge,
codeChallengeMethod
}); });
return res.data as AuthorizeResponse; return res.data as AuthorizeResponse;
} }
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) { async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
const res = await this.api.post('/oidc/authorize/new-client', { const res = await this.api.post('/oidc/authorize/new-client', {
scope, scope,
nonce, nonce,
callbackURL, callbackURL,
clientId clientId,
codeChallenge,
codeChallengeMethod
}); });
return res.data as AuthorizeResponse; return res.data as AuthorizeResponse;

View File

@@ -12,6 +12,8 @@ export type AllAppConfig = AppConfig & {
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;
smtpTls: boolean;
smtpSkipCertVerify: boolean;
}; };
export type AppConfigRawResponse = { export type AppConfigRawResponse = {

View File

@@ -4,6 +4,7 @@ export type OidcClient = {
logoURL: string; logoURL: string;
callbackURLs: [string, ...string[]]; callbackURLs: [string, ...string[]];
hasLogo: boolean; hasLogo: boolean;
isPublic: boolean;
}; };
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>; export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;

View File

@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
nonce: url.searchParams.get('nonce') || undefined, nonce: url.searchParams.get('nonce') || undefined,
state: url.searchParams.get('state')!, state: url.searchParams.get('state')!,
callbackURL: url.searchParams.get('redirect_uri')!, callbackURL: url.searchParams.get('redirect_uri')!,
client client,
codeChallenge: url.searchParams.get('code_challenge')!,
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
}; };
}; };

View File

@@ -24,7 +24,7 @@
let authorizationRequired = false; let authorizationRequired = false;
export let data: PageData; export let data: PageData;
let { scope, nonce, client, state, callbackURL } = data; let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
async function authorize() { async function authorize() {
isLoading = true; isLoading = true;
@@ -37,7 +37,7 @@
} }
await oidService await oidService
.authorize(client!.id, scope, callbackURL, nonce) .authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.then(async ({ code, callbackURL }) => { .then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });
@@ -55,7 +55,7 @@
isLoading = true; isLoading = true;
try { try {
await oidService await oidService
.authorizeNewClient(client!.id, scope, callbackURL, nonce) .authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.then(async ({ code, callbackURL }) => { .then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });

View File

@@ -0,0 +1,20 @@
import AppConfigService from '$lib/services/app-config-service';
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async () => {
const appConfigService = new AppConfigService();
let backendOk = true;
await appConfigService.list().catch(() => (backendOk = false));
return new Response(
JSON.stringify({
status: backendOk ? 'HEALTHY' : 'UNHEALTHY'
}),
{
status: backendOk ? 200 : 500,
headers: {
'content-type': 'application/json'
}
}
);
};

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -14,7 +16,9 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
let isLoading = $state(false); const appConfigService = new AppConfigService();
let isSendingTestEmail = $state(false);
let emailEnabled = $state(appConfig.emailEnabled); let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = { const updatedAppConfig = {
@@ -23,27 +27,31 @@
smtpPort: appConfig.smtpPort, smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser, smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword, smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom smtpFrom: appConfig.smtpFrom,
smtpTls: appConfig.smtpTls,
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
}; };
const formSchema = z.object({ const formSchema = z.object({
smtpHost: z.string().min(1), smtpHost: z.string().min(1),
smtpPort: z.number().min(1), smtpPort: z.number().min(1),
smtpUser: z.string().min(1), smtpUser: z.string(),
smtpPassword: z.string().min(1), smtpPassword: z.string(),
smtpFrom: z.string().email() smtpFrom: z.string().email(),
smtpTls: z.boolean(),
smtpSkipCertVerify: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() { async function onSubmit() {
console.log('submit');
const data = form.validate(); const data = form.validate();
if (!data) return false; if (!data) return false;
isLoading = true;
await callback({ await callback({
...data, ...data,
emailEnabled: true emailEnabled: true
}).finally(() => (isLoading = false)); });
toast.success('Email configuration updated successfully'); toast.success('Email configuration updated successfully');
return true; return true;
} }
@@ -59,22 +67,48 @@
emailEnabled = true; emailEnabled = true;
} }
} }
async function onTestEmail() {
isSendingTestEmail = true;
await appConfigService
.sendTestEmail()
.then(() => toast.success('Test email sent successfully to your Email address.'))
.catch(() =>
toast.error('Failed to send test email. Check the server logs for more information.')
)
.finally(() => (isSendingTestEmail = false));
}
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-2 gap-5"> <div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} /> <FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<CheckboxWithLabel
id="tls"
label="TLS"
description="Enable TLS for the SMTP connection."
bind:checked={$inputs.smtpTls.value}
/>
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div> </div>
<div class="mt-5 flex justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
{#if emailEnabled} {#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button> <Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button> <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button type="submit">Save</Button>
{:else} {:else}
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button> <Button onclick={onEnable}>Enable</Button>
{/if} {/if}
</div> </div>
</form> </form>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
@@ -51,28 +52,18 @@
description="The duration of a session in minutes before the user has to sign in again." description="The duration of a session in minutes before the user has to sign in again."
bind:input={$inputs.sessionDuration} bind:input={$inputs.sessionDuration}
/> />
<div class="items-top mt-5 flex space-x-2"> <CheckboxWithLabel
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} /> id="self-account-editing"
<div class="grid gap-1.5 leading-none"> label="Enable Self-Account Editing"
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none"> description="Whether the users should be able to edit their own account details."
Enable Self-Account Editing bind:checked={$inputs.allowOwnAccountEdit.value}
</Label> />
<p class="text-muted-foreground text-[0.8rem]"> <CheckboxWithLabel
Whether the users should be able to edit their own account details. id="emails-verified"
</p> label="Emails Verified"
</div> description="Whether the user's email should be marked as verified for the OIDC clients."
</div> bind:checked={$inputs.emailsVerified.value}
<div class="items-top mt-5 flex space-x-2"> />
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
<div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
Emails Verified
</Label>
<p class="text-muted-foreground text-[0.8rem]">
Whether the user's email should be marked as verified for the OIDC clients.
</p>
</div>
</div>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">Save</Button>

View File

@@ -26,7 +26,8 @@
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`, 'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`, 'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`, 'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json` 'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
}; };
async function updateClient(updatedClient: OidcClientCreateWithLogo) { async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -34,6 +35,8 @@
const dataPromise = oidcService.updateClient(client.id, updatedClient); const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo); const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
client.isPublic = updatedClient.isPublic;
await Promise.all([dataPromise, imagePromise]) await Promise.all([dataPromise, imagePromise])
.then(() => { .then(() => {
toast.success('OIDC client updated successfully'); toast.success('OIDC client updated successfully');
@@ -93,27 +96,29 @@
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span> <span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard> </CopyToClipboard>
</div> </div>
<div class="mb-2 mt-1 flex items-center"> {#if !client.isPublic}
<Label class="w-44">Client secret</Label> <div class="mb-2 mt-1 flex items-center">
{#if $clientSecretStore} <Label class="w-44">Client secret</Label>
<CopyToClipboard value={$clientSecretStore}> {#if $clientSecretStore}
<span class="text-muted-foreground text-sm" data-testid="client-secret"> <CopyToClipboard value={$clientSecretStore}>
{$clientSecretStore} <span class="text-muted-foreground text-sm" data-testid="client-secret">
</span> {$clientSecretStore}
</CopyToClipboard> </span>
{:else} </CopyToClipboard>
<span class="text-muted-foreground text-sm" data-testid="client-secret" {:else}
>••••••••••••••••••••••••••••••••</span <span class="text-muted-foreground text-sm" data-testid="client-secret"
> >••••••••••••••••••••••••••••••••</span
<Button >
class="ml-2" <Button
onclick={createClientSecret} class="ml-2"
size="sm" onclick={createClientSecret}
variant="ghost" size="sm"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button variant="ghost"
> aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
{/if} >
</div> {/if}
</div>
{/if}
{#if showAllDetails} {#if showAllDetails}
<div transition:slide> <div transition:slide>
{#each Object.entries(setupDetails) as [key, value]} {#each Object.entries(setupDetails) as [key, value]}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FileInput from '$lib/components/file-input.svelte'; import FileInput from '$lib/components/file-input.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -28,12 +29,14 @@
const client: OidcClientCreate = { const client: OidcClientCreate = {
name: existingClient?.name || '', name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [""] callbackURLs: existingClient?.callbackURLs || [''],
isPublic: existingClient?.isPublic || false
}; };
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
callbackURLs: z.array(z.string().url()).nonempty() callbackURLs: z.array(z.string().url()).nonempty(),
isPublic: z.boolean()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;
@@ -71,15 +74,21 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row"> <div class="grid grid-cols-2 gap-3 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} /> <FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<OidcCallbackUrlInput <OidcCallbackUrlInput
class="w-full" class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value} bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error} bind:error={$inputs.callbackURLs.error}
/> />
<CheckboxWithLabel
id="public-client"
label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
bind:checked={$inputs.isPublic.value}
/>
</div> </div>
<div class="mt-3"> <div class="mt-8">
<Label for="logo">Logo</Label> <Label for="logo">Logo</Label>
<div class="mt-2 flex items-end gap-3"> <div class="mt-2 flex items-end gap-3">
{#if logoDataURL} {#if logoDataURL}

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
@@ -70,15 +69,12 @@
<FormInput label="Username" bind:input={$inputs.username} /> <FormInput label="Username" bind:input={$inputs.username} />
</div> </div>
</div> </div>
<div class="items-top mt-5 flex space-x-2"> <CheckboxWithLabel
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} /> id="admin-privileges"
<div class="grid gap-1.5 leading-none"> label="Admin Privileges"
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none"> description="Admins have full access to the admin panel."
Admin Privileges bind:checked={$inputs.isAdmin.value}
</Label> />
<p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
</div>
</div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">Save</Button>
</div> </div>

View File

@@ -168,6 +168,8 @@ test('Update user custom claims', async ({ page }) => {
await page.getByLabel('Remove custom claim').first().click(); await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).nth(1).click(); await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('Custom claims updated successfully');
await page.reload(); await page.reload();
// Check if custom claim is removed // Check if custom claim is removed

View File

@@ -0,0 +1,31 @@
# If we aren't running as root, just exec the CMD
[ "$(id -u)" -ne 0 ] && exec "$@"
echo "Creating user and group..."
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# Check if the group with PGID exists; if not, create it
if ! getent group pocket-id-group > /dev/null 2>&1; then
addgroup -g "$PGID" pocket-id-group
fi
# Check if a user with PUID exists; if not, create it
if ! id -u pocket-id > /dev/null 2>&1; then
if ! getent passwd "$PUID" > /dev/null 2>&1; then
adduser -u "$PUID" -G pocket-id-group pocket-id
else
# If a user with the PUID already exists, use that user
existing_user=$(getent passwd "$PUID" | cut -d: -f1)
echo "Using existing user: $existing_user"
fi
fi
# Change ownership of the /app directory
mkdir -p /app/backend/data
find /app/backend/data \( ! -group "${PGID}" -o ! -user "${PUID}" \) -exec chown "${PUID}:${PGID}" {} +
# Switch to the non-root user
exec su-exec "$PUID:$PGID" "$@"

View File

@@ -8,9 +8,9 @@ echo "Starting Caddy..."
# Check if TRUST_PROXY is set to true and use the appropriate Caddyfile # Check if TRUST_PROXY is set to true and use the appropriate Caddyfile
if [ "$TRUST_PROXY" = "true" ]; then if [ "$TRUST_PROXY" = "true" ]; then
caddy start --config /etc/caddy/Caddyfile.trust-proxy & caddy start --adapter caddyfile --config /etc/caddy/Caddyfile.trust-proxy &
else else
caddy start --config /etc/caddy/Caddyfile & caddy start --adapter caddyfile --config /etc/caddy/Caddyfile &
fi fi
wait wait

View File

@@ -1,31 +0,0 @@
#!/bin/bash
# Check if the license key environment variable is set
if [ -z "$MAXMIND_LICENSE_KEY" ]; then
echo "Error: MAXMIND_LICENSE_KEY environment variable is not set."
echo "Please set it using 'export MAXMIND_LICENSE_KEY=your_license_key' and try again."
exit 1
fi
echo $MAXMIND_LICENSE_KEY
# GeoLite2 City Database URL
URL="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz"
# Download directory
DOWNLOAD_DIR="./geolite2_db"
TARGET_PATH=./backend/GeoLite2-City.mmdb
mkdir -p $DOWNLOAD_DIR
# Download the database
echo "Downloading GeoLite2 City database..."
curl -L -o "$DOWNLOAD_DIR/GeoLite2-City.tar.gz" "$URL"
# Extract the downloaded file
echo "Extracting GeoLite2 City database..."
tar -xzf "$DOWNLOAD_DIR/GeoLite2-City.tar.gz" -C $DOWNLOAD_DIR --strip-components=1
mv "$DOWNLOAD_DIR/GeoLite2-City.mmdb" $TARGET_PATH
# Clean up
rm -rf "$DOWNLOAD_DIR"
echo "GeoLite2 City database downloaded and extracted to $TARGET_PATH"