Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/check-openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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}}'
2 changes: 1 addition & 1 deletion .github/workflows/fix-format.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}'
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.rootless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 19 additions & 18 deletions docs/docs/administration/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 |
Expand Down Expand Up @@ -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 | |

</details>

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/features/command-line-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/guides/template-backup-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<DB_USERNAME> > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
docker exec -t immich_postgres pg_dump --clean --if-exists --dbname <DB_DATABASE_NAME> --username=<DB_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=<DB_USERNAME> | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
# docker exec -t immich_postgres pg_dump --clean --if-exists --dbname <DB_DATABASE_NAME> --username=<DB_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/
Expand Down
10 changes: 10 additions & 0 deletions e2e/src/specs/server/api/api-key.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
4 changes: 4 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -21016,6 +21068,7 @@
"apiKey.read",
"apiKey.update",
"apiKey.delete",
"apiKey.rotate",
"asset.read",
"asset.update",
"asset.delete",
Expand Down
12 changes: 11 additions & 1 deletion packages/cli/bin/immich
Original file line number Diff line number Diff line change
@@ -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');
5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
15 changes: 15 additions & 0 deletions packages/sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions server/src/controllers/api-key.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
12 changes: 12 additions & 0 deletions server/src/controllers/api-key.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiKeyCreateResponseDto> {
return this.service.rotate(auth, id);
}

@Delete(':id')
@Authenticated({ permission: Permission.ApiKeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
Expand Down
1 change: 1 addition & 0 deletions server/src/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading