diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 3da7cbe107..e522854059 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -37,15 +37,15 @@ jobs: - uses: actions/setup-java@v4 with: - distribution: "zulu" - java-version: "11.0.21+9" - cache: "gradle" + distribution: 'zulu' + java-version: '17' + cache: 'gradle' - name: Setup Flutter SDK uses: subosito/flutter-action@v2 with: - channel: "stable" - flutter-version: "3.19.3" + channel: 'stable' + flutter-version: '3.19.3' cache: true - name: Create the Keystore diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d74c961393..51b947418e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -208,7 +208,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.16.9' + flutter-version: '3.19.3' - name: Run tests working-directory: ./mobile run: flutter test -j 1 diff --git a/cli/Dockerfile b/cli/Dockerfile index 18f38fb4ab..17799b8506 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine3.19@sha256:7e227295e96f5b00aa79555ae166f50610940d888fc2e321cf36304cbd17d7d6 as core +FROM node:20-alpine3.19@sha256:ec0c413b1d84f3f7f67ec986ba885930c57b5318d2eb3abc6960ee05d4f2eb28 as core WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/cli/package-lock.json b/cli/package-lock.json index 11154fec52..5075514973 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,15 +47,15 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.12.7", - "typescript": "^5.4.5" + "@types/node": "^20.11.0", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 8b85b00284..1b56637baf 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -97,7 +97,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 + image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 database: container_name: immich_postgres diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 7f1f0f1a41..49b375b5f6 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -54,7 +54,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 + image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 restart: always database: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9516ea9ca7..731bd4c90a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -58,7 +58,7 @@ services: redis: container_name: immich_redis - image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 + image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 restart: always database: diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index b273f27712..6fcc47d6a4 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -52,8 +52,8 @@ Before enabling OAuth in Immich, a new client application needs to be configured Hostname - - `https://immich.example.com/auth/login`) - - `https://immich.example.com/user-settings`) + - `https://immich.example.com/auth/login` + - `https://immich.example.com/user-settings` ## Enable OAuth diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index a890d674bc..256f3619f1 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -120,7 +120,8 @@ The default configuration looks like this: "previewFormat": "jpeg", "previewSize": 1440, "quality": 80, - "colorspace": "p3" + "colorspace": "p3", + "extractEmbedded": false }, "newVersionCheck": { "enabled": true diff --git a/docs/docs/partials/_storage-template.md b/docs/docs/partials/_storage-template.md index b52fd4649a..6b733faa55 100644 --- a/docs/docs/partials/_storage-template.md +++ b/docs/docs/partials/_storage-template.md @@ -1,4 +1,4 @@ -Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level. +Immich allows the admin user to set the uploaded filename pattern at the directory and filename level as well as the [storage label for a user](/docs/administration/user-management/#set-storage-label-for-user). :::note new version On new machines running version 1.92.0 storage template engine is off by default, for [more info](https://github.com/immich-app/immich/releases/tag/v1.92.0#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template,-We%20have%20further). diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index e10f22156f..85b201f98c 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -36,7 +36,7 @@ services: <<: *server-common redis: - image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 + image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 6cd8dd90ec..4643b8b01f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.101.0", + "version": "1.102.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.101.0", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -81,15 +81,15 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.12.7", - "typescript": "^5.4.5" + "@types/node": "^20.11.0", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/e2e/package.json b/e2e/package.json index 9023de8162..34ef229a2a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.101.0", + "version": "1.102.3", "description": "", "main": "index.js", "type": "module", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 53c4c43468..d18dc2532c 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -572,6 +572,22 @@ describe('/asset', () => { } const tests = [ + { + input: 'formats/avif/8bit-sRGB.avif', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '8bit-sRGB.avif', + resized: true, + exifInfo: { + description: '', + exifImageHeight: 1080, + exifImageWidth: 1617, + fileSizeInByte: 862_424, + latitude: null, + longitude: null, + }, + }, + }, { input: 'formats/jpg/el_torcal_rocks.jpg', expected: { @@ -596,6 +612,22 @@ describe('/asset', () => { }, }, }, + { + input: 'formats/jxl/8bit-sRGB.jxl', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '8bit-sRGB.jxl', + resized: true, + exifInfo: { + description: '', + exifImageHeight: 1080, + exifImageWidth: 1440, + fileSizeInByte: 1_780_777, + latitude: null, + longitude: null, + }, + }, + }, { input: 'formats/heic/IMG_2682.heic', expected: { @@ -681,6 +713,80 @@ describe('/asset', () => { }, }, }, + { + input: 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '4_3.rw2', + resized: true, + fileCreatedAt: '2018-05-10T08:42:37.842Z', + exifInfo: { + make: 'Panasonic', + model: 'DMC-GH4', + exifImageHeight: 3456, + exifImageWidth: 4608, + exposureTime: '1/100', + fNumber: 3.2, + focalLength: 35, + iso: 400, + fileSizeInByte: 19_587_072, + dateTimeOriginal: '2018-05-10T08:42:37.842Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '12bit-compressed-(3_2).arw', + resized: true, + fileCreatedAt: '2016-09-27T10:51:44.000Z', + exifInfo: { + make: 'SONY', + model: 'ILCE-6300', + exifImageHeight: 4024, + exifImageWidth: 6048, + exposureTime: '1/320', + fNumber: 8, + focalLength: 97, + iso: 100, + lensModel: 'E PZ 18-105mm F4 G OSS', + fileSizeInByte: 25_001_984, + dateTimeOriginal: '2016-09-27T10:51:44.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', + expected: { + type: AssetTypeEnum.Image, + originalFileName: '14bit-uncompressed-(3_2).arw', + resized: true, + fileCreatedAt: '2016-01-08T15:08:01.000Z', + exifInfo: { + make: 'SONY', + model: 'ILCE-7M2', + exifImageHeight: 4024, + exifImageWidth: 6048, + exposureTime: '1.3', + fNumber: 22, + focalLength: 25, + iso: 100, + lensModel: 'E 25mm F2', + fileSizeInByte: 49_512_448, + dateTimeOriginal: '2016-01-08T15:08:01.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, ]; for (const { input, expected } of tests) { diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts index 28445f79d9..9174128bb8 100644 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ b/e2e/src/api/specs/auth.e2e-spec.ts @@ -1,7 +1,7 @@ -import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk'; -import { loginDto, signupDto, uuidDto } from 'src/fixtures'; -import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; +import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk'; +import { loginDto, signupDto } from 'src/fixtures'; +import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses'; +import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -112,70 +112,29 @@ describe('/auth/*', () => { const cookies = headers['set-cookie']; expect(cookies).toHaveLength(3); - expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`); - expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'); - expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'); - }); - }); - - describe('GET /auth/devices', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/auth/devices'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should get a list of authorized devices', async () => { - const { status, body } = await request(app) - .get('/auth/devices') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual([deviceDto.current]); - }); - }); - - describe('DELETE /auth/devices', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/auth/devices`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should logout all devices (except the current one)', async () => { - for (let i = 0; i < 5; i++) { - await login({ loginCredentialDto: loginDto.admin }); - } - - await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6); - - const { status } = await request(app).delete(`/auth/devices`).set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); - - await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1); - }); - - it('should throw an error for a non-existent device id', async () => { - const { status, body } = await request(app) - .delete(`/auth/devices/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access')); - }); - - it('should logout a device', async () => { - const [device] = await getAuthDevices({ - headers: asBearerAuth(admin.accessToken), - }); - const { status } = await request(app) - .delete(`/auth/devices/${device.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); - - const response = await request(app) - .post('/auth/validateToken') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(response.body).toEqual(errorDto.invalidToken); - expect(response.status).toBe(401); + expect(cookies[0].split(';').map((item) => item.trim())).toEqual([ + `immich_access_token=${token}`, + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'HttpOnly', + 'SameSite=Lax', + ]); + expect(cookies[1].split(';').map((item) => item.trim())).toEqual([ + 'immich_auth_type=password', + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'HttpOnly', + 'SameSite=Lax', + ]); + expect(cookies[2].split(';').map((item) => item.trim())).toEqual([ + 'immich_is_authenticated=true', + 'Max-Age=34560000', + 'Path=/', + expect.stringContaining('Expires='), + 'SameSite=Lax', + ]); }); }); diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 5cfd6a8b98..690bfae744 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, getServerConfig } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; @@ -162,19 +162,4 @@ describe('/server-info', () => { }); }); }); - - describe('POST /server-info/admin-onboarding', () => { - it('should set admin onboarding', async () => { - const config = await getServerConfig({}); - expect(config.isOnboarded).toBe(false); - - const { status } = await request(app) - .post('/server-info/admin-onboarding') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); - - const newConfig = await getServerConfig({}); - expect(newConfig.isOnboarded).toBe(true); - }); - }); }); diff --git a/e2e/src/api/specs/session.e2e-spec.ts b/e2e/src/api/specs/session.e2e-spec.ts new file mode 100644 index 0000000000..0b632f78ba --- /dev/null +++ b/e2e/src/api/specs/session.e2e-spec.ts @@ -0,0 +1,75 @@ +import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk'; +import { loginDto, signupDto, uuidDto } from 'src/fixtures'; +import { deviceDto, errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('/sessions', () => { + let admin: LoginResponseDto; + + beforeEach(async () => { + await utils.resetDatabase(); + await signUpAdmin({ signUpDto: signupDto.admin }); + admin = await login({ loginCredentialDto: loginDto.admin }); + }); + + describe('GET /sessions', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/sessions'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get a list of authorized devices', async () => { + const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([deviceDto.current]); + }); + }); + + describe('DELETE /sessions', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/sessions`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should logout all devices (except the current one)', async () => { + for (let i = 0; i < 5; i++) { + await login({ loginCredentialDto: loginDto.admin }); + } + + await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6); + + const { status } = await request(app).delete(`/sessions`).set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1); + }); + + it('should throw an error for a non-existent device id', async () => { + const { status, body } = await request(app) + .delete(`/sessions/${uuidDto.notFound}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access')); + }); + + it('should logout a device', async () => { + const [device] = await getSessions({ + headers: asBearerAuth(admin.accessToken), + }); + const { status } = await request(app) + .delete(`/sessions/${device.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(204); + + const response = await request(app) + .post('/auth/validateToken') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(response.body).toEqual(errorDto.invalidToken); + expect(response.status).toBe(401); + }); + }); +}); diff --git a/e2e/src/api/specs/system-metadata.e2e-spec.ts b/e2e/src/api/specs/system-metadata.e2e-spec.ts new file mode 100644 index 0000000000..bd17bf2524 --- /dev/null +++ b/e2e/src/api/specs/system-metadata.e2e-spec.ts @@ -0,0 +1,76 @@ +import { LoginResponseDto, getServerConfig } from '@immich/sdk'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/server-info', () => { + let admin: LoginResponseDto; + let nonAdmin: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + }); + + describe('POST /system-metadata/admin-onboarding', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/system-metadata/admin-onboarding').send({ isOnboarded: true }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .post('/system-metadata/admin-onboarding') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`) + .send({ isOnboarded: true }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should set admin onboarding', async () => { + const config = await getServerConfig({}); + expect(config.isOnboarded).toBe(false); + + const { status } = await request(app) + .post('/system-metadata/admin-onboarding') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ isOnboarded: true }); + expect(status).toBe(204); + + const newConfig = await getServerConfig({}); + expect(newConfig.isOnboarded).toBe(true); + }); + }); + + describe('GET /system-metadata/reverse-geocoding-state', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/system-metadata/reverse-geocoding-state'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .get('/system-metadata/reverse-geocoding-state') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should get the reverse geocoding state', async () => { + const { status, body } = await request(app) + .get('/system-metadata/reverse-geocoding-state') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + lastUpdate: expect.any(String), + lastImportFileName: 'cities500.txt', + }); + }); + }); +}); diff --git a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts new file mode 100644 index 0000000000..707093ab25 --- /dev/null +++ b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts @@ -0,0 +1,19 @@ +import { immichAdmin, utils } from 'src/utils'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe(`immich-admin`, () => { + beforeAll(async () => { + await utils.resetDatabase(); + await utils.adminSetup(); + }); + + describe('list-users', () => { + it('should list the admin user', async () => { + const { stdout, stderr, exitCode } = await immichAdmin(['list-users']); + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + expect(stdout).toContain("email: 'admin@immich.cloud'"); + expect(stdout).toContain("name: 'Immich Admin'"); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index a46653eb11..96994c7f0a 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -24,8 +24,8 @@ import { getConfigDefaults, login, searchMetadata, - setAdminOnboarding, signUpAdmin, + updateAdminOnboarding, updateConfig, validate, } from '@immich/sdk'; @@ -43,7 +43,7 @@ import { loginDto, signupDto } from 'src/fixtures'; import { makeRandomImage } from 'src/generators'; import request from 'supertest'; -type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; +type CommandResponse = { stdout: string; stderr: string; exitCode: number | null }; type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete'; type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number }; type AdminSetupOptions = { onboarding?: boolean }; @@ -59,13 +59,15 @@ export const testAssetDirInternal = '/data/assets'; export const tempDir = tmpdir(); export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); -export const immichCli = async (args: string[]) => { - let _resolve: (value: CliResponse) => void; - const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve)); - const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]; - const child = spawn('node', _args, { - stdio: 'pipe', - }); +export const immichCli = (args: string[]) => + executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]); +export const immichAdmin = (args: string[]) => + executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); + +const executeCommand = (command: string, args: string[]) => { + let _resolve: (value: CommandResponse) => void; + const deferred = new Promise<CommandResponse>((resolve) => (_resolve = resolve)); + const child = spawn(command, args, { stdio: 'pipe' }); let stdout = ''; let stderr = ''; @@ -138,7 +140,7 @@ export const utils = { 'asset_faces', 'activity', 'api_keys', - 'user_token', + 'sessions', 'users', 'system_metadata', 'system_config', @@ -262,7 +264,10 @@ export const utils = { await signUpAdmin({ signUpDto: signupDto.admin }); const response = await login({ loginCredentialDto: loginDto.admin }); if (options.onboarding) { - await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) }); + await updateAdminOnboarding( + { adminOnboardingUpdateDto: { isOnboarded: true } }, + { headers: asBearerAuth(response.accessToken) }, + ); } return response; }, diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 9b9670c042..6b1db353c5 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -10,7 +10,7 @@ try { export default defineConfig({ test: { - include: ['src/{api,cli}/specs/*.e2e-spec.ts'], + include: ['src/{api,cli,immich-admin}/specs/*.e2e-spec.ts'], globalSetup, testTimeout: 15_000, poolOptions: { diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 6b98b8f521..ce3c8a180a 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1150,22 +1150,23 @@ test = ["objgraph", "psutil"] [[package]] name = "gunicorn" -version = "21.2.0" +version = "22.0.0" description = "WSGI HTTP Server for UNIX" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" files = [ - {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, - {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, + {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, + {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, ] [package.dependencies] packaging = "*" [package.extras] -eventlet = ["eventlet (>=0.24.1)"] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] gevent = ["gevent (>=1.4.0)"] setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] [[package]] diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index e5d8e06d43..76f51d964e 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.101.0" +version = "1.102.3" description = "" authors = ["Hau Tran <alex.tran1502@gmail.com>"] readme = "README.md" diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 96d2db23f5..85d6206e1f 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -1,14 +1,14 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" + id "kotlin-kapt" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") + localPropertiesFile.withInputStream { localProperties.load(it) } } def flutterVersionCode = localProperties.getProperty('flutter.versionCode') @@ -21,18 +21,12 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) + keystorePropertiesFile.withInputStream { keystoreProperties.load(it) } } - android { compileSdkVersion 34 @@ -50,7 +44,6 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "app.alextran.immich" minSdkVersion 26 targetSdkVersion 33 @@ -88,6 +81,13 @@ flutter { } dependencies { + def kotlin_version = '1.9.23' + def kotlin_coroutines_version = '1.8.0' + def work_version = '2.9.0' + def concurrent_version = '1.1.0' + def guava_version = '33.1.0-android' + def glide_version = '4.16.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "androidx.work:work-runtime-ktx:$work_version" diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index 660e1d55ba..b6b78c2cba 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -276,7 +276,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_ID = 1 - private const val NOTIFICATION_ERROR_ID = 2 + private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_DETAIL_ID = 3 private const val ONE_MINUTE = 60000L @@ -304,7 +304,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct val workInfoList = workInfoFuture.get(1000, TimeUnit.MILLISECONDS) if (workInfoList != null) { for (workInfo in workInfoList) { - if (workInfo.getState() == WorkInfo.State.ENQUEUED) { + if (workInfo.state == WorkInfo.State.ENQUEUED) { val workRequest = buildWorkRequest(requireWifi, requireCharging) wm.enqueueUniqueWork(TASK_NAME_BACKUP, ExistingWorkPolicy.REPLACE, workRequest) Log.d(TAG, "updateBackupWorker updated BackupWorker constraints") @@ -346,7 +346,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct .setRequiresBatteryNotLow(true) .setRequiresCharging(requireCharging) .build(); - + val work = OneTimeWorkRequest.Builder(BackupWorker::class.java) .setConstraints(constraints) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ONE_MINUTE, TimeUnit.MILLISECONDS) @@ -359,4 +359,4 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } } -private const val TAG = "BackupWorker" \ No newline at end of file +private const val TAG = "BackupWorker" diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 4dacde5a9d..5e374c9f64 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -1,21 +1,3 @@ -buildscript { - ext.kotlin_version = '1.8.20' - ext.kotlin_coroutines_version = '1.7.1' - ext.work_version = '2.7.1' - ext.concurrent_version = '1.1.0' - ext.guava_version = '33.0.0-android' - ext.glide_version = '4.14.2' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() @@ -34,3 +16,7 @@ subprojects { tasks.register("clean", Delete) { delete rootProject.buildDir } + +tasks.named('wrapper') { + distributionType = Wrapper.DistributionType.ALL +} \ No newline at end of file diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 65307abde8..94a9a7e0bd 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 131, - "android.injected.version.name" => "1.101.0", + "android.injected.version.code" => 136, + "android.injected.version.name" => "1.102.3", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index d39c4a373f..358fb9618c 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000219"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000261"> </testcase> - <testcase classname="fastlane.lanes" name="1: bundleRelease" time="67.515419"> + <testcase classname="fastlane.lanes" name="1: bundleRelease" time="32.48099"> </testcase> - <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="35.431743"> + <testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.236974"> </testcase> diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties index 7787882b74..6357330c9e 100644 --- a/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip -distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80 \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip +distributionSha256Sum=fe696c020f241a5f69c30f763c5a7f38eec54b490db19cd2b0962dda420d7d12 \ No newline at end of file diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 44e62bcf06..7ea6533b65 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -1,11 +1,26 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.4.2" apply false + id "org.jetbrains.kotlin.android" version "1.9.23" apply false + id "org.jetbrains.kotlin.kapt" version "1.9.23" apply false +} + +include ":app" diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 1a6ca76757..46155d0c53 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -296,6 +296,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "no_assets_to_show" : "No assets to show", "notification_permission_dialog_cancel": "Cancel", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_dialog_settings": "Settings", diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index eaa4fc06ed..f9ce46c4d5 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -296,6 +296,7 @@ "motion_photos_page_title": "Photos avec mouvement", "multiselect_grid_edit_date_time_err_read_only": "Impossible de modifier la date d'un élément d'actif en lecture seule.", "multiselect_grid_edit_gps_err_read_only": "Impossible de modifier l'emplacement d'un élément en lecture seule.", + "no_assets_to_show" : "Aucun élément à afficher", "notification_permission_dialog_cancel": "Annuler", "notification_permission_dialog_content": "Pour activer les notifications, allez dans Paramètres et sélectionnez Autoriser.", "notification_permission_dialog_settings": "Paramètres", @@ -509,5 +510,7 @@ "version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89", "viewer_remove_from_stack": "Retirer de la pile", "viewer_stack_use_as_main_asset": "Utiliser comme élément principal", - "viewer_unstack": "Désempiler" + "viewer_unstack": "Désempiler", + "haptic_feedback_title": "Retour haptique", + "haptic_feedback_switch": "Activer le retour haptique" } \ No newline at end of file diff --git a/mobile/fonts/SnowburstOne.ttf b/mobile/fonts/SnowburstOne.ttf deleted file mode 100644 index e29832085c..0000000000 Binary files a/mobile/fonts/SnowburstOne.ttf and /dev/null differ diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 1894e39798..9eb77da4d4 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 150; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -525,7 +525,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 150; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -553,7 +553,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 150; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 64b4ea5474..517c5b29ef 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> - <string>1.101.0</string> + <string>1.102.2</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> - <string>147</string> + <string>150</string> <key>FLTEnableImpeller</key> <true /> <key>ITSAppUsesNonExemptEncryption</key> diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 6cf9173c1c..dd62415ff5 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.101.0" + version_number: "1.102.3" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 1d6f7ff460..c3a94c1c23 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000242"> + <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000231"> </testcase> - <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.761829"> + <testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.155919"> </testcase> - <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.47461"> + <testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.252784"> </testcase> - <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.179512"> + <testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.210502"> </testcase> - <testcase classname="fastlane.lanes" name="4: build_app" time="165.636347"> + <testcase classname="fastlane.lanes" name="4: build_app" time="175.813647"> </testcase> - <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="77.651963"> + <testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="73.512517"> </testcase> diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart index 24ee7e693f..89f73c0c64 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart @@ -39,27 +39,24 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); buildTopRow() { - return Row( + return Stack( children: [ - InkWell( - onTap: () => context.pop(), - child: const Icon( - Icons.close, - size: 20, + Align( + alignment: Alignment.topLeft, + child: InkWell( + onTap: () => context.pop(), + child: const Icon( + Icons.close, + size: 20, + ), ), ), - Expanded( - child: Align( - alignment: Alignment.center, - child: Text( - 'IMMICH', - style: TextStyle( - fontFamily: 'SnowburstOne', - fontWeight: FontWeight.bold, - color: context.primaryColor, - fontSize: 16, - ), - ), + Center( + child: Image.asset( + context.isDarkTheme + ? 'assets/immich-text-dark.png' + : 'assets/immich-text-light.png', + height: 16, ), ), ], diff --git a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart index c9cc6c04a9..482f1efc4f 100644 --- a/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart +++ b/mobile/lib/shared/ui/asset_grid/multiselect_grid.dart @@ -63,7 +63,7 @@ class MultiselectGrid extends HookConsumerWidget { const Center(child: ImmichLoadingIndicator()); Widget buildEmptyIndicator() => - emptyIndicator ?? const Center(child: Text("No assets to show")); + emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr()); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index 64bc1ec081..47b550f9d0 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -25,7 +25,6 @@ class SplashScreenPage extends HookConsumerWidget { void performLoggingIn() async { bool isSuccess = false; bool deviceIsOffline = false; - if (accessToken != null && serverUrl != null) { try { // Resolve API server endpoint from user provided serverUrl @@ -51,11 +50,15 @@ class SplashScreenPage extends HookConsumerWidget { offlineLogin: deviceIsOffline, ); } catch (error, stackTrace) { + ref.read(authenticationProvider.notifier).logout(); + log.severe( 'Cannot set success login info', error, stackTrace, ); + + context.pushRoute(const LoginRoute()); } } @@ -73,11 +76,6 @@ class SplashScreenPage extends HookConsumerWidget { } context.replaceRoute(const TabControllerRoute()); } else { - log.severe( - 'Unable to login through offline or online methods - logging out completely', - ); - - ref.read(authenticationProvider.notifier).logout(); // User was unable to login through either offline or online methods context.replaceRoute(const LoginRoute()); } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index b296bbcb55..64229329aa 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -13,6 +13,7 @@ doc/ActivityCreateDto.md doc/ActivityResponseDto.md doc/ActivityStatisticsResponseDto.md doc/AddUsersDto.md +doc/AdminOnboardingUpdateDto.md doc/AlbumApi.md doc/AlbumCountResponseDto.md doc/AlbumResponseDto.md @@ -41,7 +42,6 @@ doc/AssetTypeEnum.md doc/AudioCodec.md doc/AuditApi.md doc/AuditDeletesResponseDto.md -doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/BulkIdResponseDto.md doc/BulkIdsDto.md @@ -70,6 +70,7 @@ doc/FaceApi.md doc/FaceDto.md doc/FileChecksumDto.md doc/FileChecksumResponseDto.md +doc/FileReportApi.md doc/FileReportDto.md doc/FileReportFixDto.md doc/FileReportItemDto.md @@ -123,6 +124,7 @@ doc/QueueStatusDto.md doc/ReactionLevel.md doc/ReactionType.md doc/RecognitionConfig.md +doc/ReverseGeocodingStateResponseDto.md doc/ScanLibraryDto.md doc/SearchAlbumResponseDto.md doc/SearchApi.md @@ -142,6 +144,8 @@ doc/ServerPingResponse.md doc/ServerStatsResponseDto.md doc/ServerThemeDto.md doc/ServerVersionResponseDto.md +doc/SessionResponseDto.md +doc/SessionsApi.md doc/SharedLinkApi.md doc/SharedLinkCreateDto.md doc/SharedLinkEditDto.md @@ -172,6 +176,7 @@ doc/SystemConfigTemplateStorageOptionDto.md doc/SystemConfigThemeDto.md doc/SystemConfigTrashDto.md doc/SystemConfigUserDto.md +doc/SystemMetadataApi.md doc/TagApi.md doc/TagResponseDto.md doc/TagTypeEnum.md @@ -211,6 +216,7 @@ lib/api/audit_api.dart lib/api/authentication_api.dart lib/api/download_api.dart lib/api/face_api.dart +lib/api/file_report_api.dart lib/api/job_api.dart lib/api/library_api.dart lib/api/memory_api.dart @@ -219,9 +225,11 @@ lib/api/partner_api.dart lib/api/person_api.dart lib/api/search_api.dart lib/api/server_info_api.dart +lib/api/sessions_api.dart lib/api/shared_link_api.dart lib/api/sync_api.dart lib/api/system_config_api.dart +lib/api/system_metadata_api.dart lib/api/tag_api.dart lib/api/timeline_api.dart lib/api/trash_api.dart @@ -238,6 +246,7 @@ lib/model/activity_create_dto.dart lib/model/activity_response_dto.dart lib/model/activity_statistics_response_dto.dart lib/model/add_users_dto.dart +lib/model/admin_onboarding_update_dto.dart lib/model/album_count_response_dto.dart lib/model/album_response_dto.dart lib/model/all_job_status_response_dto.dart @@ -267,7 +276,6 @@ lib/model/asset_stats_response_dto.dart lib/model/asset_type_enum.dart lib/model/audio_codec.dart lib/model/audit_deletes_response_dto.dart -lib/model/auth_device_response_dto.dart lib/model/bulk_id_response_dto.dart lib/model/bulk_ids_dto.dart lib/model/change_password_dto.dart @@ -340,6 +348,7 @@ lib/model/queue_status_dto.dart lib/model/reaction_level.dart lib/model/reaction_type.dart lib/model/recognition_config.dart +lib/model/reverse_geocoding_state_response_dto.dart lib/model/scan_library_dto.dart lib/model/search_album_response_dto.dart lib/model/search_asset_response_dto.dart @@ -357,6 +366,7 @@ lib/model/server_ping_response.dart lib/model/server_stats_response_dto.dart lib/model/server_theme_dto.dart lib/model/server_version_response_dto.dart +lib/model/session_response_dto.dart lib/model/shared_link_create_dto.dart lib/model/shared_link_edit_dto.dart lib/model/shared_link_response_dto.dart @@ -415,6 +425,7 @@ test/activity_create_dto_test.dart test/activity_response_dto_test.dart test/activity_statistics_response_dto_test.dart test/add_users_dto_test.dart +test/admin_onboarding_update_dto_test.dart test/album_api_test.dart test/album_count_response_dto_test.dart test/album_response_dto_test.dart @@ -448,7 +459,6 @@ test/asset_type_enum_test.dart test/audio_codec_test.dart test/audit_api_test.dart test/audit_deletes_response_dto_test.dart -test/auth_device_response_dto_test.dart test/authentication_api_test.dart test/bulk_id_response_dto_test.dart test/bulk_ids_dto_test.dart @@ -477,6 +487,7 @@ test/face_api_test.dart test/face_dto_test.dart test/file_checksum_dto_test.dart test/file_checksum_response_dto_test.dart +test/file_report_api_test.dart test/file_report_dto_test.dart test/file_report_fix_dto_test.dart test/file_report_item_dto_test.dart @@ -530,6 +541,7 @@ test/queue_status_dto_test.dart test/reaction_level_test.dart test/reaction_type_test.dart test/recognition_config_test.dart +test/reverse_geocoding_state_response_dto_test.dart test/scan_library_dto_test.dart test/search_album_response_dto_test.dart test/search_api_test.dart @@ -549,6 +561,8 @@ test/server_ping_response_test.dart test/server_stats_response_dto_test.dart test/server_theme_dto_test.dart test/server_version_response_dto_test.dart +test/session_response_dto_test.dart +test/sessions_api_test.dart test/shared_link_api_test.dart test/shared_link_create_dto_test.dart test/shared_link_edit_dto_test.dart @@ -579,6 +593,7 @@ test/system_config_template_storage_option_dto_test.dart test/system_config_theme_dto_test.dart test/system_config_trash_dto_test.dart test/system_config_user_dto_test.dart +test/system_metadata_api_test.dart test/tag_api_test.dart test/tag_response_dto_test.dart test/tag_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 730307b9bf..5439d48208 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AdminOnboardingUpdateDto.md b/mobile/openapi/doc/AdminOnboardingUpdateDto.md new file mode 100644 index 0000000000..b250843019 Binary files /dev/null and b/mobile/openapi/doc/AdminOnboardingUpdateDto.md differ diff --git a/mobile/openapi/doc/AuditApi.md b/mobile/openapi/doc/AuditApi.md index 8514cdec73..2c768c40d1 100644 Binary files a/mobile/openapi/doc/AuditApi.md and b/mobile/openapi/doc/AuditApi.md differ diff --git a/mobile/openapi/doc/AuthenticationApi.md b/mobile/openapi/doc/AuthenticationApi.md index 9521568e9d..02fb94a092 100644 Binary files a/mobile/openapi/doc/AuthenticationApi.md and b/mobile/openapi/doc/AuthenticationApi.md differ diff --git a/mobile/openapi/doc/FileReportApi.md b/mobile/openapi/doc/FileReportApi.md new file mode 100644 index 0000000000..b722c86041 Binary files /dev/null and b/mobile/openapi/doc/FileReportApi.md differ diff --git a/mobile/openapi/doc/ReverseGeocodingStateResponseDto.md b/mobile/openapi/doc/ReverseGeocodingStateResponseDto.md new file mode 100644 index 0000000000..87f8aa8ab7 Binary files /dev/null and b/mobile/openapi/doc/ReverseGeocodingStateResponseDto.md differ diff --git a/mobile/openapi/doc/ServerInfoApi.md b/mobile/openapi/doc/ServerInfoApi.md index cb5cf0fd3e..e8121a8001 100644 Binary files a/mobile/openapi/doc/ServerInfoApi.md and b/mobile/openapi/doc/ServerInfoApi.md differ diff --git a/mobile/openapi/doc/AuthDeviceResponseDto.md b/mobile/openapi/doc/SessionResponseDto.md similarity index 93% rename from mobile/openapi/doc/AuthDeviceResponseDto.md rename to mobile/openapi/doc/SessionResponseDto.md index 4433e33385..9d1a11cbce 100644 Binary files a/mobile/openapi/doc/AuthDeviceResponseDto.md and b/mobile/openapi/doc/SessionResponseDto.md differ diff --git a/mobile/openapi/doc/SessionsApi.md b/mobile/openapi/doc/SessionsApi.md new file mode 100644 index 0000000000..d082a8cfed Binary files /dev/null and b/mobile/openapi/doc/SessionsApi.md differ diff --git a/mobile/openapi/doc/SystemConfigImageDto.md b/mobile/openapi/doc/SystemConfigImageDto.md index 1b9bbe726d..81e88045d5 100644 Binary files a/mobile/openapi/doc/SystemConfigImageDto.md and b/mobile/openapi/doc/SystemConfigImageDto.md differ diff --git a/mobile/openapi/doc/SystemMetadataApi.md b/mobile/openapi/doc/SystemMetadataApi.md new file mode 100644 index 0000000000..f8c2347afe Binary files /dev/null and b/mobile/openapi/doc/SystemMetadataApi.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e7320d5bb2..44bd35a683 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/audit_api.dart b/mobile/openapi/lib/api/audit_api.dart index 871c8e1905..83dde34da7 100644 Binary files a/mobile/openapi/lib/api/audit_api.dart and b/mobile/openapi/lib/api/audit_api.dart differ diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index d1f04d600e..62f8be353a 100644 Binary files a/mobile/openapi/lib/api/authentication_api.dart and b/mobile/openapi/lib/api/authentication_api.dart differ diff --git a/mobile/openapi/lib/api/file_report_api.dart b/mobile/openapi/lib/api/file_report_api.dart new file mode 100644 index 0000000000..df307e12c7 Binary files /dev/null and b/mobile/openapi/lib/api/file_report_api.dart differ diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index 77840acd19..b67045add1 100644 Binary files a/mobile/openapi/lib/api/server_info_api.dart and b/mobile/openapi/lib/api/server_info_api.dart differ diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart new file mode 100644 index 0000000000..bc0fed71e1 Binary files /dev/null and b/mobile/openapi/lib/api/sessions_api.dart differ diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart new file mode 100644 index 0000000000..f3952fda8a Binary files /dev/null and b/mobile/openapi/lib/api/system_metadata_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4bbae89285..a92f1df7a7 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart new file mode 100644 index 0000000000..50c4ae090e Binary files /dev/null and b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart differ diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart new file mode 100644 index 0000000000..71e1d3ad99 Binary files /dev/null and b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart differ diff --git a/mobile/openapi/lib/model/auth_device_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart similarity index 70% rename from mobile/openapi/lib/model/auth_device_response_dto.dart rename to mobile/openapi/lib/model/session_response_dto.dart index f1425a221f..6a44fc24bb 100644 Binary files a/mobile/openapi/lib/model/auth_device_response_dto.dart and b/mobile/openapi/lib/model/session_response_dto.dart differ diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 1c830861af..7072e11270 100644 Binary files a/mobile/openapi/lib/model/system_config_image_dto.dart and b/mobile/openapi/lib/model/system_config_image_dto.dart differ diff --git a/mobile/openapi/test/admin_onboarding_update_dto_test.dart b/mobile/openapi/test/admin_onboarding_update_dto_test.dart new file mode 100644 index 0000000000..09cc73e977 Binary files /dev/null and b/mobile/openapi/test/admin_onboarding_update_dto_test.dart differ diff --git a/mobile/openapi/test/audit_api_test.dart b/mobile/openapi/test/audit_api_test.dart index 8161d4e4db..8114283a1a 100644 Binary files a/mobile/openapi/test/audit_api_test.dart and b/mobile/openapi/test/audit_api_test.dart differ diff --git a/mobile/openapi/test/authentication_api_test.dart b/mobile/openapi/test/authentication_api_test.dart index aa2f1879d5..dea20ec9b1 100644 Binary files a/mobile/openapi/test/authentication_api_test.dart and b/mobile/openapi/test/authentication_api_test.dart differ diff --git a/mobile/openapi/test/file_report_api_test.dart b/mobile/openapi/test/file_report_api_test.dart new file mode 100644 index 0000000000..255c787002 Binary files /dev/null and b/mobile/openapi/test/file_report_api_test.dart differ diff --git a/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart b/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart new file mode 100644 index 0000000000..91fdfcfea4 Binary files /dev/null and b/mobile/openapi/test/reverse_geocoding_state_response_dto_test.dart differ diff --git a/mobile/openapi/test/server_info_api_test.dart b/mobile/openapi/test/server_info_api_test.dart index 68cd1c348b..dac465116e 100644 Binary files a/mobile/openapi/test/server_info_api_test.dart and b/mobile/openapi/test/server_info_api_test.dart differ diff --git a/mobile/openapi/test/auth_device_response_dto_test.dart b/mobile/openapi/test/session_response_dto_test.dart similarity index 88% rename from mobile/openapi/test/auth_device_response_dto_test.dart rename to mobile/openapi/test/session_response_dto_test.dart index c0cccf8d65..d704b2e5eb 100644 Binary files a/mobile/openapi/test/auth_device_response_dto_test.dart and b/mobile/openapi/test/session_response_dto_test.dart differ diff --git a/mobile/openapi/test/sessions_api_test.dart b/mobile/openapi/test/sessions_api_test.dart new file mode 100644 index 0000000000..9fc6093c19 Binary files /dev/null and b/mobile/openapi/test/sessions_api_test.dart differ diff --git a/mobile/openapi/test/system_config_image_dto_test.dart b/mobile/openapi/test/system_config_image_dto_test.dart index aef907bbe6..b46340455b 100644 Binary files a/mobile/openapi/test/system_config_image_dto_test.dart and b/mobile/openapi/test/system_config_image_dto_test.dart differ diff --git a/mobile/openapi/test/system_metadata_api_test.dart b/mobile/openapi/test/system_metadata_api_test.dart new file mode 100644 index 0000000000..bc1ce6f6f3 Binary files /dev/null and b/mobile/openapi/test/system_metadata_api_test.dart differ diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 6bf2b09026..54648fd20b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1804,5 +1804,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0 <4.0.0" + dart: ">=3.3.0 <4.0.0" flutter: ">=3.16.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b0dd2f6b61..8ae3a21318 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,10 +2,10 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.101.0+131 +version: 1.102.3+136 environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.3.0 <4.0.0' dependencies: flutter: @@ -105,9 +105,6 @@ flutter: - assets/ - assets/i18n/ fonts: - - family: SnowburstOne - fonts: - - asset: fonts/SnowburstOne.ttf - family: Inconsolata fonts: - asset: fonts/Inconsolata-Regular.ttf diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8f53f838b0..b5bf2c9f4d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2345,118 +2345,6 @@ ] } }, - "/audit/file-report": { - "get": { - "operationId": "getAuditFiles", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileReportDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Audit" - ] - } - }, - "/audit/file-report/checksum": { - "post": { - "operationId": "getFileChecksums", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileChecksumDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/FileChecksumResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Audit" - ] - } - }, - "/audit/file-report/fix": { - "post": { - "operationId": "fixAuditFiles", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileReportFixDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Audit" - ] - } - }, "/auth/admin-sign-up": { "post": { "operationId": "signUpAdmin", @@ -2530,99 +2418,6 @@ ] } }, - "/auth/devices": { - "delete": { - "operationId": "logoutAuthDevices", - "parameters": [], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Authentication" - ] - }, - "get": { - "operationId": "getAuthDevices", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AuthDeviceResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Authentication" - ] - } - }, - "/auth/devices/{id}": { - "delete": { - "operationId": "logoutAuthDevice", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Authentication" - ] - } - }, "/auth/login": { "post": { "operationId": "login", @@ -4522,6 +4317,118 @@ ] } }, + "/report": { + "get": { + "operationId": "getAuditFiles", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReportDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "File Report" + ] + } + }, + "/report/checksum": { + "post": { + "operationId": "getFileChecksums", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileChecksumDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FileChecksumResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "File Report" + ] + } + }, + "/report/fix": { + "post": { + "operationId": "fixAuditFiles", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReportFixDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "File Report" + ] + } + }, "/search": { "get": { "deprecated": true, @@ -5001,31 +4908,6 @@ ] } }, - "/server-info/admin-onboarding": { - "post": { - "operationId": "setAdminOnboarding", - "parameters": [], - "responses": { - "204": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Server Info" - ] - } - }, "/server-info/config": { "get": { "operationId": "getServerConfig", @@ -5184,6 +5066,99 @@ ] } }, + "/sessions": { + "delete": { + "operationId": "deleteAllSessions", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + }, + "get": { + "operationId": "getSessions", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SessionResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + } + }, + "/sessions/{id}": { + "delete": { + "operationId": "deleteSession", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + } + }, "/shared-link": { "get": { "operationId": "getAllSharedLinks", @@ -5885,6 +5860,103 @@ ] } }, + "/system-metadata/admin-onboarding": { + "get": { + "operationId": "getAdminOnboarding", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminOnboardingUpdateDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + }, + "post": { + "operationId": "updateAdminOnboarding", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminOnboardingUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + } + }, + "/system-metadata/reverse-geocoding-state": { + "get": { + "operationId": "getReverseGeocodingState", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReverseGeocodingStateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + } + }, "/tag": { "get": { "operationId": "getAllTags", @@ -7006,7 +7078,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.101.0", + "version": "1.102.3", "contact": {} }, "tags": [], @@ -7180,6 +7252,17 @@ ], "type": "object" }, + "AdminOnboardingUpdateDto": { + "properties": { + "isOnboarded": { + "type": "boolean" + } + }, + "required": [ + "isOnboarded" + ], + "type": "object" + }, "AlbumCountResponseDto": { "properties": { "notShared": { @@ -7892,37 +7975,6 @@ ], "type": "object" }, - "AuthDeviceResponseDto": { - "properties": { - "createdAt": { - "type": "string" - }, - "current": { - "type": "boolean" - }, - "deviceOS": { - "type": "string" - }, - "deviceType": { - "type": "string" - }, - "id": { - "type": "string" - }, - "updatedAt": { - "type": "string" - } - }, - "required": [ - "createdAt", - "current", - "deviceOS", - "deviceType", - "id", - "updatedAt" - ], - "type": "object" - }, "BulkIdResponseDto": { "properties": { "error": { @@ -9649,6 +9701,23 @@ ], "type": "object" }, + "ReverseGeocodingStateResponseDto": { + "properties": { + "lastImportFileName": { + "nullable": true, + "type": "string" + }, + "lastUpdate": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "lastImportFileName", + "lastUpdate" + ], + "type": "object" + }, "ScanLibraryDto": { "properties": { "refreshAllFiles": { @@ -10049,6 +10118,37 @@ ], "type": "object" }, + "SessionResponseDto": { + "properties": { + "createdAt": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "deviceOS": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "createdAt", + "current", + "deviceOS", + "deviceType", + "id", + "updatedAt" + ], + "type": "object" + }, "SharedLinkCreateDto": { "properties": { "albumId": { @@ -10531,6 +10631,9 @@ "colorspace": { "$ref": "#/components/schemas/Colorspace" }, + "extractEmbedded": { + "type": "boolean" + }, "previewFormat": { "$ref": "#/components/schemas/ImageFormat" }, @@ -10549,6 +10652,7 @@ }, "required": [ "colorspace", + "extractEmbedded", "previewFormat", "previewSize", "quality", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 8def6adffd..a6a751ccfa 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 887fece059..dd2360cede 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.3", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 96b071f1f9..41603bc0e8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.101.0 + * 1.102.3 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -316,27 +316,6 @@ export type AuditDeletesResponseDto = { ids: string[]; needsFullSync: boolean; }; -export type FileReportItemDto = { - checksum?: string; - entityId: string; - entityType: PathEntityType; - pathType: PathType; - pathValue: string; -}; -export type FileReportDto = { - extras: string[]; - orphans: FileReportItemDto[]; -}; -export type FileChecksumDto = { - filenames: string[]; -}; -export type FileChecksumResponseDto = { - checksum: string; - filename: string; -}; -export type FileReportFixDto = { - items: FileReportItemDto[]; -}; export type SignUpDto = { email: string; name: string; @@ -346,14 +325,6 @@ export type ChangePasswordDto = { newPassword: string; password: string; }; -export type AuthDeviceResponseDto = { - createdAt: string; - current: boolean; - deviceOS: string; - deviceType: string; - id: string; - updatedAt: string; -}; export type LoginCredentialDto = { email: string; password: string; @@ -607,6 +578,27 @@ export type AssetFaceUpdateDto = { export type PersonStatisticsResponseDto = { assets: number; }; +export type FileReportItemDto = { + checksum?: string; + entityId: string; + entityType: PathEntityType; + pathType: PathType; + pathValue: string; +}; +export type FileReportDto = { + extras: string[]; + orphans: FileReportItemDto[]; +}; +export type FileChecksumDto = { + filenames: string[]; +}; +export type FileChecksumResponseDto = { + checksum: string; + filename: string; +}; +export type FileReportFixDto = { + items: FileReportItemDto[]; +}; export type SearchFacetCountResponseDto = { count: number; value: string; @@ -791,6 +783,14 @@ export type ServerVersionResponseDto = { minor: number; patch: number; }; +export type SessionResponseDto = { + createdAt: string; + current: boolean; + deviceOS: string; + deviceType: string; + id: string; + updatedAt: string; +}; export type SharedLinkResponseDto = { album?: AlbumResponseDto; allowDownload: boolean; @@ -864,6 +864,7 @@ export type SystemConfigFFmpegDto = { }; export type SystemConfigImageDto = { colorspace: Colorspace; + extractEmbedded: boolean; previewFormat: ImageFormat; previewSize: number; quality: number; @@ -997,6 +998,13 @@ export type SystemConfigTemplateStorageOptionDto = { weekOptions: string[]; yearOptions: string[]; }; +export type AdminOnboardingUpdateDto = { + isOnboarded: boolean; +}; +export type ReverseGeocodingStateResponseDto = { + lastImportFileName: string | null; + lastUpdate: string | null; +}; export type CreateTagDto = { name: string; "type": TagTypeEnum; @@ -1650,35 +1658,6 @@ export function getAuditDeletes({ after, entityType, userId }: { ...opts })); } -export function getAuditFiles(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: FileReportDto; - }>("/audit/file-report", { - ...opts - })); -} -export function getFileChecksums({ fileChecksumDto }: { - fileChecksumDto: FileChecksumDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; - data: FileChecksumResponseDto[]; - }>("/audit/file-report/checksum", oazapfts.json({ - ...opts, - method: "POST", - body: fileChecksumDto - }))); -} -export function fixAuditFiles({ fileReportFixDto }: { - fileReportFixDto: FileReportFixDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/audit/file-report/fix", oazapfts.json({ - ...opts, - method: "POST", - body: fileReportFixDto - }))); -} export function signUpAdmin({ signUpDto }: { signUpDto: SignUpDto; }, opts?: Oazapfts.RequestOpts) { @@ -1703,28 +1682,6 @@ export function changePassword({ changePasswordDto }: { body: changePasswordDto }))); } -export function logoutAuthDevices(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/auth/devices", { - ...opts, - method: "DELETE" - })); -} -export function getAuthDevices(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AuthDeviceResponseDto[]; - }>("/auth/devices", { - ...opts - })); -} -export function logoutAuthDevice({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/auth/devices/${encodeURIComponent(id)}`, { - ...opts, - method: "DELETE" - })); -} export function login({ loginCredentialDto }: { loginCredentialDto: LoginCredentialDto; }, opts?: Oazapfts.RequestOpts) { @@ -2227,6 +2184,35 @@ export function getPersonThumbnail({ id }: { ...opts })); } +export function getAuditFiles(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: FileReportDto; + }>("/report", { + ...opts + })); +} +export function getFileChecksums({ fileChecksumDto }: { + fileChecksumDto: FileChecksumDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: FileChecksumResponseDto[]; + }>("/report/checksum", oazapfts.json({ + ...opts, + method: "POST", + body: fileChecksumDto + }))); +} +export function fixAuditFiles({ fileReportFixDto }: { + fileReportFixDto: FileReportFixDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/report/fix", oazapfts.json({ + ...opts, + method: "POST", + body: fileReportFixDto + }))); +} export function search({ clip, motion, page, q, query, recent, size, smart, $type, withArchived }: { clip?: boolean; motion?: boolean; @@ -2351,12 +2337,6 @@ export function getServerInfo(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function setAdminOnboarding(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/server-info/admin-onboarding", { - ...opts, - method: "POST" - })); -} export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2413,6 +2393,28 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sessions", { + ...opts, + method: "DELETE" + })); +} +export function getSessions(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SessionResponseDto[]; + }>("/sessions", { + ...opts + })); +} +export function deleteSession({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2596,6 +2598,31 @@ export function getStorageTemplateOptions(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function getAdminOnboarding(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminOnboardingUpdateDto; + }>("/system-metadata/admin-onboarding", { + ...opts + })); +} +export function updateAdminOnboarding({ adminOnboardingUpdateDto }: { + adminOnboardingUpdateDto: AdminOnboardingUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/system-metadata/admin-onboarding", oazapfts.json({ + ...opts, + method: "POST", + body: adminOnboardingUpdateDto + }))); +} +export function getReverseGeocodingState(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ReverseGeocodingStateResponseDto; + }>("/system-metadata/reverse-geocoding-state", { + ...opts + })); +} export function getAllTags(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2947,20 +2974,6 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } -export enum PathEntityType { - Asset = "asset", - Person = "person", - User = "user" -} -export enum PathType { - Original = "original", - Preview = "preview", - Thumbnail = "thumbnail", - EncodedVideo = "encoded_video", - Sidecar = "sidecar", - Face = "face", - Profile = "profile" -} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", @@ -2992,6 +3005,20 @@ export enum Type2 { export enum MemoryType { OnThisDay = "on_this_day" } +export enum PathEntityType { + Asset = "asset", + Person = "person", + User = "user" +} +export enum PathType { + Original = "original", + Preview = "preview", + Thumbnail = "thumbnail", + EncodedVideo = "encoded_video", + Sidecar = "sidecar", + Face = "face", + Profile = "profile" +} export enum SearchSuggestionType { Country = "country", State = "state", diff --git a/renovate.json b/renovate.json index afa68011d0..3e2c50d7f1 100644 --- a/renovate.json +++ b/renovate.json @@ -27,7 +27,8 @@ "matchFileNames": ["mobile/**"], "groupName": "mobile", "matchUpdateTypes": ["minor", "patch"], - "schedule": "on tuesday" + "schedule": "on tuesday", + "addLabels": ["📱mobile"] }, { "groupName": "exiftool", diff --git a/server/Dockerfile b/server/Dockerfile index 8ed5344395..553084ed77 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build -FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e as web +FROM node:iron-alpine3.18@sha256:d328c7bc3305e1ab26491817936c8151a47a8861ad617c16c1eeaa9c8075c8f6 as web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/server/package-lock.json b/server/package-lock.json index 286f1006b9..eb062059f3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.101.0", + "version": "1.102.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.101.0", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index d5828822cd..274eddd304 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.101.0", + "version": "1.102.3", "description": "", "author": "", "private": true, diff --git a/server/src/commands/list-users.command.ts b/server/src/commands/list-users.command.ts index 32bcc35d95..ea3e745463 100644 --- a/server/src/commands/list-users.command.ts +++ b/server/src/commands/list-users.command.ts @@ -1,5 +1,4 @@ import { Command, CommandRunner } from 'nest-commander'; -import { UserEntity } from 'src/entities/user.entity'; import { UserService } from 'src/services/user.service'; @Command({ @@ -13,16 +12,7 @@ export class ListUsersCommand extends CommandRunner { async run(): Promise<void> { try { - const users = await this.userService.getAll( - { - user: { - id: 'cli', - email: 'cli@immich.app', - isAdmin: true, - } as UserEntity, - }, - true, - ); + const users = await this.userService.listUsers(); console.dir(users); } catch (error) { console.error(error); diff --git a/server/src/constants.ts b/server/src/constants.ts index 1289701dd8..d9d4232396 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -26,12 +26,7 @@ export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile); export const MOBILE_REDIRECT = 'app.immich:/'; export const LOGIN_URL = '/auth/login?autoLaunch=0'; -export const IMMICH_ACCESS_COOKIE = 'immich_access_token'; -export const IMMICH_IS_AUTHENTICATED = 'immich_is_authenticated'; -export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type'; -export const IMMICH_API_KEY_NAME = 'api_key'; -export const IMMICH_API_KEY_HEADER = 'x-api-key'; -export const IMMICH_SHARED_LINK_ACCESS_COOKIE = 'immich_shared_link_token'; + export enum AuthType { PASSWORD = 'password', OAUTH = 'oauth', diff --git a/server/src/controllers/audit.controller.ts b/server/src/controllers/audit.controller.ts index 1487e78d47..8eea6a6e3e 100644 --- a/server/src/controllers/audit.controller.ts +++ b/server/src/controllers/audit.controller.ts @@ -1,15 +1,8 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { - AuditDeletesDto, - AuditDeletesResponseDto, - FileChecksumDto, - FileChecksumResponseDto, - FileReportDto, - FileReportFixDto, -} from 'src/dtos/audit.dto'; +import { AuditDeletesDto, AuditDeletesResponseDto } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AdminRoute, Auth, Authenticated } from 'src/middleware/auth.guard'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AuditService } from 'src/services/audit.service'; @ApiTags('Audit') @@ -22,22 +15,4 @@ export class AuditController { getAuditDeletes(@Auth() auth: AuthDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> { return this.service.getDeletes(auth, dto); } - - @AdminRoute() - @Get('file-report') - getAuditFiles(): Promise<FileReportDto> { - return this.service.getFileReport(); - } - - @AdminRoute() - @Post('file-report/checksum') - getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> { - return this.service.getChecksums(dto); - } - - @AdminRoute() - @Post('file-report/fix') - fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> { - return this.service.fixItems(dto.items); - } } diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 9b4e7a3bc7..a4c7494f2b 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,11 +1,11 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants'; +import { AuthType } from 'src/constants'; import { - AuthDeviceResponseDto, AuthDto, ChangePasswordDto, + ImmichCookie, LoginCredentialDto, LoginResponseDto, LogoutResponseDto, @@ -15,7 +15,7 @@ import { import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; -import { UUIDParamDto } from 'src/validation'; +import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; @ApiTags('Authentication') @Controller('auth') @@ -30,9 +30,15 @@ export class AuthController { @Res({ passthrough: true }) res: Response, @GetLoginDetails() loginDetails: LoginDetails, ): Promise<LoginResponseDto> { - const { response, cookie } = await this.service.login(loginCredential, loginDetails); - res.header('Set-Cookie', cookie); - return response; + const body = await this.service.login(loginCredential, loginDetails); + return respondWithCookie(res, body, { + isSecure: loginDetails.isSecure, + values: [ + { key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken }, + { key: ImmichCookie.AUTH_TYPE, value: AuthType.PASSWORD }, + { key: ImmichCookie.IS_AUTHENTICATED, value: 'true' }, + ], + }); } @PublicRoute() @@ -41,23 +47,6 @@ export class AuthController { return this.service.adminSignUp(dto); } - @Get('devices') - getAuthDevices(@Auth() auth: AuthDto): Promise<AuthDeviceResponseDto[]> { - return this.service.getDevices(auth); - } - - @Delete('devices') - @HttpCode(HttpStatus.NO_CONTENT) - logoutAuthDevices(@Auth() auth: AuthDto): Promise<void> { - return this.service.logoutDevices(auth); - } - - @Delete('devices/:id') - @HttpCode(HttpStatus.NO_CONTENT) - logoutAuthDevice(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { - return this.service.logoutDevice(auth, id); - } - @Post('validateToken') @HttpCode(HttpStatus.OK) validateAccessToken(): ValidateAccessTokenResponseDto { @@ -72,15 +61,18 @@ export class AuthController { @Post('logout') @HttpCode(HttpStatus.OK) - logout( + async logout( @Req() request: Request, @Res({ passthrough: true }) res: Response, @Auth() auth: AuthDto, ): Promise<LogoutResponseDto> { - res.clearCookie(IMMICH_ACCESS_COOKIE); - res.clearCookie(IMMICH_AUTH_TYPE_COOKIE); - res.clearCookie(IMMICH_IS_AUTHENTICATED); + const authType = (request.cookies || {})[ImmichCookie.AUTH_TYPE]; - return this.service.logout(auth, (request.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]); + const body = await this.service.logout(auth, authType); + return respondWithoutCookie(res, body, [ + ImmichCookie.ACCESS_TOKEN, + ImmichCookie.AUTH_TYPE, + ImmichCookie.IS_AUTHENTICATED, + ]); } } diff --git a/server/src/controllers/file-report.controller.ts b/server/src/controllers/file-report.controller.ts new file mode 100644 index 0000000000..6bdf726073 --- /dev/null +++ b/server/src/controllers/file-report.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { FileChecksumDto, FileChecksumResponseDto, FileReportDto, FileReportFixDto } from 'src/dtos/audit.dto'; +import { AdminRoute, Authenticated } from 'src/middleware/auth.guard'; +import { AuditService } from 'src/services/audit.service'; + +@ApiTags('File Report') +@Controller('report') +@Authenticated() +export class ReportController { + constructor(private service: AuditService) {} + + @AdminRoute() + @Get() + getAuditFiles(): Promise<FileReportDto> { + return this.service.getFileReport(); + } + + @AdminRoute() + @Post('/checksum') + getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> { + return this.service.getChecksums(dto); + } + + @AdminRoute() + @Post('/fix') + fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> { + return this.service.fixItems(dto.items); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index d136a52b04..bd10c41a43 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -8,6 +8,7 @@ import { AuditController } from 'src/controllers/audit.controller'; import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { FaceController } from 'src/controllers/face.controller'; +import { ReportController } from 'src/controllers/file-report.controller'; import { JobController } from 'src/controllers/job.controller'; import { LibraryController } from 'src/controllers/library.controller'; import { MemoryController } from 'src/controllers/memory.controller'; @@ -16,22 +17,24 @@ import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerInfoController } from 'src/controllers/server-info.controller'; +import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { SyncController } from 'src/controllers/sync.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller'; +import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; import { TagController } from 'src/controllers/tag.controller'; import { TimelineController } from 'src/controllers/timeline.controller'; import { TrashController } from 'src/controllers/trash.controller'; import { UserController } from 'src/controllers/user.controller'; export const controllers = [ - ActivityController, - AssetsController, - AssetControllerV1, - AssetController, - AppController, - AlbumController, APIKeyController, + ActivityController, + AlbumController, + AppController, + AssetController, + AssetControllerV1, + AssetsController, AuditController, AuthController, DownloadController, @@ -41,14 +44,17 @@ export const controllers = [ MemoryController, OAuthController, PartnerController, + PersonController, + ReportController, SearchController, ServerInfoController, + SessionController, SharedLinkController, SyncController, SystemConfigController, + SystemMetadataController, TagController, TimelineController, TrashController, UserController, - PersonController, ]; diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index debbd4e676..d87fb11d88 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,8 +1,10 @@ import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; +import { AuthType } from 'src/constants'; import { AuthDto, + ImmichCookie, LoginResponseDto, OAuthAuthorizeResponseDto, OAuthCallbackDto, @@ -11,6 +13,7 @@ import { import { UserResponseDto } from 'src/dtos/user.dto'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; +import { respondWithCookie } from 'src/utils/response'; @ApiTags('OAuth') @Controller('oauth') @@ -41,9 +44,15 @@ export class OAuthController { @Body() dto: OAuthCallbackDto, @GetLoginDetails() loginDetails: LoginDetails, ): Promise<LoginResponseDto> { - const { response, cookie } = await this.service.callback(dto, loginDetails); - res.header('Set-Cookie', cookie); - return response; + const body = await this.service.callback(dto, loginDetails); + return respondWithCookie(res, body, { + isSecure: loginDetails.isSecure, + values: [ + { key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken }, + { key: ImmichCookie.AUTH_TYPE, value: AuthType.OAUTH }, + { key: ImmichCookie.IS_AUTHENTICATED, value: 'true' }, + ], + }); } @Post('link') diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index e32b0d191c..35e5e17594 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ServerConfigDto, @@ -65,11 +65,4 @@ export class ServerInfoController { getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } - - @AdminRoute() - @Post('admin-onboarding') - @HttpCode(HttpStatus.NO_CONTENT) - setAdminOnboarding(): Promise<void> { - return this.service.setAdminOnboarding(); - } } diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts new file mode 100644 index 0000000000..552afcdf5a --- /dev/null +++ b/server/src/controllers/session.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto } from 'src/dtos/session.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { SessionService } from 'src/services/session.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Sessions') +@Controller('sessions') +@Authenticated() +export class SessionController { + constructor(private service: SessionService) {} + + @Get() + getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> { + return this.service.getAll(auth); + } + + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + deleteAllSessions(@Auth() auth: AuthDto): Promise<void> { + return this.service.deleteAll(auth); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> { + return this.service.delete(auth, id); + } +} diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index a7a8e3a1c6..58f2939b93 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -1,18 +1,19 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { IMMICH_SHARED_LINK_ACCESS_COOKIE } from 'src/constants'; import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; -import { AuthDto } from 'src/dtos/auth.dto'; +import { AuthDto, ImmichCookie } from 'src/dtos/auth.dto'; import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto, SharedLinkResponseDto, } from 'src/dtos/shared-link.dto'; -import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; +import { Auth, Authenticated, GetLoginDetails, SharedLinkRoute } from 'src/middleware/auth.guard'; +import { LoginDetails } from 'src/services/auth.service'; import { SharedLinkService } from 'src/services/shared-link.service'; +import { respondWithCookie } from 'src/utils/response'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Shared Link') @@ -33,20 +34,17 @@ export class SharedLinkController { @Query() dto: SharedLinkPasswordDto, @Req() request: Request, @Res({ passthrough: true }) res: Response, + @GetLoginDetails() loginDetails: LoginDetails, ): Promise<SharedLinkResponseDto> { - const sharedLinkToken = request.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE]; + const sharedLinkToken = request.cookies?.[ImmichCookie.SHARED_LINK_TOKEN]; if (sharedLinkToken) { dto.token = sharedLinkToken; } - const response = await this.service.getMine(auth, dto); - if (response.token) { - res.cookie(IMMICH_SHARED_LINK_ACCESS_COOKIE, response.token, { - expires: new Date(Date.now() + 1000 * 60 * 60 * 24), - httpOnly: true, - sameSite: 'lax', - }); - } - return response; + const body = await this.service.getMine(auth, dto); + return respondWithCookie(res, body, { + isSecure: loginDetails.isSecure, + values: body.token ? [{ key: ImmichCookie.SHARED_LINK_TOKEN, value: body.token }] : [], + }); } @Get(':id') diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts new file mode 100644 index 0000000000..7f186fec03 --- /dev/null +++ b/server/src/controllers/system-metadata.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; + +@ApiTags('System Metadata') +@Controller('system-metadata') +@Authenticated({ admin: true }) +export class SystemMetadataController { + constructor(private service: SystemMetadataService) {} + + @Get('admin-onboarding') + getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> { + return this.service.getAdminOnboarding(); + } + + @Post('admin-onboarding') + @HttpCode(HttpStatus.NO_CONTENT) + updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> { + return this.service.updateAdminOnboarding(dto); + } + + @Get('reverse-geocoding-state') + getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> { + return this.service.getReverseGeocodingState(); + } +} diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index f1c16e5698..4e5f4742a4 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { APP_MEDIA_LOCATION } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; @@ -308,4 +309,8 @@ export class StorageCore { static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string { return join(this.getNestedFolder(folder, ownerId, filename), filename); } + + static getTempPathInDir(dir: string): string { + return join(dir, `${randomUUID()}.tmp`); + } } diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index 9cbe3b8414..2520840173 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -120,6 +120,7 @@ export const defaults = Object.freeze<SystemConfig>({ previewSize: 1440, quality: 80, colorspace: Colorspace.P3, + extractEmbedded: false, }, newVersionCheck: { enabled: true, diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index f3f2270d02..5c1e01b818 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -2,16 +2,35 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { APIKeyEntity } from 'src/entities/api-key.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { UserEntity } from 'src/entities/user.entity'; +export enum ImmichCookie { + ACCESS_TOKEN = 'immich_access_token', + AUTH_TYPE = 'immich_auth_type', + IS_AUTHENTICATED = 'immich_is_authenticated', + SHARED_LINK_TOKEN = 'immich_shared_link_token', +} + +export enum ImmichHeader { + API_KEY = 'x-api-key', + USER_TOKEN = 'x-immich-user-token', + SESSION_TOKEN = 'x-immich-session-token', + SHARED_LINK_TOKEN = 'x-immich-share-key', +} + +export type CookieResponse = { + isSecure: boolean; + values: Array<{ key: ImmichCookie; value: string }>; +}; + export class AuthDto { user!: UserEntity; apiKey?: APIKeyEntity; sharedLink?: SharedLinkEntity; - userToken?: UserTokenEntity; + session?: SessionEntity; } export class LoginCredentialDto { @@ -39,7 +58,7 @@ export class LoginResponseDto { export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { return { - accessToken: accessToken, + accessToken, userId: entity.id, userEmail: entity.email, name: entity.name, @@ -78,24 +97,6 @@ export class ValidateAccessTokenResponseDto { authStatus!: boolean; } -export class AuthDeviceResponseDto { - id!: string; - createdAt!: string; - updatedAt!: string; - current!: boolean; - deviceType!: string; - deviceOS!: string; -} - -export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({ - id: entity.id, - createdAt: entity.createdAt.toISOString(), - updatedAt: entity.updatedAt.toISOString(), - current: currentId === entity.id, - deviceOS: entity.deviceOS, - deviceType: entity.deviceType, -}); - export class OAuthCallbackDto { @IsNotEmpty() @IsString() diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts new file mode 100644 index 0000000000..d96d7819ad --- /dev/null +++ b/server/src/dtos/session.dto.ts @@ -0,0 +1,19 @@ +import { SessionEntity } from 'src/entities/session.entity'; + +export class SessionResponseDto { + id!: string; + createdAt!: string; + updatedAt!: string; + current!: boolean; + deviceType!: string; + deviceOS!: string; +} + +export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({ + id: entity.id, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + current: currentId === entity.id, + deviceOS: entity.deviceOS, + deviceType: entity.deviceType, +}); diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 9f80e8d6a3..d23eef4994 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -417,6 +417,9 @@ class SystemConfigImageDto { @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) colorspace!: Colorspace; + + @ValidateBoolean() + extractEmbedded!: boolean; } class SystemConfigTrashDto { diff --git a/server/src/dtos/system-metadata.dto.ts b/server/src/dtos/system-metadata.dto.ts new file mode 100644 index 0000000000..1c04435341 --- /dev/null +++ b/server/src/dtos/system-metadata.dto.ts @@ -0,0 +1,15 @@ +import { IsBoolean } from 'class-validator'; + +export class AdminOnboardingUpdateDto { + @IsBoolean() + isOnboarded!: boolean; +} + +export class AdminOnboardingResponseDto { + isOnboarded!: boolean; +} + +export class ReverseGeocodingStateResponseDto { + lastUpdate!: string | null; + lastImportFileName!: string | null; +} diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 761b476930..59aa907199 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -13,13 +13,13 @@ import { MemoryEntity } from 'src/entities/memory.entity'; import { MoveEntity } from 'src/entities/move.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { SystemConfigEntity } from 'src/entities/system-config.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { TagEntity } from 'src/entities/tag.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { UserEntity } from 'src/entities/user.entity'; export const entities = [ @@ -44,6 +44,6 @@ export const entities = [ SystemMetadataEntity, TagEntity, UserEntity, - UserTokenEntity, + SessionEntity, LibraryEntity, ]; diff --git a/server/src/entities/user-token.entity.ts b/server/src/entities/session.entity.ts similarity index 92% rename from server/src/entities/user-token.entity.ts rename to server/src/entities/session.entity.ts index 3c2cf2cf6c..1cc9ad9857 100644 --- a/server/src/entities/user-token.entity.ts +++ b/server/src/entities/session.entity.ts @@ -1,8 +1,8 @@ import { UserEntity } from 'src/entities/user.entity'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; -@Entity('user_token') -export class UserTokenEntity { +@Entity('sessions') +export class SessionEntity { @PrimaryGeneratedColumn('uuid') id!: string; diff --git a/server/src/entities/system-config.entity.ts b/server/src/entities/system-config.entity.ts index a8a550fd6d..7126297ce3 100644 --- a/server/src/entities/system-config.entity.ts +++ b/server/src/entities/system-config.entity.ts @@ -114,6 +114,7 @@ export const SystemConfigKey = { IMAGE_PREVIEW_SIZE: 'image.previewSize', IMAGE_QUALITY: 'image.quality', IMAGE_COLORSPACE: 'image.colorspace', + IMAGE_EXTRACT_EMBEDDED: 'image.extractEmbedded', TRASH_ENABLED: 'trash.enabled', TRASH_DAYS: 'trash.days', @@ -284,6 +285,7 @@ export interface SystemConfig { previewSize: number; quality: number; colorspace: Colorspace; + extractEmbedded: boolean; }; newVersionCheck: { enabled: boolean; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 5e51e94a52..a82b38b6de 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -34,6 +34,11 @@ export interface VideoFormat { bitrate: number; } +export interface ImageDimensions { + width: number; + height: number; +} + export interface VideoInfo { format: VideoFormat; videoStreams: VideoStreamInfo[]; @@ -70,9 +75,11 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { export interface IMediaRepository { // image + extract(input: string, output: string): Promise<boolean>; resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; crop(input: string, options: CropOptions): Promise<Buffer>; generateThumbhash(imagePath: string): Promise<Buffer>; + getImageDimensions(input: string): Promise<ImageDimensions>; // video probe(input: string): Promise<VideoInfo>; diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts new file mode 100644 index 0000000000..62c2ecec7b --- /dev/null +++ b/server/src/interfaces/session.interface.ts @@ -0,0 +1,13 @@ +import { SessionEntity } from 'src/entities/session.entity'; + +export const ISessionRepository = 'ISessionRepository'; + +type E = SessionEntity; + +export interface ISessionRepository { + create<T extends Partial<E>>(dto: T): Promise<T>; + update<T extends Partial<E>>(dto: T): Promise<T>; + delete(id: string): Promise<void>; + getByToken(token: string): Promise<E | null>; + getByUserId(userId: string): Promise<E[]>; +} diff --git a/server/src/interfaces/user-token.interface.ts b/server/src/interfaces/user-token.interface.ts deleted file mode 100644 index 0fcec39fdc..0000000000 --- a/server/src/interfaces/user-token.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { UserTokenEntity } from 'src/entities/user-token.entity'; - -export const IUserTokenRepository = 'IUserTokenRepository'; - -export interface IUserTokenRepository { - create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>; - save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>; - delete(id: string): Promise<void>; - getByToken(token: string): Promise<UserTokenEntity | null>; - getAll(userId: string): Promise<UserTokenEntity[]>; -} diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 8b3abe6693..1253e99bbb 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -10,7 +10,6 @@ import { import { Reflector } from '@nestjs/core'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { Request } from 'express'; -import { IMMICH_API_KEY_NAME } from 'src/constants'; import { AuthDto } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService, LoginDetails } from 'src/services/auth.service'; @@ -21,6 +20,7 @@ export enum Metadata { ADMIN_ROUTE = 'admin_route', SHARED_ROUTE = 'shared_route', PUBLIC_SECURITY = 'public_security', + API_KEY_SECURITY = 'api_key', } export interface AuthenticatedOptions { @@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}) => { const decorators: MethodDecorator[] = [ ApiBearerAuth(), ApiCookieAuth(), - ApiSecurity(IMMICH_API_KEY_NAME), + ApiSecurity(Metadata.API_KEY_SECURITY), SetMetadata(Metadata.AUTH_ROUTE, true), ]; diff --git a/server/src/migrations/1713490844785-RenameSessionsTable.ts b/server/src/migrations/1713490844785-RenameSessionsTable.ts new file mode 100644 index 0000000000..b1b35e8ae6 --- /dev/null +++ b/server/src/migrations/1713490844785-RenameSessionsTable.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameSessionsTable1713490844785 implements MigrationInterface { + name = 'RenameSessionsTable1713490844785'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "user_token" RENAME TO "sessions"`); + await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" to "FK_57de40bc620f456c7311aa3a1e6"`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" to "FK_d37db50eecdf9b8ce4eedd2f918"`); + await queryRunner.query(`ALTER TABLE "sessions" RENAME TO "user_token"`); + } +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 0e1cab6d0b..3c6eca7270 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -173,13 +173,13 @@ WHERE -- AccessRepository.authDevice.checkOwnerAccess SELECT - "UserTokenEntity"."id" AS "UserTokenEntity_id" + "SessionEntity"."id" AS "SessionEntity_id" FROM - "user_token" "UserTokenEntity" + "sessions" "SessionEntity" WHERE ( - ("UserTokenEntity"."userId" = $1) - AND ("UserTokenEntity"."id" IN ($2)) + ("SessionEntity"."userId" = $1) + AND ("SessionEntity"."id" IN ($2)) ) -- AccessRepository.library.checkOwnerAccess diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql new file mode 100644 index 0000000000..e712c8a160 --- /dev/null +++ b/server/src/queries/session.repository.sql @@ -0,0 +1,48 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- SessionRepository.getByToken +SELECT DISTINCT + "distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id" +FROM + ( + SELECT + "SessionEntity"."id" AS "SessionEntity_id", + "SessionEntity"."userId" AS "SessionEntity_userId", + "SessionEntity"."createdAt" AS "SessionEntity_createdAt", + "SessionEntity"."updatedAt" AS "SessionEntity_updatedAt", + "SessionEntity"."deviceType" AS "SessionEntity_deviceType", + "SessionEntity"."deviceOS" AS "SessionEntity_deviceOS", + "SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id", + "SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name", + "SessionEntity__SessionEntity_user"."avatarColor" AS "SessionEntity__SessionEntity_user_avatarColor", + "SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin", + "SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email", + "SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel", + "SessionEntity__SessionEntity_user"."oauthId" AS "SessionEntity__SessionEntity_user_oauthId", + "SessionEntity__SessionEntity_user"."profileImagePath" AS "SessionEntity__SessionEntity_user_profileImagePath", + "SessionEntity__SessionEntity_user"."shouldChangePassword" AS "SessionEntity__SessionEntity_user_shouldChangePassword", + "SessionEntity__SessionEntity_user"."createdAt" AS "SessionEntity__SessionEntity_user_createdAt", + "SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt", + "SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status", + "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", + "SessionEntity__SessionEntity_user"."memoriesEnabled" AS "SessionEntity__SessionEntity_user_memoriesEnabled", + "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", + "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes" + FROM + "sessions" "SessionEntity" + LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId" + AND ( + "SessionEntity__SessionEntity_user"."deletedAt" IS NULL + ) + WHERE + (("SessionEntity"."token" = $1)) + ) "distinctAlias" +ORDER BY + "SessionEntity_id" ASC +LIMIT + 1 + +-- SessionRepository.delete +DELETE FROM "sessions" +WHERE + "id" = $1 diff --git a/server/src/queries/user.token.repository.sql b/server/src/queries/user.token.repository.sql deleted file mode 100644 index f09238e137..0000000000 --- a/server/src/queries/user.token.repository.sql +++ /dev/null @@ -1,48 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- UserTokenRepository.getByToken -SELECT DISTINCT - "distinctAlias"."UserTokenEntity_id" AS "ids_UserTokenEntity_id" -FROM - ( - SELECT - "UserTokenEntity"."id" AS "UserTokenEntity_id", - "UserTokenEntity"."userId" AS "UserTokenEntity_userId", - "UserTokenEntity"."createdAt" AS "UserTokenEntity_createdAt", - "UserTokenEntity"."updatedAt" AS "UserTokenEntity_updatedAt", - "UserTokenEntity"."deviceType" AS "UserTokenEntity_deviceType", - "UserTokenEntity"."deviceOS" AS "UserTokenEntity_deviceOS", - "UserTokenEntity__UserTokenEntity_user"."id" AS "UserTokenEntity__UserTokenEntity_user_id", - "UserTokenEntity__UserTokenEntity_user"."name" AS "UserTokenEntity__UserTokenEntity_user_name", - "UserTokenEntity__UserTokenEntity_user"."avatarColor" AS "UserTokenEntity__UserTokenEntity_user_avatarColor", - "UserTokenEntity__UserTokenEntity_user"."isAdmin" AS "UserTokenEntity__UserTokenEntity_user_isAdmin", - "UserTokenEntity__UserTokenEntity_user"."email" AS "UserTokenEntity__UserTokenEntity_user_email", - "UserTokenEntity__UserTokenEntity_user"."storageLabel" AS "UserTokenEntity__UserTokenEntity_user_storageLabel", - "UserTokenEntity__UserTokenEntity_user"."oauthId" AS "UserTokenEntity__UserTokenEntity_user_oauthId", - "UserTokenEntity__UserTokenEntity_user"."profileImagePath" AS "UserTokenEntity__UserTokenEntity_user_profileImagePath", - "UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword", - "UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt", - "UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt", - "UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status", - "UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt", - "UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled", - "UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes", - "UserTokenEntity__UserTokenEntity_user"."quotaUsageInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaUsageInBytes" - FROM - "user_token" "UserTokenEntity" - LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId" - AND ( - "UserTokenEntity__UserTokenEntity_user"."deletedAt" IS NULL - ) - WHERE - (("UserTokenEntity"."token" = $1)) - ) "distinctAlias" -ORDER BY - "UserTokenEntity_id" ASC -LIMIT - 1 - --- UserTokenRepository.delete -DELETE FROM "user_token" -WHERE - "id" = $1 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 469de11be6..a624e8bfdc 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -9,8 +9,8 @@ import { LibraryEntity } from 'src/entities/library.entity'; import { MemoryEntity } from 'src/entities/memory.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Brackets, In, Repository } from 'typeorm'; @@ -286,7 +286,7 @@ class AssetAccess implements IAssetAccess { } class AuthDeviceAccess implements IAuthDeviceAccess { - constructor(private tokenRepository: Repository<UserTokenEntity>) {} + constructor(private sessionRepository: Repository<SessionEntity>) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) @@ -295,7 +295,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess { return new Set(); } - return this.tokenRepository + return this.sessionRepository .find({ select: { id: true }, where: { @@ -457,12 +457,12 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>, @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>, - @InjectRepository(UserTokenEntity) tokenRepository: Repository<UserTokenEntity>, + @InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>, ) { this.activity = new ActivityAccess(activityRepository, albumRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); - this.authDevice = new AuthDeviceAccess(tokenRepository); + this.authDevice = new AuthDeviceAccess(sessionRepository); this.library = new LibraryAccess(libraryRepository); this.memory = new MemoryAccess(memoryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index e6466ee6b5..6ab09ac746 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -22,12 +22,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; @@ -53,12 +53,12 @@ import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemConfigRepository } from 'src/repositories/system-config.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; -import { UserTokenRepository } from 'src/repositories/user-token.repository'; import { UserRepository } from 'src/repositories/user.repository'; export const repositories = [ @@ -86,11 +86,11 @@ export const repositories = [ { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISearchRepository, useClass: SearchRepository }, + { provide: ISessionRepository, useClass: SessionRepository }, { provide: IStorageRepository, useClass: StorageRepository }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: IMediaRepository, useClass: MediaRepository }, { provide: IUserRepository, useClass: UserRepository }, - { provide: IUserTokenRepository, useClass: UserTokenRepository }, ]; diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 3936ad7e42..434fb585f8 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; @@ -9,6 +10,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { CropOptions, IMediaRepository, + ImageDimensions, ResizeOptions, TranscodeOptions, VideoInfo, @@ -26,6 +28,23 @@ export class MediaRepository implements IMediaRepository { constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { this.logger.setContext(MediaRepository.name); } + + async extract(input: string, output: string): Promise<boolean> { + try { + await exiftool.extractJpgFromRaw(input, output); + } catch (error: any) { + this.logger.debug('Could not extract JPEG from image, trying preview', error.message); + try { + await exiftool.extractPreview(input, output); + } catch (error: any) { + this.logger.debug('Could not extract preview from image', error.message); + return false; + } + } + + return true; + } + crop(input: string | Buffer, options: CropOptions): Promise<Buffer> { return sharp(input, { failOn: 'none' }) .pipelineColorspace('rgb16') @@ -133,6 +152,11 @@ export class MediaRepository implements IMediaRepository { return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); } + async getImageDimensions(input: string): Promise<ImageDimensions> { + const { width = 0, height = 0 } = await sharp(input).metadata(); + return { width, height }; + } + private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) { return ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) @@ -140,9 +164,4 @@ export class MediaRepository implements IMediaRepository { .output(output) .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); } - - private chainPath(existing: string, path: string) { - const separator = existing.endsWith(':') ? '' : ':'; - return `${existing}${separator}${path}`; - } } diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 8eeb0064ac..e7d37407d9 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -36,8 +36,8 @@ export class MetadataRepository implements IMetadataRepository { this.logger.log('Initializing metadata repository'); const geodataDate = await readFile(geodataDatePath, 'utf8'); + // TODO move to metadata service init const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); - if (geocodingMetadata?.lastUpdate === geodataDate) { return; } diff --git a/server/src/repositories/user-token.repository.ts b/server/src/repositories/session.repository.ts similarity index 54% rename from server/src/repositories/user-token.repository.ts rename to server/src/repositories/session.repository.ts index cbf3a3e3b0..ed2da7a05f 100644 --- a/server/src/repositories/user-token.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,22 +1,22 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; +import { SessionEntity } from 'src/entities/session.entity'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; @Instrumentation() @Injectable() -export class UserTokenRepository implements IUserTokenRepository { - constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {} +export class SessionRepository implements ISessionRepository { + constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {} @GenerateSql({ params: [DummyValue.STRING] }) - getByToken(token: string): Promise<UserTokenEntity | null> { + getByToken(token: string): Promise<SessionEntity | null> { return this.repository.findOne({ where: { token }, relations: { user: true } }); } - getAll(userId: string): Promise<UserTokenEntity[]> { + getByUserId(userId: string): Promise<SessionEntity[]> { return this.repository.find({ where: { userId, @@ -31,12 +31,12 @@ export class UserTokenRepository implements IUserTokenRepository { }); } - create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { - return this.repository.save(userToken); + create<T extends Partial<SessionEntity>>(dto: T): Promise<T & { id: string }> { + return this.repository.save(dto); } - save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { - return this.repository.save(userToken); + update<T extends Partial<SessionEntity>>(dto: T): Promise<T> { + return this.repository.save(dto); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index d53f319661..f00e10b13c 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -9,25 +9,25 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub, loginResponseStub } from 'test/fixtures/auth.stub'; +import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { userTokenStub } from 'test/fixtures/user-token.stub'; import { userStub } from 'test/fixtures/user.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; -import { newUserTokenRepositoryMock } from 'test/repositories/user-token.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mock, Mocked, vitest } from 'vitest'; @@ -65,7 +65,7 @@ describe('AuthService', () => { let libraryMock: Mocked<ILibraryRepository>; let loggerMock: Mocked<ILoggerRepository>; let configMock: Mocked<ISystemConfigRepository>; - let userTokenMock: Mocked<IUserTokenRepository>; + let sessionMock: Mocked<ISessionRepository>; let shareMock: Mocked<ISharedLinkRepository>; let keyMock: Mocked<IKeyRepository>; @@ -98,7 +98,7 @@ describe('AuthService', () => { libraryMock = newLibraryRepositoryMock(); loggerMock = newLoggerRepositoryMock(); configMock = newSystemConfigRepositoryMock(); - userTokenMock = newUserTokenRepositoryMock(); + sessionMock = newSessionRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); @@ -109,7 +109,7 @@ describe('AuthService', () => { libraryMock, loggerMock, userMock, - userTokenMock, + sessionMock, shareMock, keyMock, ); @@ -139,24 +139,10 @@ describe('AuthService', () => { it('should successfully log the user in', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); - - it('should generate the cookie headers (insecure)', async () => { - userMock.getByEmail.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); - await expect( - sut.login(fixtures.login, { - clientIp: '127.0.0.1', - isSecure: false, - deviceOS: '', - deviceType: '', - }), - ).resolves.toEqual(loginResponseStub.user1insecure); - expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - }); }); describe('changePassword', () => { @@ -231,14 +217,14 @@ describe('AuthService', () => { }); it('should delete the access token', async () => { - const auth = { user: { id: '123' }, userToken: { id: 'token123' } } as AuthDto; + const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto; await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); - expect(userTokenMock.delete).toHaveBeenCalledWith('token123'); + expect(sessionMock.delete).toHaveBeenCalledWith('token123'); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { @@ -282,11 +268,11 @@ describe('AuthService', () => { it('should validate using authorization header', async () => { userMock.get.mockResolvedValue(userStub.user1); - userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); + sessionMock.getByToken.mockResolvedValue(sessionStub.valid); const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({ user: userStub.user1, - userToken: userTokenStub.userToken, + session: sessionStub.valid, }); }); }); @@ -336,37 +322,26 @@ describe('AuthService', () => { describe('validate - user token', () => { it('should throw if no token is found', async () => { - userTokenMock.getByToken.mockResolvedValue(null); + sessionMock.getByToken.mockResolvedValue(null); const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' }; await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); }); it('should return an auth dto', async () => { - userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); + sessionMock.getByToken.mockResolvedValue(sessionStub.valid); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; await expect(sut.validate(headers, {})).resolves.toEqual({ user: userStub.user1, - userToken: userTokenStub.userToken, + session: sessionStub.valid, }); }); it('should update when access time exceeds an hour', async () => { - userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken); - userTokenMock.save.mockResolvedValue(userTokenStub.userToken); + sessionMock.getByToken.mockResolvedValue(sessionStub.inactive); + sessionMock.update.mockResolvedValue(sessionStub.valid); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; - await expect(sut.validate(headers, {})).resolves.toEqual({ - user: userStub.user1, - userToken: userTokenStub.userToken, - }); - expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({ - id: 'not_active', - token: 'auth_token', - userId: 'user-id', - createdAt: new Date('2021-01-01'), - updatedAt: expect.any(Date), - deviceOS: 'Android', - deviceType: 'Mobile', - }); + await expect(sut.validate(headers, {})).resolves.toBeDefined(); + expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); }); }); @@ -386,55 +361,6 @@ describe('AuthService', () => { }); }); - describe('getDevices', () => { - it('should get the devices', async () => { - userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.inactiveToken]); - await expect(sut.getDevices(authStub.user1)).resolves.toEqual([ - { - createdAt: '2021-01-01T00:00:00.000Z', - current: true, - deviceOS: '', - deviceType: '', - id: 'token-id', - updatedAt: expect.any(String), - }, - { - createdAt: '2021-01-01T00:00:00.000Z', - current: false, - deviceOS: 'Android', - deviceType: 'Mobile', - id: 'not_active', - updatedAt: expect.any(String), - }, - ]); - - expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); - }); - }); - - describe('logoutDevices', () => { - it('should logout all devices', async () => { - userTokenMock.getAll.mockResolvedValue([userTokenStub.inactiveToken, userTokenStub.userToken]); - - await sut.logoutDevices(authStub.user1); - - expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); - expect(userTokenMock.delete).toHaveBeenCalledWith('not_active'); - expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id'); - }); - }); - - describe('logoutDevice', () => { - it('should logout the device', async () => { - accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); - - await sut.logoutDevice(authStub.user1, 'token-1'); - - expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); - expect(userTokenMock.delete).toHaveBeenCalledWith('token-1'); - }); - }); - describe('getMobileRedirect', () => { it('should pass along the query params', () => { expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456'); @@ -463,7 +389,7 @@ describe('AuthService', () => { configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); userMock.getByEmail.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, @@ -478,7 +404,7 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, @@ -491,7 +417,7 @@ describe('AuthService', () => { it('should use the mobile redirect override', async () => { configMock.load.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails); @@ -501,7 +427,7 @@ describe('AuthService', () => { it('should use the mobile redirect override for ios urls with multiple slashes', async () => { configMock.load.mockResolvedValue(systemConfigStub.override); userMock.getByOAuthId.mockResolvedValue(userStub.user1); - userTokenMock.create.mockResolvedValue(userTokenStub.userToken); + sessionMock.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7bebca5989..72fee12f45 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -10,31 +10,22 @@ import cookieParser from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; -import { - AuthType, - IMMICH_ACCESS_COOKIE, - IMMICH_API_KEY_HEADER, - IMMICH_AUTH_TYPE_COOKIE, - IMMICH_IS_AUTHENTICATED, - LOGIN_URL, - MOBILE_REDIRECT, -} from 'src/constants'; -import { AccessCore, Permission } from 'src/cores/access.core'; +import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants'; +import { AccessCore } from 'src/cores/access.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserCore } from 'src/cores/user.core'; import { - AuthDeviceResponseDto, AuthDto, ChangePasswordDto, + ImmichCookie, + ImmichHeader, LoginCredentialDto, - LoginResponseDto, LogoutResponseDto, OAuthAuthorizeResponseDto, OAuthCallbackDto, OAuthConfigDto, SignUpDto, mapLoginResponse, - mapUserToken, } from 'src/dtos/auth.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { SystemConfig } from 'src/entities/system-config.entity'; @@ -44,9 +35,9 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { HumanReadableSize } from 'src/utils/bytes'; @@ -57,11 +48,6 @@ export interface LoginDetails { deviceOS: string; } -interface LoginResponse { - response: LoginResponseDto; - cookie: string[]; -} - interface OAuthProfile extends UserinfoResponse { email: string; } @@ -85,7 +71,7 @@ export class AuthService { @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository, + @Inject(ISessionRepository) private sessionRepository: ISessionRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository, ) { @@ -97,7 +83,7 @@ export class AuthService { custom.setHttpOptionsDefaults({ timeout: 30_000 }); } - async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> { + async login(dto: LoginCredentialDto, details: LoginDetails) { const config = await this.configCore.getConfig(); if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); @@ -116,12 +102,12 @@ export class AuthService { throw new UnauthorizedException('Incorrect email or password'); } - return this.createLoginResponse(user, AuthType.PASSWORD, details); + return this.createLoginResponse(user, details); } async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> { - if (auth.userToken) { - await this.userTokenRepository.delete(auth.userToken.id); + if (auth.session) { + await this.sessionRepository.delete(auth.session.id); } return { @@ -163,19 +149,20 @@ export class AuthService { } async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> { - const shareKey = (headers['x-immich-share-key'] || params.key) as string; - const userToken = (headers['x-immich-user-token'] || - params.userToken || + const shareKey = (headers[ImmichHeader.SHARED_LINK_TOKEN] || params.key) as string; + const session = (headers[ImmichHeader.USER_TOKEN] || + headers[ImmichHeader.SESSION_TOKEN] || + params.sessionKey || this.getBearerToken(headers) || this.getCookieToken(headers)) as string; - const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string; + const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string; if (shareKey) { return this.validateSharedLink(shareKey); } - if (userToken) { - return this.validateUserToken(userToken); + if (session) { + return this.validateSession(session); } if (apiKey) { @@ -185,26 +172,6 @@ export class AuthService { throw new UnauthorizedException('Authentication required'); } - async getDevices(auth: AuthDto): Promise<AuthDeviceResponseDto[]> { - const userTokens = await this.userTokenRepository.getAll(auth.user.id); - return userTokens.map((userToken) => mapUserToken(userToken, auth.userToken?.id)); - } - - async logoutDevice(auth: AuthDto, id: string): Promise<void> { - await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); - await this.userTokenRepository.delete(id); - } - - async logoutDevices(auth: AuthDto): Promise<void> { - const devices = await this.userTokenRepository.getAll(auth.user.id); - for (const device of devices) { - if (device.id === auth.userToken?.id) { - continue; - } - await this.userTokenRepository.delete(device.id); - } - } - getMobileRedirect(url: string) { return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`; } @@ -225,10 +192,7 @@ export class AuthService { return { url }; } - async callback( - dto: OAuthCallbackDto, - loginDetails: LoginDetails, - ): Promise<{ response: LoginResponseDto; cookie: string[] }> { + async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { const config = await this.configCore.getConfig(); const profile = await this.getOAuthProfile(config, dto.url); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); @@ -277,7 +241,7 @@ export class AuthService { }); } - return this.createLoginResponse(user, AuthType.OAUTH, loginDetails); + return this.createLoginResponse(user, loginDetails); } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> { @@ -374,7 +338,7 @@ export class AuthService { private getCookieToken(headers: IncomingHttpHeaders): string | null { const cookies = cookieParser.parse(headers.cookie || ''); - return cookies[IMMICH_ACCESS_COOKIE] || null; + return cookies[ImmichCookie.ACCESS_TOKEN] || null; } async validateSharedLink(key: string | string[]): Promise<AuthDto> { @@ -408,57 +372,36 @@ export class AuthService { return this.cryptoRepository.compareBcrypt(inputPassword, user.password); } - private async validateUserToken(tokenValue: string): Promise<AuthDto> { + private async validateSession(tokenValue: string): Promise<AuthDto> { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); - let userToken = await this.userTokenRepository.getByToken(hashedToken); + const session = await this.sessionRepository.getByToken(hashedToken); - if (userToken?.user) { + if (session?.user) { const now = DateTime.now(); - const updatedAt = DateTime.fromJSDate(userToken.updatedAt); + const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); if (diff.hours > 1) { - userToken = await this.userTokenRepository.save({ ...userToken, updatedAt: new Date() }); + await this.sessionRepository.update({ id: session.id, updatedAt: new Date() }); } - return { user: userToken.user, userToken }; + return { user: session.user, session: session }; } throw new UnauthorizedException('Invalid user token'); } - private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { + private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); - await this.userTokenRepository.create({ + await this.sessionRepository.create({ token, user, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, }); - const response = mapLoginResponse(user, key); - const cookie = this.getCookies(response, authType, loginDetails); - return { response, cookie }; - } - - private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) { - const maxAge = 400 * 24 * 3600; // 400 days - - let authTypeCookie = ''; - let accessTokenCookie = ''; - let isAuthenticatedCookie = ''; - - if (isSecure) { - accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - } else { - accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Path=/; Max-Age=${maxAge}; SameSite=Lax;`; - } - return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie]; + return mapLoginResponse(user, key); } private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6c40f8420a..2305708caa 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -18,12 +18,14 @@ import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; import { SearchService } from 'src/services/search.service'; import { ServerInfoService } from 'src/services/server-info.service'; +import { SessionService } from 'src/services/session.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SyncService } from 'src/services/sync.service'; import { SystemConfigService } from 'src/services/system-config.service'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; import { TagService } from 'src/services/tag.service'; import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; @@ -50,12 +52,14 @@ export const services = [ PersonService, SearchService, ServerInfoService, + SessionService, SharedLinkService, SmartInfoService, StorageService, StorageTemplateService, SyncService, SystemConfigService, + SystemMetadataService, TagService, TimelineService, TrashService, diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 1b1adcd573..6f02e72253 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -225,6 +225,15 @@ describe(MediaService.name, () => { expect(assetMock.update).not.toHaveBeenCalledWith(); }); + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + }); + it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]); assetMock.getByIds.mockResolvedValue([assetStub.image]); @@ -353,6 +362,15 @@ describe(MediaService.name, () => { expect(assetMock.update).not.toHaveBeenCalledWith(); }); + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(mediaMock.resize).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + }); + it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { @@ -375,14 +393,12 @@ describe(MediaService.name, () => { }); it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.resize).toHaveBeenCalledWith( - '/original/path.jpg', + assetStub.imageDng.originalPath, 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', { format: ImageFormat.WEBP, @@ -397,7 +413,96 @@ describe(MediaService.name, () => { }); }); - describe('handleGenerateThumbhashThumbnail', () => { + it('should extract embedded image if enabled and available', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.resize.mock.calls).toEqual([ + [ + extractedPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ], + ]); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); + + it('should resize original image if embedded image is too small', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + expect(mediaMock.resize.mock.calls).toEqual([ + [ + assetStub.imageDng.originalPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ], + ]); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); + + it('should resize original image if embedded image not found', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + expect(mediaMock.resize).toHaveBeenCalledWith( + assetStub.imageDng.originalPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + it('should resize original image if embedded image extraction is not enabled', async () => { + configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: false }]); + assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + + await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.resize).toHaveBeenCalledWith( + assetStub.imageDng.originalPath, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + colorspace: Colorspace.P3, + }, + ); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + }); + + describe('handleGenerateThumbhash', () => { it('should skip thumbhash generation if asset not found', async () => { assetMock.getByIds.mockResolvedValue([]); await sut.handleGenerateThumbhash({ id: assetStub.image.id }); @@ -410,6 +515,15 @@ describe(MediaService.name, () => { expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); }); + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + }); + it('should generate a thumbhash', async () => { const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); assetMock.getByIds.mockResolvedValue([assetStub.image]); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 47fa31abcc..1795db86d0 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { dirname } from 'node:path'; import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; @@ -42,6 +43,7 @@ import { VAAPIConfig, VP9Config, } from 'src/utils/media'; +import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @Injectable() @@ -77,7 +79,7 @@ export class MediaService { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination) + ? this.assetRepository.getAll(pagination, { isVisible: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); }); @@ -178,6 +180,10 @@ export class MediaService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); await this.assetRepository.update({ id: asset.id, previewPath }); return JobStatus.SUCCESS; @@ -191,9 +197,21 @@ export class MediaService { switch (asset.type) { case AssetType.IMAGE: { - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { format, size, colorspace, quality: image.quality }; - await this.mediaRepository.resize(asset.originalPath, path, imageOptions); + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(path)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const imageOptions = { format, size, colorspace, quality: image.quality }; + + await this.mediaRepository.resize(useExtracted ? extractedPath : asset.originalPath, path, imageOptions); + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } + } break; } @@ -230,6 +248,10 @@ export class MediaService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); await this.assetRepository.update({ id: asset.id, thumbnailPath }); return JobStatus.SUCCESS; @@ -237,7 +259,15 @@ export class MediaService { async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> { const [asset] = await this.assetRepository.getByIds([id]); - if (!asset?.previewPath) { + if (!asset) { + return JobStatus.FAILED; + } + + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + + if (!asset.previewPath) { return JobStatus.FAILED; } @@ -511,7 +541,7 @@ export class MediaService { } } - parseBitrateToBps(bitrateString: string) { + private parseBitrateToBps(bitrateString: string) { const bitrateValue = Number.parseInt(bitrateString); if (Number.isNaN(bitrateValue)) { @@ -526,4 +556,11 @@ export class MediaService { return bitrateValue; } } + + private async shouldUseExtractedImage(extractedPath: string, targetSize: number) { + const { width, height } = await this.mediaRepository.getImageDimensions(extractedPath); + const extractedSize = Math.min(width, height); + + return extractedSize >= targetSize; + } } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 77b7e552cc..721f2586ee 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -292,7 +292,12 @@ export class PersonService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true }) + ? this.assetRepository.getAll(pagination, { + orderDirection: 'DESC', + withFaces: true, + withArchived: true, + isVisible: true, + }) : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); }); @@ -322,6 +327,10 @@ export class PersonService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + const faces = await this.machineLearningRepository.detectFaces( machineLearning.url, { imagePath: asset.previewPath }, @@ -424,7 +433,7 @@ export class PersonService { this.logger.debug(`Face ${id} has ${matches.length} matches`); - const isCore = matches.length >= machineLearning.facialRecognition.minFaces; + const isCore = matches.length >= machineLearning.facialRecognition.minFaces && !face.asset.isArchived; if (!isCore && !deferred) { this.logger.debug(`Deferring non-core face ${id} for later processing`); await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } }); diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index 836909b74f..115ab4b6a1 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -1,5 +1,4 @@ import { serverVersion } from 'src/constants'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -207,13 +206,6 @@ describe(ServerInfoService.name, () => { }); }); - describe('setAdminOnboarding', () => { - it('should set admin onboarding to true', async () => { - await sut.setAdminOnboarding(); - expect(systemMetadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); - }); - }); - describe('getStats', () => { it('should total up usage by user', async () => { userMock.getUserStats.mockResolvedValue([ diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index bb092896bf..52bf8bd1d3 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -51,7 +51,9 @@ export class ServerInfoService { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { - await this.setAdminOnboarding(); + await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + isOnboarded: true, + }); } } @@ -105,10 +107,6 @@ export class ServerInfoService { }; } - setAdminOnboarding(): Promise<void> { - return this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); - } - async getStatistics(): Promise<ServerStatsResponseDto> { const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats(); const serverStats = new ServerStatsResponseDto(); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts new file mode 100644 index 0000000000..0b54564da6 --- /dev/null +++ b/server/src/services/session.service.spec.ts @@ -0,0 +1,77 @@ +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; +import { SessionService } from 'src/services/session.service'; +import { authStub } from 'test/fixtures/auth.stub'; +import { sessionStub } from 'test/fixtures/session.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; +import { Mocked } from 'vitest'; + +describe('SessionService', () => { + let sut: SessionService; + let accessMock: Mocked<IAccessRepositoryMock>; + let loggerMock: Mocked<ILoggerRepository>; + let sessionMock: Mocked<ISessionRepository>; + + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + sessionMock = newSessionRepositoryMock(); + + sut = new SessionService(accessMock, loggerMock, sessionMock); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('getAll', () => { + it('should get the devices', async () => { + sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]); + await expect(sut.getAll(authStub.user1)).resolves.toEqual([ + { + createdAt: '2021-01-01T00:00:00.000Z', + current: true, + deviceOS: '', + deviceType: '', + id: 'token-id', + updatedAt: expect.any(String), + }, + { + createdAt: '2021-01-01T00:00:00.000Z', + current: false, + deviceOS: 'Android', + deviceType: 'Mobile', + id: 'not_active', + updatedAt: expect.any(String), + }, + ]); + + expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + }); + }); + + describe('logoutDevices', () => { + it('should logout all devices', async () => { + sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]); + + await sut.deleteAll(authStub.user1); + + expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(sessionMock.delete).toHaveBeenCalledWith('not_active'); + expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id'); + }); + }); + + describe('logoutDevice', () => { + it('should logout the device', async () => { + accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1'])); + + await sut.delete(authStub.user1, 'token-1'); + + expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1'])); + expect(sessionMock.delete).toHaveBeenCalledWith('token-1'); + }); + }); +}); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts new file mode 100644 index 0000000000..7ee454d7b4 --- /dev/null +++ b/server/src/services/session.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; + +@Injectable() +export class SessionService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ISessionRepository) private sessionRepository: ISessionRepository, + ) { + this.logger.setContext(SessionService.name); + this.access = AccessCore.create(accessRepository); + } + + async getAll(auth: AuthDto): Promise<SessionResponseDto[]> { + const sessions = await this.sessionRepository.getByUserId(auth.user.id); + return sessions.map((session) => mapSession(session, auth.session?.id)); + } + + async delete(auth: AuthDto, id: string): Promise<void> { + await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); + await this.sessionRepository.delete(id); + } + + async deleteAll(auth: AuthDto): Promise<void> { + const sessions = await this.sessionRepository.getByUserId(auth.user.id); + for (const session of sessions) { + if (session.id === auth.session?.id) { + continue; + } + await this.sessionRepository.delete(session.id); + } + } +} diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 4d85c00253..8dedcb5c5f 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,8 +1,7 @@ -import { AssetEntity } from 'src/entities/asset.entity'; import { SystemConfigKey } from 'src/entities/system-config.entity'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; @@ -19,11 +18,6 @@ import { newSearchRepositoryMock } from 'test/repositories/search.repository.moc import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { Mocked } from 'vitest'; -const asset = { - id: 'asset-1', - previewPath: 'path/to/resize.ext', -} as AssetEntity; - describe(SmartInfoService.name, () => { let sut: SmartInfoService; let assetMock: Mocked<IAssetRepository>; @@ -44,7 +38,7 @@ describe(SmartInfoService.name, () => { loggerMock = newLoggerRepositoryMock(); sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock, loggerMock); - assetMock.getByIds.mockResolvedValue([asset]); + assetMock.getByIds.mockResolvedValue([assetStub.image]); }); it('should work', () => { @@ -92,17 +86,16 @@ describe(SmartInfoService.name, () => { it('should do nothing if machine learning is disabled', async () => { configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); - await sut.handleEncodeClip({ id: '123' }); + expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled(); }); it('should skip assets without a resize path', async () => { - const asset = { previewPath: '' } as AssetEntity; - assetMock.getByIds.mockResolvedValue([asset]); + assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleEncodeClip({ id: asset.id }); + expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); expect(searchMock.upsert).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled(); @@ -111,14 +104,23 @@ describe(SmartInfoService.name, () => { it('should save the returned objects', async () => { machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); - await sut.handleEncodeClip({ id: asset.id }); + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); expect(machineMock.encodeImage).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', - { imagePath: 'path/to/resize.ext' }, + { imagePath: assetStub.image.previewPath }, { enabled: true, modelName: 'ViT-B-32__openai' }, ); - expect(searchMock.upsert).toHaveBeenCalledWith('asset-1', [0.01, 0.02, 0.03]); + expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); + }); + + it('should skip invisible assets', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + + expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + + expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(searchMock.upsert).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 9de5edbd88..929d15beca 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -60,7 +60,7 @@ export class SmartInfoService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination) + ? this.assetRepository.getAll(pagination, { isVisible: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.SMART_SEARCH); }); @@ -84,6 +84,10 @@ export class SmartInfoService { return JobStatus.FAILED; } + if (!asset.isVisible) { + return JobStatus.SKIPPED; + } + if (!asset.previewPath) { return JobStatus.FAILED; } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 49bf8d6544..5f55effcac 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -129,6 +129,7 @@ const updatedConfig = Object.freeze<SystemConfig>({ previewSize: 1440, quality: 80, colorspace: Colorspace.P3, + extractEmbedded: false, }, newVersionCheck: { enabled: true, diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts new file mode 100644 index 0000000000..9d11c1c72a --- /dev/null +++ b/server/src/services/system-metadata.service.spec.ts @@ -0,0 +1,31 @@ +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { Mocked } from 'vitest'; + +describe(SystemMetadataService.name, () => { + let sut: SystemMetadataService; + let metadataMock: Mocked<ISystemMetadataRepository>; + + beforeEach(() => { + metadataMock = newSystemMetadataRepositoryMock(); + sut = new SystemMetadataService(metadataMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('updateAdminOnboarding', () => { + it('should update isOnboarded to true', async () => { + await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); + expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + }); + + it('should update isOnboarded to false', async () => { + await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); + expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + }); + }); +}); diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts new file mode 100644 index 0000000000..e8fddfc13c --- /dev/null +++ b/server/src/services/system-metadata.service.ts @@ -0,0 +1,29 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + AdminOnboardingResponseDto, + AdminOnboardingUpdateDto, + ReverseGeocodingStateResponseDto, +} from 'src/dtos/system-metadata.dto'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; + +@Injectable() +export class SystemMetadataService { + constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {} + + async getAdminOnboarding(): Promise<AdminOnboardingResponseDto> { + const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING); + return { isOnboarded: false, ...value }; + } + + async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise<void> { + await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + isOnboarded: dto.isOnboarded, + }); + } + + async getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> { + const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + return { lastUpdate: null, lastImportFileName: null, ...value }; + } +} diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index cb9012d641..9d40a14e5a 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -37,6 +37,11 @@ export class UserService { this.configCore = SystemConfigCore.create(configRepository, this.logger); } + async listUsers(): Promise<UserResponseDto[]> { + const users = await this.userRepository.getList({ withDeleted: true }); + return users.map((user) => mapUser(user)); + } + async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> { const users = await this.userRepository.getList({ withDeleted: !isAll }); return users.map((user) => mapUser(user)); diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index d0a6d4d740..ff38ded631 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -436,7 +436,7 @@ export class AV1Config extends BaseConfig { export class NVENCConfig extends BaseHWConfig { getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC]; + return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1]; } getBaseInputOptions() { @@ -566,7 +566,7 @@ export class QSVConfig extends BaseHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9]; + return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9, VideoCodec.AV1]; } // recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index bce75e1e10..cbbf751bc5 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -106,12 +106,6 @@ describe('mimeTypes', () => { expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); }); - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.profile); - // TODO: use toSorted in NodeJS 20. - expect(keys).toEqual([...keys].sort()); - }); - for (const [extension, v] of Object.entries(mimeTypes.profile)) { it(`should lookup ${extension}`, () => { expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]); @@ -128,12 +122,6 @@ describe('mimeTypes', () => { expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); }); - it('should be a sorted list', () => { - const keys = Object.keys(mimeTypes.image); - // TODO: use toSorted in NodeJS 20. - expect(keys).toEqual([...keys].sort()); - }); - it('should contain only image mime types', () => { const values = Object.values(mimeTypes.image).flat(); expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/'))); @@ -157,7 +145,6 @@ describe('mimeTypes', () => { it('should be a sorted list', () => { const keys = Object.keys(mimeTypes.video); - // TODO: use toSorted in NodeJS 20. expect(keys).toEqual([...keys].sort()); }); @@ -184,7 +171,6 @@ describe('mimeTypes', () => { it('should be a sorted list', () => { const keys = Object.keys(mimeTypes.sidecar); - // TODO: use toSorted in NodeJS 20. expect(keys).toEqual([...keys].sort()); }); @@ -198,4 +184,20 @@ describe('mimeTypes', () => { }); } }); + + describe('raw', () => { + it('should contain only lowercase mime types', () => { + const keys = Object.keys(mimeTypes.raw); + expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase())); + + const values = Object.values(mimeTypes.raw).flat(); + expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); + }); + + for (const [extension, v] of Object.entries(mimeTypes.video)) { + it(`should lookup ${extension}`, () => { + expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]); + }); + } + }); }); diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index a888e4f423..495efc9ebc 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -1,12 +1,10 @@ import { extname } from 'node:path'; import { AssetType } from 'src/entities/asset.entity'; -const image: Record<string, string[]> = { +const raw: Record<string, string[]> = { '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], '.ari': ['image/ari', 'image/x-arriflex-ari'], '.arw': ['image/arw', 'image/x-sony-arw'], - '.avif': ['image/avif'], - '.bmp': ['image/bmp'], '.cap': ['image/cap', 'image/x-phaseone-cap'], '.cin': ['image/cin', 'image/x-phantom-cin'], '.cr2': ['image/cr2', 'image/x-canon-cr2'], @@ -16,16 +14,7 @@ const image: Record<string, string[]> = { '.dng': ['image/dng', 'image/x-adobe-dng'], '.erf': ['image/erf', 'image/x-epson-erf'], '.fff': ['image/fff', 'image/x-hasselblad-fff'], - '.gif': ['image/gif'], - '.heic': ['image/heic'], - '.heif': ['image/heif'], - '.hif': ['image/hif'], '.iiq': ['image/iiq', 'image/x-phaseone-iiq'], - '.insp': ['image/jpeg'], - '.jpe': ['image/jpeg'], - '.jpeg': ['image/jpeg'], - '.jpg': ['image/jpeg'], - '.jxl': ['image/jxl'], '.k25': ['image/k25', 'image/x-kodak-k25'], '.kdc': ['image/kdc', 'image/x-kodak-kdc'], '.mrw': ['image/mrw', 'image/x-minolta-mrw'], @@ -33,7 +22,6 @@ const image: Record<string, string[]> = { '.orf': ['image/orf', 'image/x-olympus-orf'], '.ori': ['image/ori', 'image/x-olympus-ori'], '.pef': ['image/pef', 'image/x-pentax-pef'], - '.png': ['image/png'], '.psd': ['image/psd', 'image/vnd.adobe.photoshop'], '.raf': ['image/raf', 'image/x-fuji-raf'], '.raw': ['image/raw', 'image/x-panasonic-raw'], @@ -42,11 +30,27 @@ const image: Record<string, string[]> = { '.sr2': ['image/sr2', 'image/x-sony-sr2'], '.srf': ['image/srf', 'image/x-sony-srf'], '.srw': ['image/srw', 'image/x-samsung-srw'], + '.x3f': ['image/x3f', 'image/x-sigma-x3f'], +}; + +const image: Record<string, string[]> = { + ...raw, + '.avif': ['image/avif'], + '.bmp': ['image/bmp'], + '.gif': ['image/gif'], + '.heic': ['image/heic'], + '.heif': ['image/heif'], + '.hif': ['image/hif'], + '.insp': ['image/jpeg'], + '.jpe': ['image/jpeg'], + '.jpeg': ['image/jpeg'], + '.jpg': ['image/jpeg'], + '.jxl': ['image/jxl'], + '.png': ['image/png'], '.svg': ['image/svg'], '.tif': ['image/tiff'], '.tiff': ['image/tiff'], '.webp': ['image/webp'], - '.x3f': ['image/x3f', 'image/x-sigma-x3f'], }; const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); @@ -77,22 +81,25 @@ const sidecar: Record<string, string[]> = { '.xmp': ['application/xml', 'text/xml'], }; +const types = { ...image, ...video, ...sidecar }; + const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r; -const lookup = (filename: string) => - ({ ...image, ...video, ...sidecar })[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; +const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream'; export const mimeTypes = { image, profile, sidecar, video, + raw, isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isImage: (filename: string) => isType(filename, image), isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), + isRaw: (filename: string) => isType(filename, raw), lookup, assetType: (filename: string) => { const contentType = lookup(filename); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index c11c936a1a..8262b6024b 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -10,13 +10,8 @@ import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.inte import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; -import { - CLIP_MODEL_INFO, - IMMICH_ACCESS_COOKIE, - IMMICH_API_KEY_HEADER, - IMMICH_API_KEY_NAME, - serverVersion, -} from 'src/constants'; +import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Metadata } from 'src/middleware/auth.guard'; @@ -143,14 +138,14 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => { scheme: 'Bearer', in: 'header', }) - .addCookieAuth(IMMICH_ACCESS_COOKIE) + .addCookieAuth(ImmichCookie.ACCESS_TOKEN) .addApiKey( { type: 'apiKey', in: 'header', - name: IMMICH_API_KEY_HEADER, + name: ImmichHeader.API_KEY, }, - IMMICH_API_KEY_NAME, + Metadata.API_KEY_SECURITY, ) .addServer('/api') .build(); diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts new file mode 100644 index 0000000000..f318ca3300 --- /dev/null +++ b/server/src/utils/response.ts @@ -0,0 +1,36 @@ +import { CookieOptions, Response } from 'express'; +import { Duration } from 'luxon'; +import { CookieResponse, ImmichCookie } from 'src/dtos/auth.dto'; + +export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values }: CookieResponse) => { + const defaults: CookieOptions = { + path: '/', + sameSite: 'lax', + httpOnly: true, + secure: isSecure, + maxAge: Duration.fromObject({ days: 400 }).toMillis(), + }; + + const cookieOptions: Record<ImmichCookie, CookieOptions> = { + [ImmichCookie.AUTH_TYPE]: defaults, + [ImmichCookie.ACCESS_TOKEN]: defaults, + // no httpOnly so that the client can know the auth state + [ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false }, + [ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() }, + }; + + for (const { key, value } of values) { + const options = cookieOptions[key]; + res.cookie(key, value, options); + } + + return body; +}; + +export const respondWithoutCookie = <T>(res: Response, body: T, cookies: ImmichCookie[]) => { + for (const cookie of cookies) { + res.clearCookie(cookie); + } + + return body; +}; diff --git a/server/test/assets b/server/test/assets index 61131e84ec..625ec3a5e9 160000 --- a/server/test/assets +++ b/server/test/assets @@ -1 +1 @@ -Subproject commit 61131e84ec91d316265aebe375b3155308baaa89 +Subproject commit 625ec3a5e9aa9b087ad986e0c2e6a24edb4ea81e diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 7aa49866d0..ce2b070672 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -757,4 +757,45 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, }), + imageDng: Object.freeze<AssetEntity>({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.dng', + previewPath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + thumbnailPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, + isReadOnly: false, + duration: null, + isVisible: true, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + } as ExifEntity, + }), }; diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 2e56d0001a..96a0bc0141 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,6 +1,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { UserTokenEntity } from 'src/entities/user-token.entity'; import { UserEntity } from 'src/entities/user.entity'; export const adminSignupStub = { @@ -35,9 +35,9 @@ export const authStub = { email: 'immich@test.com', isAdmin: false, } as UserEntity, - userToken: { + session: { id: 'token-id', - } as UserTokenEntity, + } as SessionEntity, }), user2: Object.freeze<AuthDto>({ user: { @@ -45,9 +45,9 @@ export const authStub = { email: 'user2@immich.app', isAdmin: false, } as UserEntity, - userToken: { + session: { id: 'token-id', - } as UserTokenEntity, + } as SessionEntity, }), external1: Object.freeze<AuthDto>({ user: { @@ -55,9 +55,9 @@ export const authStub = { email: 'immich@test.com', isAdmin: false, } as UserEntity, - userToken: { + session: { id: 'token-id', - } as UserTokenEntity, + } as SessionEntity, }), adminSharedLink: Object.freeze<AuthDto>({ user: { @@ -129,51 +129,21 @@ export const loginResponseStub = { }, }, user1oauth: { - response: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - cookie: [ - 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - ], + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, }, user1password: { - response: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - cookie: [ - 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;', - ], - }, - user1insecure: { - response: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - cookie: [ - 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;', - 'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;', - ], + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, }, }; diff --git a/server/test/fixtures/user-token.stub.ts b/server/test/fixtures/session.stub.ts similarity index 72% rename from server/test/fixtures/user-token.stub.ts rename to server/test/fixtures/session.stub.ts index 2f6fcc0cd5..cdf499c8d1 100644 --- a/server/test/fixtures/user-token.stub.ts +++ b/server/test/fixtures/session.stub.ts @@ -1,8 +1,8 @@ -import { UserTokenEntity } from 'src/entities/user-token.entity'; +import { SessionEntity } from 'src/entities/session.entity'; import { userStub } from 'test/fixtures/user.stub'; -export const userTokenStub = { - userToken: Object.freeze<UserTokenEntity>({ +export const sessionStub = { + valid: Object.freeze<SessionEntity>({ id: 'token-id', token: 'auth_token', userId: userStub.user1.id, @@ -12,7 +12,7 @@ export const userTokenStub = { deviceType: '', deviceOS: '', }), - inactiveToken: Object.freeze<UserTokenEntity>({ + inactive: Object.freeze<SessionEntity>({ id: 'not_active', token: 'auth_token', userId: userStub.user1.id, diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 2eea47b6ac..da3e05fe81 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -4,9 +4,11 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => { return { generateThumbhash: vitest.fn(), + extract: vitest.fn().mockResolvedValue(false), resize: vitest.fn(), crop: vitest.fn(), probe: vitest.fn(), transcode: vitest.fn(), + getImageDimensions: vitest.fn(), }; }; diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts new file mode 100644 index 0000000000..d510eb53f7 --- /dev/null +++ b/server/test/repositories/session.repository.mock.ts @@ -0,0 +1,12 @@ +import { ISessionRepository } from 'src/interfaces/session.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => { + return { + create: vitest.fn() as any, + update: vitest.fn() as any, + delete: vitest.fn(), + getByToken: vitest.fn(), + getByUserId: vitest.fn(), + }; +}; diff --git a/server/test/repositories/user-token.repository.mock.ts b/server/test/repositories/user-token.repository.mock.ts deleted file mode 100644 index f34e65b7f3..0000000000 --- a/server/test/repositories/user-token.repository.mock.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newUserTokenRepositoryMock = (): Mocked<IUserTokenRepository> => { - return { - create: vitest.fn(), - save: vitest.fn(), - delete: vitest.fn(), - getByToken: vitest.fn(), - getAll: vitest.fn(), - }; -}; diff --git a/web/Dockerfile b/web/Dockerfile index 8659c64277..a25ac2bfac 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e +FROM node:iron-alpine3.18@sha256:d328c7bc3305e1ab26491817936c8151a47a8861ad617c16c1eeaa9c8075c8f6 RUN apk add --no-cache tini USER node diff --git a/web/package-lock.json b/web/package-lock.json index b5e3a6c2f9..89b02cc4cd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,17 +1,19 @@ { "name": "immich-web", - "version": "1.101.0", + "version": "1.102.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.101.0", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", + "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", + "@photo-sphere-viewer/video-plugin": "^5.7.2", "@zoom-image/svelte": "^0.2.6", "buffer": "^6.0.3", "copy-image-clipboard": "^2.1.2", @@ -63,7 +65,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.101.0", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" @@ -1590,6 +1592,22 @@ "three": "^0.161.0" } }, + "node_modules/@photo-sphere-viewer/equirectangular-video-adapter": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.7.2.tgz", + "integrity": "sha512-cAaot52nPqa2p77Xp1humRvuxRIa8cqbZ/XRhA8kBToFLT1Ugh9YBcDD7pM/358JtAjicUbLpT7Ioap9iEigxQ==", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.7.2" + } + }, + "node_modules/@photo-sphere-viewer/video-plugin": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.7.2.tgz", + "integrity": "sha512-vrPV9RCr4HsYiORkto1unDPeUkbN2kbyogvNUoLiQ78M4xkPOqoKxtfxCxTYoM+7gECwNL9VTF81+okck498qA==", + "peerDependencies": { + "@photo-sphere-viewer/core": "5.7.2" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.24", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", diff --git a/web/package.json b/web/package.json index 8418af55aa..34c2ee83a3 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.101.0", + "version": "1.102.3", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", @@ -61,6 +61,8 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", + "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", + "@photo-sphere-viewer/video-plugin": "^5.7.2", "@zoom-image/svelte": "^0.2.6", "buffer": "^6.0.3", "copy-image-clipboard": "^2.1.2", diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index 5b984e2305..2a1853f904 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -101,6 +101,16 @@ isEdited={config.image.colorspace !== savedConfig.image.colorspace} {disabled} /> + + <SettingSwitch + id="prefer-embedded" + title="PREFER EMBEDDED PREVIEW" + subtitle="Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts." + checked={config.image.extractEmbedded} + on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)} + isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded} + {disabled} + /> </div> <div class="ml-4"> diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index abcb248f1c..6772ff5db0 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -20,6 +20,7 @@ mdiFolderDownloadOutline, mdiHeart, mdiHeartOutline, + mdiHistory, mdiImageAlbum, mdiImageMinusOutline, mdiImageOutline, @@ -52,6 +53,7 @@ type MenuItemEvent = | 'addToAlbum' + | 'restoreAsset' | 'addToSharedAlbum' | 'asProfileImage' | 'setAsAlbumCover' @@ -70,6 +72,7 @@ delete: void; toggleArchive: void; addToAlbum: void; + restoreAsset: void; addToSharedAlbum: void; asProfileImage: void; setAsAlbumCover: void; @@ -208,12 +211,16 @@ {#if showDownloadButton} <MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text="Download" /> {/if} - <MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" /> - <MenuOption - icon={mdiShareVariantOutline} - on:click={() => onMenuClick('addToSharedAlbum')} - text="Add to shared album" - /> + {#if asset.isTrashed} + <MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text="Restore" /> + {:else} + <MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" /> + <MenuOption + icon={mdiShareVariantOutline} + on:click={() => onMenuClick('addToSharedAlbum')} + text="Add to shared album" + /> + {/if} {#if isOwner} {#if hasStackChildren} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 4fa7d72a2f..28899a7525 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -12,7 +12,7 @@ import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; import { user } from '$lib/stores/user.store'; import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils'; - import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile, unstackAssets } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; import { shortcuts } from '$lib/utils/shortcut'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; @@ -27,8 +27,8 @@ getActivityStatistics, getAllAlbums, runAssetJobs, + restoreAssets, updateAsset, - updateAssets, updateAlbumInfo, type ActivityResponseDto, type AlbumResponseDto, @@ -50,7 +50,7 @@ import PanoramaViewer from './panorama-viewer.svelte'; import PhotoViewer from './photo-viewer.svelte'; import SlideshowBar from './slideshow-bar.svelte'; - import VideoViewer from './video-viewer.svelte'; + import VideoViewer from './video-wrapper-viewer.svelte'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; @@ -404,6 +404,22 @@ await handleGetAllAlbums(); }; + const handleRestoreAsset = async () => { + try { + await restoreAssets({ bulkIdsDto: { ids: [asset.id] } }); + asset.isTrashed = false; + + dispatch('action', { type: AssetAction.RESTORE, asset }); + + notificationController.show({ + type: NotificationType.Info, + message: `Restored asset`, + }); + } catch (error) { + handleError(error, 'Error restoring asset'); + } + }; + const toggleArchive = async () => { try { const data = await updateAsset({ @@ -481,20 +497,15 @@ }; const handleUnstack = async () => { - try { - const ids = $stackAssetsStore.map(({ id }) => id); - await updateAssets({ assetBulkUpdateDto: { ids, removeParent: true } }); - for (const child of $stackAssetsStore) { - child.stackParentId = null; - child.stackCount = 0; - child.stack = []; - dispatch('action', { type: AssetAction.ADD, asset: child }); + const unstackedAssets = await unstackAssets($stackAssetsStore); + if (unstackedAssets) { + for (const asset of unstackedAssets) { + dispatch('action', { + type: AssetAction.ADD, + asset, + }); } - dispatch('close'); - notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 }); - } catch (error) { - handleError(error, `Unable to unstack`); } }; @@ -562,6 +573,7 @@ on:delete={() => trashOrDelete()} on:favorite={toggleFavorite} on:addToAlbum={() => openAlbumPicker(false)} + on:restoreAsset={() => handleRestoreAsset()} on:addToSharedAlbum={() => openAlbumPicker(true)} on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)} on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)} @@ -610,6 +622,7 @@ {:else} <VideoViewer assetId={previewStackedAsset.id} + projectionType={previewStackedAsset.exifInfo?.projectionType} on:close={closeViewer} on:onVideoEnded={() => navigateAsset()} on:onVideoStarted={handleVideoStarted} @@ -630,6 +643,7 @@ {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} <VideoViewer assetId={asset.livePhotoVideoId} + projectionType={asset.exifInfo?.projectionType} on:close={closeViewer} on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} /> @@ -643,6 +657,7 @@ {:else} <VideoViewer assetId={asset.id} + projectionType={asset.exifInfo?.projectionType} on:close={closeViewer} on:onVideoEnded={() => navigateAsset()} on:onVideoStarted={handleVideoStarted} diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index 66d8f63099..592053e5b8 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -1,22 +1,39 @@ <script lang="ts"> - import { serveFile, type AssetResponseDto } from '@immich/sdk'; + import { serveFile, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { getKey } from '$lib/utils'; - export let asset: AssetResponseDto; + import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core'; + export let asset: Pick<AssetResponseDto, 'id' | 'type'>; + + const photoSphereConfigs = + asset.type === AssetTypeEnum.Video + ? ([ + import('@photo-sphere-viewer/equirectangular-video-adapter').then( + ({ EquirectangularVideoAdapter }) => EquirectangularVideoAdapter, + ), + import('@photo-sphere-viewer/video-plugin').then(({ VideoPlugin }) => [VideoPlugin]), + true, + import('@photo-sphere-viewer/video-plugin/index.css'), + ] as [PromiseLike<AdapterConstructor>, Promise<PluginConstructor[]>, true, unknown]) + : ([undefined, [], false] as [undefined, [], false]); const loadAssetData = async () => { const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() }); - return URL.createObjectURL(data); + const url = URL.createObjectURL(data); + if (asset.type === AssetTypeEnum.Video) { + return { source: url }; + } + return url; }; </script> <div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center"> <!-- the photo sphere viewer is quite large, so lazy load it in parallel with loading the data --> - {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte')])} + {#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])} <LoadingSpinner /> - {:then [data, module]} - <svelte:component this={module.default} panorama={data} /> + {:then [data, module, adapter, plugins, navbar]} + <svelte:component this={module.default} panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} /> {:catch} Failed to load asset {/await} diff --git a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte index 796622e7fe..0c0e707693 100644 --- a/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte +++ b/web/src/lib/components/asset-viewer/photo-sphere-viewer-adapter.svelte @@ -1,17 +1,32 @@ <script lang="ts"> - import { Viewer } from '@photo-sphere-viewer/core'; + import { + Viewer, + EquirectangularAdapter, + type PluginConstructor, + type AdapterConstructor, + } from '@photo-sphere-viewer/core'; import '@photo-sphere-viewer/core/index.css'; import { onDestroy, onMount } from 'svelte'; - export let panorama: string; + export let panorama: string | { source: string }; + export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter; + export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = []; + export let navbar = false; + let container: HTMLDivElement; let viewer: Viewer; onMount(() => { viewer = new Viewer({ + adapter, + plugins, container, panorama, - navbar: false, + touchmoveTwoFingers: true, + mousewheelCtrlKey: false, + navbar, + maxFov: 180, + fisheye: true, }); }); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 14bbb6b1fa..2bf4ba4801 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -14,6 +14,9 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { getAltText } from '$lib/utils/thumbnail-util'; + import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; + + const { slideshowState, slideshowLook } = slideshowStore; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] | null = null; @@ -147,18 +150,29 @@ <div bind:this={element} transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} - class="flex h-full select-none place-content-center place-items-center" + class="relative h-full select-none" > {#if !imageLoaded} - <LoadingSpinner /> + <div class="flex h-full items-center justify-center"> + <LoadingSpinner /> + </div> {:else} - <div bind:this={imgElement} class="h-full w-full"> + <div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}> + {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} + <img + src={assetData} + alt={getAltText(asset)} + class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg" + draggable="false" + /> + {/if} <img bind:this={$photoViewer} - transition:fade={{ duration: haveFadeTransition ? 150 : 0 }} src={assetData} alt={getAltText(asset)} - class="h-full w-full object-contain" + class="h-full w-full {$slideshowState === SlideshowState.None + ? 'object-contain' + : slideshowLookCssMapping[$slideshowLook]}" draggable="false" /> {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} diff --git a/web/src/lib/components/asset-viewer/video-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte similarity index 100% rename from web/src/lib/components/asset-viewer/video-viewer.svelte rename to web/src/lib/components/asset-viewer/video-native-viewer.svelte diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte new file mode 100644 index 0000000000..59809caa25 --- /dev/null +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import { AssetTypeEnum } from '@immich/sdk'; + import { ProjectionType } from '$lib/constants'; + import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte'; + import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte'; + + export let assetId: string; + export let projectionType: string | null | undefined; +</script> + +{#if projectionType === ProjectionType.EQUIRECTANGULAR} + <PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} /> +{:else} + <VideoNativeViewer {assetId} on:onVideoEnded on:onVideoStarted /> +{/if} diff --git a/web/src/lib/components/elements/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte index fb301b65fe..9d8c8854be 100644 --- a/web/src/lib/components/elements/search-bar.svelte +++ b/web/src/lib/components/elements/search-bar.svelte @@ -28,9 +28,7 @@ <Icon path={mdiMagnify} size="24" /> </div> </button> - <!-- svelte-ignore a11y-autofocus --> <input - autofocus class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white" type="text" {placeholder} diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte index ce7212f950..7f48a9eae8 100644 --- a/web/src/lib/components/faces-page/assign-face-side-panel.svelte +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -21,7 +21,7 @@ export let peopleWithFaces: AssetFaceResponseDto[]; export let allPeople: PersonResponseDto[]; - export let editedPersonIndex: number; + export let editedPerson: PersonResponseDto; export let assetType: AssetTypeEnum; export let assetId: string; @@ -106,7 +106,7 @@ const handleCreatePerson = async () => { const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); - const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id); + const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id); const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null; @@ -229,7 +229,7 @@ <div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto"> {#if searchName == ''} {#each allPeople as person (person.id)} - {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} + {#if person.id !== editedPerson.id} <div class="w-fit"> <button class="w-[90px]" on:click={() => dispatch('reassign', person)}> <div class="relative"> @@ -255,7 +255,7 @@ {/each} {:else} {#each searchedPeople as person (person.id)} - {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} + {#if person.id !== editedPerson.id} <div class="w-fit"> <button class="w-[90px]" on:click={() => dispatch('reassign', person)}> <div class="relative"> diff --git a/web/src/lib/components/faces-page/edit-name-input.svelte b/web/src/lib/components/faces-page/edit-name-input.svelte index fa968e6914..f01ecd67e7 100644 --- a/web/src/lib/components/faces-page/edit-name-input.svelte +++ b/web/src/lib/components/faces-page/edit-name-input.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import { type PersonResponseDto } from '@immich/sdk'; - import { createEventDispatcher } from 'svelte'; + import { createEventDispatcher, onMount } from 'svelte'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import Button from '../elements/buttons/button.svelte'; @@ -9,11 +9,17 @@ export let suggestedPeople = false; export let thumbnailData: string; + let inputElement: HTMLInputElement; + const dispatch = createEventDispatcher<{ change: string; cancel: void; input: void; }>(); + + onMount(() => { + inputElement.focus(); + }); </script> <div @@ -27,13 +33,12 @@ autocomplete="off" on:submit|preventDefault={() => dispatch('change', name)} > - <!-- svelte-ignore a11y-autofocus --> <input - autofocus class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white" type="text" placeholder="New name or nickname" bind:value={name} + bind:this={inputElement} on:input={() => dispatch('input')} /> <Button size="sm" type="submit">Done</Button> diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 3cb705a1f3..489cfeb170 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -28,14 +28,14 @@ export let assetType: AssetTypeEnum; // keep track of the changes - let numberOfPersonToCreate: string[] = []; - let numberOfAssetFaceGenerated: string[] = []; + let peopleToCreate: string[] = []; + let assetFaceGenerated: string[] = []; // faces let peopleWithFaces: AssetFaceResponseDto[] = []; - let selectedPersonToReassign: (PersonResponseDto | null)[]; - let selectedPersonToCreate: (string | null)[]; - let editedPersonIndex: number; + let selectedPersonToReassign: Record<string, PersonResponseDto> = {}; + let selectedPersonToCreate: Record<string, string> = {}; + let editedPerson: PersonResponseDto; // loading spinners let isShowLoadingDone = false; @@ -49,6 +49,8 @@ let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>; let automaticRefreshTimeout: ReturnType<typeof setTimeout>; + const thumbnailWidth = '90px'; + const dispatch = createEventDispatcher<{ close: void; refresh: void; @@ -60,8 +62,6 @@ const { people } = await getAllPeople({ withHidden: true }); allPeople = people; peopleWithFaces = await getFaces({ id: assetId }); - selectedPersonToCreate = Array.from({ length: peopleWithFaces.length }); - selectedPersonToReassign = Array.from({ length: peopleWithFaces.length }); } catch (error) { handleError(error, "Can't get faces"); } finally { @@ -71,12 +71,12 @@ } const onPersonThumbnail = (personId: string) => { - numberOfAssetFaceGenerated.push(personId); + assetFaceGenerated.push(personId); if ( - isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) && + isEqual(assetFaceGenerated, peopleToCreate) && loaderLoadingDoneTimeout && automaticRefreshTimeout && - selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length + Object.keys(selectedPersonToCreate).length === peopleToCreate.length ) { clearTimeout(loaderLoadingDoneTimeout); clearTimeout(automaticRefreshTimeout); @@ -97,36 +97,41 @@ dispatch('close'); }; - const handleReset = (index: number) => { - if (selectedPersonToReassign[index]) { - selectedPersonToReassign[index] = null; + const handleReset = (id: string) => { + if (selectedPersonToReassign[id]) { + delete selectedPersonToReassign[id]; + + // trigger reactivity + selectedPersonToReassign = selectedPersonToReassign; } - if (selectedPersonToCreate[index]) { - selectedPersonToCreate[index] = null; + if (selectedPersonToCreate[id]) { + delete selectedPersonToCreate[id]; + + // trigger reactivity + selectedPersonToCreate = selectedPersonToCreate; } }; const handleEditFaces = async () => { loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner); - const numberOfChanges = - selectedPersonToCreate.filter((person) => person !== null).length + - selectedPersonToReassign.filter((person) => person !== null).length; + const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length; + if (numberOfChanges > 0) { try { - for (const [index, peopleWithFace] of peopleWithFaces.entries()) { - const personId = selectedPersonToReassign[index]?.id; + for (const personWithFace of peopleWithFaces) { + const personId = selectedPersonToReassign[personWithFace.id]?.id; if (personId) { await reassignFacesById({ id: personId, - faceDto: { id: peopleWithFace.id }, + faceDto: { id: personWithFace.id }, }); - } else if (selectedPersonToCreate[index]) { + } else if (selectedPersonToCreate[personWithFace.id]) { const data = await createPerson({ personCreateDto: {} }); - numberOfPersonToCreate.push(data.id); + peopleToCreate.push(data.id); await reassignFacesById({ id: data.id, - faceDto: { id: peopleWithFace.id }, + faceDto: { id: personWithFace.id }, }); } } @@ -141,7 +146,7 @@ } isShowLoadingDone = false; - if (numberOfPersonToCreate.length === 0) { + if (peopleToCreate.length === 0) { clearTimeout(loaderLoadingDoneTimeout); dispatch('refresh'); } else { @@ -150,23 +155,26 @@ }; const handleCreatePerson = (newFeaturePhoto: string | null) => { - const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id); + const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id); if (newFeaturePhoto && personToUpdate) { - selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto; + selectedPersonToCreate[personToUpdate.id] = newFeaturePhoto; } showSeletecFaces = false; }; const handleReassignFace = (person: PersonResponseDto | null) => { - if (person) { - selectedPersonToReassign[editedPersonIndex] = person; + const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id); + if (person && personToUpdate) { + selectedPersonToReassign[personToUpdate.id] = person; showSeletecFaces = false; } }; - const handlePersonPicker = (index: number) => { - editedPersonIndex = index; - showSeletecFaces = true; + const handlePersonPicker = (person: PersonResponseDto | null) => { + if (person) { + editedPerson = person; + showSeletecFaces = true; + } }; </script> @@ -217,35 +225,48 @@ on:mouseleave={() => ($boundingBoxesArray = [])} > <div class="relative"> - <ImageThumbnail - curve - shadow - url={selectedPersonToCreate[index] || - getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)} - altText={selectedPersonToReassign[index] - ? selectedPersonToReassign[index]?.name - : selectedPersonToCreate[index] - ? 'New person' - : getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)} - title={selectedPersonToReassign[index] - ? selectedPersonToReassign[index]?.name - : selectedPersonToCreate[index] - ? 'New person' - : getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)} - widthStyle="90px" - heightStyle="90px" - thumbhash={null} - hidden={selectedPersonToReassign[index] - ? selectedPersonToReassign[index]?.isHidden - : selectedPersonToCreate[index] - ? false - : face.person?.isHidden} - /> + {#if selectedPersonToCreate[face.id]} + <ImageThumbnail + curve + shadow + url={selectedPersonToCreate[face.id]} + altText={selectedPersonToCreate[face.id]} + title={'New person'} + widthStyle={thumbnailWidth} + heightStyle={thumbnailWidth} + /> + {:else if selectedPersonToReassign[face.id]} + <ImageThumbnail + curve + shadow + url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)} + altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id} + title={getPersonNameWithHiddenValue( + selectedPersonToReassign[face.id].name, + face.person?.isHidden, + )} + widthStyle={thumbnailWidth} + heightStyle={thumbnailWidth} + hidden={selectedPersonToReassign[face.id].isHidden} + /> + {:else} + <ImageThumbnail + curve + shadow + url={getPeopleThumbnailUrl(face.person.id)} + altText={face.person.name || face.person.id} + title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)} + widthStyle={thumbnailWidth} + heightStyle={thumbnailWidth} + hidden={face.person.isHidden} + /> + {/if} </div> - {#if !selectedPersonToCreate[index]} + + {#if !selectedPersonToCreate[face.id]} <p class="relative mt-1 truncate font-medium" title={face.person?.name}> - {#if selectedPersonToReassign[index]?.id} - {selectedPersonToReassign[index]?.name} + {#if selectedPersonToReassign[face.id]?.id} + {selectedPersonToReassign[face.id]?.name} {:else} {face.person?.name} {/if} @@ -253,8 +274,8 @@ {/if} <div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700"> - {#if selectedPersonToCreate[index] || selectedPersonToReassign[index]} - <button on:click={() => handleReset(index)} class="flex h-full w-full"> + {#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} + <button on:click={() => handleReset(face.id)} class="flex h-full w-full"> <div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform"> <div> <Icon path={mdiRestart} size={18} /> @@ -262,7 +283,7 @@ </div> </button> {:else} - <button on:click={() => handlePersonPicker(index)} class="flex h-full w-full"> + <button on:click={() => handlePersonPicker(face.person)} class="flex h-full w-full"> <div class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white" /> @@ -282,7 +303,7 @@ <AssignFaceSidePanel {peopleWithFaces} {allPeople} - {editedPersonIndex} + {editedPerson} {assetType} {assetId} on:close={() => (showSeletecFaces = false)} diff --git a/web/src/lib/components/photos-page/actions/stack-action.svelte b/web/src/lib/components/photos-page/actions/stack-action.svelte index a857da1dd3..b6a034672b 100644 --- a/web/src/lib/components/photos-page/actions/stack-action.svelte +++ b/web/src/lib/components/photos-page/actions/stack-action.svelte @@ -1,20 +1,45 @@ <script lang="ts"> import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; - import { getAssetControlContext } from '../asset-select-control-bar.svelte'; - import type { OnStack } from '$lib/utils/actions'; - import { stackAssets } from '$lib/utils/asset-utils'; - import { mdiImageMultipleOutline } from '@mdi/js'; + import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; + import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; + import { stackAssets, unstackAssets } from '$lib/utils/asset-utils'; + import type { OnStack, OnUnstack } from '$lib/utils/actions'; + export let unstack = false; export let onStack: OnStack | undefined; + export let onUnstack: OnUnstack | undefined; const { clearSelect, getOwnedAssets } = getAssetControlContext(); const handleStack = async () => { - await stackAssets([...getOwnedAssets()], (ids) => { + const selectedAssets = [...getOwnedAssets()]; + const ids = await stackAssets(selectedAssets); + if (ids) { onStack?.(ids); clearSelect(); - }); + } + }; + + const handleUnstack = async () => { + const selectedAssets = [...getOwnedAssets()]; + if (selectedAssets.length !== 1) { + return; + } + const { stack } = selectedAssets[0]; + if (!stack) { + return; + } + const assets = [selectedAssets[0], ...stack]; + const unstackedAssets = await unstackAssets(assets); + if (unstackedAssets) { + onUnstack?.(unstackedAssets); + } + clearSelect(); }; </script> -<MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} /> +{#if unstack} + <MenuOption text="Un-stack" icon={mdiImageMinusOutline} on:click={handleUnstack} /> +{:else} + <MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} /> +{/if} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 06b5627d1b..8cfb0b8b16 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -89,11 +89,10 @@ }; const onStackAssets = async () => { - if ($selectedAssets.size > 1) { - await stackAssets(Array.from($selectedAssets), (ids) => { - assetStore.removeAssets(ids); - dispatch('escape'); - }); + const ids = await stackAssets(Array.from($selectedAssets)); + if (ids) { + assetStore.removeAssets(ids); + dispatch('escape'); } }; @@ -107,6 +106,8 @@ { shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) }, { shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) }, { shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) }, + { shortcut: { key: 'PageUp' }, onShortcut: () => (element.scrollTop = 0) }, + { shortcut: { key: 'PageDown' }, onShortcut: () => (element.scrollTop = viewport.height) }, ]; if ($isMultiSelectState) { @@ -168,6 +169,7 @@ switch (action) { case removeAction: case AssetAction.TRASH: + case AssetAction.RESTORE: case AssetAction.DELETE: { // find the next asset to show or close the viewer (await handleNext()) || (await handlePrevious()) || handleClose(); diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte index 2573a7730c..16e53dd562 100644 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte @@ -83,6 +83,7 @@ hoverLabel = new Date(attr).toLocaleString($locale, { month: 'short', year: 'numeric', + timeZone: 'UTC', }); }; 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 a8f0008450..1b7c8c2fad 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 @@ -43,21 +43,20 @@ const filterPeople = (list: PersonResponseDto[], name: string) => { const nameLower = name.toLowerCase(); - return name ? list.filter((p) => p.name.toLowerCase().startsWith(nameLower)) : list; + return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list; }; </script> {#await peoplePromise then people} {#if people && people.length > 0} - {@const peopleList = showAllPeople ? filterPeople(people, name) : people.slice(0, numberOfPeople)} + {@const peopleList = showAllPeople + ? filterPeople(people, name) + : filterPeople(people, name).slice(0, numberOfPeople)} <div id="people-selection" class="-mb-4"> <div class="flex items-center w-full justify-between gap-6"> <p class="immich-form-label py-3">PEOPLE</p> - - {#if showAllPeople} - <SearchBar bind:name placeholder="Filter people" isSearching={false} /> - {/if} + <SearchBar bind:name placeholder="Filter people" isSearching={false} /> </div> <div class="flex -mx-1 max-h-64 gap-1 mt-2 flex-wrap overflow-y-auto immich-scrollbar"> diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index 7a0da7b836..64480dfdbf 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -4,27 +4,42 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; - import { mdiArrowDownThin, mdiArrowUpThin, mdiShuffle } from '@mdi/js'; - import { SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; + import { + mdiArrowDownThin, + mdiArrowUpThin, + mdiFitToPageOutline, + mdiFitToScreenOutline, + mdiPanorama, + mdiShuffle, + } from '@mdi/js'; + import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; import Button from './elements/buttons/button.svelte'; import type { RenderedOption } from './elements/dropdown.svelte'; import SettingDropdown from './shared-components/settings/setting-dropdown.svelte'; - const { slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore; + const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore; export let onClose = () => {}; - const options: Record<SlideshowNavigation, RenderedOption> = { + const navigationOptions: Record<SlideshowNavigation, RenderedOption> = { [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: 'Shuffle' }, [SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: 'Backward' }, [SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' }, }; - const handleToggle = (selectedOption: RenderedOption) => { + const lookOptions: Record<SlideshowLook, RenderedOption> = { + [SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: 'Contain' }, + [SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: 'Cover' }, + [SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: 'Blurred background' }, + }; + + const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>( + record: RenderedOption, + options: Record<Type, RenderedOption>, + ): undefined | Type => { for (const [key, option] of Object.entries(options)) { - if (option === selectedOption) { - $slideshowNavigation = key as SlideshowNavigation; - break; + if (option === record) { + return key as Type; } } }; @@ -34,9 +49,19 @@ <div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary"> <SettingDropdown title="Direction" - options={Object.values(options)} - selectedOption={options[$slideshowNavigation]} - onToggle={(option) => handleToggle(option)} + options={Object.values(navigationOptions)} + selectedOption={navigationOptions[$slideshowNavigation]} + onToggle={(option) => { + $slideshowNavigation = handleToggle(option, navigationOptions) || $slideshowNavigation; + }} + /> + <SettingDropdown + title="Look" + options={Object.values(lookOptions)} + selectedOption={lookOptions[$slideshowLook]} + onToggle={(option) => { + $slideshowLook = handleToggle(option, lookOptions) || $slideshowLook; + }} /> <SettingSwitch id="show-progress-bar" title="Show Progress Bar" bind:checked={$showProgressBar} /> <SettingInputField diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 64f17ad9e5..8821ed970a 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import Icon from '$lib/components/elements/icon.svelte'; import { locale } from '$lib/stores/preferences.store'; - import type { AuthDeviceResponseDto } from '@immich/sdk'; + import type { SessionResponseDto } from '@immich/sdk'; import { mdiAndroid, mdiApple, @@ -15,7 +15,7 @@ import { DateTime, type ToRelativeCalendarOptions } from 'luxon'; import { createEventDispatcher } from 'svelte'; - export let device: AuthDeviceResponseDto; + export let device: SessionResponseDto; const dispatcher = createEventDispatcher<{ delete: void; diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte index 3bec6cf170..b7bb05657f 100644 --- a/web/src/lib/components/user-settings-page/device-list.svelte +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -1,16 +1,16 @@ <script lang="ts"> - import { getAuthDevices, logoutAuthDevice, logoutAuthDevices, type AuthDeviceResponseDto } from '@immich/sdk'; + import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk'; import { handleError } from '../../utils/handle-error'; import Button from '../elements/buttons/button.svelte'; import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; - import { NotificationType, notificationController } from '../shared-components/notification/notification'; + import { notificationController, NotificationType } from '../shared-components/notification/notification'; import DeviceCard from './device-card.svelte'; - export let devices: AuthDeviceResponseDto[]; - let deleteDevice: AuthDeviceResponseDto | null = null; + export let devices: SessionResponseDto[]; + let deleteDevice: SessionResponseDto | null = null; let deleteAll = false; - const refresh = () => getAuthDevices().then((_devices) => (devices = _devices)); + const refresh = () => getSessions().then((_devices) => (devices = _devices)); $: currentDevice = devices.find((device) => device.current); $: otherDevices = devices.filter((device) => !device.current); @@ -21,7 +21,7 @@ } try { - await logoutAuthDevice({ id: deleteDevice.id }); + await deleteSession({ id: deleteDevice.id }); notificationController.show({ message: `Logged out device`, type: NotificationType.Info }); } catch (error) { handleError(error, 'Unable to log out device'); @@ -33,7 +33,7 @@ const handleDeleteAll = async () => { try { - await logoutAuthDevices(); + await deleteAllSessions(); notificationController.show({ message: `Logged out all devices`, type: NotificationType.Info, diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 7a3be6fb5c..d239886ed9 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -4,7 +4,8 @@ import { featureFlags } from '$lib/stores/server-config.store'; import { user } from '$lib/stores/user.store'; import { oauth } from '$lib/utils'; - import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk'; + import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk'; + import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; import SettingAccordion from '../shared-components/settings/setting-accordion.svelte'; import AppSettings from './app-settings.svelte'; import ChangePasswordSettings from './change-password-settings.svelte'; @@ -14,10 +15,9 @@ import PartnerSettings from './partner-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; - import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; export let keys: ApiKeyResponseDto[] = []; - export let devices: AuthDeviceResponseDto[] = []; + export let sessions: SessionResponseDto[] = []; let oauthOpen = oauth.isCallback(window.location) || @@ -38,7 +38,7 @@ </SettingAccordion> <SettingAccordion key="authorized-devices" title="Authorized Devices" subtitle="Manage your logged-in devices"> - <DeviceList bind:devices /> + <DeviceList bind:devices={sessions} /> </SettingAccordion> <SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories"> diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index af5558c261..98d6d742d2 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -5,7 +5,7 @@ export enum AssetAction { UNFAVORITE = 'unfavorite', TRASH = 'trash', DELETE = 'delete', - // RESTORE = 'restore', + RESTORE = 'restore', ADD = 'add', } diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts index acb78d0b0a..daa4e98791 100644 --- a/web/src/lib/stores/slideshow.store.ts +++ b/web/src/lib/stores/slideshow.store.ts @@ -13,6 +13,18 @@ export enum SlideshowNavigation { DescendingOrder = 'descending-order', } +export enum SlideshowLook { + Contain = 'contain', + Cover = 'cover', + BlurredBackground = 'blurred-background', +} + +export const slideshowLookCssMapping: Record<SlideshowLook, string> = { + [SlideshowLook.Contain]: 'object-contain', + [SlideshowLook.Cover]: 'object-cover', + [SlideshowLook.BlurredBackground]: 'object-contain', +}; + function createSlideshowStore() { const restartState = writable<boolean>(false); const stopState = writable<boolean>(false); @@ -21,6 +33,7 @@ function createSlideshowStore() { 'slideshow-navigation', SlideshowNavigation.DescendingOrder, ); + const slideshowLook = persisted<SlideshowLook>('slideshow-look', SlideshowLook.Contain); const slideshowState = writable<SlideshowState>(SlideshowState.None); const showProgressBar = persisted<boolean>('slideshow-show-progressbar', true); @@ -50,6 +63,7 @@ function createSlideshowStore() { }, }, slideshowNavigation, + slideshowLook, slideshowState, slideshowDelay, showProgressBar, diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index b6718e63a1..ecfd29a8fc 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -1,5 +1,5 @@ import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; -import { deleteAssets as deleteBulk } from '@immich/sdk'; +import { deleteAssets as deleteBulk, type AssetResponseDto } from '@immich/sdk'; import { handleError } from './handle-error'; export type OnDelete = (assetIds: string[]) => void; @@ -7,6 +7,7 @@ export type OnRestore = (ids: string[]) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (ids: string[]) => void; +export type OnUnstack = (assets: AssetResponseDto[]) => void; export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { try { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 72102d2634..50337fb06b 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; +import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { downloadRequest, getKey } from '$lib/utils'; @@ -269,43 +270,81 @@ export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserRespo return ids; }; -export async function stackAssets(assets: Array<AssetResponseDto>, onStack: (ds: string[]) => void) { +export const stackAssets = async (assets: AssetResponseDto[]) => { + if (assets.length < 2) { + return false; + } + + const parent = assets[0]; + const children = assets.slice(1); + const ids = children.map(({ id }) => id); + try { - const parent = assets.at(0); - if (!parent) { - return; - } + await updateAssets({ + assetBulkUpdateDto: { + ids, + stackParentId: parent.id, + }, + }); + } catch (error) { + handleError(error, 'Failed to stack assets'); + return false; + } - const children = assets.slice(1); - const ids = children.map(({ id }) => id); - - if (children.length > 0) { - await updateAssets({ assetBulkUpdateDto: { ids, stackParentId: parent.id } }); - } - - let childrenCount = parent.stackCount || 1; - for (const asset of children) { - asset.stackParentId = parent.id; - // Add grand-children's count to new parent - childrenCount += asset.stackCount || 1; + let grandChildren: AssetResponseDto[] = []; + for (const asset of children) { + asset.stackParentId = parent.id; + if (asset.stack) { + // Add grand-children to new parent + grandChildren = grandChildren.concat(asset.stack); // Reset children stack info asset.stackCount = null; asset.stack = []; } - - parent.stackCount = childrenCount; - - notificationController.show({ - message: `Stacked ${ids.length + 1} assets`, - type: NotificationType.Info, - timeout: 1500, - }); - - onStack(ids); - } catch (error) { - handleError(error, `Unable to stack`); } -} + + parent.stack ??= []; + parent.stack = parent.stack.concat(children, grandChildren); + parent.stackCount = parent.stack.length + 1; + + notificationController.show({ + message: `Stacked ${parent.stackCount} assets`, + type: NotificationType.Info, + button: { + text: 'View Stack', + onClick() { + return assetViewingStore.setAssetId(parent.id); + }, + }, + }); + + return ids; +}; + +export const unstackAssets = async (assets: AssetResponseDto[]) => { + const ids = assets.map(({ id }) => id); + try { + await updateAssets({ + assetBulkUpdateDto: { + ids, + removeParent: true, + }, + }); + } catch (error) { + handleError(error, 'Failed to un-stack assets'); + return; + } + for (const asset of assets) { + asset.stackParentId = null; + asset.stackCount = null; + asset.stack = []; + } + notificationController.show({ + type: NotificationType.Info, + message: `Un-stacked ${assets.length} assets`, + }); + return assets; +}; export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => { if (get(isSelectingAllAssets)) { diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index ba9db04c74..09d36890f5 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -89,7 +89,7 @@ const handleSearch = async (force: boolean) => { $page.url.searchParams.set(QueryParameter.SEARCHED_PEOPLE, searchName); - await goto($page.url); + await goto($page.url, { keepFocus: true }); await handleSearchPeople(force); }; diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 00409cdc1b..f711b081d6 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -30,7 +30,14 @@ const assetInteractionStore = createAssetInteractionStore(); const { isMultiSelectState, selectedAssets } = assetInteractionStore; - $: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite); + let isAllFavorite: boolean; + let isAssetStackSelected: boolean; + + $: { + const selection = [...$selectedAssets]; + isAllFavorite = selection.every((asset) => asset.isFavorite); + isAssetStackSelected = selection.length === 1 && !!selection[0].stack; + } const handleEscape = () => { if ($showAssetViewer) { @@ -62,8 +69,12 @@ <FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} /> <AssetSelectContextMenu icon={mdiDotsVertical} title="Menu"> <DownloadAction menuItem /> - {#if $selectedAssets.size > 1} - <StackAction onStack={(assetIds) => assetStore.removeAssets(assetIds)} /> + {#if $selectedAssets.size > 1 || isAssetStackSelected} + <StackAction + unstack={isAssetStackSelected} + onStack={(assetIds) => assetStore.removeAssets(assetIds)} + onUnstack={(assets) => assetStore.addAssets(assets)} + /> {/if} <ChangeDate menuItem /> <ChangeLocation menuItem /> diff --git a/web/src/routes/(user)/trash/+page.svelte b/web/src/routes/(user)/trash/+page.svelte index 5be97f2821..2728f25b02 100644 --- a/web/src/routes/(user)/trash/+page.svelte +++ b/web/src/routes/(user)/trash/+page.svelte @@ -39,8 +39,12 @@ try { await emptyTrash(); + const deletedAssetIds = assetStore.assets.map((a) => a.id); + const numberOfAssets = deletedAssetIds.length; + assetStore.removeAssets(deletedAssetIds); + notificationController.show({ - message: `Empty trash initiated. Refresh the page to see the changes`, + message: `Permanently deleted ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`, type: NotificationType.Info, }); } catch (error) { @@ -52,8 +56,12 @@ try { await restoreTrash(); + const restoredAssetIds = assetStore.assets.map((a) => a.id); + const numberOfAssets = restoredAssetIds.length; + assetStore.removeAssets(restoredAssetIds); + notificationController.show({ - message: `Restore trash initiated. Refresh the page to see the changes`, + message: `Restored ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`, type: NotificationType.Info, }); } catch (error) { diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte index 359ce33d2c..d23ecda446 100644 --- a/web/src/routes/(user)/user-settings/+page.svelte +++ b/web/src/routes/(user)/user-settings/+page.svelte @@ -19,7 +19,7 @@ </svelte:fragment> <section class="mx-4 flex place-content-center"> <div class="w-full max-w-3xl"> - <UserSettingsList keys={data.keys} devices={data.devices} /> + <UserSettingsList keys={data.keys} sessions={data.sessions} /> </div> </section> </UserPageLayout> diff --git a/web/src/routes/(user)/user-settings/+page.ts b/web/src/routes/(user)/user-settings/+page.ts index 84aed0db00..eba40243e9 100644 --- a/web/src/routes/(user)/user-settings/+page.ts +++ b/web/src/routes/(user)/user-settings/+page.ts @@ -1,16 +1,16 @@ import { authenticate } from '$lib/utils/auth'; -import { getApiKeys, getAuthDevices } from '@immich/sdk'; +import { getApiKeys, getSessions } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async () => { await authenticate(); const keys = await getApiKeys(); - const devices = await getAuthDevices(); + const sessions = await getSessions(); return { keys, - devices, + sessions, meta: { title: 'Settings', }, diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 09139a7f7e..4647ad8bde 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -5,7 +5,7 @@ import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte'; import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; - import { setAdminOnboarding } from '@immich/sdk'; + import { updateAdminOnboarding } from '@immich/sdk'; let index = 0; @@ -28,7 +28,7 @@ const handleDoneClicked = async () => { if (index >= onboardingSteps.length - 1) { - await setAdminOnboarding(); + await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); await goto(AppRoute.PHOTOS); } else { index++;