From 6bb8f4fcc4eb2954ec34a3d7138bfb20b5a11e25 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 14:47:35 -0400 Subject: [PATCH 001/144] refactor: clean class (#26885) --- web/src/lib/components/album-page/album-cover.svelte | 2 +- web/src/lib/components/assets/broken-asset.svelte | 7 ++++--- .../lib/components/assets/thumbnail/video-thumbnail.svelte | 5 +++-- .../components/sharedlinks-page/covers/asset-cover.svelte | 5 +++-- .../lib/components/sharedlinks-page/covers/no-cover.svelte | 6 ++++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index c6242c5fad..2af2be77f6 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -11,7 +11,7 @@ class?: string; } - let { album, preload = false, class: className = '' }: Props = $props(); + let { album, preload = false, class: className }: Props = $props(); let alt = $derived(album.albumName || $t('unnamed_album')); let thumbnailUrl = $derived( diff --git a/web/src/lib/components/assets/broken-asset.svelte b/web/src/lib/components/assets/broken-asset.svelte index fbbbf4c815..bb67de390e 100644 --- a/web/src/lib/components/assets/broken-asset.svelte +++ b/web/src/lib/components/assets/broken-asset.svelte @@ -1,4 +1,5 @@
diff --git a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte index b4772cc1c4..76956fbb26 100644 --- a/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/video-thumbnail.svelte @@ -1,4 +1,5 @@ @@ -19,7 +20,7 @@ (isBroken = true)} - class="size-full rounded-xl object-cover aspect-square {className}" + class={cleanClass('size-full rounded-xl object-cover aspect-square', className)} data-testid="album-image" draggable="false" loading={preload ? 'eager' : 'lazy'} diff --git a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte index 1e09c6bcfa..319a5e7f9e 100644 --- a/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte +++ b/web/src/lib/components/sharedlinks-page/covers/no-cover.svelte @@ -1,16 +1,18 @@ Date: Thu, 12 Mar 2026 19:48:00 +0100 Subject: [PATCH 002/144] fix(server): restrict individual shared link asset removal to owners (#26868) * fix(server): restrict individual shared link asset removal to owners * make open-api --- .../specs/server/api/shared-link.e2e-spec.ts | 10 ++++++++ e2e/src/specs/web/shared-link.e2e-spec.ts | 24 +++++++++++++++++++ mobile/openapi/lib/api/shared_links_api.dart | 21 +++------------- open-api/immich-openapi-specs.json | 17 +------------ open-api/typescript-sdk/src/fetch-client.ts | 9 ++----- .../shared-link.controller.spec.ts | 15 +++++++++++- .../src/controllers/shared-link.controller.ts | 2 +- web/src/lib/services/shared-link.service.ts | 2 -- 8 files changed, 55 insertions(+), 45 deletions(-) diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 80232beb75..00c455d6cb 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -438,6 +438,16 @@ describe('/shared-links', () => { expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); }); + it('should reject guests removing assets from an individual shared link', async () => { + const { status, body } = await request(app) + .delete(`/shared-links/${linkWithAssets.id}/assets`) + .query({ key: linkWithAssets.key }) + .send({ assetIds: [asset1.id] }); + + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) .delete(`/shared-links/${linkWithAssets.id}/assets`) diff --git a/e2e/src/specs/web/shared-link.e2e-spec.ts b/e2e/src/specs/web/shared-link.e2e-spec.ts index f6d1ec98d4..8380840935 100644 --- a/e2e/src/specs/web/shared-link.e2e-spec.ts +++ b/e2e/src/specs/web/shared-link.e2e-spec.ts @@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils'; test.describe('Shared Links', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let asset2: AssetMediaResponseDto; let album: AlbumResponseDto; let sharedLink: SharedLinkResponseDto; let sharedLinkPassword: SharedLinkResponseDto; + let individualSharedLink: SharedLinkResponseDto; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + asset2 = await utils.createAsset(admin.accessToken); album = await createAlbum( { createAlbumDto: { @@ -39,6 +42,10 @@ test.describe('Shared Links', () => { albumId: album.id, password: 'test-password', }); + individualSharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id, asset2.id], + }); }); test('download from a shared link', async ({ page }) => { @@ -109,4 +116,21 @@ test.describe('Shared Links', () => { await page.waitForURL('/photos'); await page.locator(`[data-asset-id="${asset.id}"]`).waitFor(); }); + + test('owner can remove assets from an individual shared link', async ({ context, page }) => { + await utils.setAuthCookies(context, admin.accessToken); + + await page.goto(`/share/${individualSharedLink.key}`); + await page.locator(`[data-asset="${asset.id}"]`).waitFor(); + await expect(page.locator(`[data-asset]`)).toHaveCount(2); + + await page.locator(`[data-asset="${asset.id}"]`).hover(); + await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click(); + + await page.getByRole('button', { name: 'Remove from shared link' }).click(); + await page.getByRole('button', { name: 'Remove', exact: true }).click(); + + await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0); + await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1); + }); }); diff --git a/mobile/openapi/lib/api/shared_links_api.dart b/mobile/openapi/lib/api/shared_links_api.dart index 37eeffcf46..084662ace8 100644 --- a/mobile/openapi/lib/api/shared_links_api.dart +++ b/mobile/openapi/lib/api/shared_links_api.dart @@ -427,11 +427,7 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { + Future removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { // ignore: prefer_const_declarations final apiPath = r'/shared-links/{id}/assets' .replaceAll('{id}', id); @@ -443,13 +439,6 @@ class SharedLinksApi { final headerParams = {}; final formParams = {}; - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - if (slug != null) { - queryParams.addAll(_queryParams('', 'slug', slug)); - } - const contentTypes = ['application/json']; @@ -473,12 +462,8 @@ class SharedLinksApi { /// * [String] id (required): /// /// * [AssetIdsDto] assetIdsDto (required): - /// - /// * [String] key: - /// - /// * [String] slug: - Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async { - final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, ); + Future?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async { + final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d2eb322009..2227273535 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11605,22 +11605,6 @@ "format": "uuid", "type": "string" } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "slug", - "required": false, - "in": "query", - "schema": { - "type": "string" - } } ], "requestBody": { @@ -11677,6 +11661,7 @@ "state": "Stable" } ], + "x-immich-permission": "sharedLink.update", "x-immich-state": "Stable" }, "put": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5c8ac6dbc1..5a47cf2707 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -5987,19 +5987,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { /** * Remove assets from a shared link */ -export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { +export function removeSharedLinkAssets({ id, assetIdsDto }: { id: string; - key?: string; - slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; - }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key, - slug - }))}`, oazapfts.json({ + }>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", body: assetIdsDto diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index 96c84040ca..d8b89d0029 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -1,7 +1,8 @@ import { SharedLinkController } from 'src/controllers/shared-link.controller'; -import { SharedLinkType } from 'src/enum'; +import { Permission, SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import request from 'supertest'; +import { factory } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(SharedLinkController.name, () => { @@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => { expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); }); + + describe('DELETE /shared-links/:id/assets', () => { + it('should require shared link update permission', async () => { + await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] }); + + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }), + }), + ); + }); + }); }); diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index 1f91409e80..c7ba589a9f 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -180,7 +180,7 @@ export class SharedLinkController { } @Delete(':id/assets') - @Authenticated({ sharedLink: true }) + @Authenticated({ permission: Permission.SharedLinkUpdate }) @Endpoint({ summary: 'Remove assets from a shared link', description: diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index fc4bbe11c0..135c67b95a 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -1,5 +1,4 @@ import { goto } from '$app/navigation'; -import { authManager } from '$lib/managers/auth-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import QrCodeModal from '$lib/modals/QrCodeModal.svelte'; @@ -138,7 +137,6 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons try { const results = await removeSharedLinkAssets({ - ...authManager.params, id: sharedLink.id, assetIdsDto: { assetIds }, }); From 001d7d083f0d7d541a54a9495961746992e212a8 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:48:49 +0100 Subject: [PATCH 003/144] refactor: small test factories (#26862) --- server/src/services/activity.service.spec.ts | 45 +++-- server/src/services/api-key.service.spec.ts | 77 ++++---- server/src/services/asset.service.spec.ts | 9 +- server/src/services/auth.service.spec.ts | 184 +++++++++--------- server/src/services/cli.service.spec.ts | 10 +- server/src/services/map.service.spec.ts | 4 +- server/src/services/partner.service.spec.ts | 53 ++--- server/src/services/session.service.spec.ts | 13 +- server/src/services/sync.service.spec.ts | 3 +- .../src/services/user-admin.service.spec.ts | 7 +- server/src/services/user.service.spec.ts | 21 +- server/test/factories/activity.factory.ts | 42 ++++ server/test/factories/api-key.factory.ts | 42 ++++ server/test/factories/auth.factory.ts | 17 +- server/test/factories/partner.factory.ts | 50 +++++ server/test/factories/session.factory.ts | 35 ++++ server/test/factories/types.ts | 8 + server/test/small.factory.ts | 183 +---------------- 18 files changed, 414 insertions(+), 389 deletions(-) create mode 100644 server/test/factories/activity.factory.ts create mode 100644 server/test/factories/api-key.factory.ts create mode 100644 server/test/factories/partner.factory.ts create mode 100644 server/test/factories/session.factory.ts diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index d1a9f53a20..03cd0132c1 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -1,8 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { ReactionType } from 'src/dtos/activity.dto'; import { ActivityService } from 'src/services/activity.service'; +import { ActivityFactory } from 'test/factories/activity.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { getForActivity } from 'test/mappers'; -import { factory, newUuid, newUuids } from 'test/small.factory'; +import { newUuid, newUuids } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ActivityService.name, () => { @@ -24,7 +26,7 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined }); }); @@ -36,7 +38,7 @@ describe(ActivityService.name, () => { mocks.activity.search.mockResolvedValue([]); await expect( - sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }), + sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }), ).resolves.toEqual([]); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true }); @@ -48,7 +50,9 @@ describe(ActivityService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([]); - await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]); + await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual( + [], + ); expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false }); }); @@ -61,7 +65,10 @@ describe(ActivityService.name, () => { mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); - await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 }); + await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({ + comments: 1, + likes: 3, + }); }); }); @@ -70,18 +77,18 @@ describe(ActivityService.name, () => { const [albumId, assetId] = newUuids(); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a comment', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ albumId, assetId, userId }); + const activity = ActivityFactory.create({ albumId, assetId, userId }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); - await sut.create(factory.auth({ user: { id: userId } }), { + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.COMMENT, @@ -99,38 +106,38 @@ describe(ActivityService.name, () => { it('should fail because activity is disabled for the album', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId }); + const activity = ActivityFactory.create({ albumId, assetId }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); await expect( - sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), + sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create a like', async () => { const [albumId, assetId, userId] = newUuids(); - const activity = factory.activity({ userId, albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true }); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.create.mockResolvedValue(getForActivity(activity)); mocks.activity.search.mockResolvedValue([]); - await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true }); }); it('should skip if like exists', async () => { const [albumId, assetId] = newUuids(); - const activity = factory.activity({ albumId, assetId, isLiked: true }); + const activity = ActivityFactory.create({ albumId, assetId, isLiked: true }); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.activity.search.mockResolvedValue([getForActivity(activity)]); - await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE }); + await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE }); expect(mocks.activity.create).not.toHaveBeenCalled(); }); @@ -138,29 +145,29 @@ describe(ActivityService.name, () => { describe('delete', () => { it('should require access', async () => { - await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException); expect(mocks.activity.delete).not.toHaveBeenCalled(); }); it('should let the activity owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); it('should let the album owner delete a comment', async () => { - const activity = factory.activity(); + const activity = ActivityFactory.create(); mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id])); mocks.activity.delete.mockResolvedValue(); - await sut.delete(factory.auth(), activity.id); + await sut.delete(AuthFactory.create(), activity.id); expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id); }); diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 3a31dbbea1..68165d642f 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -1,7 +1,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Permission } from 'src/enum'; import { ApiKeyService } from 'src/services/api-key.service'; -import { factory, newUuid } from 'test/small.factory'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(ApiKeyService.name, () => { @@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => { }); it('should not require a name', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the api key does not have sufficient permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.AssetRead] }) + .build(); await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf( BadRequestException, @@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => { describe('update', () => { it('should throw an error if the key is not found', async () => { const id = newUuid(); - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => { }); it('should update a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newName = 'New name'; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => { }); it('should update permissions', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate]; mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => { describe('api key auth', () => { it('should prevent adding Permission.all', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => { it('should prevent adding a new permission', async () => { const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead]; - const auth = factory.auth({ apiKey: { permissions } }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions }); + const auth = AuthFactory.from().apiKey({ permissions }).build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => { }); it('should allow removing permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } }); - const apiKey = factory.apiKey({ + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead, Permission.AssetDelete], }); @@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => { }); it('should allow adding new permissions', async () => { - const auth = factory.auth({ - apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }, - }); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] }); + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); @@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => { describe('delete', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => { }); it('should delete a key', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.delete.mockResolvedValue(); @@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => { describe('getMine', () => { it('should not work with a session token', async () => { - const session = factory.session(); - const auth = factory.auth({ session }); + const session = SessionFactory.create(); + const auth = AuthFactory.from().session(session).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the key is not found', async () => { - const apiKey = factory.authApiKey(); - const auth = factory.auth({ apiKey }); + const apiKey = ApiKeyFactory.create(); + const auth = AuthFactory.from().apiKey(apiKey).build(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => { describe('getById', () => { it('should throw an error if the key is not found', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); const id = newUuid(); mocks.apiKey.getById.mockResolvedValue(void 0); @@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => { }); it('should get a key by id', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getById.mockResolvedValue(apiKey); @@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => { describe('getAll', () => { it('should return all the keys for a user', async () => { - const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id }); + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); mocks.apiKey.getByUserId.mockResolvedValue([apiKey]); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 7da0452d45..718ec00f1d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -7,6 +7,7 @@ import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; @@ -80,8 +81,8 @@ describe(AssetService.name, () => { }); it('should not include partner assets if not in timeline', async () => { - const partner = factory.partner({ inTimeline: false }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: false }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); @@ -92,8 +93,8 @@ describe(AssetService.name, () => { }); it('should include partner assets if in timeline', async () => { - const partner = factory.partner({ inTimeline: true }); - const auth = factory.auth({ user: { id: partner.sharedWithId } }); + const partner = PartnerFactory.create({ inTimeline: true }); + const auth = AuthFactory.create({ id: partner.sharedWithId }); mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 81f601da0a..f2cc3ada95 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; import { UserMetadataItem } from 'src/types'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { factory, newUuid } from 'test/small.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const oauthResponse = ({ @@ -91,8 +95,8 @@ describe(AuthService.name, () => { }); it('should successfully log the user in', async () => { - const user = { ...(factory.user() as UserAdmin), password: 'immich_password' }; - const session = factory.session(); + const user = UserFactory.create({ password: 'immich_password' }); + const session = SessionFactory.create(); mocks.user.getByEmail.mockResolvedValue(user); mocks.session.create.mockResolvedValue(session); @@ -113,8 +117,8 @@ describe(AuthService.name, () => { describe('changePassword', () => { it('should change the password', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -132,8 +136,8 @@ describe(AuthService.name, () => { }); it('should throw when password does not match existing password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.crypto.compareBcrypt.mockReturnValue(false); @@ -144,8 +148,8 @@ describe(AuthService.name, () => { }); it('should throw when user does not have a password', async () => { - const user = factory.user(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password' }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' }); @@ -154,8 +158,8 @@ describe(AuthService.name, () => { }); it('should change the password and logout other sessions', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true }; mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' }); @@ -175,7 +179,7 @@ describe(AuthService.name, () => { describe('logout', () => { it('should return the end session endpoint', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -186,7 +190,7 @@ describe(AuthService.name, () => { }); it('should return the default redirect', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, @@ -262,11 +266,11 @@ describe(AuthService.name, () => { }); it('should validate using authorization header', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -340,7 +344,7 @@ describe(AuthService.name, () => { }); it('should accept a base64url key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -361,7 +365,7 @@ describe(AuthService.name, () => { }); it('should accept a hex key', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, user } as any; mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); @@ -396,7 +400,7 @@ describe(AuthService.name, () => { }); it('should accept a valid slug', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any; mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink); @@ -428,11 +432,11 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), pinExpiresAt: null, appVersion: null, }; @@ -455,11 +459,11 @@ describe(AuthService.name, () => { }); it('should throw if admin route and not an admin', async () => { - const session = factory.session(); + const session = SessionFactory.create(); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -477,11 +481,11 @@ describe(AuthService.name, () => { }); it('should update when access time exceeds an hour', async () => { - const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); + const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); const sessionWithToken = { id: session.id, updatedAt: session.updatedAt, - user: factory.authUser(), + user: UserFactory.create(), isPendingSyncReset: false, pinExpiresAt: null, appVersion: null, @@ -517,8 +521,8 @@ describe(AuthService.name, () => { }); it('should throw an error if api key has insufficient permissions', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -533,8 +537,8 @@ describe(AuthService.name, () => { }); it('should default to requiring the all permission when omitted', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] }); mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); @@ -548,10 +552,12 @@ describe(AuthService.name, () => { }); it('should not require any permission when metadata is set to `false`', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); const result = sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, @@ -562,10 +568,12 @@ describe(AuthService.name, () => { }); it('should return an auth dto', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [Permission.All] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] }) + .user(authUser) + .build(); - mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser }); + mocks.apiKey.getKey.mockResolvedValue(authApiKey); await expect( sut.authenticate({ @@ -629,12 +637,12 @@ describe(AuthService.name, () => { }); it('should link an existing user', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.user.getByEmail.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -649,7 +657,7 @@ describe(AuthService.name, () => { }); it('should not link to a user with a different oauth sub', async () => { - const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' }); + const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.user.getByEmail.mockResolvedValueOnce(user); @@ -669,13 +677,13 @@ describe(AuthService.name, () => { }); it('should allow auto registering by default', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -690,13 +698,13 @@ describe(AuthService.name, () => { }); it('should throw an error if user should be auto registered but the email claim does not exist', async () => { - const user = factory.userAdmin({ isAdmin: true }); + const user = UserFactory.create({ isAdmin: true }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect( @@ -717,11 +725,11 @@ describe(AuthService.name, () => { 'app.immich:///oauth-callback?code=abc123', ]) { it(`should use the mobile redirect override for a url of ${url}`, async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.user.getByOAuthId.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails); @@ -735,13 +743,13 @@ describe(AuthService.name, () => { } it('should use the default quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -755,14 +763,14 @@ describe(AuthService.name, () => { }); it('should ignore an invalid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -776,14 +784,14 @@ describe(AuthService.name, () => { }); it('should ignore a negative quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 }); mocks.user.getAdmin.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -797,14 +805,14 @@ describe(AuthService.name, () => { }); it('should set quota for 0 quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 }); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -825,15 +833,15 @@ describe(AuthService.name, () => { }); it('should use a valid storage quota', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -855,7 +863,7 @@ describe(AuthService.name, () => { it('should sync the profile picture', async () => { const fileId = newUuid(); - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg'; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); @@ -871,7 +879,7 @@ describe(AuthService.name, () => { data: new Uint8Array([1, 2, 3, 4, 5]).buffer, }); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -889,7 +897,7 @@ describe(AuthService.name, () => { }); it('should not sync the profile picture if the user already has one', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); + const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.oauth.getProfile.mockResolvedValue({ @@ -899,7 +907,7 @@ describe(AuthService.name, () => { }); mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -914,15 +922,15 @@ describe(AuthService.name, () => { }); it('should only allow "admin" and "user" for the role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' }); mocks.user.getByEmail.mockResolvedValue(void 0); - mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -943,14 +951,14 @@ describe(AuthService.name, () => { }); it('should create an admin user if the role claim is set to admin', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' }); mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -971,7 +979,7 @@ describe(AuthService.name, () => { }); it('should accept a custom role claim', async () => { - const user = factory.userAdmin({ oauthId: 'oauth-id' }); + const user = UserFactory.create({ oauthId: 'oauth-id' }); mocks.systemMetadata.get.mockResolvedValue({ oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' }, @@ -980,7 +988,7 @@ describe(AuthService.name, () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.create.mockResolvedValue(user); - mocks.session.create.mockResolvedValue(factory.session()); + mocks.session.create.mockResolvedValue(SessionFactory.create()); await expect( sut.callback( @@ -1003,8 +1011,8 @@ describe(AuthService.name, () => { describe('link', () => { it('should link an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ apiKey: { permissions: [] }, user }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1019,8 +1027,8 @@ describe(AuthService.name, () => { }); it('should not link an already linked oauth.sub', async () => { - const authUser = factory.authUser(); - const authApiKey = factory.authApiKey({ permissions: [] }); + const authUser = UserFactory.create(); + const authApiKey = ApiKeyFactory.create({ permissions: [] }); const auth = { user: authUser, apiKey: authApiKey }; mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); @@ -1036,8 +1044,8 @@ describe(AuthService.name, () => { describe('unlink', () => { it('should unlink an account', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user, apiKey: { permissions: [] } }); + const user = UserFactory.create(); + const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.user.update.mockResolvedValue(user); @@ -1050,8 +1058,8 @@ describe(AuthService.name, () => { describe('setupPinCode', () => { it('should setup a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' }); @@ -1065,8 +1073,8 @@ describe(AuthService.name, () => { }); it('should fail if the user already has a PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1076,8 +1084,8 @@ describe(AuthService.name, () => { describe('changePinCode', () => { it('should change the PIN code', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); const dto = { pinCode: '123456', newPinCode: '012345' }; mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); @@ -1091,37 +1099,37 @@ describe(AuthService.name, () => { }); it('should fail if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); await expect( - sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }), + sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }), ).rejects.toThrow('Wrong PIN code'); }); }); describe('resetPinCode', () => { it('should reset the PIN code', async () => { - const currentSession = factory.session(); - const user = factory.userAdmin(); + const currentSession = SessionFactory.create(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); - await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); + await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { - const user = factory.userAdmin(); + const user = UserFactory.create(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); + await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code'); }); }); }); diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 36a3d2eb2c..347d9eef00 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,7 +1,7 @@ import { jwtVerify } from 'jose'; import { MaintenanceAction, SystemMetadataKey } from 'src/enum'; import { CliService } from 'src/services/cli.service'; -import { factory } from 'test/small.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe, it } from 'vitest'; @@ -15,7 +15,7 @@ describe(CliService.name, () => { describe('listUsers', () => { it('should list users', async () => { - mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]); + mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]); await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true }); }); @@ -32,10 +32,10 @@ describe(CliService.name, () => { }); it('should default to a random password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); - mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true })); + mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); const ask = vitest.fn().mockImplementation(() => {}); @@ -50,7 +50,7 @@ describe(CliService.name, () => { }); it('should use the supplied password', async () => { - const admin = factory.userAdmin({ isAdmin: true }); + const admin = UserFactory.create({ isAdmin: true }); mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index 287c5c7c63..fdf7aee68b 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -2,9 +2,9 @@ import { MapService } from 'src/services/map.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { userStub } from 'test/fixtures/user.stub'; import { getForAlbum, getForPartner } from 'test/mappers'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MapService.name, () => { @@ -40,7 +40,7 @@ describe(MapService.name, () => { it('should include partner assets', async () => { const auth = AuthFactory.create(); - const partner = factory.partner({ sharedWithId: auth.user.id }); + const partner = PartnerFactory.create({ sharedWithId: auth.user.id }); const asset = AssetFactory.from() .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index 0f80ca84f1..029462a865 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,9 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { PartnerDirection } from 'src/repositories/partner.repository'; import { PartnerService } from 'src/services/partner.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { UserFactory } from 'test/factories/user.factory'; -import { getDehydrated, getForPartner } from 'test/mappers'; -import { factory } from 'test/small.factory'; +import { getForPartner } from 'test/mappers'; import { newTestService, ServiceMocks } from 'test/utils'; describe(PartnerService.name, () => { @@ -22,15 +23,9 @@ describe(PartnerService.name, () => { it("should return a list of partners with whom I've shared my library", async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const sharedWithUser2 = factory.partner({ - sharedBy: getDehydrated(user1), - sharedWith: getDehydrated(user2), - }); - const sharedWithUser1 = factory.partner({ - sharedBy: getDehydrated(user2), - sharedWith: getDehydrated(user1), - }); - const auth = factory.auth({ user: { id: user1.id } }); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); @@ -41,15 +36,9 @@ describe(PartnerService.name, () => { it('should return a list of partners who have shared their libraries with me', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const sharedWithUser2 = factory.partner({ - sharedBy: getDehydrated(user1), - sharedWith: getDehydrated(user2), - }); - const sharedWithUser1 = factory.partner({ - sharedBy: getDehydrated(user2), - sharedWith: getDehydrated(user1), - }); - const auth = factory.auth({ user: { id: user1.id } }); + const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]); await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined(); @@ -61,8 +50,8 @@ describe(PartnerService.name, () => { it('should create a new partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(void 0); mocks.partner.create.mockResolvedValue(getForPartner(partner)); @@ -78,8 +67,8 @@ describe(PartnerService.name, () => { it('should throw an error when the partner already exists', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(getForPartner(partner)); @@ -93,8 +82,8 @@ describe(PartnerService.name, () => { it('should remove a partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.partner.get.mockResolvedValue(getForPartner(partner)); @@ -104,8 +93,8 @@ describe(PartnerService.name, () => { }); it('should throw an error when the partner does not exist', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); mocks.partner.get.mockResolvedValue(void 0); @@ -117,8 +106,8 @@ describe(PartnerService.name, () => { describe('update', () => { it('should require access', async () => { - const user2 = factory.user(); - const auth = factory.auth(); + const user2 = UserFactory.create(); + const auth = AuthFactory.create(); await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException); }); @@ -126,8 +115,8 @@ describe(PartnerService.name, () => { it('should update partner', async () => { const user1 = UserFactory.create(); const user2 = UserFactory.create(); - const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) }); - const auth = factory.auth({ user: { id: user1.id } }); + const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build(); + const auth = AuthFactory.create({ id: user1.id }); mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id])); mocks.partner.update.mockResolvedValue(getForPartner(partner)); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 7eacd148ad..8f4409a508 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,7 +1,8 @@ import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { SessionFactory } from 'test/factories/session.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { @@ -25,9 +26,9 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - const currentSession = factory.session(); - const otherSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const otherSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); @@ -42,8 +43,8 @@ describe('SessionService', () => { describe('logoutDevices', () => { it('should logout all devices', async () => { - const currentSession = factory.session(); - const auth = factory.auth({ session: currentSession }); + const currentSession = SessionFactory.create(); + const auth = AuthFactory.from().session(currentSession).build(); mocks.session.invalidate.mockResolvedValue(); diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 3b7fbfcd95..234e3ac223 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,6 +1,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SyncService } from 'src/services/sync.service'; import { AssetFactory } from 'test/factories/asset.factory'; +import { PartnerFactory } from 'test/factories/partner.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { getForAsset, getForPartner } from 'test/mappers'; import { factory } from 'test/small.factory'; @@ -42,7 +43,7 @@ describe(SyncService.name, () => { describe('getChangesForDeltaSync', () => { it('should return a response requiring a full sync when partners are out of sync', async () => { - const partner = factory.partner(); + const partner = PartnerFactory.create(); const auth = factory.auth({ user: { id: partner.sharedWithId } }); mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]); diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index d8e13fcfbd..49aefaa870 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; import { JobName, UserStatus } from 'src/enum'; import { UserAdminService } from 'src/services/user-admin.service'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; import { describe } from 'vitest'; @@ -126,8 +127,8 @@ describe(UserAdminService.name, () => { }); it('should not allow deleting own account', async () => { - const user = factory.userAdmin({ isAdmin: false }); - const auth = factory.auth({ user }); + const user = UserFactory.create({ isAdmin: false }); + const auth = AuthFactory.create(user); mocks.user.get.mockResolvedValue(user); await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index bd896ffc24..0dc83928fc 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -3,10 +3,11 @@ import { UserAdmin } from 'src/database'; import { CacheControl, JobName, UserMetadataKey } from 'src/enum'; import { UserService } from 'src/services/user.service'; import { ImmichFileResponse } from 'src/utils/file'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; const makeDeletedAt = (daysAgo: number) => { @@ -28,8 +29,8 @@ describe(UserService.name, () => { describe('getAll', () => { it('admin should get all users', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -39,8 +40,8 @@ describe(UserService.name, () => { }); it('non-admin should get all users when publicUsers enabled', async () => { - const user = factory.userAdmin(); - const auth = factory.auth({ user }); + const user = UserFactory.create(); + const auth = AuthFactory.create(user); mocks.user.getList.mockResolvedValue([user]); @@ -105,7 +106,7 @@ describe(UserService.name, () => { it('should throw an error if the user profile could not be updated with the new image', async () => { const file = { path: '/profile/path' } as Express.Multer.File; - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error')); @@ -113,7 +114,7 @@ describe(UserService.name, () => { }); it('should delete the previous profile image', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const file = { path: '/profile/path' } as Express.Multer.File; const files = [user.profileImagePath]; @@ -149,7 +150,7 @@ describe(UserService.name, () => { }); it('should delete the profile image if user has one', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); const files = [user.profileImagePath]; mocks.user.get.mockResolvedValue(user); @@ -178,7 +179,7 @@ describe(UserService.name, () => { }); it('should return the profile picture', async () => { - const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' }); + const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' }); mocks.user.get.mockResolvedValue(user); await expect(sut.getProfileImage(user.id)).resolves.toEqual( @@ -205,7 +206,7 @@ describe(UserService.name, () => { }); it('should queue user ready for deletion', async () => { - const user = factory.user(); + const user = UserFactory.create(); mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]); await sut.handleUserDeleteCheck(); diff --git a/server/test/factories/activity.factory.ts b/server/test/factories/activity.factory.ts new file mode 100644 index 0000000000..861b115158 --- /dev/null +++ b/server/test/factories/activity.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; +import { build } from 'test/factories/builder.factory'; +import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ActivityFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ActivityLike = {}) { + return ActivityFactory.from(dto).build(); + } + + static from(dto: ActivityLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ActivityFactory({ + albumId: newUuid(), + assetId: null, + comment: null, + createdAt: newDate(), + id: newUuid(), + isLiked: false, + userId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/api-key.factory.ts b/server/test/factories/api-key.factory.ts new file mode 100644 index 0000000000..d16b50ba57 --- /dev/null +++ b/server/test/factories/api-key.factory.ts @@ -0,0 +1,42 @@ +import { Selectable } from 'kysely'; +import { Permission } from 'src/enum'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; +import { build } from 'test/factories/builder.factory'; +import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ApiKeyFactory { + #user!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: ApiKeyLike = {}) { + return ApiKeyFactory.from(dto).build(); + } + + static from(dto: ApiKeyLike = {}) { + const userId = dto.userId ?? newUuid(); + return new ApiKeyFactory({ + createdAt: newDate(), + id: newUuid(), + key: Buffer.from('api-key-buffer'), + name: 'API Key', + permissions: [Permission.All], + updatedAt: newDate(), + updateId: newUuidV7(), + userId, + ...dto, + }).user({ id: userId }); + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#user = build(UserFactory.from(dto), builder); + this.value.userId = this.#user.build().id; + return this; + } + + build() { + return { ...this.value, user: this.#user.build() }; + } +} diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts index 9c738aabac..fd38c42649 100644 --- a/server/test/factories/auth.factory.ts +++ b/server/test/factories/auth.factory.ts @@ -1,12 +1,16 @@ import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiKeyFactory } from 'test/factories/api-key.factory'; import { build } from 'test/factories/builder.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; -import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; +import { newUuid } from 'test/small.factory'; export class AuthFactory { #user: UserFactory; #sharedLink?: SharedLinkFactory; + #apiKey?: ApiKeyFactory; + #session?: AuthDto['session']; private constructor(user: UserFactory) { this.#user = user; @@ -20,8 +24,8 @@ export class AuthFactory { return new AuthFactory(UserFactory.from(dto)); } - apiKey() { - // TODO + apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder) { + this.#apiKey = build(ApiKeyFactory.from(dto), builder); return this; } @@ -30,6 +34,11 @@ export class AuthFactory { return this; } + session(dto: Partial = {}) { + this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto }; + return this; + } + build(): AuthDto { const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); @@ -43,6 +52,8 @@ export class AuthFactory { quotaSizeInBytes, }, sharedLink: this.#sharedLink?.build(), + apiKey: this.#apiKey?.build(), + session: this.#session, }; } } diff --git a/server/test/factories/partner.factory.ts b/server/test/factories/partner.factory.ts new file mode 100644 index 0000000000..f631db1eb5 --- /dev/null +++ b/server/test/factories/partner.factory.ts @@ -0,0 +1,50 @@ +import { Selectable } from 'kysely'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { build } from 'test/factories/builder.factory'; +import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PartnerFactory { + #sharedWith!: UserFactory; + #sharedBy!: UserFactory; + + private constructor(private value: Selectable) {} + + static create(dto: PartnerLike = {}) { + return PartnerFactory.from(dto).build(); + } + + static from(dto: PartnerLike = {}) { + const sharedById = dto.sharedById ?? newUuid(); + const sharedWithId = dto.sharedWithId ?? newUuid(); + return new PartnerFactory({ + createdAt: newDate(), + createId: newUuidV7(), + inTimeline: true, + sharedById, + sharedWithId, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }) + .sharedBy({ id: sharedById }) + .sharedWith({ id: sharedWithId }); + } + + sharedWith(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedWith = build(UserFactory.from(dto), builder); + this.value.sharedWithId = this.#sharedWith.build().id; + return this; + } + + sharedBy(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#sharedBy = build(UserFactory.from(dto), builder); + this.value.sharedById = this.#sharedBy.build().id; + return this; + } + + build() { + return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() }; + } +} diff --git a/server/test/factories/session.factory.ts b/server/test/factories/session.factory.ts new file mode 100644 index 0000000000..8d4cb28727 --- /dev/null +++ b/server/test/factories/session.factory.ts @@ -0,0 +1,35 @@ +import { Selectable } from 'kysely'; +import { SessionTable } from 'src/schema/tables/session.table'; +import { SessionLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class SessionFactory { + private constructor(private value: Selectable) {} + + static create(dto: SessionLike = {}) { + return SessionFactory.from(dto).build(); + } + + static from(dto: SessionLike = {}) { + return new SessionFactory({ + appVersion: null, + createdAt: newDate(), + deviceOS: 'android', + deviceType: 'mobile', + expiresAt: null, + id: newUuid(), + isPendingSyncReset: false, + parentId: null, + pinExpiresAt: null, + token: Buffer.from('abc123'), + updateId: newUuidV7(), + updatedAt: newDate(), + userId: newUuid(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 0e070c1bcc..e2d9e4e1c3 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,13 +1,17 @@ import { Selectable } from 'kysely'; +import { ActivityTable } from 'src/schema/tables/activity.table'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; +import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; +import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -26,3 +30,7 @@ export type AssetFaceLike = Partial>; export type PersonLike = Partial>; export type StackLike = Partial>; export type MemoryLike = Partial>; +export type PartnerLike = Partial>; +export type ActivityLike = Partial>; +export type ApiKeyLike = Partial>; +export type SessionLike = Partial>; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index c734fdcb2d..57098e01ee 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,26 +1,7 @@ -import { ShallowDehydrateObject } from 'kysely'; -import { - Activity, - Album, - ApiKey, - AuthApiKey, - AuthSharedLink, - AuthUser, - Exif, - Library, - Partner, - Person, - Session, - Tag, - User, - UserAdmin, -} from 'src/database'; +import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; -import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum'; -import { UserMetadataItem } from 'src/types'; -import { UserFactory } from 'test/factories/user.factory'; +import { AssetFileType, Permission, UserStatus } from 'src/enum'; import { v4, v7 } from 'uuid'; export const newUuid = () => v4(); @@ -109,49 +90,6 @@ const authUserFactory = (authUser: Partial = {}) => { return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; }; -const partnerFactory = ({ - sharedBy: sharedByProvided, - sharedWith: sharedWithProvided, - ...partner -}: Partial = {}) => { - const hydrateUser = (user: Partial>) => ({ - ...user, - profileChangedAt: user.profileChangedAt ? new Date(user.profileChangedAt) : undefined, - }); - const sharedBy = UserFactory.create(sharedByProvided ? hydrateUser(sharedByProvided) : {}); - const sharedWith = UserFactory.create(sharedWithProvided ? hydrateUser(sharedWithProvided) : {}); - - return { - sharedById: sharedBy.id, - sharedBy, - sharedWithId: sharedWith.id, - sharedWith, - createId: newUuidV7(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - inTimeline: true, - ...partner, - }; -}; - -const sessionFactory = (session: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - deviceOS: 'android', - deviceType: 'mobile', - token: Buffer.from('abc123'), - parentId: null, - expiresAt: null, - userId: newUuid(), - pinExpiresAt: newDate(), - isPendingSyncReset: false, - appVersion: session.appVersion ?? null, - ...session, -}); - const queueStatisticsFactory = (dto?: Partial) => ({ active: 0, completed: 0, @@ -162,22 +100,6 @@ const queueStatisticsFactory = (dto?: Partial) => ({ ...dto, }); -const userFactory = (user: Partial = {}) => ({ - id: newUuid(), - name: 'Test User', - email: 'test@immich.cloud', - avatarColor: null, - profileImagePath: '', - profileChangedAt: newDate(), - metadata: [ - { - key: UserMetadataKey.Onboarding, - value: 'true', - }, - ] as UserMetadataItem[], - ...user, -}); - const userAdminFactory = (user: Partial = {}) => { const { id = newUuid(), @@ -219,34 +141,6 @@ const userAdminFactory = (user: Partial = {}) => { }; }; -const activityFactory = (activity: Omit, 'user'> = {}) => { - const userId = activity.userId || newUuid(); - return { - id: newUuid(), - comment: null, - isLiked: false, - userId, - user: UserFactory.create({ id: userId }), - assetId: newUuid(), - albumId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - ...activity, - }; -}; - -const apiKeyFactory = (apiKey: Partial = {}) => ({ - id: newUuid(), - userId: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - updateId: newUuidV7(), - name: 'Api Key', - permissions: [Permission.All], - ...apiKey, -}); - const libraryFactory = (library: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), @@ -328,88 +222,15 @@ const assetOcrFactory = ( ...ocr, }); -const tagFactory = (tag: Partial): Tag => ({ - id: newUuid(), - color: null, - createdAt: newDate(), - parentId: null, - updatedAt: newDate(), - value: `tag-${newUuid()}`, - ...tag, -}); - -const assetEditFactory = (edit?: Partial): AssetEditActionItem => { - switch (edit?.action) { - case AssetEditAction.Crop: { - return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit }; - } - case AssetEditAction.Mirror: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit }; - } - case AssetEditAction.Rotate: { - return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit }; - } - default: { - return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }; - } - } -}; - -const personFactory = (person?: Partial): Person => ({ - birthDate: newDate(), - color: null, - createdAt: newDate(), - faceAssetId: null, - id: newUuid(), - isFavorite: false, - isHidden: false, - name: 'person', - ownerId: newUuid(), - thumbnailPath: '/path/to/person/thumbnail.jpg', - updatedAt: newDate(), - updateId: newUuidV7(), - ...person, -}); - -const albumFactory = (album?: Partial>) => ({ - albumName: 'My Album', - albumThumbnailAssetId: null, - albumUsers: [], - assets: [], - createdAt: newDate(), - deletedAt: null, - description: 'Album description', - id: newUuid(), - isActivityEnabled: false, - order: AssetOrder.Desc, - ownerId: newUuid(), - sharedLinks: [], - updatedAt: newDate(), - updateId: newUuidV7(), - ...album, -}); - export const factory = { - activity: activityFactory, - apiKey: apiKeyFactory, assetOcr: assetOcrFactory, auth: authFactory, - authApiKey: authApiKeyFactory, - authUser: authUserFactory, library: libraryFactory, - partner: partnerFactory, queueStatistics: queueStatisticsFactory, - session: sessionFactory, - user: userFactory, - userAdmin: userAdminFactory, versionHistory: versionHistoryFactory, jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, - person: personFactory, - assetEdit: assetEditFactory, - tag: tagFactory, - album: albumFactory, uuid: newUuid, buffer: () => Buffer.from('this is a fake buffer'), date: newDate, From 990aff441bb52642dd6c1e66d652d993c5f3845e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 12 Mar 2026 16:10:55 -0400 Subject: [PATCH 004/144] fix: add to shared link (#26886) --- .../src/repositories/shared-link.repository.ts | 16 +++++++++++++++- server/src/services/asset-media.service.ts | 9 +++++++++ server/src/services/shared-link.service.ts | 6 ++++++ .../share-page/individual-shared-viewer.svelte | 16 +++------------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 48afcf7d92..bc81e75c81 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -4,7 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import _ from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Album, columns } from 'src/database'; -import { DummyValue, GenerateSql } from 'src/decorators'; +import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { SharedLinkType } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -249,6 +249,20 @@ export class SharedLinkRepository { await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute(); } + @ChunkedArray({ paramIndex: 1 }) + async addAssets(id: string, assetIds: string[]) { + if (assetIds.length === 0) { + return []; + } + + return await this.db + .insertInto('shared_link_asset') + .values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id }))) + .onConflict((oc) => oc.doNothing()) + .returning(['shared_link_asset.assetId']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID] }) private getSharedLinks(id: string) { return this.db diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 020bda4df7..3c981ea61e 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -151,6 +151,10 @@ export class AssetMediaService extends BaseService { } const asset = await this.create(auth.user.id, dto, file, sidecarFile); + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]); + } + await this.userRepository.updateUsage(auth.user.id, file.size); return { id: asset.id, status: AssetMediaStatus.CREATED }; @@ -341,6 +345,11 @@ export class AssetMediaService extends BaseService { this.logger.error(`Error locating duplicate for checksum constraint`); throw new InternalServerErrorException(); } + + if (auth.sharedLink) { + await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]); + } + return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index b942c32326..26b15031ee 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { + if (auth.sharedLink) { + this.logger.deprecate( + 'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication', + ); + } + const sharedLink = await this.findOrFail(auth.user.id, id); if (sharedLink.type !== SharedLinkType.Individual) { diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 94e00500fb..64eb98bec0 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -16,7 +16,7 @@ import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { handleError } from '$lib/utils/handle-error'; import { toTimelineAsset } from '$lib/utils/timeline-util'; - import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; + import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk'; import { IconButton, Logo, toastManager } from '@immich/ui'; import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -48,21 +48,11 @@ const handleUploadAssets = async (files: File[] = []) => { try { - let results: (string | undefined)[] = []; - results = await (!files || files.length === 0 || !Array.isArray(files) + await (!files || files.length === 0 || !Array.isArray(files) ? openFileUploadDialog() : fileUploadHandler({ files })); - const data = await addSharedLinkAssets({ - ...authManager.params, - id: sharedLink.id, - assetIdsDto: { - assetIds: results.filter((id) => !!id) as string[], - }, - }); - const added = data.filter((item) => item.success).length; - - toastManager.success($t('assets_added_count', { values: { count: added } })); + toastManager.success(); } catch (error) { handleError(error, $t('errors.unable_to_add_assets_to_shared_link')); } From f3b7cd6198365a7fc38e6b46e18dcb599bfbc448 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 12 Mar 2026 15:15:21 -0500 Subject: [PATCH 005/144] refactor: move encoded video to asset files table (#26863) * refactor: move encoded video to asset files table * chore: update --- server/src/cores/storage.core.ts | 23 +++++------- server/src/database.ts | 1 - server/src/dtos/asset-response.dto.ts | 1 - server/src/enum.ts | 1 + server/src/queries/asset.job.repository.sql | 36 ++++++++++++++----- server/src/queries/asset.repository.sql | 16 ++++++--- .../src/repositories/asset-job.repository.ts | 24 +++++++++---- server/src/repositories/asset.repository.ts | 21 +++++++++-- .../src/repositories/database.repository.ts | 1 - .../1773242919341-EncodedVideoAssetFiles.ts | 25 +++++++++++++ server/src/schema/tables/asset.table.ts | 3 -- .../src/services/asset-media.service.spec.ts | 17 ++++++--- server/src/services/asset.service.ts | 2 +- server/src/services/media.service.spec.ts | 6 ++-- server/src/services/media.service.ts | 16 ++++++--- server/src/utils/asset.util.ts | 2 ++ server/src/utils/database.ts | 21 +++++++++-- server/test/factories/asset.factory.ts | 1 - server/test/mappers.ts | 1 - 19 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index c6821404dc..3345f6e129 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -154,10 +154,11 @@ export class StorageCore { } async moveAssetVideo(asset: StorageAsset) { + const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); return this.moveFile({ entityId: asset.id, pathType: AssetPathType.EncodedVideo, - oldPath: asset.encodedVideoPath, + oldPath: encodedVideoFile?.path || null, newPath: StorageCore.getEncodedVideoPath(asset), }); } @@ -303,21 +304,15 @@ export class StorageCore { case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); } - case AssetFileType.FullSize: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); - } - case AssetFileType.Preview: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); - } - case AssetFileType.Thumbnail: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); - } - case AssetPathType.EncodedVideo: { - return this.assetRepository.update({ id, encodedVideoPath: newPath }); - } + + case AssetFileType.FullSize: + case AssetFileType.EncodedVideo: + case AssetFileType.Thumbnail: + case AssetFileType.Preview: case AssetFileType.Sidecar: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath }); + return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath }); } + case PersonPathType.Face: { return this.personRepository.update({ id, thumbnailPath: newPath }); } diff --git a/server/src/database.ts b/server/src/database.ts index fc790259d1..3e3192c21a 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -154,7 +154,6 @@ export type StorageAsset = { id: string; ownerId: string; files: AssetFile[]; - encodedVideoPath: string | null; }; export type Stack = { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 644c9caeb8..8b38b2e124 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -153,7 +153,6 @@ export type MapAsset = { duplicateId: string | null; duration: string | null; edits?: ShallowDehydrateObject[]; - encodedVideoPath: string | null; exifInfo?: ShallowDehydrateObject> | null; faces?: ShallowDehydrateObject[]; fileCreatedAt: Date; diff --git a/server/src/enum.ts b/server/src/enum.ts index 887c8fa93c..60f45efd6e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,7 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + EncodedVideo = 'encoded_video', } export enum AlbumUserRole { diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index a9c407782b..cebb9fe95e 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -175,7 +175,6 @@ where select "asset"."id", "asset"."ownerId", - "asset"."encodedVideoPath", ( select coalesce(json_agg(agg), '[]') @@ -463,7 +462,6 @@ select "asset"."libraryId", "asset"."ownerId", "asset"."livePhotoVideoId", - "asset"."encodedVideoPath", "asset"."originalPath", "asset"."isOffline", to_json("asset_exif") as "exifInfo", @@ -521,12 +519,17 @@ select from "asset" where - "asset"."type" = $1 - and ( - "asset"."encodedVideoPath" is null - or "asset"."encodedVideoPath" = $2 + "asset"."type" = 'VIDEO' + and not exists ( + select + "asset_file"."id" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = 'encoded_video' ) - and "asset"."visibility" != $3 + and "asset"."visibility" != 'hidden' and "asset"."deletedAt" is null -- AssetJobRepository.getForVideoConversion @@ -534,12 +537,27 @@ select "asset"."id", "asset"."ownerId", "asset"."originalPath", - "asset"."encodedVideoPath" + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_file"."id", + "asset_file"."path", + "asset_file"."type", + "asset_file"."isEdited" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + ) as agg + ) as "files" from "asset" where "asset"."id" = $1 - and "asset"."type" = $2 + and "asset"."type" = 'VIDEO' -- AssetJobRepository.streamForMetadataExtraction select diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index a74a05f466..a2525c3b17 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -629,13 +629,21 @@ order by -- AssetRepository.getForVideo select - "asset"."encodedVideoPath", - "asset"."originalPath" + "asset"."originalPath", + ( + select + "asset_file"."path" + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $1 + ) as "encodedVideoPath" from "asset" where - "asset"."id" = $1 - and "asset"."type" = $2 + "asset"."id" = $2 + and "asset"."type" = $3 -- AssetRepository.getForOcr select diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index a8067473e4..3765cad7ed 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -104,7 +104,7 @@ export class AssetJobRepository { getForMigrationJob(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId']) .select(withFiles) .where('asset.id', '=', id) .executeTakeFirst(); @@ -268,7 +268,6 @@ export class AssetJobRepository { 'asset.libraryId', 'asset.ownerId', 'asset.livePhotoVideoId', - 'asset.encodedVideoPath', 'asset.originalPath', 'asset.isOffline', ]) @@ -310,11 +309,21 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .select(['asset.id']) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .$if(!force, (qb) => qb - .where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')])) - .where('asset.visibility', '!=', AssetVisibility.Hidden), + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('asset_file') + .select('asset_file.id') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)), + ), + ), + ) + .where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), ) .where('asset.deletedAt', 'is', null) .stream(); @@ -324,9 +333,10 @@ export class AssetJobRepository { getForVideoConversion(id: string) { return this.db .selectFrom('asset') - .select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath']) + .select(['asset.id', 'asset.ownerId', 'asset.originalPath']) + .select(withFiles) .where('asset.id', '=', id) - .where('asset.type', '=', AssetType.Video) + .where('asset.type', '=', sql.lit(AssetType.Video)) .executeTakeFirst(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 200137a137..2e1d02ef28 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -36,6 +36,7 @@ import { withExif, withFaces, withFacesAndPeople, + withFilePath, withFiles, withLibrary, withOwner, @@ -1019,8 +1020,21 @@ export class AssetRepository { .execute(); } - async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise { - await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute(); + async deleteFile({ + assetId, + type, + edited, + }: { + assetId: string; + type: AssetFileType; + edited?: boolean; + }): Promise { + await this.db + .deleteFrom('asset_file') + .where('assetId', '=', asUuid(assetId)) + .where('type', '=', type) + .$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!)) + .execute(); } async deleteFiles(files: Pick, 'id'>[]): Promise { @@ -1139,7 +1153,8 @@ export class AssetRepository { async getForVideo(id: string) { return this.db .selectFrom('asset') - .select(['asset.encodedVideoPath', 'asset.originalPath']) + .select(['asset.originalPath']) + .select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath')) .where('asset.id', '=', id) .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 4ffb37c79c..7ae1119bbc 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -431,7 +431,6 @@ export class DatabaseRepository { .updateTable('asset') .set((eb) => ({ originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]), - encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]), })) .execute(); diff --git a/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts new file mode 100644 index 0000000000..4a62a7e842 --- /dev/null +++ b/server/src/schema/migrations/1773242919341-EncodedVideoAssetFiles.ts @@ -0,0 +1,25 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + INSERT INTO "asset_file" ("assetId", "type", "path") + SELECT "id", 'encoded_video', "encodedVideoPath" + FROM "asset" + WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != ''; + `.execute(db); + + await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db); + + await sql` + UPDATE "asset" + SET "encodedVideoPath" = af."path" + FROM "asset_file" af + WHERE "asset"."id" = af."assetId" + AND af."type" = 'encoded_video' + AND af."isEdited" = false; + `.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 12e9c36125..8bdaa59bc6 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -92,9 +92,6 @@ export class AssetTable { @Column({ type: 'character varying', nullable: true }) duration!: string | null; - @Column({ type: 'character varying', nullable: true, default: '' }) - encodedVideoPath!: string | null; - @Column({ type: 'bytea', index: true }) checksum!: Buffer; // sha1 checksum diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index f49dd3cb50..1bf8bafdf7 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -163,7 +163,6 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - encodedVideoPath: '', duration: '0:00:00.000000', files: [] as AssetFile[], exifInfo: { @@ -711,13 +710,18 @@ describe(AssetMediaService.name, () => { }); it('should return the encoded video path if available', async () => { - const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' }); + const asset = AssetFactory.from() + .file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' }) + .build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: asset.files[0].path, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ - path: asset.encodedVideoPath!, + path: '/path/to/encoded/video.mp4', cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), @@ -727,7 +731,10 @@ describe(AssetMediaService.name, () => { it('should fall back to the original path', async () => { const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.asset.getForVideo.mockResolvedValue(asset); + mocks.asset.getForVideo.mockResolvedValue({ + originalPath: asset.originalPath, + encodedVideoPath: null, + }); await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual( new ImmichFileResponse({ diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 387b700f01..1e5d23a98d 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -370,7 +370,7 @@ export class AssetService extends BaseService { assetFiles.editedFullsizeFile?.path, assetFiles.editedPreviewFile?.path, assetFiles.editedThumbnailFile?.path, - asset.encodedVideoPath, + assetFiles.encodedVideoFile?.path, ]; if (deleteOnDisk && !asset.isOffline) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 279d57becd..51a10a39c2 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2254,7 +2254,9 @@ describe(MediaService.name, () => { }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' }); + const asset = AssetFactory.from({ type: AssetType.Video }) + .file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' }) + .build(); mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); @@ -2264,7 +2266,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, - data: { files: [asset.encodedVideoPath] }, + data: { files: ['/encoded/video/path.mp4'] }, }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 8158ade192..ea0b1e9142 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -39,7 +39,7 @@ import { VideoInterfaces, VideoStreamInfo, } from 'src/types'; -import { getDimensions } from 'src/utils/asset.util'; +import { getAssetFile, getDimensions } from 'src/utils/asset.util'; import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; @@ -605,10 +605,11 @@ export class MediaService extends BaseService { let { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream); if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) { - if (asset.encodedVideoPath) { + const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false }); + if (encodedVideo) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); - await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } }); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } }); + await this.assetRepository.deleteFiles([encodedVideo]); } else { this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } @@ -656,7 +657,12 @@ export class MediaService extends BaseService { this.logger.log(`Successfully encoded ${asset.id}`); - await this.assetRepository.update({ id: asset.id, encodedVideoPath: output }); + await this.assetRepository.upsertFile({ + assetId: asset.id, + type: AssetFileType.EncodedVideo, + path: output, + isEdited: false, + }); return JobStatus.Success; } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index d6ab825028..5420e60361 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -26,6 +26,8 @@ export const getAssetFiles = (files: AssetFile[]) => ({ editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }), + + encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }), }); export const addAssets = async ( diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 2e22a9f479..03998d9462 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -355,7 +355,16 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) - .$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!)) + .$if(!!options.encodedVideoPath, (qb) => + qb + .innerJoin('asset_file', (join) => + join + .onRef('asset.id', '=', 'asset_file.assetId') + .on('asset_file.type', '=', AssetFileType.EncodedVideo) + .on('asset_file.isEdited', '=', false), + ) + .where('asset_file.path', '=', options.encodedVideoPath!), + ) .$if(!!options.originalPath, (qb) => qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), ) @@ -380,7 +389,15 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!)) .$if(options.isEncoded !== undefined, (qb) => - qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), + qb.where((eb) => { + const exists = eb.exists((eb) => + eb + .selectFrom('asset_file') + .whereRef('assetId', '=', 'asset.id') + .where('type', '=', AssetFileType.EncodedVideo), + ); + return options.isEncoded ? exists : eb.not(exists); + }), ) .$if(options.isMotion !== undefined, (qb) => qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 4d54ba820b..ec596dc86e 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -55,7 +55,6 @@ export class AssetFactory { deviceId: '', duplicateId: null, duration: null, - encodedVideoPath: null, fileCreatedAt: newDate(), fileModifiedAt: newDate(), isExternal: false, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 73c1bcd6d7..7f324663be 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -183,7 +183,6 @@ export const getForAssetDeletion = (asset: ReturnType) => libraryId: asset.libraryId, ownerId: asset.ownerId, livePhotoVideoId: asset.livePhotoVideoId, - encodedVideoPath: asset.encodedVideoPath, originalPath: asset.originalPath, isOffline: asset.isOffline, exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null, From c91d8745b460736fa2bb878857e26cf4d3891457 Mon Sep 17 00:00:00 2001 From: luis15pt <100942871+luis15pt@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:27:44 +0000 Subject: [PATCH 006/144] fix: use correct original URL for 360 video panorama playback (#26831) Co-authored-by: Claude Opus 4.6 --- web/src/lib/utils.spec.ts | 26 ++++++++++++++++++++++++++ web/src/lib/utils.ts | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 3bc8665279..221fc38568 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -74,6 +74,32 @@ describe('utils', () => { expect(url).toContain(asset.id); }); + it('should return original URL for video assets with forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset, forceOriginal: true }); + + expect(url).toContain('/original'); + expect(url).toContain(asset.id); + }); + + it('should return thumbnail URL for video assets without forceOriginal', () => { + const asset = assetFactory.build({ + originalPath: 'video.mp4', + originalMimeType: 'video/mp4', + type: AssetTypeEnum.Video, + }); + + const url = getAssetUrl({ asset }); + + expect(url).toContain('/thumbnail'); + expect(url).toContain(asset.id); + }); + it('should return thumbnail URL for static images in shared link even with download and showMetadata permissions', () => { const asset = assetFactory.build({ originalPath: 'image.gif', diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 8b6665bf94..9d0c32ae94 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -200,7 +200,9 @@ const forceUseOriginal = (asset: AssetResponseDto) => { export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { if (forceOriginal || get(alwaysLoadOriginalFile) || forceUseOriginal(asset)) { - return isWebCompatibleImage(asset) ? AssetMediaSize.Original : AssetMediaSize.Fullsize; + return asset.type === AssetTypeEnum.Video || isWebCompatibleImage(asset) + ? AssetMediaSize.Original + : AssetMediaSize.Fullsize; } return AssetMediaSize.Preview; }; From 754f072ef9bb1fcebe4676bc5bb6e3b92ba1880d Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:37:51 +0100 Subject: [PATCH 007/144] fix(web): disable drag and drop for internal items (#26897) --- .../drag-and-drop-upload-overlay.svelte | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte index 77178aa992..b37b8c0739 100644 --- a/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte +++ b/web/src/lib/components/shared-components/drag-and-drop-upload-overlay.svelte @@ -13,6 +13,7 @@ let isInLockedFolder = $derived(isLockedFolderRoute(page.route.id)); let dragStartTarget: EventTarget | null = $state(null); + let isInternalDrag = false; const onDragEnter = (e: DragEvent) => { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { @@ -133,7 +134,19 @@ } }; + const ondragstart = () => { + isInternalDrag = true; + }; + + const ondragend = () => { + isInternalDrag = false; + }; + const ondragenter = (e: DragEvent) => { + if (isInternalDrag) { + return; + } + e.preventDefault(); e.stopPropagation(); onDragEnter(e); @@ -146,6 +159,10 @@ }; const ondrop = async (e: DragEvent) => { + if (isInternalDrag) { + return; + } + e.preventDefault(); e.stopPropagation(); await onDrop(e); @@ -159,7 +176,7 @@ - + {#if dragStartTarget} From 226b9390dbf810d3c0c2961b672383fdb803e7af Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:38:21 -0500 Subject: [PATCH 008/144] fix(mobile): video auth (#26887) * fix video auth * update commit --- mobile/android/app/build.gradle | 2 + .../app/alextran/immich/MainActivity.kt | 2 + .../alextran/immich/core/HttpClientManager.kt | 66 +++++++++++++++++++ .../immich/images/RemoteImagesImpl.kt | 57 ++++------------ mobile/ios/Runner/AppDelegate.swift | 2 + mobile/pubspec.lock | 12 ++-- mobile/pubspec.yaml | 2 +- 7 files changed, 90 insertions(+), 53 deletions(-) diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index bd90986f60..103cf79e4e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -113,6 +113,8 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation 'org.chromium.net:cronet-embedded:143.7445.0' + implementation("androidx.media3:media3-datasource-okhttp:1.9.2") + implementation("androidx.media3:media3-datasource-cronet:1.9.2") implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.concurrent:concurrent-futures:$concurrent_version" diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index a85929a0e9..06649de8f0 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.NetworkApiPlugin +import me.albemala.native_video_player.NativeVideoPlayerPlugin import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.RemoteImageApi @@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() { companion object { fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { HttpClientManager.initialize(ctx) + NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory flutterEngine.plugins.add(NetworkApiPlugin()) val messenger = flutterEngine.dartExecutor.binaryMessenger diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 180ae4735d..5b53b2a49a 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -3,7 +3,13 @@ package app.alextran.immich.core import android.content.Context import android.content.SharedPreferences import android.security.KeyChain +import androidx.annotation.OptIn import androidx.core.content.edit +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cronet.CronetDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource import app.alextran.immich.BuildConfig import app.alextran.immich.NativeBuffer import okhttp3.Cache @@ -16,6 +22,7 @@ import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient +import org.chromium.net.CronetEngine import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream @@ -25,6 +32,8 @@ import java.security.KeyStore import java.security.Principal import java.security.PrivateKey import java.security.cert.X509Certificate +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext @@ -56,6 +65,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) { */ object HttpClientManager { private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB + const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB private const val KEEP_ALIVE_CONNECTIONS = 10 private const val KEEP_ALIVE_DURATION_MINUTES = 5L private const val MAX_REQUESTS_PER_HOST = 64 @@ -67,6 +77,11 @@ object HttpClientManager { private lateinit var appContext: Context private lateinit var prefs: SharedPreferences + var cronetEngine: CronetEngine? = null + private set + private lateinit var cronetStorageDir: File + val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4) + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } var keyChainAlias: String? = null @@ -107,6 +122,10 @@ object HttpClientManager { val cacheDir = File(File(context.cacheDir, "okhttp"), "api") client = build(cacheDir) + + cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() } + cronetEngine = buildCronetEngine() + initialized = true } } @@ -223,6 +242,53 @@ object HttpClientManager { ?.joinToString("; ") { "${it.name}=${it.value}" } } + fun getAuthHeaders(url: String): Map { + val result = mutableMapOf() + headers.forEach { (key, value) -> result[key] = value } + loadCookieHeader(url)?.let { result["Cookie"] = it } + url.toHttpUrlOrNull()?.let { httpUrl -> + if (httpUrl.username.isNotEmpty()) { + result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password) + } + } + return result + } + + fun rebuildCronetEngine(): CronetEngine { + val old = cronetEngine!! + cronetEngine = buildCronetEngine() + return old + } + + val cronetStoragePath: File get() = cronetStorageDir + + @OptIn(UnstableApi::class) + fun createDataSourceFactory(headers: Map): DataSource.Factory { + return if (isMtls) { + OkHttpDataSource.Factory(client.newBuilder().cache(null).build()) + } else { + ResolvingDataSource.Factory( + CronetDataSource.Factory(cronetEngine!!, cronetExecutor) + ) { dataSpec -> + val newHeaders = dataSpec.httpRequestHeaders.toMutableMap() + newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString())) + newHeaders["Cache-Control"] = "no-store" + dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build() + } + } + } + + private fun buildCronetEngine(): CronetEngine { + return CronetEngine.Builder(appContext) + .enableHttp2(true) + .enableQuic(true) + .enableBrotli(true) + .setStoragePath(cronetStorageDir.absolutePath) + .setUserAgent(USER_AGENT) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES) + .build() + } + private fun build(cacheDir: File): OkHttpClient { val connectionPool = ConnectionPool( maxIdleConnections = KEEP_ALIVE_CONNECTIONS, diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index b820b45425..8e9fc3f6d5 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeByteBuffer import app.alextran.immich.core.HttpClientManager -import app.alextran.immich.core.USER_AGENT import kotlinx.coroutines.* import okhttp3.Cache import okhttp3.Call @@ -15,9 +14,6 @@ import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.Credentials -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.chromium.net.CronetEngine import org.chromium.net.CronetException import org.chromium.net.UrlRequest import org.chromium.net.UrlResponseInfo @@ -31,10 +27,6 @@ import java.nio.file.Path import java.nio.file.SimpleFileVisitor import java.nio.file.attribute.BasicFileAttributes import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors - - -private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024 private class RemoteRequest(val cancellationSignal: CancellationSignal) @@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { } private object ImageFetcherManager { - private lateinit var appContext: Context private lateinit var cacheDir: File private lateinit var fetcher: ImageFetcher private var initialized = false @@ -110,7 +101,6 @@ private object ImageFetcherManager { if (initialized) return synchronized(this) { if (initialized) return - appContext = context.applicationContext cacheDir = context.cacheDir fetcher = build() HttpClientManager.addClientChangedListener(::invalidate) @@ -143,7 +133,7 @@ private object ImageFetcherManager { return if (HttpClientManager.isMtls) { OkHttpImageFetcher.create(cacheDir) } else { - CronetImageFetcher(appContext, cacheDir) + CronetImageFetcher() } } } @@ -161,19 +151,11 @@ private sealed interface ImageFetcher { fun clearCache(onCleared: (Result) -> Unit) } -private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher { - private val ctx = context - private var engine: CronetEngine - private val executor = Executors.newFixedThreadPool(4) +private class CronetImageFetcher : ImageFetcher { private val stateLock = Any() private var activeCount = 0 private var draining = false private var onCacheCleared: ((Result) -> Unit)? = null - private val storageDir = File(cacheDir, "cronet").apply { mkdirs() } - - init { - engine = build(context) - } override fun fetch( url: String, @@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } val callback = FetchCallback(onSuccess, onFailure, ::onComplete) - val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor) - HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) } - HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) } - url.toHttpUrlOrNull()?.let { httpUrl -> - if (httpUrl.username.isNotEmpty()) { - requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password)) - } + val requestBuilder = HttpClientManager.cronetEngine!! + .newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor) + HttpClientManager.getAuthHeaders(url).forEach { (key, value) -> + requestBuilder.addHeader(key, value) } val request = requestBuilder.build() signal.setOnCancelListener(request::cancel) request.start() } - private fun build(ctx: Context): CronetEngine { - return CronetEngine.Builder(ctx) - .enableHttp2(true) - .enableQuic(true) - .enableBrotli(true) - .setStoragePath(storageDir.absolutePath) - .setUserAgent(USER_AGENT) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES) - .build() - } - private fun onComplete() { val didDrain = synchronized(stateLock) { activeCount-- @@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche } private fun onDrained() { - engine.shutdown() val onCacheCleared = synchronized(stateLock) { val onCacheCleared = onCacheCleared this.onCacheCleared = null onCacheCleared } - if (onCacheCleared == null) { - executor.shutdown() - } else { + if (onCacheCleared != null) { + val oldEngine = HttpClientManager.rebuildCronetEngine() + oldEngine.shutdown() CoroutineScope(Dispatchers.IO).launch { - val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) } - // Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result - engine = build(ctx) + val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) } synchronized(stateLock) { draining = false } onCacheCleared(result) } @@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor( val dir = File(cacheDir, "okhttp") val client = HttpClientManager.getClient().newBuilder() - .cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES)) + .cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES)) .build() return OkHttpImageFetcher(client) diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index f842285b23..8487db7b48 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import BackgroundTasks import Flutter +import native_video_player import network_info_plus import path_provider_foundation import permission_handler_apple @@ -18,6 +19,7 @@ import UIKit UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } + SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index de116abb7e..89a43f328b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1194,10 +1194,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1218,8 +1218,8 @@ packages: dependency: "direct main" description: path: "." - ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" - resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2" + ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 + resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6 url: "https://github.com/immich-app/native_video_player" source: git version: "1.3.1" @@ -1897,10 +1897,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" thumbhash: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3a075d67ff..77955c06ab 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -56,7 +56,7 @@ dependencies: native_video_player: git: url: https://github.com/immich-app/native_video_player - ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2' + ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6' network_info_plus: ^6.1.3 octo_image: ^2.1.0 openapi: From c2a279e49ea585065f5a4e21450544862dc668c4 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:40:04 +0100 Subject: [PATCH 009/144] fix(web): keep header fixed on individual shared links (#26892) --- .../individual-shared-viewer.svelte | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 64eb98bec0..0bf1a2f7f2 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -74,8 +74,12 @@ }; -
- {#if sharedLink?.allowUpload || assets.length > 1} +{#if sharedLink?.allowUpload || assets.length > 1} +
+ +
+ +
{#if assetInteraction.selectionActive} {/if} -
- -
- {:else if assets.length === 1} - {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} - {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} - - {/await} +
+{:else if assets.length === 1} + {#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset} + {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + {/await} - {/if} -
+ {/await} +{/if} From e322d44f9553e51da46ed85568aaa3dc9951b7d2 Mon Sep 17 00:00:00 2001 From: Nathaniel Hourt Date: Fri, 13 Mar 2026 09:41:50 -0500 Subject: [PATCH 010/144] fix: SMTP over TLS (#26893) Final step on #22833 PReq #22833 is about adding support for SMTP-over-TLS rather than just STARTTLS when sending emails. That PReq adds almost everything; it just forgot to actually pass the flag to Nodemailer at the end. This adds that last line of code and makes it work correctly (for me, anyways!). Co-authored-by: Nathaniel --- server/src/repositories/email.repository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/repositories/email.repository.ts b/server/src/repositories/email.repository.ts index 1bc4f0981a..a0cc23661a 100644 --- a/server/src/repositories/email.repository.ts +++ b/server/src/repositories/email.repository.ts @@ -162,6 +162,7 @@ export class EmailRepository { host: options.host, port: options.port, tls: { rejectUnauthorized: !options.ignoreCert }, + secure: options.secure, auth: options.username || options.password ? { From 10fa928abeee45e994ab29f9b36178982fb971a0 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 13 Mar 2026 15:43:00 +0100 Subject: [PATCH 011/144] feat: require pull requests to follow template (#26902) * feat: require pull requests to follow template * fix: persist-credentials: false --- .github/workflows/check-pr-template.yml | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/check-pr-template.yml diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml new file mode 100644 index 0000000000..f60498d269 --- /dev/null +++ b/.github/workflows/check-pr-template.yml @@ -0,0 +1,80 @@ +name: Check PR Template + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] + types: [opened, edited] + +permissions: {} + +jobs: + parse: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.head.repo.fork == true }} + permissions: + contents: read + outputs: + uses_template: ${{ steps.check.outputs.uses_template }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: .github/pull_request_template.md + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Check required sections + id: check + env: + BODY: ${{ github.event.pull_request.body }} + run: | + OK=true + while IFS= read -r header; do + printf '%s\n' "$BODY" | grep -qF "$header" || OK=false + done < <(grep "^## " .github/pull_request_template.md) + echo "uses_template=$OK" >> "$GITHUB_OUTPUT" + + act: + runs-on: ubuntu-latest + needs: parse + permissions: + pull-requests: write + steps: + - name: Close PR + if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \ + -f query=' + mutation CommentAndClosePR($prId: ID!, $body: String!) { + addComment(input: { + subjectId: $prId, + body: $body + }) { + __typename + } + closePullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' + + - name: Reopen PR (sections now present, PR closed) + if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }} + env: + GH_TOKEN: ${{ github.token }} + NODE_ID: ${{ github.event.pull_request.node_id }} + run: | + gh api graphql \ + -f prId="$NODE_ID" \ + -f query=' + mutation ReopenPR($prId: ID!) { + reopenPullRequest(input: { + pullRequestId: $prId + }) { + __typename + } + }' From 55513cd59f74a48aecce7ac73c252009d6ab81da Mon Sep 17 00:00:00 2001 From: Belnadifia Date: Fri, 13 Mar 2026 22:14:45 +0100 Subject: [PATCH 012/144] feat(server): support IDPs that only send the userinfo in the ID token (#26717) Co-authored-by: irouply Co-authored-by: Daniel Dietzler --- e2e-auth-server/auth-server.ts | 29 ++++++++++++++++++--- e2e/src/specs/server/api/oauth.e2e-spec.ts | 19 ++++++++++++++ server/src/repositories/oauth.repository.ts | 11 +++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/e2e-auth-server/auth-server.ts b/e2e-auth-server/auth-server.ts index a190ecd023..9aef56510d 100644 --- a/e2e-auth-server/auth-server.ts +++ b/e2e-auth-server/auth-server.ts @@ -10,6 +10,7 @@ export enum OAuthClient { export enum OAuthUser { NO_EMAIL = 'no-email', NO_NAME = 'no-name', + ID_TOKEN_CLAIMS = 'id-token-claims', WITH_QUOTA = 'with-quota', WITH_USERNAME = 'with-username', WITH_ROLE = 'with-role', @@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({ email_verified: true, }); -const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +const getClaims = (sub: string, use?: string) => { + if (sub === OAuthUser.ID_TOKEN_CLAIMS) { + return { + sub, + email: `oauth-${sub}@immich.app`, + email_verified: true, + name: use === 'id_token' ? 'ID Token User' : 'Userinfo User', + }; + } + return claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +}; const setup = async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); - const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect']; + const redirectUris = [ + 'http://127.0.0.1:2285/auth/login', + 'https://photos.immich.app/oauth/mobile-redirect', + ]; const port = 2286; const host = '0.0.0.0'; const oidc = new Provider(`http://${host}:${port}`, { @@ -66,7 +80,10 @@ const setup = async () => { console.error(error); ctx.body = 'Internal Server Error'; }, - findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }), + findAccount: (ctx, sub) => ({ + accountId: sub, + claims: (use) => getClaims(sub, use), + }), scopes: ['openid', 'email', 'profile'], claims: { openid: ['sub'], @@ -94,6 +111,7 @@ const setup = async () => { state: 'oidc.state', }, }, + conformIdTokenClaims: false, pkce: { required: () => false, }, @@ -125,7 +143,10 @@ const setup = async () => { ], }); - const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`); + const onStart = () => + console.log( + `[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`, + ); const app = oidc.listen(port, host, onStart); return () => app.close(); }; diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index cbd68c003a..ae9064375f 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -380,4 +380,23 @@ describe(`/oauth`, () => { }); }); }); + + describe('idTokenClaims', () => { + it('should use claims from the ID token if IDP includes them', async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + }); + const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS); + const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); + expect(status).toBe(201); + expect(body).toMatchObject({ + accessToken: expect.any(String), + name: 'ID Token User', + userEmail: 'oauth-id-token-claims@immich.app', + userId: expect.any(String), + }); + }); + }); }); diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index a42955ba10..5af5163f8f 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -70,7 +70,16 @@ export class OAuthRepository { try { const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier }); - const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + + let profile: OAuthProfile; + const tokenClaims = tokens.claims(); + if (tokenClaims && 'email' in tokenClaims) { + this.logger.debug('Using ID token claims instead of userinfo endpoint'); + profile = tokenClaims as OAuthProfile; + } else { + profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + } + if (!profile.sub) { throw new Error('Unexpected profile response, no `sub`'); } From 2c6d4f3fe1df8b1acba1de90cda5312f61fd4323 Mon Sep 17 00:00:00 2001 From: rthrth-svg <267244824+rthrth-svg@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:27:08 +0000 Subject: [PATCH 013/144] fix(web): copy yearMonth in MonthGroup to avoid shared object reference with asset (#26890) Co-authored-by: Min Idzelis --- .../timeline-manager/month-group.svelte.ts | 2 +- .../timeline-manager.svelte.spec.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/web/src/lib/managers/timeline-manager/month-group.svelte.ts b/web/src/lib/managers/timeline-manager/month-group.svelte.ts index 3b3860eb9c..b41deb5785 100644 --- a/web/src/lib/managers/timeline-manager/month-group.svelte.ts +++ b/web/src/lib/managers/timeline-manager/month-group.svelte.ts @@ -60,7 +60,7 @@ export class MonthGroup { this.#initialCount = initialCount; this.#sortOrder = order; - this.yearMonth = yearMonth; + this.yearMonth = { year: yearMonth.year, month: yearMonth.month }; this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth)); this.loader = new CancellableTask( diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 8addc173c4..943b5d12a8 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -355,6 +355,29 @@ describe('TimelineManager', () => { expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined(); expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1); }); + + it('yearMonth is not a shared reference with asset.localDateTime (reference bug)', () => { + const asset = deriveLocalDateTimeFromFileCreatedAt( + timelineAssetFactory.build({ + fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'), + }), + ); + + timelineManager.upsertAssets([asset]); + const januaryMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!; + const monthYearMonth = januaryMonth.yearMonth; + + const originalMonth = monthYearMonth.month; + expect(originalMonth).toEqual(1); + + // Simulating updateObject + asset.localDateTime.month = 3; + asset.localDateTime.day = 20; + + expect(monthYearMonth.month).toEqual(originalMonth); + expect(monthYearMonth.month).toEqual(1); + }); + it('asset is removed during upsert when TimelineManager if visibility changes', async () => { await timelineManager.updateOptions({ visibility: AssetVisibility.Archive, From 0581b497509d0ffe8296be194bdcf1b30b1ca313 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 13 Mar 2026 23:55:00 +0100 Subject: [PATCH 014/144] fix: ignore optional headers in pr template check (#26910) --- .github/workflows/check-pr-template.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-pr-template.yml b/.github/workflows/check-pr-template.yml index f60498d269..4dcdd20f72 100644 --- a/.github/workflows/check-pr-template.yml +++ b/.github/workflows/check-pr-template.yml @@ -29,7 +29,7 @@ jobs: OK=true while IFS= read -r header; do printf '%s\n' "$BODY" | grep -qF "$header" || OK=false - done < <(grep "^## " .github/pull_request_template.md) + done < <(sed '//d' .github/pull_request_template.md | grep "^## ") echo "uses_template=$OK" >> "$GITHUB_OUTPUT" act: From 48fe111daaf93d3a80346be05f57a6ebb91aa6d1 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 13 Mar 2026 23:04:55 -0400 Subject: [PATCH 015/144] feat(web): improve OCR overlay text fitting, reactivity, and accessibility (#26678) - Precise font sizing using canvas measureText instead of character-count heuristic - Fix overlay repositioning on viewport resize by computing metrics from reactive state instead of DOM reads - Fix animation delay on resize by using transition-colors instead of transition-all - Add keyboard accessibility: OCR boxes are focusable via Tab with reading-order sort - Show text on focus (same styling as hover) with proper ARIA attributes --- .../asset-viewer/ocr-bounding-box.svelte | 29 +++- .../asset-viewer/photo-viewer.svelte | 3 +- web/src/lib/utils/ocr-utils.ts | 125 +++++++++++++++++- 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte index 6f6caad0fc..d5551b9cc5 100644 --- a/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte +++ b/web/src/lib/components/asset-viewer/ocr-bounding-box.svelte @@ -1,6 +1,6 @@
{ocrBox.text}
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 55c765ce22..4a6a02cb4a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -73,7 +73,8 @@ } const natural = getNaturalSize(assetViewerManager.imgRef); - const scaled = scaleToFit(natural, container); + const scaled = scaleToFit(natural, { width: containerWidth, height: containerHeight }); + return { contentWidth: scaled.width, contentHeight: scaled.height, diff --git a/web/src/lib/utils/ocr-utils.ts b/web/src/lib/utils/ocr-utils.ts index 3da36cf57a..c483eb9551 100644 --- a/web/src/lib/utils/ocr-utils.ts +++ b/web/src/lib/utils/ocr-utils.ts @@ -1,18 +1,38 @@ import type { OcrBoundingBox } from '$lib/stores/ocr.svelte'; import type { ContentMetrics } from '$lib/utils/container-utils'; +import { clamp } from 'lodash-es'; export type Point = { x: number; y: number; }; +const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + +export type VerticalMode = 'none' | 'cjk' | 'rotated'; + export interface OcrBox { id: string; points: Point[]; text: string; confidence: number; + verticalMode: VerticalMode; } +const CJK_PATTERN = + /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uAC00-\uD7AF\uFF00-\uFFEF]/; + +const VERTICAL_ASPECT_RATIO = 1.5; + +const containsCjk = (text: string): boolean => CJK_PATTERN.test(text); + +const getVerticalMode = (width: number, height: number, text: string): VerticalMode => { + if (height / width < VERTICAL_ASPECT_RATIO) { + return 'none'; + } + return containsCjk(text) ? 'cjk' : 'rotated'; +}; + /** * Calculate bounding box transform from OCR points. Result matrix can be used as input for css matrix3d. * @param points - Array of 4 corner points of the bounding box @@ -21,8 +41,6 @@ export interface OcrBox { export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; width: number; height: number } => { const [topLeft, topRight, bottomRight, bottomLeft] = points; - // Approximate width and height to prevent text distortion as much as possible - const distance = (p1: Point, p2: Point) => Math.hypot(p2.x - p1.x, p2.y - p1.y); const width = Math.max(distance(topLeft, topRight), distance(bottomLeft, bottomRight)); const height = Math.max(distance(topLeft, bottomLeft), distance(topRight, bottomRight)); @@ -55,6 +73,96 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[]; return { matrix, width, height }; }; +const BORDER_SIZE = 4; +const HORIZONTAL_PADDING = 16 + BORDER_SIZE; +const VERTICAL_PADDING = 8 + BORDER_SIZE; +const REFERENCE_FONT_SIZE = 100; +const MIN_FONT_SIZE = 8; +const MAX_FONT_SIZE = 96; +const FALLBACK_FONT = `${REFERENCE_FONT_SIZE}px sans-serif`; + +let sharedCanvasContext: CanvasRenderingContext2D | null = null; +let resolvedFont: string | undefined; + +const getCanvasContext = (): CanvasRenderingContext2D | null => { + if (sharedCanvasContext !== null) { + return sharedCanvasContext; + } + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + sharedCanvasContext = context; + return sharedCanvasContext; +}; + +const getReferenceFont = (): string => { + if (resolvedFont !== undefined) { + return resolvedFont; + } + const fontFamily = globalThis.getComputedStyle?.(document.documentElement).getPropertyValue('--font-sans').trim(); + resolvedFont = fontFamily ? `${REFERENCE_FONT_SIZE}px ${fontFamily}` : FALLBACK_FONT; + return resolvedFont; +}; + +export const calculateFittedFontSize = ( + text: string, + boxWidth: number, + boxHeight: number, + verticalMode: VerticalMode, +): number => { + const isVertical = verticalMode === 'cjk' || verticalMode === 'rotated'; + const availableWidth = boxWidth - (isVertical ? VERTICAL_PADDING : HORIZONTAL_PADDING); + const availableHeight = boxHeight - (isVertical ? HORIZONTAL_PADDING : VERTICAL_PADDING); + + const context = getCanvasContext(); + + if (verticalMode === 'cjk') { + if (!context) { + const fontSize = Math.min(availableWidth, availableHeight / text.length); + return clamp(fontSize, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + let maxCharWidth = 0; + let totalCharHeight = 0; + for (const character of text) { + const metrics = context.measureText(character); + const charWidth = metrics.width; + const charHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + maxCharWidth = Math.max(maxCharWidth, charWidth); + totalCharHeight += Math.max(charWidth, charHeight); + } + + const scaleFromWidth = (availableWidth / maxCharWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (availableHeight / totalCharHeight) * REFERENCE_FONT_SIZE; + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + const fitWidth = verticalMode === 'rotated' ? availableHeight : availableWidth; + const fitHeight = verticalMode === 'rotated' ? availableWidth : availableHeight; + + if (!context) { + return clamp((1.4 * fitWidth) / text.length, MIN_FONT_SIZE, MAX_FONT_SIZE); + } + + // Unsupported in Safari iOS <16.6; falls back to default canvas font, giving less accurate but functional sizing + // eslint-disable-next-line tscompat/tscompat + context.font = getReferenceFont(); + + const metrics = context.measureText(text); + const measuredWidth = metrics.width; + const measuredHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent; + + const scaleFromWidth = (fitWidth / measuredWidth) * REFERENCE_FONT_SIZE; + const scaleFromHeight = (fitHeight / measuredHeight) * REFERENCE_FONT_SIZE; + + return clamp(Math.min(scaleFromWidth, scaleFromHeight), MIN_FONT_SIZE, MAX_FONT_SIZE); +}; + export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentMetrics): OcrBox[] => { const boxes: OcrBox[] = []; for (const ocr of ocrData) { @@ -68,13 +176,26 @@ export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: ContentM y: point.y * metrics.contentHeight + metrics.offsetY, })); + const boxWidth = Math.max(distance(points[0], points[1]), distance(points[3], points[2])); + const boxHeight = Math.max(distance(points[0], points[3]), distance(points[1], points[2])); + boxes.push({ id: ocr.id, points, text: ocr.text, confidence: ocr.textScore, + verticalMode: getVerticalMode(boxWidth, boxHeight, ocr.text), }); } + const rowThreshold = metrics.contentHeight * 0.02; + boxes.sort((a, b) => { + const yDifference = a.points[0].y - b.points[0].y; + if (Math.abs(yDifference) < rowThreshold) { + return a.points[0].x - b.points[0].x; + } + return yDifference; + }); + return boxes; }; From ff936f901d0f3c0ea058a25b93a17659105d6be2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:09:42 -0500 Subject: [PATCH 016/144] fix(mobile): duplicate server urls returned (#26864) remove server url Co-authored-by: Alex --- mobile/lib/services/api.service.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index bc5e46f769..e296ac522d 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -176,10 +176,6 @@ class ApiService { if (serverEndpoint != null && serverEndpoint.isNotEmpty) { urls.add(serverEndpoint); } - final serverUrl = Store.tryGet(StoreKey.serverUrl); - if (serverUrl != null && serverUrl.isNotEmpty) { - urls.add(serverUrl); - } final localEndpoint = Store.tryGet(StoreKey.localEndpoint); if (localEndpoint != null && localEndpoint.isNotEmpty) { urls.add(localEndpoint); From b66c97b785169b3d40fb8b4d8d8027a72a2a5f4f Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:23:07 -0500 Subject: [PATCH 017/144] fix(mobile): use shared auth for background_downloader (#26911) shared client for background_downloader on ios --- .../alextran/immich/core/HttpClientManager.kt | 23 ++++++++ mobile/ios/Runner/AppDelegate.swift | 1 + .../ios/Runner/Core/URLSessionManager.swift | 55 +++++++++++++++++-- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt index 5b53b2a49a..e7268396e8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/core/HttpClientManager.kt @@ -27,7 +27,11 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.io.ByteArrayInputStream import java.io.File +import java.net.Authenticator +import java.net.CookieHandler +import java.net.PasswordAuthentication import java.net.Socket +import java.net.URI import java.security.KeyStore import java.security.Principal import java.security.PrivateKey @@ -104,6 +108,25 @@ object HttpClientManager { keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null) cookieJar.init(prefs) + System.setProperty("http.agent", USER_AGENT) + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + val url = requestingURL ?: return null + if (url.userInfo.isNullOrEmpty()) return null + val parts = url.userInfo.split(":", limit = 2) + return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray()) + } + }) + CookieHandler.setDefault(object : CookieHandler() { + override fun get(uri: URI, requestHeaders: Map>): Map> { + val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap() + val cookies = cookieJar.loadForRequest(httpUrl) + if (cookies.isEmpty()) return emptyMap() + return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" })) + } + + override fun put(uri: URI, responseHeaders: Map>) {} + }) val savedHeaders = prefs.getString(PREFS_HEADERS, null) if (savedHeaders != null) { diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 8487db7b48..81af41ab08 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -20,6 +20,7 @@ import UIKit } SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage + URLSessionManager.patchBackgroundDownloader() GeneratedPluginRegistrant.register(with: self) let controller: FlutterViewController = window?.rootViewController as! FlutterViewController AppDelegate.registerPlugins(with: controller.engine, controller: controller) diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 9868d4eb59..0b73ed71a6 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -51,7 +51,7 @@ class URLSessionManager: NSObject { diskCapacity: 1024 * 1024 * 1024, directory: cacheDir ) - private static let userAgent: String = { + static let userAgent: String = { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" return "Immich_iOS_\(version)" }() @@ -158,6 +158,49 @@ class URLSessionManager: NSObject { return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } + + /// Patches background_downloader's URLSession to use shared auth configuration. + /// Must be called before background_downloader creates its session (i.e. early in app startup). + static func patchBackgroundDownloader() { + // Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config + let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:") + let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:)) + if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel), + let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) { + method_exchangeImplementations(original, swizzled) + } + + // Add auth challenge handling to background_downloader's UrlSessionDelegate + guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return } + + let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(sessionBlock), "v@:@@@?") + + let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge, + @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void + = { _, session, task, challenge, completion in + URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task) + } + class_replaceMethod(targetClass, + NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"), + imp_implementationWithBlock(taskBlock), "v@:@@@@?") + } +} + +private extension URLSessionConfiguration { + @objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration { + // After swizzle, this calls the original implementation + let config = immich_background(withIdentifier: id) + config.httpCookieStorage = URLSessionManager.cookieStorage + config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent] + return config + } } class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate { @@ -168,7 +211,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler) } - + func urlSession( _ session: URLSession, task: URLSessionTask, @@ -177,7 +220,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb ) { handleChallenge(session, challenge, completionHandler, task: task) } - + func handleChallenge( _ session: URLSession, _ challenge: URLAuthenticationChallenge, @@ -190,7 +233,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb default: completionHandler(.performDefaultHandling, nil) } } - + private func handleClientCertificate( _ session: URLSession, completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void @@ -200,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb kSecAttrLabel as String: CLIENT_CERT_LABEL, kSecReturnRef as String: true, ] - + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecSuccess, let identity = item { @@ -214,7 +257,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb } completion(.performDefaultHandling, nil) } - + private func handleBasicAuth( _ session: URLSession, task: URLSessionTask?, From 82db581cc57bad139311e7f3cc86d7c9418eaf3b Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 16 Mar 2026 19:06:51 +0100 Subject: [PATCH 018/144] feat(mobile): open in browser (#26369) * feat(mobile): open in browser * chore: open in browser instead of webview * chore: allow archived asset * fix: moved openinbrowser above unstack * feat: deeplink into favorites, trash & archived * fix: use remoteId (for tests to succeed) --------- Co-authored-by: Alex --- i18n/en.json | 1 + .../open_in_browser_action_button.widget.dart | 61 +++++++++++++++++++ mobile/lib/utils/action_button.utils.dart | 10 +++ 3 files changed, 72 insertions(+) create mode 100644 mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart diff --git a/i18n/en.json b/i18n/en.json index c3b998ec13..956ed03989 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1651,6 +1651,7 @@ "only_favorites": "Only favorites", "open": "Open", "open_calendar": "Open calendar", + "open_in_browser": "Open in browser", "open_in_map_view": "Open in map view", "open_in_openstreetmap": "Open in OpenStreetMap", "open_the_search_filters": "Open the search filters", diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart new file mode 100644 index 0000000000..17703d0beb --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class OpenInBrowserActionButton extends ConsumerWidget { + final String remoteId; + final TimelineOrigin origin; + final bool iconOnly; + final bool menuItem; + final Color? iconColor; + + const OpenInBrowserActionButton({ + super.key, + required this.remoteId, + required this.origin, + this.iconOnly = false, + this.menuItem = false, + this.iconColor, + }); + + void _onTap() async { + final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', ''); + + String originPath = ''; + switch (origin) { + case TimelineOrigin.favorite: + originPath = '/favorites'; + break; + case TimelineOrigin.trash: + originPath = '/trash'; + break; + case TimelineOrigin.archive: + originPath = '/archive'; + break; + default: + break; + } + + final url = '$serverEndpoint$originPath/photos/$remoteId'; + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + label: 'open_in_browser'.t(context: context), + iconData: Icons.open_in_browser, + iconColor: iconColor, + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: _onTap, + ); + } +} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 2e26d8e80d..071956392c 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; @@ -75,6 +76,7 @@ enum ActionButtonType { viewInTimeline, download, upload, + openInBrowser, unstack, archive, unarchive, @@ -149,6 +151,7 @@ enum ActionButtonType { context.isOwner && // !context.isInLockedView && // context.isStacked, + ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView, ActionButtonType.likeActivity => !context.isInLockedView && context.currentAlbum != null && @@ -236,6 +239,13 @@ enum ActionButtonType { ), ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), + ActionButtonType.openInBrowser => OpenInBrowserActionButton( + remoteId: context.asset.remoteId!, + origin: context.timelineOrigin, + iconOnly: iconOnly, + menuItem: menuItem, + iconColor: context.originalTheme?.iconTheme.color, + ), ActionButtonType.similarPhotos => SimilarPhotosActionButton( assetId: (context.asset as RemoteAsset).id, iconOnly: iconOnly, From 4e44fb9cf7ea8de8bb1c61f755c40772a674eacf Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:15:20 +0100 Subject: [PATCH 019/144] fix(web): prevent search page error on missing album filter (#26948) --- .../search/[[photos=photos]]/[[assetId=id]]/+page.svelte | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 701bc0ff59..4f050b8629 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -67,7 +67,7 @@ type SearchTerms = MetadataSearchDto & Pick; let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY)); let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); - let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); + let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {}); $effect(() => { // we want this to *only* be reactive on `terms` @@ -137,15 +137,13 @@ const searchDto: SearchTerms = { page: nextPage, withExif: true, - isVisible: true, - language: $lang, ...terms, }; try { const { albums, assets } = ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled - ? await searchSmart({ smartSearchDto: searchDto }) + ? await searchSmart({ smartSearchDto: { ...searchDto, language: $lang } }) : await searchAssets({ metadataSearchDto: searchDto }); searchResultAlbums.push(...albums.items); @@ -230,7 +228,7 @@ const onAlbumAddAssets = ({ assetIds }: { assetIds: string[] }) => { cancelMultiselect(assetInteraction); - if (terms.isNotInAlbum.toString() == 'true') { + if (terms.isNotInAlbum) { const assetIdSet = new Set(assetIds); searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id)); } From 9dafc8e8e90cfe3b960a1ba056dd5bd064c1cf37 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:17:55 +0100 Subject: [PATCH 020/144] fix(web): make link fit album card (#26958) --- web/src/lib/components/album-page/album-card-group.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index 99aa8f2b71..eb931d834c 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -65,6 +65,7 @@ {#each albums as album, index (album.id)} oncontextmenu(event, album)} > From bba4a00eb11eff7d44d7573d60b608783fdbaf0e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:40:22 +0100 Subject: [PATCH 021/144] chore(deps): update github-actions (#26967) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/check-openapi.yml | 2 +- .github/workflows/cli.yml | 4 +- .github/workflows/codeql-analysis.yml | 6 +-- .github/workflows/docs-build.yml | 4 +- .github/workflows/fix-format.yml | 4 +- .github/workflows/prepare-release.yml | 6 +-- .github/workflows/sdk.yml | 4 +- .github/workflows/test.yml | 54 +++++++++++++-------------- 8 files changed, 42 insertions(+), 42 deletions(-) diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index ca9f91bbe8..b725b45f6c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30 + uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31 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/cli.yml b/.github/workflows/cli.yml index a2c763a0f6..d3eb66810e 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -42,10 +42,10 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4f093a170e..3450fe96bb 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 # ℹ️ 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 @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 28828f22c6..02d7b3456a 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -67,10 +67,10 @@ jobs: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './docs/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 1daa279cd2..0091bcef89 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -29,10 +29,10 @@ jobs: persist-credentials: true - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index a1d31a61ea..6030dc8752 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -63,13 +63,13 @@ jobs: ref: main - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 1bcdec4747..2da7d79b26 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -30,10 +30,10 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 # Setup .npmrc file to publish to npm - - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './open-api/typescript-sdk/.nvmrc' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1cad2b0023..2a2ebe2389 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,9 +75,9 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -119,9 +119,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -166,9 +166,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './cli/.nvmrc' cache: 'pnpm' @@ -208,9 +208,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -252,9 +252,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -290,9 +290,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './web/.nvmrc' cache: 'pnpm' @@ -338,9 +338,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -385,9 +385,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -424,9 +424,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -496,9 +496,9 @@ jobs: submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './e2e/.nvmrc' cache: 'pnpm' @@ -620,7 +620,7 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Install uv - uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0 + uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 with: python-version: 3.11 - name: Install dependencies @@ -661,9 +661,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './.github/.nvmrc' cache: 'pnpm' @@ -712,9 +712,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' @@ -774,9 +774,9 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './server/.nvmrc' cache: 'pnpm' From 16749ff8ba6f69bec2dadc50b9aa083fdcf15b02 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:33:43 +0000 Subject: [PATCH 022/144] fix(server): sync files to disk (#26881) Ensure that all files are flushed after they've been written. At current, files are not explicitly flushed to disk, which can cause data corruption. In extreme circumstances, it's possible that uploaded files may not ever be persisted at all. --- .../src/middleware/file-upload.interceptor.ts | 108 ++++++++---------- server/src/repositories/storage.repository.ts | 2 +- 2 files changed, 49 insertions(+), 61 deletions(-) diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6dfd11ee4b..63acb13789 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -3,13 +3,16 @@ import { PATH_METADATA } from '@nestjs/common/constants'; import { Reflector } from '@nestjs/core'; import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils'; import { NextFunction, RequestHandler } from 'express'; -import multer, { StorageEngine, diskStorage } from 'multer'; +import multer from 'multer'; import { createHash, randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { pipeline } from 'node:stream'; import { Observable } from 'rxjs'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { RouteKey } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; import { ImmichFile, UploadFile, UploadFiles } from 'src/types'; import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util'; @@ -26,8 +29,6 @@ export function getFiles(files: UploadFiles) { }; } -type DiskStorageCallback = (error: Error | null, result: string) => void; - type ImmichMulterFile = Express.Multer.File & { uuid: string }; interface Callback { @@ -35,34 +36,21 @@ interface Callback { (error: null, result: T): void; } -const callbackify = (target: (...arguments_: any[]) => T, callback: Callback) => { - try { - return callback(null, target()); - } catch (error: Error | any) { - return callback(error); - } -}; - @Injectable() export class FileUploadInterceptor implements NestInterceptor { private handlers: { userProfile: RequestHandler; assetUpload: RequestHandler; }; - private defaultStorage: StorageEngine; constructor( private reflect: Reflector, private assetService: AssetMediaService, + private storageRepository: StorageRepository, private logger: LoggingRepository, ) { this.logger.setContext(FileUploadInterceptor.name); - this.defaultStorage = diskStorage({ - filename: this.filename.bind(this), - destination: this.destination.bind(this), - }); - const instance = multer({ fileFilter: this.fileFilter.bind(this), storage: { @@ -99,60 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor { } private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) { - return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback); - } - - private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { - return callbackify( - () => this.assetService.getUploadFilename(asUploadRequest(request, file)), - callback as Callback, - ); - } - - private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { - return callbackify( - () => this.assetService.getUploadFolder(asUploadRequest(request, file)), - callback as Callback, - ); + try { + callback(null, this.assetService.canUploadFile(asUploadRequest(request, file))); + } catch (error: Error | any) { + callback(error); + } } private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback>) { - (file as ImmichMulterFile).uuid = randomUUID(); - request.on('error', (error) => { this.logger.warn('Request error while uploading file, cleaning up', error); this.assetService.onUploadError(request, file).catch(this.logger.error); }); - if (!this.isAssetUploadFile(file)) { - this.defaultStorage._handleFile(request, file, callback); - return; - } + try { + (file as ImmichMulterFile).uuid = randomUUID(); - const hash = createHash('sha1'); - file.stream.on('data', (chunk) => hash.update(chunk)); - this.defaultStorage._handleFile(request, file, (error, info) => { - if (error) { - hash.destroy(); - callback(error); - } else { - callback(null, { ...info, checksum: hash.digest() }); - } - }); + const uploadRequest = asUploadRequest(request, file); + + const path = join( + this.assetService.getUploadFolder(uploadRequest), + this.assetService.getUploadFilename(uploadRequest), + ); + + const writeStream = this.storageRepository.createWriteStream(path); + const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null; + + let size = 0; + + file.stream.on('data', (chunk) => { + hash?.update(chunk); + size += chunk.length; + }); + + pipeline(file.stream, writeStream, (error) => { + if (error) { + hash?.destroy(); + return callback(error); + } + callback(null, { + path, + size, + checksum: hash?.digest(), + }); + }); + } catch (error: Error | any) { + callback(error); + } } - private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { - this.defaultStorage._removeFile(request, file, callback); - } - - private isAssetUploadFile(file: Express.Multer.File) { - switch (file.fieldname as UploadFieldName) { - case UploadFieldName.ASSET_DATA: { - return true; - } - } - - return false; + private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) { + this.storageRepository + .unlink(file.path) + .then(() => callback(null)) + .catch(callback); } private getHandler(route: RouteKey) { diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 5a1a936e77..c7ba4ab6cc 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -63,7 +63,7 @@ export class StorageRepository { } createWriteStream(filepath: string): Writable { - return createWriteStream(filepath, { flags: 'w' }); + return createWriteStream(filepath, { flags: 'w', flush: true }); } createOrOverwriteFile(filepath: string, buffer: Buffer) { From ac6938a629206a6b8e7cb949d0c13e37579276c0 Mon Sep 17 00:00:00 2001 From: Preslav Penchev <55917783+pressslav@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:38:06 +0000 Subject: [PATCH 023/144] fix(web): allow pasting PIN code from clipboard or password manager (#26944) * fix(web): allow pasting PIN code from clipboard or password manager The keydown handler was blocking Ctrl+V/Cmd+V since it called preventDefault() on all non-numeric keys. Also adds an onpaste handler to distribute pasted digits across the individual inputs. * refactor: handle paste in handleInput, remove maxlength * cleanup + fix digit focus --------- Co-authored-by: Preslav Penchev Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- .../user-settings-page/PinCodeInput.svelte | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte index c438d8f0b1..2ab48c0afe 100644 --- a/web/src/lib/components/user-settings-page/PinCodeInput.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte @@ -49,25 +49,22 @@ const handleInput = (event: Event, index: number) => { const target = event.target as HTMLInputElement; - let currentPinValue = target.value; + const digits = target.value.replaceAll(/\D/g, '').slice(0, pinLength - index); - if (target.value.length > 1) { - currentPinValue = value.slice(0, 1); - } - - if (Number.isNaN(Number(value))) { + if (digits.length === 0) { pinValues[index] = ''; - target.value = ''; + value = pinValues.join('').trim(); return; } - pinValues[index] = currentPinValue; + for (let i = 0; i < digits.length; i++) { + pinValues[index + i] = digits[i]; + } value = pinValues.join('').trim(); - if (value && index < pinLength - 1) { - focusNext(index); - } + const lastFilledIndex = Math.min(index + digits.length, pinLength - 1); + pinCodeInputElements[lastFilledIndex]?.focus(); if (value.length === pinLength) { onFilled?.(value); @@ -104,12 +101,6 @@ } return; } - default: { - if (Number.isNaN(Number(event.key))) { - event.preventDefault(); - } - break; - } } } @@ -125,7 +116,6 @@ {type} inputmode="numeric" pattern="[0-9]*" - maxlength="1" bind:this={pinCodeInputElements[index]} id="pin-code-{index}" class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light" From 9b0b2bfcf2ffd6d49b355e39ccf9ffb73cc38d20 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:39:39 +0100 Subject: [PATCH 024/144] fix(web): jump to primary stacked asset from memory (#26978) --- .../memory-page/memory-viewer.svelte | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 2098f48049..d20eb29227 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -63,8 +63,9 @@ let playerInitialized = $state(false); let paused = $state(false); let current = $state(undefined); - let currentMemoryAssetFull = $derived.by(async () => - current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, + const currentAssetId = $derived(current?.asset.id); + const currentMemoryAssetFull = $derived.by(async () => + currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined, ); let currentTimelineAssets = $derived(current?.memory.assets ?? []); let viewerAssets = $derived([ @@ -550,14 +551,18 @@
- + {#await currentMemoryAssetFull then asset} + {#if asset} + + {/if} + {/await}
From 677cb660f55fe7d81888c1a794993f8c6c21e402 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:43:14 +0000 Subject: [PATCH 025/144] fix(mobile): reflect asset deletions instantly (#26835) Sometimes the current asset won't update when deleted, or it won't refresh until an event (like showing details) happens. --- .../archive_action_button.widget.dart | 6 +- .../delete_action_button.widget.dart | 6 +- ...delete_permanent_action_button.widget.dart | 6 +- ...e_to_lock_folder_action_button.widget.dart | 6 +- ...emove_from_album_action_button.widget.dart | 6 +- .../trash_action_button.widget.dart | 6 +- .../unarchive_action_button.widget.dart | 6 +- .../asset_viewer/asset_viewer.page.dart | 64 ++++++++----------- 8 files changed, 47 insertions(+), 59 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart index 7c7e96c1c5..a673dff1d7 100644 --- a/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/archive_action_button.widget.dart @@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; Future performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).archive(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).archive(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 94ee1b2343..2121ef3159 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget { if (confirm != true) return; } - final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index 710ec506c2..27a1a4d8af 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget { false; if (!confirm) return; - final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'delete_permanently_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart index 341791c1af..2f7c3899eb 100644 --- a/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart @@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; Future performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).moveToLockFolder(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'move_to_lock_folder_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart index fd88e94cf7..97a36a56dc 100644 --- a/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart @@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'remove_from_album_action_prompt'.t( context: context, args: {'count': result.count.toString()}, diff --git a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart index 5f1e385769..e95569af45 100644 --- a/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/trash_action_button.widget.dart @@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget { return; } - final result = await ref.read(actionProvider.notifier).trash(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).trash(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart index 8cf0bcba92..98e868d953 100644 --- a/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart @@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; Future performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async { if (!context.mounted) return; - final result = await ref.read(actionProvider.notifier).unArchive(source); - ref.read(multiSelectProvider.notifier).reset(); - if (source == ActionSource.viewer) { EventStream.shared.emit(const ViewerReloadAssetEvent()); } + final result = await ref.read(actionProvider.notifier).unArchive(source); + ref.read(multiSelectProvider.notifier).reset(); + final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 903105406c..4d8954d4ef 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState { late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted); late int _currentPage = widget.initialIndex; + late int _totalAssets = ref.read(timelineServiceProvider).totalAssets; StreamSubscription? _reloadSubscription; KeepAliveLink? _stackChildrenKeepAlive; - bool _assetReloadRequested = false; - void _onTapNavigate(int direction) { final page = _pageController.page?.toInt(); if (page == null) return; final target = page + direction; - final maxPage = ref.read(timelineServiceProvider).totalAssets - 1; + final maxPage = _totalAssets - 1; if (target >= 0 && target <= maxPage) { - _currentPage = target; _pageController.jumpToPage(target); _onAssetChanged(target); } @@ -141,7 +139,6 @@ class _AssetViewerState extends ConsumerState { final page = _pageController.page?.round(); if (page != null && page != _currentPage) { - _currentPage = page; _onAssetChanged(page); } return false; @@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState { } void _onAssetChanged(int index) async { - final timelineService = ref.read(timelineServiceProvider); - final asset = await timelineService.getAssetAsync(index); + _currentPage = index; + + final asset = await ref.read(timelineServiceProvider).getAssetAsync(index); if (asset == null) return; AssetViewer._setAsset(ref, asset); @@ -193,11 +191,20 @@ class _AssetViewerState extends ConsumerState { case TimelineReloadEvent(): _onTimelineReloadEvent(); case ViewerReloadAssetEvent(): - _assetReloadRequested = true; + _onViewerReloadEvent(); default: } } + void _onViewerReloadEvent() { + if (_totalAssets <= 1) return; + + final index = _pageController.page?.round() ?? 0; + final target = index >= _totalAssets - 1 ? index - 1 : index + 1; + _pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut); + _onAssetChanged(target); + } + void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); final totalAssets = timelineService.totalAssets; @@ -207,43 +214,24 @@ class _AssetViewerState extends ConsumerState { return; } - var index = _pageController.page?.round() ?? 0; final currentAsset = ref.read(assetViewerProvider).currentAsset; - if (currentAsset != null) { - final newIndex = timelineService.getIndex(currentAsset.heroTag); - if (newIndex != null && newIndex != index) { - index = newIndex; - _currentPage = index; - _pageController.jumpToPage(index); - } - } + final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null; + final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1); - if (index >= totalAssets) { - index = totalAssets - 1; - _currentPage = index; + if (index != _currentPage) { _pageController.jumpToPage(index); + _onAssetChanged(index); + } else if (currentAsset != null && assetIndex == null) { + _onAssetChanged(index); } - if (_assetReloadRequested) { - _assetReloadRequested = false; - _onAssetReloadEvent(index); + if (_totalAssets != totalAssets) { + setState(() { + _totalAssets = totalAssets; + }); } } - void _onAssetReloadEvent(int index) async { - final timelineService = ref.read(timelineServiceProvider); - - final newAsset = await timelineService.getAssetAsync(index); - if (newAsset == null) return; - - final currentAsset = ref.read(assetViewerProvider).currentAsset; - - // Do not reload if the asset has not changed - if (newAsset.heroTag == currentAsset?.heroTag) return; - - _onAssetChanged(index); - } - void _setSystemUIMode(bool controls, bool details) { final mode = !controls || (CurrentPlatform.isIOS && details) ? SystemUiMode.immersiveSticky @@ -301,7 +289,7 @@ class _AssetViewerState extends ConsumerState { : CurrentPlatform.isIOS ? const FastScrollPhysics() : const FastClampingScrollPhysics(), - itemCount: ref.read(timelineServiceProvider).totalAssets, + itemCount: _totalAssets, itemBuilder: (context, index) => AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate), ), From 34caed3b2b9b53b2e70cfed97e20347f8f9e87d6 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:06:22 +0000 Subject: [PATCH 026/144] fix(server): flaky metadata tests (#26964) --- server/src/services/metadata.service.spec.ts | 4 ++-- server/test/factories/asset.factory.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 19dd1e5352..7cb42990ea 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -330,7 +330,7 @@ describe(MetadataService.name, () => { duration: null, fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, - localDateTime: asset.localDateTime, + localDateTime: asset.fileCreatedAt, width: null, height: null, }); @@ -360,7 +360,7 @@ describe(MetadataService.name, () => { duration: null, fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, - localDateTime: asset.localDateTime, + localDateTime: asset.fileCreatedAt, width: null, height: null, }); diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index ec596dc86e..321a6f8ddd 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -19,7 +19,7 @@ import { UserLike, } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; -import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory'; +import { newSha1, newUuid, newUuidV7 } from 'test/small.factory'; export class AssetFactory { #owner!: UserFactory; @@ -43,10 +43,12 @@ export class AssetFactory { const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`); + let now = Date.now(); + return new AssetFactory({ id, - createdAt: newDate(), - updatedAt: newDate(), + createdAt: new Date(now++), + updatedAt: new Date(now++), deletedAt: null, updateId: newUuidV7(), status: AssetStatus.Active, @@ -55,14 +57,14 @@ export class AssetFactory { deviceId: '', duplicateId: null, duration: null, - fileCreatedAt: newDate(), - fileModifiedAt: newDate(), + fileCreatedAt: new Date(now++), + fileModifiedAt: new Date(now++), isExternal: false, isFavorite: false, isOffline: false, libraryId: null, livePhotoVideoId: null, - localDateTime: newDate(), + localDateTime: new Date(now), originalFileName, originalPath: `/data/library/${originalFileName}`, ownerId: newUuid(), From 0519833d757fb04dc36ea344b5fd644ebad30a0d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 17 Mar 2026 17:53:48 -0400 Subject: [PATCH 027/144] refactor: prefer tv (#26993) --- .../components/album-page/album-title.svelte | 15 ++++++++++++--- .../search-bar/search-people-section.svelte | 17 ++++++++++++----- .../shared-components/single-grid-row.svelte | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/album-page/album-title.svelte b/web/src/lib/components/album-page/album-title.svelte index e467d04a12..1ded91dcd4 100644 --- a/web/src/lib/components/album-page/album-title.svelte +++ b/web/src/lib/components/album-page/album-title.svelte @@ -4,6 +4,7 @@ import { handleError } from '$lib/utils/handle-error'; import { updateAlbumInfo } from '@immich/sdk'; import { t } from 'svelte-i18n'; + import { tv } from 'tailwind-variants'; interface Props { id: string; @@ -36,14 +37,22 @@ return; } }; + + const styles = tv({ + base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90', + variants: { + isOwned: { + true: 'hover:border-gray-400', + false: 'hover:border-transparent', + }, + }, + }); e.currentTarget.blur() }} onblur={handleUpdateName} - class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned - ? 'hover:border-gray-400' - : 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90" + class={styles({ isOwned })} type="text" bind:value={newAlbumName} disabled={!isOwned} diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index 1a2ce7b05b..0f8780a566 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -9,6 +9,7 @@ import { mdiArrowRight, mdiClose } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { SvelteSet } from 'svelte/reactivity'; + import { tv } from 'tailwind-variants'; interface Props { selectedPeople: SvelteSet; @@ -49,6 +50,16 @@ const nameLower = name.toLowerCase(); return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list; }; + + const styles = tv({ + base: 'flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all', + variants: { + selected: { + true: 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white', + false: 'border-transparent', + }, + }, + }); {#await peoplePromise} @@ -74,11 +85,7 @@ {#each peopleList as person (person.id)}