diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 522444db972458..3f5491ce967560 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@b7c3adeb54330db1903d27c61db520e5661ad55b # v0.1.11 + uses: oasdiff/oasdiff-action/breaking@033c15c845bef10f148afb0fa781bf1b2a7fe1bf # v0.1.12 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 522de204ea7b6c..36063f8c49d9fc 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -71,7 +71,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -84,6 +84,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 2638c31fca2fd3..5b35793116ba5e 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -38,7 +38,7 @@ jobs: run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix - name: Commit and push - uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0 + uses: EndBug/add-and-commit@645ecc0dd0a57f4d86d26c0aa5fc42c0a856fbca # v11.0.0 with: default_author: github_actions message: 'chore: fix formatting' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index abc84c7cdb21e8..22a0d2494cc2ef 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -70,7 +70,7 @@ jobs: # TODO move to mise - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 - name: Bump version env: @@ -88,7 +88,7 @@ jobs: - name: Commit and tag id: push-tag - uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0 + uses: EndBug/add-and-commit@645ecc0dd0a57f4d86d26c0aa5fc42c0a856fbca # v11.0.0 with: default_author: github_actions message: 'chore: version ${{ steps.output.outputs.version }}' diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a4a820ec70f6b5..3f807a1f170407 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -156,7 +156,7 @@ services: container_name: immich_redis image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 database: container_name: immich_postgres diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index b717de02f91a2b..8e981c58242c4d 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -58,7 +58,7 @@ services: container_name: immich_redis image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 restart: always database: diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 65b13f2b9da620..d92d6669bde18c 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -68,7 +68,7 @@ services: volumes: - ./redis:/data healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 restart: always database: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4bcf52ca6e4353..b13add0998827c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -51,7 +51,7 @@ services: container_name: immich_redis image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 restart: always database: diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 246b1be7fe4a91..06fdda699709e3 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -154,9 +154,10 @@ identity_providers: - client_id: 'immich' client_name: 'Immich' # https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-a-client-identifier-or-client-secret - client_secret: $pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' + client_secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' public: false - require_pkce: false + require_pkce: true + pkce_challenge_method: 'S256' redirect_uris: - 'https://example.immich.app/auth/login' - 'https://example.immich.app/user-settings' @@ -180,7 +181,7 @@ Configuration of OAuth in Immich System Settings | Setting | Value | | ---------------------------------- | ------------------------------------------------------------------- | -| Issuer URL | `https://example.immich.app/.well-known/openid-configuration` | +| Issuer URL | `https://auth.example.com` | | Client ID | immich | | Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... | | Token Endpoint Auth Method | client_secret_post | @@ -212,21 +213,21 @@ Configuration of Authorised redirect URIs (Authentik OAuth2/OpenID Provider) Configuration of OAuth in Immich System Settings -| Setting | Value | -| ---------------------------- | ---------------------------------------------------------------------------------- | -| Issuer URL | `https://example.immich.app/application/o/immich/.well-known/openid-configuration` | -| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... | -| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... | -| Scope | openid email profile | -| Signing Algorithm | RS256 | -| Storage Label Claim | preferred_username | -| Storage Quota Claim | immich_quota | -| Default Storage Quota (GiB) | 0 (empty for unlimited quota) | -| Button Text | Sign in with Authentik (optional) | -| Auto Register | Enabled (optional) | -| Auto Launch | Enabled (optional) | -| Mobile Redirect URI Override | Disable | -| Mobile Redirect URI | | +| Setting | Value | +| ---------------------------- | ------------------------------------------------------------------- | +| Issuer URL | `https://authentik.example.com/application/o/immich/` | +| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... | +| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... | +| Scope | openid email profile | +| Signing Algorithm | RS256 | +| Storage Label Claim | preferred_username | +| Storage Quota Claim | immich_quota | +| Default Storage Quota (GiB) | 0 (empty for unlimited quota) | +| Button Text | Sign in with Authentik (optional) | +| Auto Register | Enabled (optional) | +| Auto Launch | Enabled (optional) | +| Mobile Redirect URI Override | Disable | +| Mobile Redirect URI | | diff --git a/docs/docs/features/command-line-interface.md b/docs/docs/features/command-line-interface.md index c3eab1605fee50..965db81b07020e 100644 --- a/docs/docs/features/command-line-interface.md +++ b/docs/docs/features/command-line-interface.md @@ -15,7 +15,7 @@ If you are looking to import your Google Photos takeout, we recommend this commu ## Requirements -- Node.js 20 or above +- Node.js 22 or above - Npm If you can't install node/npm, there is also a Docker version available below. diff --git a/docs/docs/guides/template-backup-script.md b/docs/docs/guides/template-backup-script.md index 19647d4ae16280..dfec17a99a8d3d 100644 --- a/docs/docs/guides/template-backup-script.md +++ b/docs/docs/guides/template-backup-script.md @@ -52,9 +52,9 @@ REMOTE_BACKUP_PATH="/path/to/remote/backup/directory" ### Local # Backup Immich database -docker exec -t immich_postgres pg_dumpall --clean --if-exists --username= > "$UPLOAD_LOCATION"/database-backup/immich-database.sql +docker exec -t immich_postgres pg_dump --clean --if-exists --dbname --username= > "$UPLOAD_LOCATION"/database-backup/immich-database.sql # For deduplicating backup programs such as Borg or Restic, compressing the content can increase backup size by making it harder to deduplicate. If you are using a different program or still prefer to compress, you can use the following command instead: -# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username= | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz +# docker exec -t immich_postgres pg_dump --clean --if-exists --dbname --username= | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz ### Append to local Borg repository borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/ diff --git a/e2e/src/specs/server/api/api-key.e2e-spec.ts b/e2e/src/specs/server/api/api-key.e2e-spec.ts index 28d134a66438a5..2339889afe4c65 100644 --- a/e2e/src/specs/server/api/api-key.e2e-spec.ts +++ b/e2e/src/specs/server/api/api-key.e2e-spec.ts @@ -169,6 +169,16 @@ describe('/api-keys', () => { }); }); + describe('POST /api-keys/:id/rotate', () => { + it('should not work without permission', async () => { + const { apiKey } = await create(user.accessToken, [Permission.ApiKeyUpdate]); + const { secret } = await create(user.accessToken, [Permission.ApiKeyUpdate]); + const { status, body } = await request(app).post(`/api-keys/${apiKey.id}/rotate`).set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('apiKey.rotate')); + }); + }); + describe('DELETE /api-keys/:id', () => { it('should require authorization', async () => { const { apiKey } = await create(user.accessToken, [Permission.All]); diff --git a/i18n/en.json b/i18n/en.json index 0acfe68fa39c12..d0c8d3cbe71cdf 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1042,6 +1042,8 @@ "unable_to_hide_person": "Unable to hide person", "unable_to_link_motion_video": "Unable to link motion video", "unable_to_link_oauth_account": "Unable to link OAuth account", + "unable_to_load_map": "Unable to load map", + "unable_to_load_map_description": "The map requires WebGL to work properly.", "unable_to_log_out_all_devices": "Unable to log out all devices", "unable_to_log_out_device": "Unable to log out device", "unable_to_login_with_oauth": "Unable to login with OAuth", @@ -1796,6 +1798,8 @@ "role": "Role", "role_editor": "Editor", "role_viewer": "Viewer", + "rotate_api_key_prompt": "Are you sure you want to rotate this API key? The current key will stop working immediately.", + "rotate_key": "Rotate key", "running": "Running", "save": "Save", "saved": "Saved", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d00faab9a08af2..02d7fc80efeede 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3264,6 +3264,58 @@ "x-immich-state": "Deprecated" } }, + "/api-keys/{id}/rotate": { + "post": { + "description": "Generates a new secret for an API key, immediately invalidating the previous one. The current user must own this API key.", + "operationId": "rotateApiKey", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Rotate an API key", + "tags": [ + "API keys" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + } + ], + "x-immich-permission": "apiKey.rotate" + } + }, "/assets": { "delete": { "description": "Deletes multiple assets at the same time.", @@ -21016,6 +21068,7 @@ "apiKey.read", "apiKey.update", "apiKey.delete", + "apiKey.rotate", "asset.read", "asset.update", "asset.delete", diff --git a/packages/cli/bin/immich b/packages/cli/bin/immich index 924fff1230b60d..280b3c021b3465 100755 --- a/packages/cli/bin/immich +++ b/packages/cli/bin/immich @@ -1,2 +1,12 @@ #!/usr/bin/env node -import '../dist/index.js'; +import { createRequire } from 'node:module'; +import semver from 'semver'; + +const { engines } = createRequire(import.meta.url)('../package.json'); + +if (!semver.satisfies(process.version, engines.node, { includePrerelease: true })) { + console.error(`The Immich CLI requires Node.js ${engines.node}, but you are running ${process.version}.`); + process.exit(1); +} + +await import('../dist/index.js'); diff --git a/packages/cli/package.json b/packages/cli/package.json index c5ee0e4eeafbca..2a0f0a76bf6260 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,13 +59,14 @@ "check": "tsc --noEmit" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "dependencies": { "chokidar": "^4.0.3", "fast-glob": "^3.3.2", "fastq": "^1.17.1", "lodash-es": "^4.17.21", - "micromatch": "^4.0.8" + "micromatch": "^4.0.8", + "semver": "^7.8.1" } } diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index b2f98b58ef18cc..9a1f93cb302366 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -4112,6 +4112,20 @@ export function updateApiKey({ id, apiKeyUpdateDto }: { body: apiKeyUpdateDto }))); } +/** + * Rotate an API key + */ +export function rotateApiKey({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: ApiKeyCreateResponseDto; + }>(`/api-keys/${encodeURIComponent(id)}/rotate`, { + ...opts, + method: "POST" + })); +} /** * Delete assets */ @@ -7215,6 +7229,7 @@ export enum Permission { ApiKeyRead = "apiKey.read", ApiKeyUpdate = "apiKey.update", ApiKeyDelete = "apiKey.delete", + ApiKeyRotate = "apiKey.rotate", AssetRead = "asset.read", AssetUpdate = "asset.update", AssetDelete = "asset.delete", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1120bc0afb2931..919ee04b992176 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: micromatch: specifier: ^4.0.8 version: 4.0.8 + semver: + specifier: ^7.8.1 + version: 7.8.5 devDependencies: '@eslint/js': specifier: ^10.0.0 diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 08963362b6820b..815e00a9a56970 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -44,6 +44,14 @@ describe(ApiKeyController.name, () => { }); }); + describe('POST /api-keys/:id/rotate', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post(`/api-keys/123/rotate`); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + }); + describe('DELETE /api-keys/:id', () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`); diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index b7e3627faa9211..a179e253de6561 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -87,6 +87,18 @@ export class ApiKeyController { return this.service.update(auth, id, dto); } + @Post(':id/rotate') + @Authenticated({ permission: Permission.ApiKeyRotate }) + @Endpoint({ + summary: 'Rotate an API key', + description: + 'Generates a new secret for an API key, immediately invalidating the previous one. The current user must own this API key.', + history: new HistoryBuilder().added('v3'), + }) + rotateApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.rotate(auth, id); + } + @Delete(':id') @Authenticated({ permission: Permission.ApiKeyDelete }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/server/src/enum.ts b/server/src/enum.ts index e88a9a667c79e0..b6e2f6e4686ce6 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -117,6 +117,7 @@ export enum Permission { ApiKeyRead = 'apiKey.read', ApiKeyUpdate = 'apiKey.update', ApiKeyDelete = 'apiKey.delete', + ApiKeyRotate = 'apiKey.rotate', // ASSET_CREATE = 'asset.create', AssetRead = 'asset.read', diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index b5f1dc7d188a51..88e21d653e4e3f 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -54,6 +54,8 @@ select "asset"."deletedAt" is null and "asset"."stackId" = "stack"."id" and "asset"."visibility" in ('archive', 'timeline') + order by + "asset"."fileCreatedAt" asc ) as agg ) as "assets" from @@ -139,6 +141,8 @@ select "asset"."deletedAt" is null and "asset"."stackId" = "stack"."id" and "asset"."visibility" in ('archive', 'timeline') + order by + "asset"."fileCreatedAt" asc ) as agg ) as "assets" from diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b3f23399cfd553..5c3d4addce0710 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -594,10 +594,10 @@ export class AssetRepository { eb .selectFrom('asset as stacked') .selectAll('stack') - .select((eb) => - eb - .fn>>('array_agg', [eb.table('stacked')]) - .as('assets'), + .select( + sql< + ShallowDehydrateObject>[] + >`array_agg(to_json(stacked) ORDER BY stacked."fileCreatedAt" ASC)`.as('assets'), ) .whereRef('stacked.stackId', '=', 'stack.id') .whereRef('stacked.id', '!=', 'stack.primaryAssetId') diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index d313d682bd7cf0..1cb0b33fce707d 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -41,7 +41,8 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) => { .select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')) .where('asset.deletedAt', 'is', null) .whereRef('asset.stackId', '=', 'stack.id') - .$call(withDefaultVisibility), + .$call(withDefaultVisibility) + .orderBy('asset.fileCreatedAt', 'asc'), ).as('assets'); }; diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 68165d642fc8b8..64ae09a0aabe4a 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -188,6 +188,51 @@ describe(ApiKeyService.name, () => { }); }); + describe('rotate', () => { + it('should throw an error if the key is not found', async () => { + const auth = AuthFactory.create(); + const id = newUuid(); + + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.rotate(auth, id)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.apiKey.update).not.toHaveBeenCalled(); + }); + + it('should replace the secret of a key', async () => { + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); + + mocks.crypto.randomBytesAsText.mockReturnValue('super-secret'); + mocks.apiKey.getById.mockResolvedValue(apiKey); + mocks.apiKey.update.mockResolvedValue(apiKey); + + await expect(sut.rotate(auth, apiKey.id)).resolves.toEqual( + expect.objectContaining({ secret: 'super-secret', apiKey: expect.objectContaining({ id: apiKey.id }) }), + ); + + expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { + key: Buffer.from('super-secret (hashed)'), + }); + }); + + it('should not rotate a key with permissions the caller does not have', async () => { + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyRotate] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] }); + + mocks.apiKey.getById.mockResolvedValue(apiKey); + + await expect(sut.rotate(auth, apiKey.id)).rejects.toThrow( + 'Cannot rotate an API Key with permissions you do not have', + ); + + expect(mocks.apiKey.update).not.toHaveBeenCalled(); + }); + }); + describe('delete', () => { it('should throw an error if the key is not found', async () => { const auth = AuthFactory.create(); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index acd3b17e94ff67..2b9e1a814c069c 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -5,6 +5,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; +import { findOrFail } from 'src/utils/misc'; @Injectable() export class ApiKeyService extends BaseService { @@ -45,6 +46,24 @@ export class ApiKeyService extends BaseService { return this.map(key); } + async rotate(auth: AuthDto, id: string): Promise { + const existing = await findOrFail(() => this.apiKeyRepository.getById(auth.user.id, id), 'API Key not found'); + + if ( + auth.apiKey && + !isGranted({ requested: existing.permissions as Permission[], current: auth.apiKey.permissions }) + ) { + throw new BadRequestException('Cannot rotate an API Key with permissions you do not have'); + } + + const token = this.cryptoRepository.randomBytesAsText(32); + const hashed = this.cryptoRepository.hashSha256(token); + + const newKey = await this.apiKeyRepository.update(auth.user.id, id, { key: hashed }); + + return { secret: token, apiKey: this.map(newKey) }; + } + async delete(auth: AuthDto, id: string): Promise { const exists = await this.apiKeyRepository.getById(auth.user.id, id); if (!exists) { diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index 37964e7b6fa00e..50263862523099 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -565,6 +565,48 @@ describe(DatabaseBackupService.name, () => { `); }); }); + + describe('using an unparsable URL', () => { + beforeEach(() => { + // unix domain socket URLs cannot be parsed by `new URL` + const dbUrl = 'socket://mypg:mypwd@/var/run/postgresql?db=myimmich'; + const configMock = { + getEnv: () => ({ database: { config: { connectionType: 'url', url: dbUrl }, skipMigrations: false } }), + getWorker: () => ImmichWorker.Api, + isDev: () => false, + } as unknown as any; + + sut = new DatabaseBackupService( + mocks.logger as never, + mocks.storage as never, + configMock as never, + mocks.systemMetadata as never, + mocks.process, + mocks.database as never, + mocks.user as never, + mocks.cron as never, + mocks.job as never, + void 0 as never, + ); + }); + + it('should fallback to reasonable defaults', async () => { + await expect(sut.buildPostgresLaunchArguments('pg_dump')).resolves.toMatchInlineSnapshot(` + { + "args": [ + "socket://mypg:mypwd@/var/run/postgresql?db=myimmich", + "--clean", + "--if-exists", + ], + "bin": "/usr/lib/postgresql/14/bin/pg_dump", + "databaseMajorVersion": 14, + "databasePassword": "", + "databaseUsername": "postgres", + "databaseVersion": "14.10 (Debian 14.10-1.pgdg120+1)", + } + `); + }); + }); }); describe('uploadBackup', () => { diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index a277b028a54497..6e1da7a1b709ce 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -129,6 +129,7 @@ export class DatabaseBackupService { const args: string[] = []; let databaseUsername; + let databasePassword; if (isUrlConnection) { if (bin !== 'pg_dump') { @@ -142,16 +143,19 @@ export class DatabaseBackupService { parsedUrl.searchParams.delete('uselibpqcompat'); databaseUsername = parsedUrl.username || parsedUrl.searchParams.get('user'); + databasePassword = parsedUrl.password; url = parsedUrl.href; } // assume typical values if we can't parse URL or not present databaseUsername ??= 'postgres'; + databasePassword ??= ''; args.push(url); } else { databaseUsername = databaseConfig.username; + databasePassword = databaseConfig.password; args.push( '--username', @@ -214,7 +218,7 @@ export class DatabaseBackupService { bin: `/usr/lib/postgresql/${databaseMajorVersion}/bin/${bin}`, args, databaseUsername, - databasePassword: isUrlConnection ? new URL(databaseConfig.url).password : databaseConfig.password, + databasePassword, databaseVersion, databaseMajorVersion, }; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 860189d9a90e12..dc3bc34e3c8cd4 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -22,6 +22,7 @@ import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -443,6 +444,7 @@ const newRealRepository = (key: T, db: Kysely case AlbumRepository: case AlbumUserRepository: case ActivityRepository: + case ApiKeyRepository: case AssetRepository: case AssetEditRepository: case AssetJobRepository: diff --git a/server/test/medium/specs/services/api-key.service.spec.ts b/server/test/medium/specs/services/api-key.service.spec.ts new file mode 100644 index 00000000000000..1a5842dab404b8 --- /dev/null +++ b/server/test/medium/specs/services/api-key.service.spec.ts @@ -0,0 +1,68 @@ +import { Kysely } from 'kysely'; +import { Permission } from 'src/enum'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { DB } from 'src/schema'; +import { ApiKeyService } from 'src/services/api-key.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(ApiKeyService, { + database: db || defaultDatabase, + real: [ApiKeyRepository, CryptoRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(ApiKeyService.name, () => { + describe('rotate', () => { + it('should not rotate an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.rotate(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + }); + + it('should not rotate a key with permissions the caller does not have', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + const auth = factory.auth({ user, apiKey: { permissions: [Permission.ApiKeyRotate] } }); + + await expect(sut.rotate(auth, apiKey.id)).rejects.toThrow( + 'Cannot rotate an API Key with permissions you do not have', + ); + }); + + it('should replace the secret of an api key', async () => { + const { sut, ctx } = setup(); + const apiKeyRepo = ctx.get(ApiKeyRepository); + const crypto = ctx.get(CryptoRepository); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { apiKey, secret } = await sut.create(auth, { permissions: [Permission.All] }); + + const rotated = await sut.rotate(auth, apiKey.id); + + expect(rotated.secret).not.toEqual(secret); + expect(rotated.apiKey).toEqual( + expect.objectContaining({ id: apiKey.id, name: apiKey.name, permissions: [Permission.All] }), + ); + await expect(apiKeyRepo.getKey(crypto.hashSha256(secret))).resolves.toBeUndefined(); + await expect(apiKeyRepo.getKey(crypto.hashSha256(rotated.secret))).resolves.toEqual( + expect.objectContaining({ id: apiKey.id }), + ); + }); + }); +}); diff --git a/web/src/lib/components/shared-components/map/Map.svelte b/web/src/lib/components/shared-components/map/Map.svelte index 9811a8a7be7ee2..21fc991116ba18 100644 --- a/web/src/lib/components/shared-components/map/Map.svelte +++ b/web/src/lib/components/shared-components/map/Map.svelte @@ -16,7 +16,7 @@ import { mapSettings } from '$lib/stores/preferences.store'; import { getAssetMediaUrl, handlePromiseError } from '$lib/utils'; import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk'; - import { Icon, modalManager, Theme, themeManager } from '@immich/ui'; + import { Alert, Container, Icon, modalManager, Text, Theme, themeManager } from '@immich/ui'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; import { isEqual, omit } from 'lodash-es'; @@ -322,109 +322,119 @@ - - { - event.setMaxZoom(18); - event.on('click', handleMapClick); - if (!simplified) { - event.addControl(new GlobeControl(), 'top-left'); - } - }} - bind:map -> - {#snippet children({ map }: { map: Map })} - {#if showSimpleControls} - - - {#if !simplified} - - - + + + { + event.setMaxZoom(18); + event.on('click', handleMapClick); + if (!simplified) { + event.addControl(new GlobeControl(), 'top-left'); + } + }} + bind:map + > + {#snippet children({ map }: { map: Map })} + {#if showSimpleControls} + + + {#if !simplified} + + + + {/if} {/if} - {/if} - - {#if showSettings} - - - - - - - - {/if} - - {#if onOpenInMapView && showSimpleControls} - - - onOpenInMapView()}> - - - - - {/if} - - asFeature(marker)) ?? [], - }} - id="geojson" - cluster={{ radius: 35, maxZoom: 18 }} - > - handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))} - > - {#snippet children({ feature })} -
- {feature.properties?.point_count?.toLocaleString()} -
- {/snippet} -
- { - if (!popup) { - handleAssetClick(event.feature.properties?.id, map); - } + + {#if showSettings} + + + + + + + + {/if} + + {#if onOpenInMapView && showSimpleControls} + + + onOpenInMapView()}> + + + + + {/if} + + asFeature(marker)) ?? [], }} + id="geojson" + cluster={{ radius: 35, maxZoom: 18 }} > - {#snippet children({ feature }: { feature: Feature })} - {#if useLocationPin} - - {:else} - {feature.properties?.city - {/if} - {#if popup} - - {@render popup({ marker: asMarker(feature) })} - - {/if} - {/snippet} - -
+ handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))} + > + {#snippet children({ feature })} +
+ {feature.properties?.point_count?.toLocaleString()} +
+ {/snippet} +
+ { + if (!popup) { + handleAssetClick(event.feature.properties?.id, map); + } + }} + > + {#snippet children({ feature }: { feature: Feature })} + {#if useLocationPin} + + {:else} + {feature.properties?.city + {/if} + {#if popup} + + {@render popup({ marker: asMarker(feature) })} + + {/if} + {/snippet} + + + {/snippet} +
+ + {#snippet failed()} + + + {$t('errors.unable_to_load_map_description')} + + {/snippet} -
+ diff --git a/web/src/lib/services/api-key.service.ts b/web/src/lib/services/api-key.service.ts index 1908bf3b653172..e5346627ff130e 100644 --- a/web/src/lib/services/api-key.service.ts +++ b/web/src/lib/services/api-key.service.ts @@ -1,16 +1,18 @@ import { createApiKey, deleteApiKey, + rotateApiKey, updateApiKey, type ApiKeyCreateDto, type ApiKeyResponseDto, type ApiKeyUpdateDto, } from '@immich/sdk'; import { modalManager, toastManager, type ActionItem } from '@immich/ui'; -import { mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiAutorenew, mdiPencilOutline, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; import { eventManager } from '$lib/managers/event-manager.svelte'; import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte'; +import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte'; import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; @@ -32,13 +34,19 @@ export const getApiKeyActions = ($t: MessageFormatter, apiKey: ApiKeyResponseDto onAction: () => modalManager.show(ApiKeyUpdateModal, { apiKey }), }; + const Rotate: ActionItem = { + title: $t('rotate_key'), + icon: mdiAutorenew, + onAction: () => handleRotateApiKey(apiKey), + }; + const Delete: ActionItem = { title: $t('delete_key'), icon: mdiTrashCanOutline, onAction: () => handleDeleteApiKey(apiKey), }; - return { Update, Delete }; + return { Update, Rotate, Delete }; }; export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => { @@ -87,6 +95,23 @@ export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpda } }; +export const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => { + const $t = await getFormatter(); + + const confirmed = await modalManager.showDialog({ prompt: $t('rotate_api_key_prompt') }); + if (!confirmed) { + return; + } + + try { + const response = await rotateApiKey({ id: apiKey.id }); + eventManager.emit('ApiKeyUpdate', response.apiKey); + await modalManager.show(ApiKeySecretModal, { secret: response.secret }); + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + } +}; + export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => { const $t = await getFormatter(); diff --git a/web/src/routes/(user)/user-settings/UserApiKeyList.svelte b/web/src/routes/(user)/user-settings/UserApiKeyList.svelte index 441b01f9dd9a4b..d9f89f1765459e 100644 --- a/web/src/routes/(user)/user-settings/UserApiKeyList.svelte +++ b/web/src/routes/(user)/user-settings/UserApiKeyList.svelte @@ -51,7 +51,7 @@ {#each keys as key (key.id)} - {@const { Update, Delete } = getApiKeyActions($t, key)} + {@const { Update, Rotate, Delete } = getApiKeyActions($t, key)} {key.name} @@ -64,6 +64,7 @@ {new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)} +