diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ac9cefe6..8920b5b0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,8 +5,7 @@ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", "features": { - "ghcr.io/devcontainers/features/go:1": {}, - "ghcr.io/devcontainers-extra/features/caddy:1": {} + "ghcr.io/devcontainers/features/go:1": {} }, "customizations": { "vscode": { diff --git a/.dockerignore b/.dockerignore index c985a409..1c70f0fb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,9 @@ node_modules /frontend/.svelte-kit /frontend/build /backend/bin +/backend/frontend/dist +/frontend/tests/.auth +/frontend/tests/.report # Env @@ -15,4 +18,5 @@ node_modules # Application specific data -/scripts/development \ No newline at end of file +/scripts/development +/backend/GeoLite2-City.mmdb \ No newline at end of file diff --git a/.env.example b/.env.example index a70bbc00..a285a5a7 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables -PUBLIC_APP_URL=http://localhost +APP_URL=http://localhost:1411 TRUST_PROXY=false MAXMIND_LICENSE_KEY= PUID=1000 diff --git a/.github/workflows/backend-linter.yml b/.github/workflows/backend-linter.yml index 6fa85cde..1ea6009d 100644 --- a/.github/workflows/backend-linter.yml +++ b/.github/workflows/backend-linter.yml @@ -35,5 +35,6 @@ jobs: uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0 with: version: v2.0.2 + args: --build-tags=exclude_frontend working-directory: backend only-new-issues: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml deleted file mode 100644 index 54b25746..00000000 --- a/.github/workflows/build-and-push-docker-image.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Build and Push Docker Image - -on: - release: - types: [published] - -jobs: - build: - runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471 - permissions: - contents: read - packages: write - steps: - - name: checkout code - uses: actions/checkout@v3 - - - name: Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ghcr.io/${{ github.repository }} - tags: | - type=semver,pattern={{version}},prefix=v - type=semver,pattern={{major}}.{{minor}},prefix=v - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - - name: 'Login to GitHub Container Registry' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{github.repository_owner}} - password: ${{secrets.GITHUB_TOKEN}} - - - name: Build and push - uses: docker/build-push-action@v4 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0d32642f..943888f8 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -56,7 +56,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 22 cache: "npm" cache-dependency-path: frontend/package-lock.json @@ -116,7 +116,7 @@ jobs: run: | docker run -d --name pocket-id-sqlite \ --network pocket-id-network \ - -p 80:80 \ + -p 1411:1411 \ -e APP_ENV=test \ pocket-id:test @@ -155,7 +155,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 22 cache: "npm" cache-dependency-path: frontend/package-lock.json @@ -253,7 +253,7 @@ jobs: run: | docker run -d --name pocket-id-postgres \ --network pocket-id-network \ - -p 80:80 \ + -p 1411:1411 \ -e APP_ENV=test \ -e DB_PROVIDER=postgres \ -e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c332a342 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +jobs: + build-docker-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}},prefix=v + type=semver,pattern={{major}}.{{minor}},prefix=v + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: "Login to GitHub Container Registry" + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{github.repository_owner}} + password: ${{secrets.GITHUB_TOKEN}} + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + build-binaries: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Build frontend + working-directory: frontend + run: npm run build + - name: Build binaries + run: sh scripts/development/build-binaries.sh + - name: Upload binaries to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload ${{ github.ref_name }} backend/.bin/* + + publish-release: + runs-on: ubuntu-latest + needs: [build-docker-image, build-binaries] + permissions: + contents: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Mark release as published + run: gh release edit ${{ github.ref_name }} --draft=false diff --git a/.github/workflows/svelte-check.yml b/.github/workflows/svelte-check.yml index e7c412f1..4caccc67 100644 --- a/.github/workflows/svelte-check.yml +++ b/.github/workflows/svelte-check.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "lts/*" + node-version: 22 cache: "npm" cache-dependency-path: frontend/package-lock.json diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a62ddb62..c5a618dd 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -29,7 +29,7 @@ jobs: working-directory: backend run: | set -e -o pipefail - go test -v ./... | tee /tmp/TestResults.log + go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log - uses: actions/upload-artifact@v4 if: always() with: diff --git a/.github/workflows/update-aaguids.yml b/.github/workflows/update-aaguids.yml index 5685758f..b816034c 100644 --- a/.github/workflows/update-aaguids.yml +++ b/.github/workflows/update-aaguids.yml @@ -25,6 +25,7 @@ jobs: run: | mkdir -p backend/resources jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json + rm data.json - name: Create Pull Request uses: peter-evans/create-pull-request@v7 diff --git a/.gitignore b/.gitignore index c57c54c2..6429e800 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ vite.config.ts.timestamp-* *.dll *.so *.dylib +/backend/.bin # Application specific data @@ -37,6 +38,7 @@ data /frontend/tests/.report pocket-id-backend /backend/GeoLite2-City.mmdb +/backend/frontend/dist # Misc .DS_Store diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 242f8d91..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - // See https://go.microsoft.com/fwlink/?LinkId=733558 - // for the documentation about the tasks.json format - "version": "2.0.0", - "tasks": [ - { - "label": "Run Caddy", - "type": "shell", - "command": "caddy run --config reverse-proxy/Caddyfile", - "isBackground": true, - "problemMatcher": { - "owner": "custom", - "pattern": [ - { - "regexp": ".", - "file": 1, - "location": 2, - "message": 3 - } - ], - "background": { - "activeOnStart": true, - "beginsPattern": ".*", - "endsPattern": "Caddyfile.*" - } - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "runOptions": { - "runOn": "folderOpen", - "instanceLimit": 1 - } - } - ] -} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb2a9fe7..964ce94d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,9 +31,11 @@ Before you submit the pull request for review please ensure that - You run `npm run format` to format the code ## Setup project + Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup: ## 1. Using DevContainers + 1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed 2. Clone and open the repo in VS Code 3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer @@ -48,8 +50,8 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. #### Setup 1. Open the `backend` folder -2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development` -3. Start the backend with `go run -tags e2etest ./cmd` +2. Copy the `.env.example` file to `.env` and edit the variables as needed +3. Start the backend with `go run -tags e2etest,exclude_frontend ./cmd` ### Frontend @@ -58,27 +60,18 @@ The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in Ty #### Setup 1. Open the `frontend` folder -2. Copy the `.env.example` file to `.env` +2. Copy the `.env.example` file to `.env` and edit the variables as needed 3. Install the dependencies with `npm install` 4. Start the frontend with `npm run dev` -### Reverse Proxy -We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself. - -#### Setup -Run `caddy run --config reverse-proxy/Caddyfile` in the root folder. - -You're all set! - -## Debugging -1. The VS Code is currently setup to auto launch caddy on opening the folder. (Defined in [tasks.json](.vscode/tasks.json)) -2. Press `F5` to start a debug session. This will launch both frontend and backend and attach debuggers to those process. (Defined in [launch.json](.vscode/launch.json)) +You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode. ### Testing We are using [Playwright](https://playwright.dev) for end-to-end testing. The tests can be run like this: + 1. Start the backend normally 2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js` 3. Run the tests with `npm run test` diff --git a/Dockerfile b/Dockerfile index 508cc1cd..fc8fe23c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,55 +1,50 @@ # Tags passed to "go build" ARG BUILD_TAGS="" -ARG VERSION="unknown" # Stage 1: Build Frontend FROM node:22-alpine AS frontend-builder -WORKDIR /app/frontend +WORKDIR /build COPY ./frontend/package*.json ./ RUN npm ci COPY ./frontend ./ -RUN npm run build -RUN npm prune --production +RUN BUILD_OUTPUT_PATH=dist npm run build # Stage 2: Build Backend FROM golang:1.24-alpine AS backend-builder ARG BUILD_TAGS -WORKDIR /app/backend +WORKDIR /build COPY ./backend/go.mod ./backend/go.sum ./ RUN go mod download -RUN apk add --no-cache gcc musl-dev - COPY ./backend ./ -WORKDIR /app/backend/cmd -RUN CGO_ENABLED=0 \ +COPY --from=frontend-builder /build/dist ./frontend/dist +COPY .version .version + +WORKDIR /build/cmd +RUN VERSION=$(cat /build/.version) \ + CGO_ENABLED=0 \ GOOS=linux \ go build \ - -tags "${BUILD_TAGS}" \ - -ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION}" \ - -o /app/backend/pocket-id-backend \ - . + -tags "${BUILD_TAGS}" \ + -ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION}" \ + -o /build/pocket-id-backend \ + . # Stage 3: Production Image -FROM node:22-alpine -# Delete default node user -RUN deluser --remove-home node - -RUN apk add --no-cache caddy curl su-exec -COPY ./reverse-proxy /etc/caddy/ - +FROM alpine WORKDIR /app -COPY --from=frontend-builder /app/frontend/build ./frontend/build -COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules -COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json -COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend +RUN apk add --no-cache curl su-exec -COPY ./scripts ./scripts -RUN find ./scripts -name "*.sh" -exec chmod +x {} \; +COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id +COPY ./scripts/docker /app/docker +COPY ./scripts/create-one-time-access-token.sh /app/ -EXPOSE 80 +RUN chmod +x /app/pocket-id /app/create-one-time-access-token.sh && \ + find /app/docker -name "*.sh" -exec chmod +x {} \; + +EXPOSE 1411 ENV APP_ENV=production -ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"] -CMD ["sh", "./scripts/docker/entrypoint.sh"] +ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"] +CMD ["/app/pocket-id"] diff --git a/backend/.env.example b/backend/.env.example index 57f383b3..09a7d04d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,10 +1,7 @@ -APP_ENV=production -PUBLIC_APP_URL=http://localhost -# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS -DB_PROVIDER=sqlite -# MAXMIND_LICENSE_KEY=fixme # needed for IP geolocation in the audit log -SQLITE_DB_PATH=data/pocket-id.db -POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id -UPLOAD_PATH=data/uploads -PORT=8080 -HOST=0.0.0.0 +# Sample .env file for development +# All environment variables can be found on https://pocket-id.org/docs/configuration/environment-variables + +APP_ENV=development +APP_URL=http://localhost:1411 +PORT=1411 +MAXMIND_LICENSE_KEY=your_license_key \ No newline at end of file diff --git a/backend/frontend/frontend_excluded.go b/backend/frontend/frontend_excluded.go new file mode 100644 index 00000000..19ab84ad --- /dev/null +++ b/backend/frontend/frontend_excluded.go @@ -0,0 +1,9 @@ +//go:build exclude_frontend + +package frontend + +import "github.com/gin-gonic/gin" + +func RegisterFrontend(router *gin.Engine) error { + return ErrFrontendNotIncluded +} diff --git a/backend/frontend/frontend_included.go b/backend/frontend/frontend_included.go new file mode 100644 index 00000000..1848c4e4 --- /dev/null +++ b/backend/frontend/frontend_included.go @@ -0,0 +1,77 @@ +//go:build !exclude_frontend + +package frontend + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +//go:embed all:dist/* +var frontendFS embed.FS + +func RegisterFrontend(router *gin.Engine) error { + distFS, err := fs.Sub(frontendFS, "dist") + if err != nil { + return fmt.Errorf("failed to create sub FS: %w", err) + } + + cacheMaxAge := time.Hour * 24 + fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds())) + + router.NoRoute(func(c *gin.Context) { + // Try to serve the requested file + path := strings.TrimPrefix(c.Request.URL.Path, "/") + if _, err := fs.Stat(distFS, path); os.IsNotExist(err) { + // File doesn't exist, serve index.html instead + c.Request.URL.Path = "/" + } + + fileServer.ServeHTTP(c.Writer, c.Request) + }) + + return nil +} + +// FileServerWithCaching wraps http.FileServer to add caching headers +type FileServerWithCaching struct { + root http.FileSystem + lastModified time.Time + cacheMaxAge int + lastModifiedHeaderValue string + cacheControlHeaderValue string +} + +func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching { + return &FileServerWithCaching{ + root: root, + lastModified: time.Now(), + cacheMaxAge: maxAge, + lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat), + cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge), + } +} + +func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Check if the client has a cached version + if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" { + ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince) + if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) { + // Client's cached version is up to date + w.WriteHeader(http.StatusNotModified) + return + } + } + + w.Header().Set("Last-Modified", f.lastModifiedHeaderValue) + w.Header().Set("Cache-Control", f.cacheControlHeaderValue) + + http.FileServer(f.root).ServeHTTP(w, r) +} diff --git a/backend/frontend/shared.go b/backend/frontend/shared.go new file mode 100644 index 00000000..d3ebfc1b --- /dev/null +++ b/backend/frontend/shared.go @@ -0,0 +1,5 @@ +package frontend + +import "errors" + +var ErrFrontendNotIncluded = errors.New("frontend is not included") diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 6fca8caa..4993079c 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -2,12 +2,15 @@ package bootstrap import ( "context" + "errors" "fmt" "log" "net" "net/http" "time" + "github.com/pocket-id/pocket-id/backend/frontend" + "github.com/gin-gonic/gin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "golang.org/x/time/rate" @@ -45,6 +48,10 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { r := gin.Default() r.Use(gin.Logger()) + if !common.EnvConfig.TrustProxy { + _ = r.SetTrustedProxies(nil) + } + if common.EnvConfig.TracingEnabled { r.Use(otelgin.Middleware("pocket-id-backend")) } @@ -55,6 +62,13 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add()) + err := frontend.RegisterFrontend(r) + if errors.Is(err, frontend.ErrFrontendNotIncluded) { + log.Println("Frontend is not included in the build. Skipping frontend registration.") + } else if err != nil { + return nil, fmt.Errorf("failed to register frontend: %w", err) + } + // Initialize middleware for specific routes authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index f9c395e2..9e52cbf1 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -25,19 +25,20 @@ const ( type EnvConfigSchema struct { AppEnv string `env:"APP_ENV"` - AppURL string `env:"PUBLIC_APP_URL"` + AppURL string `env:"APP_URL"` DbProvider DbProvider `env:"DB_PROVIDER"` DbConnectionString string `env:"DB_CONNECTION_STRING"` UploadPath string `env:"UPLOAD_PATH"` KeysPath string `env:"KEYS_PATH"` - Port string `env:"BACKEND_PORT"` + Port string `env:"PORT"` Host string `env:"HOST"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` - UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"` + UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` MetricsEnabled bool `env:"METRICS_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"` + TrustProxy bool `env:"TRUST_PROXY"` } var EnvConfig = &EnvConfigSchema{ @@ -46,8 +47,8 @@ var EnvConfig = &EnvConfigSchema{ DbConnectionString: "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate", UploadPath: "data/uploads", KeysPath: "data/keys", - AppURL: "http://localhost", - Port: "8080", + AppURL: "http://localhost:1411", + Port: "1411", Host: "0.0.0.0", MaxMindLicenseKey: "", GeoLiteDBPath: "data/GeoLite2-City.mmdb", @@ -55,6 +56,7 @@ var EnvConfig = &EnvConfigSchema{ UiConfigDisabled: false, MetricsEnabled: false, TracingEnabled: false, + TrustProxy: false, } func init() { @@ -78,9 +80,9 @@ func init() { parsedAppUrl, err := url.Parse(EnvConfig.AppURL) if err != nil { - log.Fatal("PUBLIC_APP_URL is not a valid URL") + log.Fatal("APP_URL is not a valid URL") } if parsedAppUrl.Path != "" { - log.Fatal("PUBLIC_APP_URL must not contain a path") + log.Fatal("APP_URL must not contain a path") } } diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index 7d6ca5f2..9b79ef19 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -68,6 +68,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { return } + // Manually add uiConfigDisabled which isn't in the database but defined with an environment variable + configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{ + Key: "uiConfigDisabled", + Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled), + Type: "boolean", + }) + c.JSON(http.StatusOK, configVariablesDto) } diff --git a/docker-compose.yml b/docker-compose.yml index cdc896d8..f48d7943 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,12 +4,12 @@ services: restart: unless-stopped env_file: .env ports: - - 3000:80 + - 1411:1411 volumes: - - "./data:/app/backend/data" + - "./data:/app/data" # Optional healthcheck healthcheck: - test: "curl -f http://localhost/healthz" + test: "curl -f http://localhost:1411/healthz" interval: 1m30s timeout: 5s retries: 2 diff --git a/frontend/.env.example b/frontend/.env.example index a6586fe0..1b670cba 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,2 @@ -PUBLIC_APP_URL=http://localhost -# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS -INTERNAL_BACKEND_URL=http://localhost:8080 +# If the backend in your development environment is running on a different port, change the value of the variable below. +DEVELOPMENT_BACKEND_URL=http://localhost:1411 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6a0f5084..eca2b104 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pocket-id-frontend", - "version": "0.51.0", + "version": "0.53.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pocket-id-frontend", - "version": "0.51.0", + "version": "0.53.0", "dependencies": { "@simplewebauthn/browser": "^13.1.0", "@tailwindcss/vite": "^4.0.0", @@ -30,8 +30,7 @@ "@inlang/plugin-message-format": "^4.0.0", "@internationalized/date": "^3.7.0", "@playwright/test": "^1.50.0", - "@sveltejs/adapter-auto": "^4.0.0", - "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.20.7", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/eslint": "^9.6.1", @@ -1019,98 +1018,6 @@ "node": ">=18.16.0" } }, - "node_modules/@rollup/plugin-commonjs": { - "version": "28.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.2.tgz", - "integrity": "sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.0.tgz", - "integrity": "sha512-0FPvAeVUT/zdWoO0jnb/V5BlBsUSNfkIOtFHzMO4H9MOklrmQFY6FduVHKucNb/aTFxvnGhj4MNj/T1oNdDfNg==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.40.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", @@ -1413,33 +1320,16 @@ "sqlite-wasm": "bin/index.js" } }, - "node_modules/@sveltejs/adapter-auto": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz", - "integrity": "sha512-kmuYSQdD2AwThymQF0haQhM8rE5rhutQXG4LNbnbShwhMO4qQGnKaaTy+88DuNSuoQDi58+thpq8XpHc1+oEKQ==", + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz", + "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==", "dev": true, - "dependencies": { - "import-meta-resolve": "^4.1.0" - }, + "license": "MIT", "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, - "node_modules/@sveltejs/adapter-node": { - "version": "5.2.12", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.12.tgz", - "integrity": "sha512-0bp4Yb3jKIEcZWVcJC/L1xXp9zzJS4hDwfb4VITAkfT4OVdkspSHsx7YhqJDbb2hgLl6R9Vs7VQR+fqIVOxPUQ==", - "dev": true, - "dependencies": { - "@rollup/plugin-commonjs": "^28.0.1", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.0", - "rollup": "^4.9.5" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.4.0" - } - }, "node_modules/@sveltejs/kit": { "version": "2.20.7", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.7.tgz", @@ -1770,12 +1660,6 @@ "@types/node": "*" } }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true - }, "node_modules/@types/validator": { "version": "13.12.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", @@ -2410,12 +2294,6 @@ "node": ">= 6" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2910,12 +2788,6 @@ "node": ">=4.0" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3154,15 +3026,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3226,18 +3089,6 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/human-id": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.1.tgz", @@ -3291,21 +3142,6 @@ "node": ">=0.8.19" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3336,12 +3172,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3351,15 +3181,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3997,12 +3818,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4424,26 +4239,6 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4691,18 +4486,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/svelte": { "version": "5.19.3", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 34a39d68..e73bf732 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,8 +35,7 @@ "@inlang/plugin-message-format": "^4.0.0", "@internationalized/date": "^3.7.0", "@playwright/test": "^1.50.0", - "@sveltejs/adapter-auto": "^4.0.0", - "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.20.7", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/eslint": "^9.6.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index fc194354..024e4a3d 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ ? [['html', { outputFolder: 'tests/.report' }], ['github']] : [['line'], ['html', { open: 'never', outputFolder: 'tests/.report' }]], use: { - baseURL: 'http://localhost', + baseURL: process.env.APP_URL ?? 'http://localhost:1411', video: 'retain-on-failure', trace: 'on-first-retry' }, diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts deleted file mode 100644 index 67c83d0a..00000000 --- a/frontend/src/hooks.server.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { env } from '$env/dynamic/private'; -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; -import { paraglideMiddleware } from '$lib/paraglide/server'; -import type { Handle, HandleServerError } from '@sveltejs/kit'; -import { sequence } from '@sveltejs/kit/hooks'; -import { AxiosError } from 'axios'; -import { decodeJwt } from 'jose'; - -// Workaround so that we can also import this environment variable into client-side code -// If we would directly import $env/dynamic/private into the api-service.ts file, it would throw an error -// this is still secure as process will just be undefined in the browser -process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost:8080'; - -// Handle to use the paraglide middleware -const paraglideHandle: Handle = ({ event, resolve }) => { - return paraglideMiddleware(event.request, ({ locale }) => { - return resolve(event, { - transformPageChunk: ({ html }) => html.replace('%lang%', locale) - }); - }); -}; - -const authenticationHandle: Handle = async ({ event, resolve }) => { - const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME)); - - const path = event.url.pathname; - const isUnauthenticatedOnlyPath = - path == '/login' || path.startsWith('/login/') || path == '/lc' || path.startsWith('/lc/'); - const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path); - const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/'); - - if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) { - return new Response(null, { - status: 303, - headers: { location: '/login' } - }); - } - - if (isUnauthenticatedOnlyPath && isSignedIn) { - return new Response(null, { - status: 303, - headers: { location: '/settings' } - }); - } - - if (isAdminPath && !isAdmin) { - return new Response(null, { - status: 303, - headers: { location: '/settings' } - }); - } - - return resolve(event); -}; - -export const handle: Handle = sequence(paraglideHandle, authenticationHandle); - -export const handleError: HandleServerError = async ({ error, message, status }) => { - if (error instanceof AxiosError) { - message = error.response?.data.error || message; - status = error.response?.status || status; - console.error( - `Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}` - ); - } else { - console.error(error); - } - - return { - message, - status - }; -}; - -function verifyJwt(accessToken: string | undefined) { - let isSignedIn = false; - let isAdmin = false; - - if (accessToken) { - const jwtPayload = decodeJwt<{ isAdmin: boolean }>(accessToken); - if (jwtPayload?.exp && jwtPayload.exp * 1000 > Date.now()) { - isSignedIn = true; - isAdmin = !!jwtPayload?.isAdmin; - } - } - - return { isSignedIn, isAdmin }; -} diff --git a/frontend/src/hooks.ts b/frontend/src/hooks.ts new file mode 100644 index 00000000..0aaa5edf --- /dev/null +++ b/frontend/src/hooks.ts @@ -0,0 +1,31 @@ +import { paraglideMiddleware } from '$lib/paraglide/server'; +import type { Handle, HandleServerError } from '@sveltejs/kit'; +import { AxiosError } from 'axios'; + +// Handle to use the paraglide middleware +const paraglideHandle: Handle = ({ event, resolve }) => { + return paraglideMiddleware(event.request, ({ locale }) => { + return resolve(event, { + transformPageChunk: ({ html }) => html.replace('%lang%', locale) + }); + }); +}; + +export const handle: Handle = paraglideHandle; + +export const handleError: HandleServerError = async ({ error, message, status }) => { + if (error instanceof AxiosError) { + message = error.response?.data.error || message; + status = error.response?.status || status; + console.error( + `Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}` + ); + } else { + console.error(error); + } + + return { + message, + status + }; +}; diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts deleted file mode 100644 index 5e019b9d..00000000 --- a/frontend/src/lib/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const HTTPS_ENABLED = process.env.PUBLIC_APP_URL?.startsWith('https://') ?? false; -export const ACCESS_TOKEN_COOKIE_NAME = HTTPS_ENABLED ? '__Host-access_token' : 'access_token'; diff --git a/frontend/src/lib/services/api-service.ts b/frontend/src/lib/services/api-service.ts index f9cd49a7..58d9bc0a 100644 --- a/frontend/src/lib/services/api-service.ts +++ b/frontend/src/lib/services/api-service.ts @@ -1,19 +1,13 @@ -import { browser } from '$app/environment'; import axios from 'axios'; abstract class APIService { api = axios.create({ - withCredentials: true + baseURL: '/api' }); - constructor(accessToken?: string) { - if (accessToken) { - this.api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; - } - if (browser) { - this.api.defaults.baseURL = '/api'; - } else { - this.api.defaults.baseURL = process!.env!.INTERNAL_BACKEND_URL + '/api'; + constructor() { + if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) { + this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL; } } } diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index 66387872..eab50d96 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -1,6 +1,4 @@ -import { version as currentVersion } from '$app/environment'; import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; -import axios from 'axios'; import APIService from './api-service'; export default class AppConfigService extends APIService { @@ -55,28 +53,6 @@ export default class AppConfigService extends APIService { await this.api.post('/application-configuration/sync-ldap'); } - async getVersionInformation() { - const response = await axios - .get('https://api.github.com/repos/pocket-id/pocket-id/releases/latest', { - timeout: 2000 - }) - .then((res) => res.data) - .catch(() => null); - - let newestVersion: string | undefined; - let isUpToDate: boolean | undefined; - if (response) { - newestVersion = response.tag_name.replace('v', ''); - isUpToDate = newestVersion === currentVersion; - } - - return { - isUpToDate, - newestVersion, - currentVersion - }; - } - private parseConfigList(data: AppConfigRawResponse) { const appConfig: Partial = {}; data.forEach(({ key, value }) => { diff --git a/frontend/src/lib/services/version-service.ts b/frontend/src/lib/services/version-service.ts new file mode 100644 index 00000000..f9c0c131 --- /dev/null +++ b/frontend/src/lib/services/version-service.ts @@ -0,0 +1,109 @@ +import { version as currentVersion } from '$app/environment'; +import axios from 'axios'; + +const VERSION_CACHE_KEY = 'version_cache'; +const CACHE_DURATION = 2 * 60 * 60 * 1000; // 2 hours + +async function getNewestVersion() { + const cachedData = await getVersionFromCache(); + + // If we have valid cached data, return it + if (cachedData) { + return cachedData; + } + + // Otherwise fetch from API + try { + const response = await axios + .get('https://api.github.com/repos/pocket-id/pocket-id/releases/latest', { + timeout: 2000 + }) + .then((res) => res.data); + console.log('Fetched newest version:', response); + const newestVersion = response.tag_name.replace('v', ''); + + // Cache the result + cacheVersion(newestVersion); + + return newestVersion; + } catch (error) { + console.error('Failed to fetch newest version:', error); + // If fetch fails but we have an expired cache, return that as fallback + const cache = getCacheObject(); + return cache?.newestVersion || currentVersion; + } +} + +function getCurrentVersion() { + return currentVersion; +} + +async function isUpToDate() { + const newestVersion = await getNewestVersion(); + const currentVersion = getCurrentVersion(); + + // If the current version changed, invalidate the cache + const cache = getCacheObject(); + if (cache?.lastCurrentVersion && currentVersion !== cache.lastCurrentVersion) { + invalidateCache(); + } + + return newestVersion === currentVersion; +} + +// Helper methods for caching +function getCacheObject() { + const cacheJson = localStorage.getItem(VERSION_CACHE_KEY); + if (!cacheJson) return null; + + try { + return JSON.parse(cacheJson); + } catch (e) { + console.error('Failed to parse cache:', e); + return null; + } +} + +async function getVersionFromCache() { + const cache = getCacheObject(); + + if (!cache || !cache.newestVersion || !cache.timestamp) { + return null; + } + + const now = Date.now(); + + // Check if cache is still valid + if (now - cache.timestamp > CACHE_DURATION) { + invalidateCache(); + return null; + } + + // Check if current version matches what it was when we cached + if (cache.lastCurrentVersion && cache.lastCurrentVersion !== currentVersion) { + invalidateCache(); + return null; + } + + return cache.newestVersion; +} + +async function cacheVersion(version :string) { + const cacheObject = { + newestVersion: version, + timestamp: Date.now(), + lastCurrentVersion: currentVersion + }; + + localStorage.setItem(VERSION_CACHE_KEY, JSON.stringify(cacheObject)); +} + +async function invalidateCache() { + localStorage.removeItem(VERSION_CACHE_KEY); +} + +export default { + getNewestVersion, + getCurrentVersion, + isUpToDate +}; diff --git a/frontend/src/lib/types/application-configuration.ts b/frontend/src/lib/types/application-configuration.ts index 7855ac8c..cc2fc699 100644 --- a/frontend/src/lib/types/application-configuration.ts +++ b/frontend/src/lib/types/application-configuration.ts @@ -5,6 +5,7 @@ export type AppConfig = { emailOneTimeAccessAsAdminEnabled: boolean; ldapEnabled: boolean; disableAnimations: boolean; + uiConfigDisabled: boolean; }; export type AllAppConfig = AppConfig & { @@ -49,7 +50,7 @@ export type AppConfigRawResponse = { }[]; export type AppVersionInformation = { - isUpToDate?: boolean; - newestVersion?: string; + isUpToDate: boolean | null; + newestVersion: string | null; currentVersion: string; }; diff --git a/frontend/src/lib/utils/profile-picture-util.ts b/frontend/src/lib/utils/profile-picture-util.ts index ef36f7f5..a53db104 100644 --- a/frontend/src/lib/utils/profile-picture-util.ts +++ b/frontend/src/lib/utils/profile-picture-util.ts @@ -1,5 +1,3 @@ -import { browser } from '$app/environment'; - type SkipCacheUntil = { [key: string]: number; }; @@ -9,14 +7,12 @@ export function getProfilePictureUrl(userId?: string) { let url = `/api/users/${userId}/profile-picture.png`; - if (browser) { - const skipCacheUntil = getSkipCacheUntil(userId); - const skipCache = skipCacheUntil > Date.now(); - if (skipCache) { - const skipCacheParam = new URLSearchParams(); - skipCacheParam.append('skip-cache', skipCacheUntil.toString()); - url += '?' + skipCacheParam.toString(); - } + const skipCacheUntil = getSkipCacheUntil(userId); + const skipCache = skipCacheUntil > Date.now(); + if (skipCache) { + const skipCacheParam = new URLSearchParams(); + skipCacheParam.append('skip-cache', skipCacheUntil.toString()); + url += '?' + skipCacheParam.toString(); } return url.toString(); diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts deleted file mode 100644 index ee6bd312..00000000 --- a/frontend/src/routes/+layout.server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; -import AppConfigService from '$lib/services/app-config-service'; -import UserService from '$lib/services/user-service'; -import type { LayoutServerLoad } from './$types'; - -export const load: LayoutServerLoad = async ({ cookies }) => { - const accessToken = cookies.get(ACCESS_TOKEN_COOKIE_NAME); - const userService = new UserService(accessToken); - const appConfigService = new AppConfigService(accessToken); - - const userPromise = userService.getCurrent().catch(() => null); - - const appConfigPromise = appConfigService.list().catch((e) => { - console.error( - `Failed to get application configuration: ${e.response?.data.error || e.message}` - ); - return null; - }); - - const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]); - - return { - user, - appConfig - }; -}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c7ae447c..25999ab3 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,5 +1,4 @@
-
+

{m.smtp_configuration()}

@@ -160,6 +159,6 @@ - +
diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte index 9d4921bb..63314b03 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-general-form.svelte @@ -1,9 +1,9 @@
-
+
- import { env } from '$env/dynamic/public'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte'; import FormInput from '$lib/components/form/form-input.svelte'; import { Button } from '$lib/components/ui/button'; import { m } from '$lib/paraglide/messages'; import AppConfigService from '$lib/services/app-config-service'; + import appConfigStore from '$lib/stores/application-configuration-store'; import type { AllAppConfig } from '$lib/types/application-configuration'; import { axiosErrorToast } from '$lib/utils/error-util'; import { createForm } from '$lib/utils/form-util'; @@ -20,7 +20,6 @@ } = $props(); const appConfigService = new AppConfigService(); - const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true'; let ldapEnabled = $state(appConfig.ldapEnabled); let ldapSyncing = $state(false); @@ -106,7 +105,7 @@

{m.client_configuration()}

-
+
{#if ldapEnabled} - - + {:else} - + {/if}
diff --git a/frontend/src/routes/settings/admin/oidc-clients/+page.server.ts b/frontend/src/routes/settings/admin/oidc-clients/+page.ts similarity index 60% rename from frontend/src/routes/settings/admin/oidc-clients/+page.server.ts rename to frontend/src/routes/settings/admin/oidc-clients/+page.ts index 1b2fd774..9626009e 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/+page.server.ts +++ b/frontend/src/routes/settings/admin/oidc-clients/+page.ts @@ -1,10 +1,9 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import OIDCService from '$lib/services/oidc-service'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; -import type { PageServerLoad } from './$types'; +import type { PageLoad } from './$types'; -export const load: PageServerLoad = async ({ cookies }) => { - const oidcService = new OIDCService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); +export const load: PageLoad = async () => { + const oidcService = new OIDCService(); const clientsRequestOptions: SearchPaginationSortRequest = { sort: { diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.server.ts b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.server.ts deleted file mode 100644 index 065ebfc2..00000000 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; -import OidcService from '$lib/services/oidc-service'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, cookies }) => { - const oidcService = new OidcService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); - return await oidcService.getClient(params.id); -}; diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.ts b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.ts new file mode 100644 index 00000000..0e035b5b --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.ts @@ -0,0 +1,7 @@ +import OidcService from '$lib/services/oidc-service'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + const oidcService = new OidcService(); + return await oidcService.getClient(params.id); +}; diff --git a/frontend/src/routes/settings/admin/user-groups/+page.server.ts b/frontend/src/routes/settings/admin/user-groups/+page.ts similarity index 61% rename from frontend/src/routes/settings/admin/user-groups/+page.server.ts rename to frontend/src/routes/settings/admin/user-groups/+page.ts index 80c3d25d..7be7a28b 100644 --- a/frontend/src/routes/settings/admin/user-groups/+page.server.ts +++ b/frontend/src/routes/settings/admin/user-groups/+page.ts @@ -1,10 +1,9 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import UserGroupService from '$lib/services/user-group-service'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; -import type { PageServerLoad } from './$types'; +import type { PageLoad } from './$types'; -export const load: PageServerLoad = async ({ cookies }) => { - const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); +export const load: PageLoad = async () => { + const userGroupService = new UserGroupService(); const userGroupsRequestOptions: SearchPaginationSortRequest = { sort: { diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.server.ts b/frontend/src/routes/settings/admin/user-groups/[id]/+page.server.ts deleted file mode 100644 index 00938c96..00000000 --- a/frontend/src/routes/settings/admin/user-groups/[id]/+page.server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; -import UserGroupService from '$lib/services/user-group-service'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, cookies }) => { - const userGroupService = new UserGroupService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); - const userGroup = await userGroupService.get(params.id); - - return { userGroup }; -}; diff --git a/frontend/src/routes/settings/admin/user-groups/[id]/+page.ts b/frontend/src/routes/settings/admin/user-groups/[id]/+page.ts new file mode 100644 index 00000000..6c273f88 --- /dev/null +++ b/frontend/src/routes/settings/admin/user-groups/[id]/+page.ts @@ -0,0 +1,9 @@ +import UserGroupService from '$lib/services/user-group-service'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + const userGroupService = new UserGroupService(); + const userGroup = await userGroupService.get(params.id); + + return { userGroup }; +}; diff --git a/frontend/src/routes/settings/admin/users/+page.server.ts b/frontend/src/routes/settings/admin/users/+page.ts similarity index 59% rename from frontend/src/routes/settings/admin/users/+page.server.ts rename to frontend/src/routes/settings/admin/users/+page.ts index 59e6ad46..84ade516 100644 --- a/frontend/src/routes/settings/admin/users/+page.server.ts +++ b/frontend/src/routes/settings/admin/users/+page.ts @@ -1,10 +1,9 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import UserService from '$lib/services/user-service'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; -import type { PageServerLoad } from './$types'; +import type { PageLoad } from './$types'; -export const load: PageServerLoad = async ({ cookies }) => { - const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); +export const load: PageLoad = async () => { + const userService = new UserService(); const usersRequestOptions: SearchPaginationSortRequest = { sort: { diff --git a/frontend/src/routes/settings/admin/users/[id]/+page.server.ts b/frontend/src/routes/settings/admin/users/[id]/+page.server.ts deleted file mode 100644 index ab80a1b2..00000000 --- a/frontend/src/routes/settings/admin/users/[id]/+page.server.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; -import UserService from '$lib/services/user-service'; -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ params, cookies }) => { - const userService = new UserService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); - const user = await userService.get(params.id); - - return { - user - }; -}; diff --git a/frontend/src/routes/settings/admin/users/[id]/+page.ts b/frontend/src/routes/settings/admin/users/[id]/+page.ts new file mode 100644 index 00000000..7baf9c4f --- /dev/null +++ b/frontend/src/routes/settings/admin/users/[id]/+page.ts @@ -0,0 +1,11 @@ +import UserService from '$lib/services/user-service'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + const userService = new UserService(); + const user = await userService.get(params.id); + + return { + user + }; +}; diff --git a/frontend/src/routes/settings/audit-log/+page.server.ts b/frontend/src/routes/settings/audit-log/+page.ts similarity index 60% rename from frontend/src/routes/settings/audit-log/+page.server.ts rename to frontend/src/routes/settings/audit-log/+page.ts index a4ae7662..17aca050 100644 --- a/frontend/src/routes/settings/audit-log/+page.server.ts +++ b/frontend/src/routes/settings/audit-log/+page.ts @@ -1,10 +1,9 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import AuditLogService from '$lib/services/audit-log-service'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; -import type { PageServerLoad } from './$types'; +import type { PageLoad } from './$types'; -export const load: PageServerLoad = async ({ cookies }) => { - const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); +export const load: PageLoad = async () => { + const auditLogService = new AuditLogService(); const auditLogsRequestOptions: SearchPaginationSortRequest = { sort: { column: 'createdAt', diff --git a/frontend/src/routes/settings/audit-log/global/+page.server.ts b/frontend/src/routes/settings/audit-log/global/+page.ts similarity index 60% rename from frontend/src/routes/settings/audit-log/global/+page.server.ts rename to frontend/src/routes/settings/audit-log/global/+page.ts index fc26b341..955c4e88 100644 --- a/frontend/src/routes/settings/audit-log/global/+page.server.ts +++ b/frontend/src/routes/settings/audit-log/global/+page.ts @@ -1,10 +1,9 @@ -import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import AuditLogService from '$lib/services/audit-log-service'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; -import type { PageServerLoad } from './$types'; +import type { PageLoad } from './$types'; -export const load: PageServerLoad = async ({ cookies }) => { - const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); +export const load: PageLoad = async () => { + const auditLogService = new AuditLogService(); const requestOptions: SearchPaginationSortRequest = { sort: { diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index eb463e83..50c81410 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-node'; +import adapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import packageJson from './package.json' with { type: 'json' }; @@ -12,11 +12,14 @@ const config = { // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // If your environment is not supported, or you settled on a specific environment, switch out the adapter. // See https://kit.svelte.dev/docs/adapters for more information about adapters. - adapter: adapter(), + adapter: adapter({ + fallback: "index.html", + pages: process.env.BUILD_OUTPUT_PATH ?? "../backend/frontend/dist", + }), version: { name: packageJson.version - } - } + }, + }, }; export default config; diff --git a/frontend/tests/data.ts b/frontend/tests/data.ts index 31a3631f..8ffa4f36 100644 --- a/frontend/tests/data.ts +++ b/frontend/tests/data.ts @@ -84,33 +84,3 @@ export const refreshTokens = [ expired: true } ]; - -export const idTokens = [ - { - token: - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6ImlkLXRva2VuIn0.noxQ-sCNHh7f8EaySJT7oF0DlmjYcM-FdMPH45Yuuvt5-bTpLLkggN9aq8RILmkGL9xUVsfZbYkWV5EkGobxfIoXITE98xH54BQwtpOjLL_HZLF4kFXarUyGLGO3zeVJAQzyofVz_1rKfDlZdi5Zmm-91cO5OiOtshfluDqt1h1D-E5h4ShT0eN7apvSvQnD7806-3tfxP0GHE-HuerR1Qbv9p0uWmuhT0CkVIM-K2dKBHdhLtquRqxNp2EuD_T-HA3WJgvkTTWp-JZ6NqvWDMy3M-jB-_Bs9eABERlTSTp7H2XCMGbwRSBZDmSn-97LPwc-NO5JYEkgZOeVr_r6qg', - clientId: oidcClients.nextcloud.id, - expired: true - }, - { - token: - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6ImlkLXRva2VuIn0.ry7s3xP4-vvvZPzcRCvR1yBl32Pi09ryC6Z-67E1P4xChe8MaMoiQgImS5ZNbZiYzBN4cdkQsExXZK1FP-kMD019k3uNKPq0fIREBwrT9wXPqQJlLSBmN-tVkjLm90-b310SG5p65aajWvMkcPmJleG6y24_zoPFr3ISGI87vV6zdyoqG55pc-GkT7FwiEFIZJGQAzl7u1uOi7sQrda8Y6rF_SCC-f9I4PnHblnaTne8pfXe9jXKJeY1ZKj2Qh9dRPhWCLPHHV1YErUyoMP9oeMVzYpno-pBYVOiT9Ktl6CpG-jqB8smKqDEhZrSejgZ256h34f8jNL1SEhpM-4_cQ', - clientId: oidcClients.nextcloud.id, - expired: false - } -]; - -export const accessTokens = [ - { - token: - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6MTc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6Im9hdXRoLWFjY2Vzcy10b2tlbiJ9.REFSDFsGso9u7WxpyMmMVvjQMgulbidQNUft-kBRg7nw5LN9pOWhO0Zlr1tZnnrA1LenZRv0BvLIf0qekwGEC4FOPmJ6-As2ggIcoBIXpUR2A4Hhuy0FtqbCUgIkda1Dcx9w1Rmfzi0eHY_-1H_98rDgS5RxqweNA_YP3RsnJqBsc9GYhDarrf1nyCOplshGOEiyisUGoU2TaURI6DTcCiDzVOm_esZqokoZTpKlQw6ZugDDObro0eWYgROo97_3cqPRgRjSYBYRAGCHhZom3bFkjmz3wqpeoGmUNgL022x3-gl7QjurpJMQrKJ7wkFs0bh2uFnnngnh2w6m4j8-5w', - clientId: oidcClients.nextcloud.id, - expired: true - }, - { - token: - 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6Mjc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6Im9hdXRoLWFjY2Vzcy10b2tlbiJ9.FaFsHJS_8wbvQvctftNTPyzAe9IhbpJiHIkhg28RrFRFfnBMq0QycmTUh00MJPXkUfd_j5tcCnXybF1efHsq6WbP4AWFG_TJMUyz7a9SYt1lGR8dxo3eys0YAX5eJQ5YoVTKNrivSKrC37Rg3VlcZVWXp6KBAxRWVl3OUlquSC6q7HNKAKg8sbBJiGpUJ37wwanOTE2XhYGvFB2_gxS36tvOuSTV3CVg_7Fctej7gNhKMXBFMJiIFurxZaeNud8620xtv-vJX6ALa1Qu1SkWhhZN2Yx3WuODZNlni3rUps-THoEdqh62jNwItE9wB7C0fGEKuUqVIllaF9I_7i2s3w', - clientId: oidcClients.nextcloud.id, - expired: false - } -]; diff --git a/frontend/tests/oidc.spec.ts b/frontend/tests/oidc.spec.ts index c1ad6e9c..87cb9c6d 100644 --- a/frontend/tests/oidc.spec.ts +++ b/frontend/tests/oidc.spec.ts @@ -1,6 +1,7 @@ import test, { expect } from '@playwright/test'; -import { accessTokens, idTokens, oidcClients, refreshTokens, users } from './data'; +import { oidcClients, refreshTokens, users } from './data'; import { cleanupBackend } from './utils/cleanup.util'; +import { generateIdToken, generateOauthAccessToken } from './utils/jwt.util'; import oidcUtil from './utils/oidc.util'; import passkeyUtil from './utils/passkey.util'; @@ -117,7 +118,7 @@ test('End session without id token hint shows confirmation page', async ({ page test('End session with id token hint redirects to callback URL', async ({ page }) => { const client = oidcClients.nextcloud; - const idToken = idTokens.filter((token) => token.expired)[0].token; + const idToken = await generateIdToken(users.tim, client.id); let redirectedCorrectly = false; await page .goto( @@ -193,8 +194,8 @@ test('Using refresh token invalidates it for future use', async ({ request }) => test.describe('Introspection endpoint', () => { const client = oidcClients.nextcloud; - const validAccessToken = accessTokens.filter((token) => !token.expired)[0].token; test('without client_id and client_secret fails', async ({ request }) => { + const validAccessToken = await generateOauthAccessToken(users.tim, client.id); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' @@ -207,7 +208,8 @@ test.describe('Introspection endpoint', () => { expect(introspectionResponse.status()).toBe(400); }); - test('with client_id and client_secret succeeds', async ({ request }) => { + test('with client_id and client_secret succeeds', async ({ request, baseURL }) => { + const validAccessToken = await generateOauthAccessToken(users.tim, client.id); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -222,7 +224,7 @@ test.describe('Introspection endpoint', () => { const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(true); expect(introspectionBody.token_type).toBe('access_token'); - expect(introspectionBody.iss).toBe('http://localhost'); + expect(introspectionBody.iss).toBe(baseURL); expect(introspectionBody.sub).toBe(users.tim.id); expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]); }); @@ -265,7 +267,7 @@ test.describe('Introspection endpoint', () => { }); test("expired access_token can't be verified", async ({ request }) => { - const expiredAccessToken = accessTokens.filter((token) => token.expired)[0].token; + const expiredAccessToken = await generateOauthAccessToken(users.tim, client.id, true); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' diff --git a/frontend/tests/utils/cleanup.util.ts b/frontend/tests/utils/cleanup.util.ts index a5567ed8..aaf2c82c 100644 --- a/frontend/tests/utils/cleanup.util.ts +++ b/frontend/tests/utils/cleanup.util.ts @@ -1,5 +1,6 @@ import axios from 'axios'; +import playwrightConfig from '../../playwright.config'; export async function cleanupBackend() { - await axios.post('http://localhost/api/test/reset'); + await axios.post(playwrightConfig.use!.baseURL + '/api/test/reset'); } diff --git a/frontend/tests/utils/jwt.util.ts b/frontend/tests/utils/jwt.util.ts new file mode 100644 index 00000000..56f6e929 --- /dev/null +++ b/frontend/tests/utils/jwt.util.ts @@ -0,0 +1,58 @@ +import * as jose from 'jose'; +import playwrightConfig from '../../playwright.config'; + +const PRIVATE_KEY_STRING = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`; + +type User = { + id: string; + email: string; + firstname: string; + lastname: string; +}; + +const privateKey = JSON.parse(PRIVATE_KEY_STRING); +const privateKeyImported = await jose.importJWK(privateKey, 'RS256'); + + +export async function generateIdToken(user: User, clientId: string, expired = false) { + const now = Math.floor(Date.now() / 1000); + const expiration = expired ? now + 1 : now + 1000000000; // Either expired or valid for a long time + + const payload = { + aud: clientId, + email: user.email, + email_verified: true, + exp: expiration, + family_name: user.lastname, + given_name: user.firstname, + iat: now, + iss: playwrightConfig.use!.baseURL, + name: `${user.firstname} ${user.lastname}`, + nonce: 'oW1A1O78GQ15D73OsHEx7WQKj7ZqvHLZu_37mdXIqAQ', + sub: user.id, + type: 'id-token' + }; + + return await new jose.SignJWT(payload) + .setProtectedHeader({ alg: 'RS256', kid: privateKey.kid, typ: 'JWT' }) + .sign(privateKeyImported); +} + + +export async function generateOauthAccessToken(user: User, clientId: string, expired = false) { + const now = Math.floor(Date.now() / 1000); + const expiration = expired ? now - 1000 : now + 1000000000; // Either expired or valid for a long time + + const payload = { + aud: [clientId], + exp: expiration, + iat: now, + iss: playwrightConfig.use!.baseURL, + sub: user.id, + type: 'oauth-access-token' + }; + + return await new jose.SignJWT(payload) + .setProtectedHeader({ alg: 'RS256', kid: privateKey.kid, typ: 'JWT' }) + .sign(privateKeyImported); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2ccc9965..6b4540ae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,5 +13,13 @@ export default defineConfig({ cookieName: 'locale', strategy: ['cookie', 'preferredLanguage', 'baseLocale'] }) - ] + ], + + server: { + proxy: { + '/api': { + target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411', + } + } + } }); diff --git a/reverse-proxy/Caddyfile b/reverse-proxy/Caddyfile deleted file mode 100644 index d6a8e077..00000000 --- a/reverse-proxy/Caddyfile +++ /dev/null @@ -1,5 +0,0 @@ -:{$CADDY_PORT:80} { - reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} - reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080} - reverse_proxy /* http://localhost:{$PORT:3000} -} diff --git a/reverse-proxy/Caddyfile.trust-proxy b/reverse-proxy/Caddyfile.trust-proxy deleted file mode 100644 index 3eb6e6ad..00000000 --- a/reverse-proxy/Caddyfile.trust-proxy +++ /dev/null @@ -1,14 +0,0 @@ -:{$CADDY_PORT:80} { - reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} { - trusted_proxies 0.0.0.0/0 - trusted_proxies ::/0 - } - reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080} { - trusted_proxies 0.0.0.0/0 - trusted_proxies ::/0 - } - reverse_proxy /* http://localhost:{$PORT:3000} { - trusted_proxies 0.0.0.0/0 - trusted_proxies ::/0 - } -} diff --git a/scripts/create-one-time-access-token.sh b/scripts/create-one-time-access-token.sh index c4a2a1a1..d41f8d7b 100644 --- a/scripts/create-one-time-access-token.sh +++ b/scripts/create-one-time-access-token.sh @@ -1,6 +1,7 @@ #!/bin/sh -DB_PATH="./backend/data/pocket-id.db" +# TODO: Should parse DB_CONNECTION_STRING +DB_PATH="/app/data/pocket-id.db" DB_PROVIDER="${DB_PROVIDER:=sqlite}" # Parse command-line arguments for the -d flag (database path) @@ -108,7 +109,7 @@ fi echo "=================================================" if [ $? -eq 0 ]; then echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"." - echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://}/lc/$SECRET_TOKEN" + echo "Use the following URL to sign in once: ${APP_URL:=https://}/lc/$SECRET_TOKEN" else echo "Error creating access token." exit 1 diff --git a/scripts/development/build-binaries.sh b/scripts/development/build-binaries.sh new file mode 100644 index 00000000..98220053 --- /dev/null +++ b/scripts/development/build-binaries.sh @@ -0,0 +1,57 @@ +set -eu +cd backend +mkdir -p .bin + +# Function to build for a specific platform +build_platform() { + target=$1 + os=$2 + arch=$3 + arm_version=${4:-""} + pocket_id_version=$(cat ../.version) + + # Set the binary extension to exe for Windows + binary_ext="" + if [ "$os" = "windows" ]; then + binary_ext=".exe" + fi + + output_dir=".bin/pocket-id-${target}${binary_ext}" + + printf "Building %s/%s%s" "$os" "$arch" "$([ -n "$arm_version" ] && echo " GOARM=$arm_version" || echo "")... " + + # Build environment variables + env_vars="GOOS=${os} GOARCH=${arch}" + if [ -n "$arm_version" ]; then + env_vars="${env_vars} GOARM=${arm_version}" + fi + + # Build the binary + eval "${env_vars} go build \ + -ldflags='-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${pocket_id_version}' \ + -o \"${output_dir}\" \ + -trimpath \ + ./cmd" + + printf "Done\n" +} + +# linux builds +build_platform "linux-amd64" "linux" "amd64" "" +build_platform "linux-386" "linux" "386" "" +build_platform "linux-arm64" "linux" "arm64" "" +build_platform "linux-armv7" "linux" "arm" "7" + +# macOS builds +build_platform "macos-x64" "darwin" "amd64" "" +build_platform "macos-arm64" "darwin" "arm64" "" + +# Windows builds +build_platform "windows-x64" "windows" "amd64" "" +build_platform "windows-arm64" "windows" "arm64" "" + +# FreeBSD builds +build_platform "freebsd-amd64" "freebsd" "amd64" "" +build_platform "freebsd-arm64" "freebsd" "arm64" "" + +echo "Compilation done" diff --git a/scripts/development/create-release.sh b/scripts/development/create-release.sh index 96d5b119..7df2c2cf 100644 --- a/scripts/development/create-release.sh +++ b/scripts/development/create-release.sh @@ -112,7 +112,7 @@ fi # Create the release on GitHub echo "Creating GitHub release..." -gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes "$CHANGELOG" +gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes "$CHANGELOG" --draft if [ $? -eq 0 ]; then echo "GitHub release created successfully." diff --git a/scripts/docker/create-user.sh b/scripts/docker/create-user.sh deleted file mode 100644 index b874958f..00000000 --- a/scripts/docker/create-user.sh +++ /dev/null @@ -1,31 +0,0 @@ -# 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" "$@" \ No newline at end of file diff --git a/scripts/docker/entrypoint.sh b/scripts/docker/entrypoint.sh index bbc38083..429211be 100644 --- a/scripts/docker/entrypoint.sh +++ b/scripts/docker/entrypoint.sh @@ -1,28 +1,38 @@ -echo "Starting frontend..." -node frontend/build & +#!/bin/sh -echo "Starting backend..." -cd backend && ./pocket-id-backend & +# Ensure we are in the /app folder +cd /app -if [ "$CADDY_DISABLED" != "true" ]; then - echo "Starting Caddy..." - - # https://caddyserver.com/docs/conventions#data-directory - export XDG_DATA_HOME=${XDG_DATA_HOME:-/app/backend/data/.local/share} - # https://caddyserver.com/docs/conventions#configuration-directory - export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-/app/backend/data/.config} - - # Check if TRUST_PROXY is set to true and use the appropriate Caddyfile - if [ "$TRUST_PROXY" = "true" ]; then - caddy run --adapter caddyfile --config /etc/caddy/Caddyfile.trust-proxy & - else - caddy run --adapter caddyfile --config /etc/caddy/Caddyfile & - fi -else - echo "Caddy is disabled. Skipping..." +# If we aren't running as root, just exec the CMD +if [ "$(id -u)" -ne 0 ] ; then + exec "$@" + exit 0 fi -# Set up trap to catch child process terminations -trap 'exit 1' SIGCHLD +PUID=${PUID:-1000} +PGID=${PGID:-1000} -wait +# Check if the group with PGID exists; if not, create it +if ! getent group pocket-id-group > /dev/null 2>&1; then + echo "Creating group $PGID..." + 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 + echo "Creating user $PUID..." + adduser -u "$PUID" -G pocket-id-group pocket-id > /dev/null 2>&1 + 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/data directory +mkdir -p /app/data +find /app/data \( ! -group "${PGID}" -o ! -user "${PUID}" \) -exec chown "${PUID}:${PGID}" {} + + +# Switch to the non-root user +exec su-exec "$PUID:$PGID" "$@"