From 3a0ddfb92daee9a55d569c59abc0427a16d626bf Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 14 May 2025 23:13:13 -0400 Subject: [PATCH 01/13] fix(server): vacuum after deleting people (#18299) * vacuum after deleting people * update sql --- server/src/queries/person.repository.sql | 12 ------------ server/src/repositories/person.repository.ts | 6 +----- server/src/services/person.service.spec.ts | 16 ++++++++++++++-- server/src/services/person.service.ts | 2 ++ .../test/repositories/person.repository.mock.ts | 1 + 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index c77d9835fa..2ab0045e32 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -13,12 +13,6 @@ set "personId" = $1 where "asset_faces"."sourceType" = $2 -VACUUM -ANALYZE asset_faces, -face_search, -person -REINDEX TABLE asset_faces -REINDEX TABLE person -- PersonRepository.delete delete from "person" @@ -29,12 +23,6 @@ where delete from "asset_faces" where "asset_faces"."sourceType" = $1 -VACUUM -ANALYZE asset_faces, -face_search, -person -REINDEX TABLE asset_faces -REINDEX TABLE person -- PersonRepository.getAllWithoutFaces select diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 789c47ccaf..478ff15d53 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -105,8 +105,6 @@ export class PersonRepository { .set({ personId: null }) .where('asset_faces.sourceType', '=', sourceType) .execute(); - - await this.vacuum({ reindexVectors: false }); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -121,8 +119,6 @@ export class PersonRepository { @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute(); - - await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces(options: GetAllFacesOptions = {}) { @@ -519,7 +515,7 @@ export class PersonRepository { await this.db.updateTable('asset_faces').set({ deletedAt: new Date() }).where('asset_faces.id', '=', id).execute(); } - private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { await sql`VACUUM ANALYZE asset_faces, face_search, person`.execute(this.db); await sql`REINDEX TABLE asset_faces`.execute(this.db); await sql`REINDEX TABLE person`.execute(this.db); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 52e5ff03ee..d9df2225f4 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -459,6 +459,7 @@ describe(PersonService.name, () => { await sut.handleQueueDetectFaces({ force: false }); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(false); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACE_DETECTION, @@ -475,6 +476,7 @@ describe(PersonService.name, () => { expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -492,6 +494,7 @@ describe(PersonService.name, () => { expect(mocks.person.delete).not.toHaveBeenCalled(); expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); expect(mocks.storage.unlink).not.toHaveBeenCalled(); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -521,6 +524,7 @@ describe(PersonService.name, () => { ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); }); }); @@ -584,6 +588,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); }); it('should queue all assets', async () => { @@ -611,6 +616,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); it('should run nightly if new face has been added since last run', async () => { @@ -629,11 +635,14 @@ describe(PersonService.name, () => { mocks.person.getAllWithoutFaces.mockResolvedValue([]); mocks.person.unassignFaces.mockResolvedValue(); - await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); + await sut.handleQueueRecognizeFaces({ force: false, nightly: true }); expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); - expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); + expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ + personId: null, + sourceType: SourceType.MACHINE_LEARNING, + }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -643,6 +652,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun: expect.any(String), }); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); }); it('should skip nightly if no new face has been added since last run', async () => { @@ -660,6 +670,7 @@ describe(PersonService.name, () => { expect(mocks.person.getAllFaces).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); + expect(mocks.person.vacuum).not.toHaveBeenCalled(); }); it('should delete existing people if forced', async () => { @@ -688,6 +699,7 @@ describe(PersonService.name, () => { ]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath); + expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e6161b8f9c..23ba562ba6 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -259,6 +259,7 @@ export class PersonService extends BaseService { if (force) { await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); + await this.personRepository.vacuum({ reindexVectors: true }); } let jobs: JobItem[] = []; @@ -409,6 +410,7 @@ export class PersonService extends BaseService { if (force) { await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); + await this.personRepository.vacuum({ reindexVectors: false }); } else if (waiting) { this.logger.debug( `Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`, diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 59377576b1..2875c9ada5 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -33,5 +33,6 @@ export const newPersonRepositoryMock = (): Mocked Date: Wed, 14 May 2025 23:23:34 -0400 Subject: [PATCH 02/13] fix(server): do not filter out assets without preview path for person thumbnail generation (#18300) * allow assets without preview path * update sql * Update person.repository.ts Co-authored-by: Jason Rasmussen * update sql, e2e --------- Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/asset.e2e-spec.ts | 2 -- server/src/queries/person.repository.sql | 14 ++++++++++---- server/src/repositories/person.repository.ts | 14 +++++++++----- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 8c203860df..4673db5426 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -202,7 +202,6 @@ describe('/asset', () => { { name: 'Marie Curie', birthDate: null, - thumbnailPath: '', isHidden: false, faces: [ { @@ -219,7 +218,6 @@ describe('/asset', () => { { name: 'Pierre Curie', birthDate: null, - thumbnailPath: '', isHidden: false, faces: [ { diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 2ab0045e32..659abbde03 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -133,18 +133,24 @@ select "asset_faces"."imageHeight" as "oldHeight", "assets"."type", "assets"."originalPath", - "asset_files"."path" as "previewPath", - "exif"."orientation" as "exifOrientation" + "exif"."orientation" as "exifOrientation", + ( + select + "asset_files"."path" + from + "asset_files" + where + "asset_files"."assetId" = "assets"."id" + and "asset_files"."type" = 'preview' + ) as "previewPath" from "person" inner join "asset_faces" on "asset_faces"."id" = "person"."faceAssetId" inner join "assets" on "asset_faces"."assetId" = "assets"."id" left join "exif" on "exif"."assetId" = "assets"."id" - left join "asset_files" on "asset_files"."assetId" = "assets"."id" where "person"."id" = $1 and "asset_faces"."deletedAt" is null - and "asset_files"."type" = $2 -- PersonRepository.reassignFace update "asset_faces" diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 478ff15d53..0b48e57f7a 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFaces, DB, FaceSearch, Person } from 'src/db'; @@ -261,7 +261,6 @@ export class PersonRepository { .innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId') .innerJoin('assets', 'asset_faces.assetId', 'assets.id') .leftJoin('exif', 'exif.assetId', 'assets.id') - .leftJoin('asset_files', 'asset_files.assetId', 'assets.id') .select([ 'person.ownerId', 'asset_faces.boundingBoxX1 as x1', @@ -272,13 +271,18 @@ export class PersonRepository { 'asset_faces.imageHeight as oldHeight', 'assets.type', 'assets.originalPath', - 'asset_files.path as previewPath', 'exif.orientation as exifOrientation', ]) + .select((eb) => + eb + .selectFrom('asset_files') + .select('asset_files.path') + .whereRef('asset_files.assetId', '=', 'assets.id') + .where('asset_files.type', '=', sql.lit(AssetFileType.PREVIEW)) + .as('previewPath'), + ) .where('person.id', '=', id) .where('asset_faces.deletedAt', 'is', null) - .where('asset_files.type', '=', AssetFileType.PREVIEW) - .$narrowType<{ exifImageWidth: NotNull; exifImageHeight: NotNull }>() .executeTakeFirst(); } From 709a7b70aa10805c6eeb0fb82bc6ed0e485a7019 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Wed, 14 May 2025 23:34:22 -0400 Subject: [PATCH 03/13] chore: no sql generation for queries with side effects (#18301) no sql generation for queries with side effects --- server/src/queries/asset.repository.sql | 31 +++++++++++++ server/src/queries/audit.repository.sql | 5 --- server/src/queries/memory.repository.sql | 6 --- server/src/queries/move.repository.sql | 13 ------ .../src/queries/notification.repository.sql | 18 -------- server/src/queries/partner.repository.sql | 44 ------------------- server/src/queries/person.repository.sql | 29 +----------- .../queries/system.metadata.repository.sql | 9 ---- server/src/queries/tag.repository.sql | 25 +++++------ .../queries/version.history.repository.sql | 8 ---- server/src/repositories/asset.repository.ts | 8 +--- server/src/repositories/audit.repository.ts | 1 - server/src/repositories/memory.repository.ts | 1 - server/src/repositories/move.repository.ts | 3 +- .../repositories/notification.repository.ts | 1 - server/src/repositories/partner.repository.ts | 1 - server/src/repositories/person.repository.ts | 5 +-- .../system-metadata.repository.ts | 1 - server/src/repositories/tag.repository.ts | 5 +-- .../version-history.repository.ts | 1 - 20 files changed, 48 insertions(+), 167 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4a3fbf0e39..4564971ac2 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -432,3 +432,34 @@ where and "assets"."updatedAt" > $3 limit $4 + +-- AssetRepository.detectOfflineExternalAssets +update "assets" +set + "isOffline" = $1, + "deletedAt" = $2 +where + "isOffline" = $3 + and "isExternal" = $4 + and "libraryId" = $5::uuid + and ( + not "originalPath" like $6 + or "originalPath" like $7 + ) + +-- AssetRepository.filterNewExternalAssetPaths +select + "path" +from + unnest(array[$1]::text[]) as "path" +where + not exists ( + select + "originalPath" + from + "assets" + where + "assets"."originalPath" = "path" + and "libraryId" = $2::uuid + and "isExternal" = $3 + ) diff --git a/server/src/queries/audit.repository.sql b/server/src/queries/audit.repository.sql index 3c83d2d3e8..b1a10abf48 100644 --- a/server/src/queries/audit.repository.sql +++ b/server/src/queries/audit.repository.sql @@ -14,8 +14,3 @@ order by "audit"."entityId" desc, "audit"."entityType" desc, "audit"."createdAt" desc - --- AuditRepository.removeBefore -delete from "audit" -where - "createdAt" < $1 diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index d44d017045..e9e7340bf6 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -1,11 +1,5 @@ -- NOTE: This file is auto generated by ./sql-generator --- MemoryRepository.cleanup -delete from "memories" -where - "createdAt" < $1 - and "isSaved" = $2 - -- MemoryRepository.search select "memories".*, diff --git a/server/src/queries/move.repository.sql b/server/src/queries/move.repository.sql index a65c7a8b85..50c9ad7dd9 100644 --- a/server/src/queries/move.repository.sql +++ b/server/src/queries/move.repository.sql @@ -16,19 +16,6 @@ where returning * --- MoveRepository.cleanMoveHistory -delete from "move_history" -where - "move_history"."entityId" not in ( - select - "id" - from - "assets" - where - "assets"."id" = "move_history"."entityId" - ) - and "move_history"."pathType" = 'original' - -- MoveRepository.cleanMoveHistorySingle delete from "move_history" where diff --git a/server/src/queries/notification.repository.sql b/server/src/queries/notification.repository.sql index c55e00d226..f7e211d80a 100644 --- a/server/src/queries/notification.repository.sql +++ b/server/src/queries/notification.repository.sql @@ -1,23 +1,5 @@ -- NOTE: This file is auto generated by ./sql-generator --- NotificationRepository.cleanup -delete from "notifications" -where - ( - ( - "deletedAt" is not null - and "deletedAt" < $1 - ) - or ( - "readAt" > $2 - and "createdAt" < $3 - ) - or ( - "readAt" = $4 - and "createdAt" < $5 - ) - ) - -- NotificationRepository.search select "id", diff --git a/server/src/queries/partner.repository.sql b/server/src/queries/partner.repository.sql index e7170f367e..100f1bc638 100644 --- a/server/src/queries/partner.repository.sql +++ b/server/src/queries/partner.repository.sql @@ -100,50 +100,6 @@ where "sharedWithId" = $1 and "sharedById" = $2 --- PartnerRepository.create -insert into - "partners" ("sharedWithId", "sharedById") -values - ($1, $2) -returning - *, - ( - select - to_json(obj) - from - ( - select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" - from - "users" as "sharedBy" - where - "sharedBy"."id" = "partners"."sharedById" - ) as obj - ) as "sharedBy", - ( - select - to_json(obj) - from - ( - select - "id", - "name", - "email", - "avatarColor", - "profileImagePath", - "profileChangedAt" - from - "users" as "sharedWith" - where - "sharedWith"."id" = "partners"."sharedWithId" - ) as obj - ) as "sharedWith" - -- PartnerRepository.update update "partners" set diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 659abbde03..fefc25ee6a 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -7,22 +7,10 @@ set where "asset_faces"."personId" = $2 --- PersonRepository.unassignFaces -update "asset_faces" -set - "personId" = $1 -where - "asset_faces"."sourceType" = $2 - -- PersonRepository.delete delete from "person" where - "person"."id" in $1 - --- PersonRepository.deleteFaces -delete from "asset_faces" -where - "asset_faces"."sourceType" = $1 + "person"."id" in ($1) -- PersonRepository.getAllWithoutFaces select @@ -216,21 +204,6 @@ where "person"."ownerId" = $3 and "asset_faces"."deletedAt" is null --- PersonRepository.refreshFaces -with - "added_embeddings" as ( - insert into - "face_search" ("faceId", "embedding") - values - ($1, $2) - ) -select -from - ( - select - 1 - ) as "dummy" - -- PersonRepository.getFacesByIds select "asset_faces".*, diff --git a/server/src/queries/system.metadata.repository.sql b/server/src/queries/system.metadata.repository.sql index c4fd7b96f8..8bdf1b3ad7 100644 --- a/server/src/queries/system.metadata.repository.sql +++ b/server/src/queries/system.metadata.repository.sql @@ -8,15 +8,6 @@ from where "key" = $1 --- SystemMetadataRepository.set -insert into - "system_metadata" ("key", "value") -values - ($1, $2) -on conflict ("key") do update -set - "value" = $3 - -- SystemMetadataRepository.delete delete from "system_metadata" where diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql index d728d3af88..af757d96b7 100644 --- a/server/src/queries/tag.repository.sql +++ b/server/src/queries/tag.repository.sql @@ -58,7 +58,7 @@ from where "userId" = $1 order by - "value" asc + "value" -- TagRepository.create insert into @@ -94,6 +94,15 @@ where "tagsId" = $1 and "assetsId" in ($2) +-- TagRepository.upsertAssetIds +insert into + "tag_asset" ("assetId", "tagsIds") +values + ($1, $2) +on conflict do nothing +returning + * + -- TagRepository.replaceAssetTags begin delete from "tag_asset" @@ -107,17 +116,3 @@ on conflict do nothing returning * rollback - --- TagRepository.deleteEmptyTags -begin -select - "tags"."id", - count("assets"."id") as "count" -from - "assets" - inner join "tag_asset" on "tag_asset"."assetsId" = "assets"."id" - inner join "tags_closure" on "tags_closure"."id_descendant" = "tag_asset"."tagsId" - inner join "tags" on "tags"."id" = "tags_closure"."id_descendant" -group by - "tags"."id" -commit diff --git a/server/src/queries/version.history.repository.sql b/server/src/queries/version.history.repository.sql index a9805e8c25..2e898cac31 100644 --- a/server/src/queries/version.history.repository.sql +++ b/server/src/queries/version.history.repository.sql @@ -15,11 +15,3 @@ from "version_history" order by "createdAt" desc - --- VersionHistoryRepository.create -insert into - "version_history" ("version") -values - ($1) -returning - * diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 9bd115089f..d49124b04b 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -817,9 +817,7 @@ export class AssetRepository { .execute(); } - @GenerateSql({ - params: [{ libraryId: DummyValue.UUID, importPaths: [DummyValue.STRING], exclusionPatterns: [DummyValue.STRING] }], - }) + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING], [DummyValue.STRING]] }) async detectOfflineExternalAssets( libraryId: string, importPaths: string[], @@ -846,9 +844,7 @@ export class AssetRepository { .executeTakeFirstOrThrow(); } - @GenerateSql({ - params: [{ libraryId: DummyValue.UUID, paths: [DummyValue.STRING] }], - }) + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) async filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise { const result = await this.db .selectFrom(unnest(paths).as('path')) diff --git a/server/src/repositories/audit.repository.ts b/server/src/repositories/audit.repository.ts index 48d7f28d12..1193e26ebe 100644 --- a/server/src/repositories/audit.repository.ts +++ b/server/src/repositories/audit.repository.ts @@ -38,7 +38,6 @@ export class AuditRepository { return records.map(({ entityId }) => entityId); } - @GenerateSql({ params: [DummyValue.DATE] }) async removeBefore(before: Date): Promise { await this.db.deleteFrom('audit').where('createdAt', '<', before).execute(); } diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 44c7c30857..1a1ea2827b 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -12,7 +12,6 @@ import { IBulkAsset } from 'src/types'; export class MemoryRepository implements IBulkAsset { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID] }) cleanup() { return this.db .deleteFrom('memories') diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index 21c52aec65..a21167fffd 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -37,7 +37,6 @@ export class MoveRepository { return this.db.deleteFrom('move_history').where('id', '=', id).returningAll().executeTakeFirstOrThrow(); } - @GenerateSql() async cleanMoveHistory(): Promise { await this.db .deleteFrom('move_history') @@ -52,7 +51,7 @@ export class MoveRepository { .execute(); } - @GenerateSql() + @GenerateSql({ params: [DummyValue.UUID] }) async cleanMoveHistorySingle(assetId: string): Promise { await this.db .deleteFrom('move_history') diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts index 112bb97e60..b35f532094 100644 --- a/server/src/repositories/notification.repository.ts +++ b/server/src/repositories/notification.repository.ts @@ -9,7 +9,6 @@ import { NotificationSearchDto } from 'src/dtos/notification.dto'; export class NotificationRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [DummyValue.UUID] }) cleanup() { return this.db .deleteFrom('notifications') diff --git a/server/src/repositories/partner.repository.ts b/server/src/repositories/partner.repository.ts index ea762d0aaf..31350541ca 100644 --- a/server/src/repositories/partner.repository.ts +++ b/server/src/repositories/partner.repository.ts @@ -47,7 +47,6 @@ export class PartnerRepository { .executeTakeFirst(); } - @GenerateSql({ params: [{ sharedWithId: DummyValue.UUID, sharedById: DummyValue.UUID }] }) create(values: Insertable) { return this.db .insertInto('partners') diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0b48e57f7a..ad18d7ed67 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -98,7 +98,6 @@ export class PersonRepository { return Number(result.numChangedRows ?? 0); } - @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { await this.db .updateTable('asset_faces') @@ -107,7 +106,7 @@ export class PersonRepository { .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ params: [[DummyValue.UUID]] }) async delete(ids: string[]): Promise { if (ids.length === 0) { return; @@ -116,7 +115,6 @@ export class PersonRepository { await this.db.deleteFrom('person').where('person.id', 'in', ids).execute(); } - @GenerateSql({ params: [{ sourceType: SourceType.EXIF }] }) async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.db.deleteFrom('asset_faces').where('asset_faces.sourceType', '=', sourceType).execute(); } @@ -400,7 +398,6 @@ export class PersonRepository { return results.map(({ id }) => id); } - @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] }) async refreshFaces( facesToAdd: (Insertable & { assetId: string })[], faceIdsToRemove: string[], diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index 2038f204f7..fcccde6a5c 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -26,7 +26,6 @@ export class SystemMetadataRepository { return metadata.value as SystemMetadata[T]; } - @GenerateSql({ params: ['metadata_key', { foo: 'bar' }] }) async set(key: T, value: SystemMetadata[T]): Promise { await this.db .insertInto('system_metadata') diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 9a3b33188f..a7cdc9554c 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -68,7 +68,7 @@ export class TagRepository { @GenerateSql({ params: [DummyValue.UUID] }) getAll(userId: string) { - return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value asc').execute(); + return this.db.selectFrom('tags').select(columns.tag).where('userId', '=', userId).orderBy('value').execute(); } @GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] }) @@ -126,7 +126,7 @@ export class TagRepository { await this.db.deleteFrom('tag_asset').where('tagsId', '=', tagId).where('assetsId', 'in', assetIds).execute(); } - @GenerateSql({ params: [{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }] }) + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagsIds: [DummyValue.UUID] }]] }) @Chunked() upsertAssetIds(items: Insertable[]) { if (items.length === 0) { @@ -160,7 +160,6 @@ export class TagRepository { }); } - @GenerateSql() async deleteEmptyTags() { // TODO rewrite as a single statement await this.db.transaction().execute(async (tx) => { diff --git a/server/src/repositories/version-history.repository.ts b/server/src/repositories/version-history.repository.ts index 063ee0da84..b1d2696164 100644 --- a/server/src/repositories/version-history.repository.ts +++ b/server/src/repositories/version-history.repository.ts @@ -18,7 +18,6 @@ export class VersionHistoryRepository { return this.db.selectFrom('version_history').selectAll().orderBy('createdAt', 'desc').executeTakeFirst(); } - @GenerateSql({ params: [{ version: 'v1.123.0' }] }) create(version: Insertable) { return this.db.insertInto('version_history').values(version).returningAll().executeTakeFirstOrThrow(); } From 4935f3e0bbf699ef83f219a9f32d53b4cbe7822e Mon Sep 17 00:00:00 2001 From: Ruslan Date: Thu, 15 May 2025 18:32:31 +0300 Subject: [PATCH 04/13] fix(docs): Update old jellyfin docs links (#18311) Update old jellyfin docs links Updated old links to jellyfin docs --- docs/docs/features/hardware-transcoding.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index 18c7f6b298..d28cd97de0 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -121,6 +121,6 @@ Once this is done, you can continue to step 3 of "Basic Setup". [hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.transcoding.yml [nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html -[jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux -[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations +[jellyfin-lp]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#low-power-encoding +[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel#known-issues-and-limitations-on-linux [libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases From b7b0b9b6d8d7ed1ca03ad6fdccb1b1a7d499af44 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 May 2025 09:35:21 -0600 Subject: [PATCH 05/13] feat: locked/private view (#18268) * feat: locked/private view * feat: locked/private view * pr feedback * fix: redirect loop * pr feedback --- i18n/en.json | 16 +++ mobile/lib/utils/openapi_patching.dart | 1 + mobile/openapi/README.md | 1 + .../openapi/lib/api/authentication_api.dart | 39 ++++++ .../openapi/lib/model/asset_response_dto.dart | 94 +++++++++++++- .../openapi/lib/model/asset_visibility.dart | 3 + .../lib/model/auth_status_response_dto.dart | 10 +- mobile/openapi/lib/model/sync_asset_v1.dart | 3 + open-api/immich-openapi-specs.json | 57 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 20 ++- server/src/controllers/auth.controller.ts | 7 ++ .../src/controllers/search.controller.spec.ts | 2 +- server/src/database.ts | 4 +- server/src/db.d.ts | 1 + server/src/dtos/asset-response.dto.ts | 2 + server/src/dtos/auth.dto.ts | 1 + server/src/enum.ts | 1 + server/src/queries/access.repository.sql | 1 + server/src/queries/album.repository.sql | 5 + server/src/queries/session.repository.sql | 1 + server/src/repositories/access.repository.ts | 3 +- server/src/repositories/album.repository.ts | 6 +- .../1746844028242-AddLockedVisibilityEnum.ts | 9 ++ .../1746987967923-AddPinExpiresAtColumn.ts | 9 ++ server/src/schema/tables/session.table.ts | 3 + server/src/services/album.service.spec.ts | 9 +- .../src/services/asset-media.service.spec.ts | 10 +- server/src/services/asset.service.spec.ts | 1 + server/src/services/asset.service.ts | 6 +- server/src/services/auth.service.spec.ts | 12 +- server/src/services/auth.service.ts | 37 ++++++ server/src/services/metadata.service.spec.ts | 6 +- server/src/services/metadata.service.ts | 2 +- server/src/services/session.service.spec.ts | 1 + .../src/services/shared-link.service.spec.ts | 2 + server/src/utils/access.ts | 14 +-- server/test/fixtures/auth.stub.ts | 6 +- server/test/fixtures/shared-link.stub.ts | 1 + server/test/small.factory.ts | 3 +- .../components/asset-viewer/actions/action.ts | 2 + .../actions/set-visibility-action.svelte | 60 +++++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 49 +++++--- .../components/layouts/AuthPageLayout.svelte | 19 +-- .../photos-page/actions/delete-assets.svelte | 8 +- .../actions/select-all-assets.svelte | 30 +++-- .../actions/set-visibility-action.svelte | 72 +++++++++++ .../components/photos-page/asset-grid.svelte | 13 +- .../empty-placeholder.svelte | 9 +- .../side-bar/user-sidebar.svelte | 10 ++ .../PinCodeChangeForm.svelte | 79 ++++++++++++ .../PinCodeCreateForm.svelte | 72 +++++++++++ .../user-settings-page/PinCodeInput.svelte | 29 ++++- .../user-settings-page/PinCodeSettings.svelte | 118 +++--------------- web/src/lib/constants.ts | 4 + web/src/lib/utils/actions.ts | 1 + .../[[assetId=id]]/+page.svelte | 76 +++++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 28 +++++ .../(user)/photos/[[assetId=id]]/+page.svelte | 7 ++ web/src/routes/auth/pin-prompt/+page.svelte | 84 +++++++++++++ web/src/routes/auth/pin-prompt/+page.ts | 22 ++++ web/src/test-data/factories/asset-factory.ts | 3 +- 61 files changed, 1018 insertions(+), 186 deletions(-) create mode 100644 server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts create mode 100644 server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts create mode 100644 web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte create mode 100644 web/src/lib/components/photos-page/actions/set-visibility-action.svelte create mode 100644 web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte create mode 100644 web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte create mode 100644 web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts create mode 100644 web/src/routes/auth/pin-prompt/+page.svelte create mode 100644 web/src/routes/auth/pin-prompt/+page.ts diff --git a/i18n/en.json b/i18n/en.json index b712faa3c2..05b236b33a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,4 +1,19 @@ { + "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", + "enter_your_pin_code": "Enter your PIN code", + "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder", + "pin_verification": "PIN code verification", + "wrong_pin_code": "Wrong PIN code", + "nothing_here_yet": "Nothing here yet", + "move_to_locked_folder": "Move to Locked Folder", + "remove_from_locked_folder": "Remove from Locked Folder", + "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder", + "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library", + "move": "Move", + "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.", + "locked_folder": "Locked Folder", + "add_to_locked_folder": "Add to Locked Folder", + "move_off_locked_folder": "Move out of Locked Folder", "user_pin_code_settings": "PIN Code", "user_pin_code_settings_description": "Manage your PIN code", "current_pin_code": "Current PIN code", @@ -837,6 +852,7 @@ "error_saving_image": "Error: {error}", "error_title": "Error - Something went wrong", "errors": { + "unable_to_move_to_locked_folder": "Unable to move to locked folder", "cannot_navigate_next_asset": "Cannot navigate to the next asset", "cannot_navigate_previous_asset": "Cannot navigate to previous asset", "cant_apply_changes": "Can't apply changes", diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 708aec603f..d054749b1e 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -29,6 +29,7 @@ dynamic upgradeDto(dynamic value, String targetType) { case 'UserResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); + addDefault(value, 'visibility', AssetVisibility.timeline); } break; case 'UserAdminResponseDto': diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9a3055911d..3aed98adf1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -117,6 +117,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | +*AuthenticationApi* | [**verifyPinCode**](doc//AuthenticationApi.md#verifypincode) | **POST** /auth/pin-code/verify | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index f850bdf403..446a0616ed 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -396,4 +396,43 @@ class AuthenticationApi { } return null; } + + /// Performs an HTTP 'POST /auth/pin-code/verify' operation and returns the [Response]. + /// Parameters: + /// + /// * [PinCodeSetupDto] pinCodeSetupDto (required): + Future verifyPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/pin-code/verify'; + + // ignore: prefer_final_locals + Object? postBody = pinCodeSetupDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [PinCodeSetupDto] pinCodeSetupDto (required): + Future verifyPinCode(PinCodeSetupDto pinCodeSetupDto,) async { + final response = await verifyPinCodeWithHttpInfo(pinCodeSetupDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 5f01f84419..74af8bd1eb 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -43,6 +43,7 @@ class AssetResponseDto { required this.type, this.unassignedFaces = const [], required this.updatedAt, + required this.visibility, }); /// base64 encoded sha1 hash @@ -132,6 +133,8 @@ class AssetResponseDto { DateTime updatedAt; + AssetResponseDtoVisibilityEnum visibility; + @override bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && other.checksum == checksum && @@ -163,7 +166,8 @@ class AssetResponseDto { other.thumbhash == thumbhash && other.type == type && _deepEquality.equals(other.unassignedFaces, unassignedFaces) && - other.updatedAt == updatedAt; + other.updatedAt == updatedAt && + other.visibility == visibility; @override int get hashCode => @@ -197,10 +201,11 @@ class AssetResponseDto { (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + (unassignedFaces.hashCode) + - (updatedAt.hashCode); + (updatedAt.hashCode) + + (visibility.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]'; Map toJson() { final json = {}; @@ -270,6 +275,7 @@ class AssetResponseDto { json[r'type'] = this.type; json[r'unassignedFaces'] = this.unassignedFaces; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'visibility'] = this.visibility; return json; } @@ -312,6 +318,7 @@ class AssetResponseDto { type: AssetTypeEnum.fromJson(json[r'type'])!, unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, + visibility: AssetResponseDtoVisibilityEnum.fromJson(json[r'visibility'])!, ); } return null; @@ -378,6 +385,87 @@ class AssetResponseDto { 'thumbhash', 'type', 'updatedAt', + 'visibility', }; } + +class AssetResponseDtoVisibilityEnum { + /// Instantiate a new enum with the provided [value]. + const AssetResponseDtoVisibilityEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const archive = AssetResponseDtoVisibilityEnum._(r'archive'); + static const timeline = AssetResponseDtoVisibilityEnum._(r'timeline'); + static const hidden = AssetResponseDtoVisibilityEnum._(r'hidden'); + static const locked = AssetResponseDtoVisibilityEnum._(r'locked'); + + /// List of all possible values in this [enum][AssetResponseDtoVisibilityEnum]. + static const values = [ + archive, + timeline, + hidden, + locked, + ]; + + static AssetResponseDtoVisibilityEnum? fromJson(dynamic value) => AssetResponseDtoVisibilityEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetResponseDtoVisibilityEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetResponseDtoVisibilityEnum] to String, +/// and [decode] dynamic data back to [AssetResponseDtoVisibilityEnum]. +class AssetResponseDtoVisibilityEnumTypeTransformer { + factory AssetResponseDtoVisibilityEnumTypeTransformer() => _instance ??= const AssetResponseDtoVisibilityEnumTypeTransformer._(); + + const AssetResponseDtoVisibilityEnumTypeTransformer._(); + + String encode(AssetResponseDtoVisibilityEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetResponseDtoVisibilityEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetResponseDtoVisibilityEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'archive': return AssetResponseDtoVisibilityEnum.archive; + case r'timeline': return AssetResponseDtoVisibilityEnum.timeline; + case r'hidden': return AssetResponseDtoVisibilityEnum.hidden; + case r'locked': return AssetResponseDtoVisibilityEnum.locked; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetResponseDtoVisibilityEnumTypeTransformer] instance. + static AssetResponseDtoVisibilityEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/asset_visibility.dart b/mobile/openapi/lib/model/asset_visibility.dart index 4d0c7ee8d3..498bf17c38 100644 --- a/mobile/openapi/lib/model/asset_visibility.dart +++ b/mobile/openapi/lib/model/asset_visibility.dart @@ -26,12 +26,14 @@ class AssetVisibility { static const archive = AssetVisibility._(r'archive'); static const timeline = AssetVisibility._(r'timeline'); static const hidden = AssetVisibility._(r'hidden'); + static const locked = AssetVisibility._(r'locked'); /// List of all possible values in this [enum][AssetVisibility]. static const values = [ archive, timeline, hidden, + locked, ]; static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value); @@ -73,6 +75,7 @@ class AssetVisibilityTypeTransformer { case r'archive': return AssetVisibility.archive; case r'timeline': return AssetVisibility.timeline; case r'hidden': return AssetVisibility.hidden; + case r'locked': return AssetVisibility.locked; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart index 203923164f..0ccd87114e 100644 --- a/mobile/openapi/lib/model/auth_status_response_dto.dart +++ b/mobile/openapi/lib/model/auth_status_response_dto.dart @@ -13,30 +13,36 @@ part of openapi.api; class AuthStatusResponseDto { /// Returns a new [AuthStatusResponseDto] instance. AuthStatusResponseDto({ + required this.isElevated, required this.password, required this.pinCode, }); + bool isElevated; + bool password; bool pinCode; @override bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto && + other.isElevated == isElevated && other.password == password && other.pinCode == pinCode; @override int get hashCode => // ignore: unnecessary_parenthesis + (isElevated.hashCode) + (password.hashCode) + (pinCode.hashCode); @override - String toString() => 'AuthStatusResponseDto[password=$password, pinCode=$pinCode]'; + String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]'; Map toJson() { final json = {}; + json[r'isElevated'] = this.isElevated; json[r'password'] = this.password; json[r'pinCode'] = this.pinCode; return json; @@ -51,6 +57,7 @@ class AuthStatusResponseDto { final json = value.cast(); return AuthStatusResponseDto( + isElevated: mapValueOfType(json, r'isElevated')!, password: mapValueOfType(json, r'password')!, pinCode: mapValueOfType(json, r'pinCode')!, ); @@ -100,6 +107,7 @@ class AuthStatusResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'isElevated', 'password', 'pinCode', }; diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index e1d3199428..f5d59b6ae9 100644 --- a/mobile/openapi/lib/model/sync_asset_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -293,12 +293,14 @@ class SyncAssetV1VisibilityEnum { static const archive = SyncAssetV1VisibilityEnum._(r'archive'); static const timeline = SyncAssetV1VisibilityEnum._(r'timeline'); static const hidden = SyncAssetV1VisibilityEnum._(r'hidden'); + static const locked = SyncAssetV1VisibilityEnum._(r'locked'); /// List of all possible values in this [enum][SyncAssetV1VisibilityEnum]. static const values = [ archive, timeline, hidden, + locked, ]; static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value); @@ -340,6 +342,7 @@ class SyncAssetV1VisibilityEnumTypeTransformer { case r'archive': return SyncAssetV1VisibilityEnum.archive; case r'timeline': return SyncAssetV1VisibilityEnum.timeline; case r'hidden': return SyncAssetV1VisibilityEnum.hidden; + case r'locked': return SyncAssetV1VisibilityEnum.locked; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3c0dc09953..2dbec35079 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2470,6 +2470,41 @@ ] } }, + "/auth/pin-code/verify": { + "post": { + "operationId": "verifyPinCode", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PinCodeSetupDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Authentication" + ] + } + }, "/auth/status": { "get": { "operationId": "getAuthStatus", @@ -9150,6 +9185,15 @@ "updatedAt": { "format": "date-time", "type": "string" + }, + "visibility": { + "enum": [ + "archive", + "timeline", + "hidden", + "locked" + ], + "type": "string" } }, "required": [ @@ -9171,7 +9215,8 @@ "ownerId", "thumbhash", "type", - "updatedAt" + "updatedAt", + "visibility" ], "type": "object" }, @@ -9226,7 +9271,8 @@ "enum": [ "archive", "timeline", - "hidden" + "hidden", + "locked" ], "type": "string" }, @@ -9241,6 +9287,9 @@ }, "AuthStatusResponseDto": { "properties": { + "isElevated": { + "type": "boolean" + }, "password": { "type": "boolean" }, @@ -9249,6 +9298,7 @@ } }, "required": [ + "isElevated", "password", "pinCode" ], @@ -12664,7 +12714,8 @@ "enum": [ "archive", "timeline", - "hidden" + "hidden", + "locked" ], "type": "string" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 144e7f8ac1..ad7413e6fd 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -329,6 +329,7 @@ export type AssetResponseDto = { "type": AssetTypeEnum; unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; updatedAt: string; + visibility: Visibility; }; export type AlbumResponseDto = { albumName: string; @@ -520,6 +521,7 @@ export type PinCodeSetupDto = { pinCode: string; }; export type AuthStatusResponseDto = { + isElevated: boolean; password: boolean; pinCode: boolean; }; @@ -2076,6 +2078,15 @@ export function changePinCode({ pinCodeChangeDto }: { body: pinCodeChangeDto }))); } +export function verifyPinCode({ pinCodeSetupDto }: { + pinCodeSetupDto: PinCodeSetupDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({ + ...opts, + method: "POST", + body: pinCodeSetupDto + }))); +} export function getAuthStatus(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3574,7 +3585,8 @@ export enum UserStatus { export enum AssetVisibility { Archive = "archive", Timeline = "timeline", - Hidden = "hidden" + Hidden = "hidden", + Locked = "locked" } export enum AlbumUserRole { Editor = "editor", @@ -3591,6 +3603,12 @@ export enum AssetTypeEnum { Audio = "AUDIO", Other = "OTHER" } +export enum Visibility { + Archive = "archive", + Timeline = "timeline", + Hidden = "hidden", + Locked = "locked" +} export enum AssetOrder { Asc = "asc", Desc = "desc" diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 56acaa5c6d..5d3ba8be95 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -101,4 +101,11 @@ export class AuthController { async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { return this.service.resetPinCode(auth, dto); } + + @Post('pin-code/verify') + @HttpCode(HttpStatus.OK) + @Authenticated() + async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { + return this.service.verifyPinCode(auth, dto); + } } diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index 14130fabcb..39d2cb8fcd 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -66,7 +66,7 @@ describe(SearchController.name, () => { .send({ visibility: 'immich' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']), + errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']), ); }); diff --git a/server/src/database.ts b/server/src/database.ts index a13b074448..29c746aa1f 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -200,6 +200,7 @@ export type Album = Selectable & { export type AuthSession = { id: string; + hasElevatedPermission: boolean; }; export type Partner = { @@ -233,6 +234,7 @@ export type Session = { updatedAt: Date; deviceOS: string; deviceType: string; + pinExpiresAt: Date | null; }; export type Exif = Omit, 'updatedAt' | 'updateId'>; @@ -306,7 +308,7 @@ export const columns = { 'users.quotaSizeInBytes', ], authApiKey: ['api_keys.id', 'api_keys.permissions'], - authSession: ['sessions.id', 'sessions.updatedAt'], + authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'], authSharedLink: [ 'shared_links.id', 'shared_links.userId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 1b039f9982..1fd7fdc22b 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -347,6 +347,7 @@ export interface Sessions { updatedAt: Generated; updateId: Generated; userId: string; + pinExpiresAt: Timestamp | null; } export interface SessionSyncCheckpoints { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 480ad0b9b9..2a44a34b58 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -43,6 +43,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { isArchived!: boolean; isTrashed!: boolean; isOffline!: boolean; + visibility!: AssetVisibility; exifInfo?: ExifResponseDto; tags?: TagResponseDto[]; people?: PersonWithFacesResponseDto[]; @@ -184,6 +185,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false, isArchived: entity.visibility === AssetVisibility.ARCHIVE, isTrashed: !!entity.deletedAt, + visibility: entity.visibility, duration: entity.duration ?? '0:00:00.00000', exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index cc05d2d860..8644426ab2 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -138,4 +138,5 @@ export class OAuthAuthorizeResponseDto { export class AuthStatusResponseDto { pinCode!: boolean; password!: boolean; + isElevated!: boolean; } diff --git a/server/src/enum.ts b/server/src/enum.ts index f214593975..fedfaa6b79 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -627,4 +627,5 @@ export enum AssetVisibility { * Video part of the LivePhotos and MotionPhotos */ HIDDEN = 'hidden', + LOCKED = 'locked', } diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index f550c5b0c1..c73f44c19d 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -98,6 +98,7 @@ from where "assets"."id" in ($1) and "assets"."ownerId" = $2 + and "assets"."visibility" != $3 -- AccessRepository.asset.checkPartnerAccess select diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index f4eb6a9929..2b351368ef 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -392,6 +392,11 @@ where order by "albums"."createdAt" desc +-- AlbumRepository.removeAssetsFromAll +delete from "albums_assets_assets" +where + "albums_assets_assets"."assetsId" in ($1) + -- AlbumRepository.getAssetIds select * diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index eea2356897..c2daa2a49c 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -12,6 +12,7 @@ where select "sessions"."id", "sessions"."updatedAt", + "sessions"."pinExpiresAt", ( select to_json(obj) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 5680ce2c64..b25007c4ea 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -168,7 +168,7 @@ class AssetAccess { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, assetIds: Set) { + async checkOwnerAccess(userId: string, assetIds: Set, hasElevatedPermission: boolean | undefined) { if (assetIds.size === 0) { return new Set(); } @@ -178,6 +178,7 @@ class AssetAccess { .select('assets.id') .where('assets.id', 'in', [...assetIds]) .where('assets.ownerId', '=', userId) + .$if(!hasElevatedPermission, (eb) => eb.where('assets.visibility', '!=', AssetVisibility.LOCKED)) .execute() .then((assets) => new Set(assets.map((asset) => asset.id))); } diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 1768135210..c8bdae6d31 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -220,8 +220,10 @@ export class AlbumRepository { await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute(); } - async removeAsset(assetId: string): Promise { - await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute(); + @GenerateSql({ params: [[DummyValue.UUID]] }) + @Chunked() + async removeAssetsFromAll(assetIds: string[]): Promise { + await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', 'in', assetIds).execute(); } @Chunked({ paramIndex: 1 }) diff --git a/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts b/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts new file mode 100644 index 0000000000..9a344be66d --- /dev/null +++ b/server/src/schema/migrations/1746844028242-AddLockedVisibilityEnum.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TYPE "asset_visibility_enum" ADD VALUE IF NOT EXISTS 'locked';`.execute(db); +} + +export async function down(): Promise { + // noop +} diff --git a/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts b/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts new file mode 100644 index 0000000000..b0f7d072d5 --- /dev/null +++ b/server/src/schema/migrations/1746987967923-AddPinExpiresAtColumn.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" ADD "pinExpiresAt" timestamp with time zone;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" DROP COLUMN "pinExpiresAt";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index ad43d0d6e4..090b469b54 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -36,4 +36,7 @@ export class SessionTable { @UpdateIdColumn({ indexName: 'IDX_sessions_update_id' }) updateId!: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + pinExpiresAt!: Date | null; } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 9a3bb605f7..c2b792d091 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -163,7 +163,7 @@ describe(AlbumService.name, () => { ); expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false); expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', { id: albumStub.empty.id, userId: 'user-id', @@ -207,6 +207,7 @@ describe(AlbumService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['asset-1', 'asset-2']), + false, ); }); }); @@ -688,7 +689,11 @@ describe(AlbumService.name, () => { { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1']), + false, + ); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 8490e8aaea..bb8f7115b8 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -481,7 +481,11 @@ describe(AssetMediaService.name, () => { it('should require the asset.download permission', async () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( + authStub.admin.user.id, + new Set(['asset-1']), + undefined, + ); expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); }); @@ -512,7 +516,7 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined); expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); @@ -611,7 +615,7 @@ describe(AssetMediaService.name, () => { it('should require asset.view permissions', async () => { await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined); expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 1e4cfddcf5..333f4530de 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -122,6 +122,7 @@ describe(AssetService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), + undefined, ); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 3ab6fcb8a7..556641fdb0 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -14,7 +14,7 @@ import { mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum'; +import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; @@ -125,6 +125,10 @@ export class AssetService extends BaseService { options.rating !== undefined ) { await this.assetRepository.updateAll(ids, options); + + if (options.visibility === AssetVisibility.LOCKED) { + await this.albumRepository.removeAssetsFromAll(ids); + } } } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 82172d6b95..fb1a5ae042 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -253,6 +253,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -265,7 +266,7 @@ describe(AuthService.name, () => { }), ).resolves.toEqual({ user: sessionWithToken.user, - session: { id: session.id }, + session: { id: session.id, hasElevatedPermission: false }, }); }); }); @@ -376,6 +377,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -388,7 +390,7 @@ describe(AuthService.name, () => { }), ).resolves.toEqual({ user: sessionWithToken.user, - session: { id: session.id }, + session: { id: session.id, hasElevatedPermission: false }, }); }); @@ -398,6 +400,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -417,6 +420,7 @@ describe(AuthService.name, () => { id: session.id, updatedAt: session.updatedAt, user: factory.authUser(), + pinExpiresAt: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -916,13 +920,17 @@ describe(AuthService.name, () => { describe('resetPinCode', () => { it('should reset the PIN code', async () => { + const currentSession = factory.session(); const user = factory.userAdmin(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); + mocks.session.getByUserId.mockResolvedValue([currentSession]); + mocks.session.update.mockResolvedValue(currentSession); await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); + expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null }); }); it('should throw if the PIN code does not match', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 65dd84693b..496c252643 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -126,6 +126,10 @@ export class AuthService extends BaseService { this.resetPinChecks(user, dto); await this.userRepository.update(auth.user.id, { pinCode: null }); + const sessions = await this.sessionRepository.getByUserId(auth.user.id); + for (const session of sessions) { + await this.sessionRepository.update(session.id, { pinExpiresAt: null }); + } } async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { @@ -444,10 +448,25 @@ export class AuthService extends BaseService { await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); } + // Pin check + let hasElevatedPermission = false; + + if (session.pinExpiresAt) { + const pinExpiresAt = DateTime.fromJSDate(session.pinExpiresAt); + hasElevatedPermission = pinExpiresAt > now; + + if (hasElevatedPermission && now.plus({ minutes: 5 }) > pinExpiresAt) { + await this.sessionRepository.update(session.id, { + pinExpiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate(), + }); + } + } + return { user: session.user, session: { id: session.id, + hasElevatedPermission, }, }; } @@ -455,6 +474,23 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid user token'); } + async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise { + const user = await this.userRepository.getForPinCode(auth.user.id); + if (!user) { + throw new UnauthorizedException(); + } + + this.resetPinChecks(user, { pinCode: dto.pinCode }); + + if (!auth.session) { + throw new BadRequestException('Session is missing'); + } + + await this.sessionRepository.update(auth.session.id, { + pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()), + }); + } + private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const key = this.cryptoRepository.newPassword(32); const token = this.cryptoRepository.hashSha256(key); @@ -493,6 +529,7 @@ export class AuthService extends BaseService { return { pinCode: !!user.pinCode, password: !!user.password, + isElevated: !!auth.session?.hasElevatedPermission, }; } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 28cb42a16b..7b2cba1250 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1310,7 +1310,7 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), ); - expect(mocks.album.removeAsset).not.toHaveBeenCalled(); + expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled(); }); it('should handle not finding a match', async () => { @@ -1331,7 +1331,7 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).not.toHaveBeenCalledWith( expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), ); - expect(mocks.album.removeAsset).not.toHaveBeenCalled(); + expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled(); }); it('should link photo and video', async () => { @@ -1356,7 +1356,7 @@ describe(MetadataService.name, () => { id: assetStub.livePhotoMotionAsset.id, visibility: AssetVisibility.HIDDEN, }); - expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); + expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); }); it('should notify clients on live photo link', async () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3497b808da..109f5f6936 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -158,7 +158,7 @@ export class MetadataService extends BaseService { await Promise.all([ this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }), this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }), - this.albumRepository.removeAsset(motionAsset.id), + this.albumRepository.removeAssetsFromAll([motionAsset.id]), ]); await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index c3ab5619be..6e26b26407 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -34,6 +34,7 @@ describe('SessionService', () => { token: '420', userId: '42', updateId: 'uuid-v7', + pinExpiresAt: null, }, ]); mocks.session.delete.mockResolvedValue(); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 66a0a925c7..b3b4c4b1cf 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -156,6 +156,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), + false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, @@ -186,6 +187,7 @@ describe(SharedLinkService.name, () => { expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([assetStub.image.id]), + false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index b04d23f114..e2fe7429f3 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -81,7 +81,7 @@ const checkSharedLinkAccess = async ( case Permission.ASSET_SHARE: { // TODO: fix this to not use sharedLink.userId for access control - return await access.asset.checkOwnerAccess(sharedLink.userId, ids); + return await access.asset.checkOwnerAccess(sharedLink.userId, ids, false); } case Permission.ALBUM_READ: { @@ -119,38 +119,38 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe } case Permission.ASSET_READ: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } case Permission.ASSET_SHARE: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); return setUnion(isOwner, isPartner); } case Permission.ASSET_VIEW: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } case Permission.ASSET_DOWNLOAD: { - const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } case Permission.ASSET_UPDATE: { - return await access.asset.checkOwnerAccess(auth.user.id, ids); + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } case Permission.ASSET_DELETE: { - return await access.asset.checkOwnerAccess(auth.user.id, ids); + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } case Permission.ALBUM_READ: { diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 9ef55398d3..3e5825c0cc 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,4 +1,4 @@ -import { Session } from 'src/database'; +import { AuthSession } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; const authUser = { @@ -26,7 +26,7 @@ export const authStub = { user: authUser.user1, session: { id: 'token-id', - } as Session, + } as AuthSession, }), user2: Object.freeze({ user: { @@ -39,7 +39,7 @@ export const authStub = { }, session: { id: 'token-id', - } as Session, + } as AuthSession, }), adminSharedLink: Object.freeze({ user: authUser.admin, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index fc4b74ba2d..f3096280d9 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -70,6 +70,7 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, + visibility: AssetVisibility.TIMELINE, }; const assetResponseWithoutMetadata = { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 94ae3b74aa..01091854fa 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -58,7 +58,7 @@ const authFactory = ({ } if (session) { - auth.session = { id: session.id }; + auth.session = { id: session.id, hasElevatedPermission: false }; } if (sharedLink) { @@ -127,6 +127,7 @@ const sessionFactory = (session: Partial = {}) => ({ deviceType: 'mobile', token: 'abc123', userId: newUuid(), + pinExpiresAt: newDate(), ...session, }); diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index 40b189080f..d85325b59a 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -13,6 +13,8 @@ type ActionMap = { [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto }; + [AssetAction.SET_VISIBILITY_LOCKED]: { asset: AssetResponseDto }; + [AssetAction.SET_VISIBILITY_TIMELINE]: { asset: AssetResponseDto }; }; export type Action = { diff --git a/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte new file mode 100644 index 0000000000..6a7f6d3078 --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/set-visibility-action.svelte @@ -0,0 +1,60 @@ + + + toggleLockedVisibility()} + text={isLocked ? $t('move_off_locked_folder') : $t('add_to_locked_folder')} + icon={isLocked ? mdiFolderMoveOutline : mdiEyeOffOutline} +/> 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 b0ac455bc8..9436dc13c8 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 @@ -12,6 +12,7 @@ import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; + import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte'; import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; @@ -27,6 +28,7 @@ import { AssetJobName, AssetTypeEnum, + Visibility, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, @@ -91,6 +93,7 @@ const sharedLink = getSharedLink(); let isOwner = $derived($user && asset.ownerId === $user?.id); let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); + let isLocked = $derived(asset.visibility === Visibility.Locked); // $: showEditorButton = // isOwner && @@ -112,7 +115,7 @@ {/if}
- {#if !asset.isTrashed && $user} + {#if !asset.isTrashed && $user && !isLocked} {/if} {#if asset.isOffline} @@ -159,17 +162,20 @@ - {#if showSlideshow} + {#if showSlideshow && !isLocked} {/if} {#if showDownloadButton} {/if} - {#if asset.isTrashed} - - {:else} - - + + {#if !isLocked} + {#if asset.isTrashed} + + {:else} + + + {/if} {/if} {#if isOwner} @@ -183,21 +189,28 @@ {#if person} {/if} - {#if asset.type === AssetTypeEnum.Image} + {#if asset.type === AssetTypeEnum.Image && !isLocked} {/if} - - openFileUploadDialog({ multiple: false, assetId: asset.id })} - text={$t('replace_with_upload')} - /> - {#if !asset.isArchived && !asset.isTrashed} + + {#if !isLocked} + goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)} - text={$t('view_in_timeline')} + icon={mdiUpload} + onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })} + text={$t('replace_with_upload')} /> + {#if !asset.isArchived && !asset.isTrashed} + goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)} + text={$t('view_in_timeline')} + /> + {/if} + {/if} + + {#if !asset.isTrashed} + {/if}
@@ -18,12 +19,14 @@
- - - - {title} - - + {#if withHeader} + + + + {title} + + + {/if} {@render children?.()} diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 75bdc0f8a6..5cdcffb937 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -1,12 +1,12 @@ -{#if $isSelectingAllAssets} - +{#if withText} + {:else} - + {/if} diff --git a/web/src/lib/components/photos-page/actions/set-visibility-action.svelte b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte new file mode 100644 index 0000000000..c11ba114ce --- /dev/null +++ b/web/src/lib/components/photos-page/actions/set-visibility-action.svelte @@ -0,0 +1,72 @@ + + +{#if menuItem} + +{:else} + +{/if} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index dd17874a61..508e3dea6c 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -39,7 +39,13 @@ enableRouting: boolean; assetStore: AssetStore; assetInteraction: AssetInteraction; - removeAction?: AssetAction.UNARCHIVE | AssetAction.ARCHIVE | AssetAction.FAVORITE | AssetAction.UNFAVORITE | null; + removeAction?: + | AssetAction.UNARCHIVE + | AssetAction.ARCHIVE + | AssetAction.FAVORITE + | AssetAction.UNFAVORITE + | AssetAction.SET_VISIBILITY_TIMELINE + | null; withStacked?: boolean; showArchiveIcon?: boolean; isShared?: boolean; @@ -417,7 +423,9 @@ case AssetAction.TRASH: case AssetAction.RESTORE: case AssetAction.DELETE: - case AssetAction.ARCHIVE: { + case AssetAction.ARCHIVE: + case AssetAction.SET_VISIBILITY_LOCKED: + case AssetAction.SET_VISIBILITY_TIMELINE: { // find the next asset to show or close the viewer // eslint-disable-next-line @typescript-eslint/no-unused-expressions (await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); @@ -445,6 +453,7 @@ case AssetAction.UNSTACK: { updateUnstackedAssetInTimeline(assetStore, action.assets); + break; } } }; diff --git a/web/src/lib/components/shared-components/empty-placeholder.svelte b/web/src/lib/components/shared-components/empty-placeholder.svelte index 922d7ad92f..63c30a0c4a 100644 --- a/web/src/lib/components/shared-components/empty-placeholder.svelte +++ b/web/src/lib/components/shared-components/empty-placeholder.svelte @@ -6,9 +6,10 @@ text: string; fullWidth?: boolean; src?: string; + title?: string; } - let { onClick = undefined, text, fullWidth = false, src = empty1Url }: Props = $props(); + let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props(); let width = $derived(fullWidth ? 'w-full' : 'w-1/2'); @@ -24,5 +25,9 @@ class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}" > -

{text}

+ + {#if title} +

{title}

+ {/if} +

{text}

diff --git a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte index 08911b4ef5..74cf69b08e 100644 --- a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte +++ b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte @@ -19,6 +19,8 @@ mdiImageMultiple, mdiImageMultipleOutline, mdiLink, + mdiLock, + mdiLockOutline, mdiMagnify, mdiMap, mdiMapOutline, @@ -40,6 +42,7 @@ let isSharingSelected: boolean = $state(false); let isTrashSelected: boolean = $state(false); let isUtilitiesSelected: boolean = $state(false); + let isLockedFolderSelected: boolean = $state(false); @@ -128,6 +131,13 @@ icon={isArchiveSelected ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline} > + + {#if $featureFlags.trash} + import { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; + import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte'; + import { handleError } from '$lib/utils/handle-error'; + import { changePinCode } from '@immich/sdk'; + import { Button } from '@immich/ui'; + import { t } from 'svelte-i18n'; + import { fade } from 'svelte/transition'; + + let currentPinCode = $state(''); + let newPinCode = $state(''); + let confirmPinCode = $state(''); + let isLoading = $state(false); + let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode); + + interface Props { + onChanged?: () => void; + } + + let { onChanged }: Props = $props(); + + const handleSubmit = async (event: Event) => { + event.preventDefault(); + await handleChangePinCode(); + }; + + const handleChangePinCode = async () => { + isLoading = true; + try { + await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } }); + + resetForm(); + + notificationController.show({ + message: $t('pin_code_changed_successfully'), + type: NotificationType.Info, + }); + + onChanged?.(); + } catch (error) { + handleError(error, $t('unable_to_change_pin_code')); + } finally { + isLoading = false; + } + }; + + const resetForm = () => { + currentPinCode = ''; + newPinCode = ''; + confirmPinCode = ''; + }; + + +
+
+
+
+

{$t('change_pin_code')}

+ + + + + +
+ +
+ + +
+
+
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte b/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte new file mode 100644 index 0000000000..ae07e976b7 --- /dev/null +++ b/web/src/lib/components/user-settings-page/PinCodeCreateForm.svelte @@ -0,0 +1,72 @@ + + +
+
+ {#if showLabel} +

{$t('setup_pin_code')}

+ {/if} + + + +
+ +
+ + +
+
diff --git a/web/src/lib/components/user-settings-page/PinCodeInput.svelte b/web/src/lib/components/user-settings-page/PinCodeInput.svelte index e149f26851..01de7b3563 100644 --- a/web/src/lib/components/user-settings-page/PinCodeInput.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeInput.svelte @@ -1,12 +1,25 @@
-
-
-
- {#if hasPinCode} -

{$t('change_pin_code')}

- - - - - - {:else} -

{$t('setup_pin_code')}

- - - - {/if} -
- -
- - -
-
-
+ {#if hasPinCode} +
+ +
+ {:else} +
+ (hasPinCode = true)} /> +
+ {/if}
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index e4603217e0..167c976eeb 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -10,6 +10,8 @@ export enum AssetAction { ADD_TO_ALBUM = 'add-to-album', UNSTACK = 'unstack', KEEP_THIS_DELETE_OTHERS = 'keep-this-delete-others', + SET_VISIBILITY_LOCKED = 'set-visibility-locked', + SET_VISIBILITY_TIMELINE = 'set-visibility-timeline', } export enum AppRoute { @@ -43,12 +45,14 @@ export enum AppRoute { AUTH_REGISTER = '/auth/register', AUTH_CHANGE_PASSWORD = '/auth/change-password', AUTH_ONBOARDING = '/auth/onboarding', + AUTH_PIN_PROMPT = '/auth/pin-prompt', UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', FOLDERS = '/folders', TAGS = '/tags', + LOCKED = '/locked', } export enum ProjectionType { diff --git a/web/src/lib/utils/actions.ts b/web/src/lib/utils/actions.ts index 472f55cbca..45fc21a7d9 100644 --- a/web/src/lib/utils/actions.ts +++ b/web/src/lib/utils/actions.ts @@ -15,6 +15,7 @@ export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnStack = (result: StackResponse) => void; export type OnUnstack = (assets: AssetResponseDto[]) => void; +export type OnSetVisibility = (ids: string[]) => void; export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { const $t = get(t); diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..49b40866dd --- /dev/null +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,76 @@ + + + +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > + + + + + + + assetStore.removeAssets(assetIds)} /> + + +{/if} + + + + {#snippet empty()} + + {/snippet} + + diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..9b9d86a4b3 --- /dev/null +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,28 @@ +import { AppRoute } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { getAuthStatus } from '@immich/sdk'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const { isElevated, pinCode } = await getAuthStatus(); + + if (!isElevated || !pinCode) { + const continuePath = encodeURIComponent(url.pathname); + const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`; + + redirect(302, redirectPath); + } + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + return { + asset, + meta: { + title: $t('locked_folder'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 73f04380a5..20f4ca0abc 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -12,6 +12,7 @@ import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; + import SetVisibilityAction from '$lib/components/photos-page/actions/set-visibility-action.svelte'; import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; @@ -75,6 +76,11 @@ assetStore.updateAssets([still]); }; + const handleSetVisibility = (assetIds: string[]) => { + assetStore.removeAssets(assetIds); + assetInteraction.clearMultiselect(); + }; + beforeNavigate(() => { isFaceEditMode.value = false; }); @@ -142,6 +148,7 @@ {/if} assetStore.removeAssets(assetIds)} /> +
diff --git a/web/src/routes/auth/pin-prompt/+page.svelte b/web/src/routes/auth/pin-prompt/+page.svelte new file mode 100644 index 0000000000..91480cd35c --- /dev/null +++ b/web/src/routes/auth/pin-prompt/+page.svelte @@ -0,0 +1,84 @@ + + + + {#if hasPinCode} +
+
+ {#if isVerified} +
+ +
+ {:else} +
+ +
+ {/if} + +

{$t('enter_your_pin_code_subtitle')}

+ + onPinFilled(pinCode, true)} + /> +
+
+ {:else} +
+
+
+ +
+

+ {$t('new_pin_code_subtitle')} +

+ (hasPinCode = true)} /> +
+
+ {/if} +
diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts new file mode 100644 index 0000000000..e2b79605d8 --- /dev/null +++ b/web/src/routes/auth/pin-prompt/+page.ts @@ -0,0 +1,22 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAuthStatus } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(); + + const { pinCode } = await getAuthStatus(); + + const continuePath = url.searchParams.get('continue'); + + const $t = await getFormatter(); + + return { + meta: { + title: $t('pin_verification'), + }, + hasPinCode: !!pinCode, + continuePath, + }; +}) satisfies PageLoad; diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 656c4143a7..b727286590 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; +import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; export const assetFactory = Sync.makeFactory({ @@ -24,4 +24,5 @@ export const assetFactory = Sync.makeFactory({ checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), + visibility: Visibility.Timeline, }); From 7146ec99b121b950aec30b24bf876c33152040f7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 11:44:10 -0400 Subject: [PATCH 06/13] chore: use default theme config (#18314) --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- web/src/app.css | 28 ---------------------------- web/src/routes/+layout.svelte | 1 + web/tailwind.config.js | 17 +++++------------ 5 files changed, 11 insertions(+), 45 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 76278058f1..12d65473c9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.20.0", + "@immich/ui": "^0.21.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -1337,9 +1337,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.20.0.tgz", - "integrity": "sha512-euK3N0AhQLB28qFteorRKyDUdet3UpA9MEAd8eBLbTtTFZKvZismBGa4J7pHbQrSkuOlbmJD5LJuM575q8zigQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.21.1.tgz", + "integrity": "sha512-ofDbLMYgM3Bnrv1nCbyPV5Gw9PdWvyhTAJPtojw4C3r2m7CbRW1kJDHt5M79n6xAVgjMOFyre1lOE5cwSSvRQA==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/web/package.json b/web/package.json index 8a9f6472b6..7bf5e36189 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.20.0", + "@immich/ui": "^0.21.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/app.css b/web/src/app.css index 211d34bb6c..1693aacab8 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -21,34 +21,6 @@ --immich-dark-success: 56 142 60; --immich-dark-warning: 245 124 0; } - - :root { - /* light */ - --immich-ui-primary: 66 80 175; - --immich-ui-dark: 58 58 58; - --immich-ui-light: 255 255 255; - --immich-ui-success: 16 188 99; - --immich-ui-danger: 200 60 60; - --immich-ui-warning: 216 143 64; - --immich-ui-info: 8 111 230; - --immich-ui-gray: 246 246 246; - - --immich-ui-default-border: 209 213 219; - } - - .dark { - /* dark */ - --immich-ui-primary: 172 203 250; - --immich-ui-light: 0 0 0; - --immich-ui-dark: 229 231 235; - --immich-ui-danger: 246 125 125; - --immich-ui-success: 72 237 152; - --immich-ui-warning: 254 197 132; - --immich-ui-info: 121 183 254; - --immich-ui-gray: 33 33 33; - - --immich-ui-default-border: 55 65 81; - } } @font-face { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 3a6320a265..fe0c680ec3 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -16,6 +16,7 @@ import { copyToClipboard } from '$lib/utils'; import { isAssetViewerRoute } from '$lib/utils/navigation'; import { setTranslations } from '@immich/ui'; + import '@immich/ui/theme/default.css'; import { onMount, type Snippet } from 'svelte'; import { t } from 'svelte-i18n'; import { run } from 'svelte/legacy'; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 2e13e5997d..ae241a44bb 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,5 +1,8 @@ +import { tailwindConfig } from '@immich/ui/theme/default.js'; import plugin from 'tailwindcss/plugin'; +const { colors, borderColor } = tailwindConfig(); + /** @type {import('tailwindcss').Config} */ export default { content: [ @@ -29,19 +32,9 @@ export default { 'immich-dark-success': 'rgb(var(--immich-dark-success) / )', 'immich-dark-warning': 'rgb(var(--immich-dark-warning) / )', - primary: 'rgb(var(--immich-ui-primary) / )', - light: 'rgb(var(--immich-ui-light) / )', - dark: 'rgb(var(--immich-ui-dark) / )', - success: 'rgb(var(--immich-ui-success) / )', - danger: 'rgb(var(--immich-ui-danger) / )', - warning: 'rgb(var(--immich-ui-warning) / )', - info: 'rgb(var(--immich-ui-info) / )', - subtle: 'rgb(var(--immich-ui-gray) / )', + ...colors, }, - borderColor: ({ theme }) => ({ - ...theme('colors'), - DEFAULT: 'rgb(var(--immich-ui-default-border) / )', - }), + borderColor, fontFamily: { 'immich-mono': ['Overpass Mono', 'monospace'], }, From 585997d46f688b21ae88d6f6a0a3c04082973927 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 15 May 2025 20:28:20 +0200 Subject: [PATCH 07/13] fix: person edit sidebar cursedness (#18318) --- web/src/lib/components/faces-page/assign-face-side-panel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e8a774a364..d45a8d2320 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 @@ -74,7 +74,7 @@
{#if !searchFaces} From 61173290579226e5790e64dfafec7c87701ebb1d Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 15 May 2025 13:34:33 -0500 Subject: [PATCH 08/13] feat: add session creation endpoint (#18295) --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/sessions_api.dart | 47 ++++++ mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/model/permission.dart | 3 + .../openapi/lib/model/session_create_dto.dart | 145 +++++++++++++++++ .../model/session_create_response_dto.dart | 147 ++++++++++++++++++ open-api/immich-openapi-specs.json | 92 +++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 28 ++++ server/src/controllers/session.controller.ts | 10 +- server/src/db.d.ts | 2 + server/src/dtos/session.dto.ts | 24 +++ server/src/enum.ts | 1 + server/src/queries/session.repository.sql | 4 + server/src/repositories/crypto.repository.ts | 2 +- server/src/repositories/session.repository.ts | 17 ++ .../1747329504572-AddNewSessionColumns.ts | 15 ++ server/src/schema/tables/session.table.ts | 6 + server/src/services/api-key.service.spec.ts | 8 +- server/src/services/api-key.service.ts | 7 +- server/src/services/auth.service.ts | 8 +- server/src/services/cli.service.ts | 2 +- server/src/services/session.service.spec.ts | 25 +-- server/src/services/session.service.ts | 33 ++-- .../repositories/crypto.repository.mock.ts | 2 +- server/test/small.factory.ts | 2 + .../user-settings-page/device-card.svelte | 3 + 27 files changed, 592 insertions(+), 50 deletions(-) create mode 100644 mobile/openapi/lib/model/session_create_dto.dart create mode 100644 mobile/openapi/lib/model/session_create_response_dto.dart create mode 100644 server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3aed98adf1..9544b2ddab 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -194,6 +194,7 @@ Class | Method | HTTP request | Description *ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | *ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | +*SessionsApi* | [**createSession**](doc//SessionsApi.md#createsession) | **POST** /sessions | *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | @@ -420,6 +421,8 @@ Class | Method | HTTP request | Description - [ServerThemeDto](doc//ServerThemeDto.md) - [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md) - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) + - [SessionCreateDto](doc//SessionCreateDto.md) + - [SessionCreateResponseDto](doc//SessionCreateResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b2cbe222e8..d0e39e0965 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -218,6 +218,8 @@ part 'model/server_storage_response_dto.dart'; part 'model/server_theme_dto.dart'; part 'model/server_version_history_response_dto.dart'; part 'model/server_version_response_dto.dart'; +part 'model/session_create_dto.dart'; +part 'model/session_create_response_dto.dart'; part 'model/session_response_dto.dart'; part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 203f801b72..9f850fb4c8 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -16,6 +16,53 @@ class SessionsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /sessions' operation and returns the [Response]. + /// Parameters: + /// + /// * [SessionCreateDto] sessionCreateDto (required): + Future createSessionWithHttpInfo(SessionCreateDto sessionCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sessions'; + + // ignore: prefer_final_locals + Object? postBody = sessionCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SessionCreateDto] sessionCreateDto (required): + Future createSession(SessionCreateDto sessionCreateDto,) async { + final response = await createSessionWithHttpInfo(sessionCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SessionCreateResponseDto',) as SessionCreateResponseDto; + + } + return null; + } + /// Performs an HTTP 'DELETE /sessions' operation and returns the [Response]. Future deleteAllSessionsWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index cdd69307ad..f40d09ecc3 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -492,6 +492,10 @@ class ApiClient { return ServerVersionHistoryResponseDto.fromJson(value); case 'ServerVersionResponseDto': return ServerVersionResponseDto.fromJson(value); + case 'SessionCreateDto': + return SessionCreateDto.fromJson(value); + case 'SessionCreateResponseDto': + return SessionCreateResponseDto.fromJson(value); case 'SessionResponseDto': return SessionResponseDto.fromJson(value); case 'SharedLinkCreateDto': diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 1735bc2eb5..73ecbd5868 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -81,6 +81,7 @@ class Permission { static const personPeriodStatistics = Permission._(r'person.statistics'); static const personPeriodMerge = Permission._(r'person.merge'); static const personPeriodReassign = Permission._(r'person.reassign'); + static const sessionPeriodCreate = Permission._(r'session.create'); static const sessionPeriodRead = Permission._(r'session.read'); static const sessionPeriodUpdate = Permission._(r'session.update'); static const sessionPeriodDelete = Permission._(r'session.delete'); @@ -166,6 +167,7 @@ class Permission { personPeriodStatistics, personPeriodMerge, personPeriodReassign, + sessionPeriodCreate, sessionPeriodRead, sessionPeriodUpdate, sessionPeriodDelete, @@ -286,6 +288,7 @@ class PermissionTypeTransformer { case r'person.statistics': return Permission.personPeriodStatistics; case r'person.merge': return Permission.personPeriodMerge; case r'person.reassign': return Permission.personPeriodReassign; + case r'session.create': return Permission.sessionPeriodCreate; case r'session.read': return Permission.sessionPeriodRead; case r'session.update': return Permission.sessionPeriodUpdate; case r'session.delete': return Permission.sessionPeriodDelete; diff --git a/mobile/openapi/lib/model/session_create_dto.dart b/mobile/openapi/lib/model/session_create_dto.dart new file mode 100644 index 0000000000..aacf1150a5 --- /dev/null +++ b/mobile/openapi/lib/model/session_create_dto.dart @@ -0,0 +1,145 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SessionCreateDto { + /// Returns a new [SessionCreateDto] instance. + SessionCreateDto({ + this.deviceOS, + this.deviceType, + this.duration, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? deviceOS; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? deviceType; + + /// session duration, in seconds + /// + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? duration; + + @override + bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto && + other.deviceOS == deviceOS && + other.deviceType == deviceType && + other.duration == duration; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (deviceOS == null ? 0 : deviceOS!.hashCode) + + (deviceType == null ? 0 : deviceType!.hashCode) + + (duration == null ? 0 : duration!.hashCode); + + @override + String toString() => 'SessionCreateDto[deviceOS=$deviceOS, deviceType=$deviceType, duration=$duration]'; + + Map toJson() { + final json = {}; + if (this.deviceOS != null) { + json[r'deviceOS'] = this.deviceOS; + } else { + // json[r'deviceOS'] = null; + } + if (this.deviceType != null) { + json[r'deviceType'] = this.deviceType; + } else { + // json[r'deviceType'] = null; + } + if (this.duration != null) { + json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } + return json; + } + + /// Returns a new [SessionCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionCreateDto? fromJson(dynamic value) { + upgradeDto(value, "SessionCreateDto"); + if (value is Map) { + final json = value.cast(); + + return SessionCreateDto( + deviceOS: mapValueOfType(json, r'deviceOS'), + deviceType: mapValueOfType(json, r'deviceType'), + duration: num.parse('${json[r'duration']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SessionCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SessionCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SessionCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart new file mode 100644 index 0000000000..1ef346c96a --- /dev/null +++ b/mobile/openapi/lib/model/session_create_response_dto.dart @@ -0,0 +1,147 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SessionCreateResponseDto { + /// Returns a new [SessionCreateResponseDto] instance. + SessionCreateResponseDto({ + required this.createdAt, + required this.current, + required this.deviceOS, + required this.deviceType, + required this.id, + required this.token, + required this.updatedAt, + }); + + String createdAt; + + bool current; + + String deviceOS; + + String deviceType; + + String id; + + String token; + + String updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SessionCreateResponseDto && + other.createdAt == createdAt && + other.current == current && + other.deviceOS == deviceOS && + other.deviceType == deviceType && + other.id == id && + other.token == token && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (current.hashCode) + + (deviceOS.hashCode) + + (deviceType.hashCode) + + (id.hashCode) + + (token.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, token=$token, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt; + json[r'current'] = this.current; + json[r'deviceOS'] = this.deviceOS; + json[r'deviceType'] = this.deviceType; + json[r'id'] = this.id; + json[r'token'] = this.token; + json[r'updatedAt'] = this.updatedAt; + return json; + } + + /// Returns a new [SessionCreateResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionCreateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SessionCreateResponseDto"); + if (value is Map) { + final json = value.cast(); + + return SessionCreateResponseDto( + createdAt: mapValueOfType(json, r'createdAt')!, + current: mapValueOfType(json, r'current')!, + deviceOS: mapValueOfType(json, r'deviceOS')!, + deviceType: mapValueOfType(json, r'deviceType')!, + id: mapValueOfType(json, r'id')!, + token: mapValueOfType(json, r'token')!, + updatedAt: mapValueOfType(json, r'updatedAt')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SessionCreateResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SessionCreateResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionCreateResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SessionCreateResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'current', + 'deviceOS', + 'deviceType', + 'id', + 'token', + 'updatedAt', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2dbec35079..d4a1e219c9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5618,6 +5618,46 @@ "tags": [ "Sessions" ] + }, + "post": { + "operationId": "createSession", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] } }, "/sessions/{id}": { @@ -11052,6 +11092,7 @@ "person.statistics", "person.merge", "person.reassign", + "session.create", "session.read", "session.update", "session.delete", @@ -12038,6 +12079,57 @@ ], "type": "object" }, + "SessionCreateDto": { + "properties": { + "deviceOS": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "duration": { + "description": "session duration, in seconds", + "minimum": 1, + "type": "number" + } + }, + "type": "object" + }, + "SessionCreateResponseDto": { + "properties": { + "createdAt": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "deviceOS": { + "type": "string" + }, + "deviceType": { + "type": "string" + }, + "id": { + "type": "string" + }, + "token": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "createdAt", + "current", + "deviceOS", + "deviceType", + "id", + "token", + "updatedAt" + ], + "type": "object" + }, "SessionResponseDto": { "properties": { "createdAt": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ad7413e6fd..de0a723ffa 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1078,6 +1078,21 @@ export type SessionResponseDto = { id: string; updatedAt: string; }; +export type SessionCreateDto = { + deviceOS?: string; + deviceType?: string; + /** session duration, in seconds */ + duration?: number; +}; +export type SessionCreateResponseDto = { + createdAt: string; + current: boolean; + deviceOS: string; + deviceType: string; + id: string; + token: string; + updatedAt: string; +}; export type SharedLinkResponseDto = { album?: AlbumResponseDto; allowDownload: boolean; @@ -2917,6 +2932,18 @@ export function getSessions(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createSession({ sessionCreateDto }: { + sessionCreateDto: SessionCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: SessionCreateResponseDto; + }>("/sessions", oazapfts.json({ + ...opts, + method: "POST", + body: sessionCreateDto + }))); +} export function deleteSession({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -3678,6 +3705,7 @@ export enum Permission { PersonStatistics = "person.statistics", PersonMerge = "person.merge", PersonReassign = "person.reassign", + SessionCreate = "session.create", SessionRead = "session.read", SessionUpdate = "session.update", SessionDelete = "session.delete", diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index d526c2e599..addcfd8fe9 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -1,7 +1,7 @@ -import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SessionResponseDto } from 'src/dtos/session.dto'; +import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { SessionService } from 'src/services/session.service'; @@ -12,6 +12,12 @@ import { UUIDParamDto } from 'src/validation'; export class SessionController { constructor(private service: SessionService) {} + @Post() + @Authenticated({ permission: Permission.SESSION_CREATE }) + createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise { + return this.service.create(auth, dto); + } + @Get() @Authenticated({ permission: Permission.SESSION_READ }) getSessions(@Auth() auth: AuthDto): Promise { diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 1fd7fdc22b..6efbd5f7d7 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -343,6 +343,8 @@ export interface Sessions { deviceOS: Generated; deviceType: Generated; id: Generated; + parentId: string | null; + expiredAt: Date | null; token: string; updatedAt: Generated; updateId: Generated; diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index b54264a5b4..f109e44fa0 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -1,4 +1,24 @@ +import { IsInt, IsPositive, IsString } from 'class-validator'; import { Session } from 'src/database'; +import { Optional } from 'src/validation'; + +export class SessionCreateDto { + /** + * session duration, in seconds + */ + @IsInt() + @IsPositive() + @Optional() + duration?: number; + + @IsString() + @Optional() + deviceType?: string; + + @IsString() + @Optional() + deviceOS?: string; +} export class SessionResponseDto { id!: string; @@ -9,6 +29,10 @@ export class SessionResponseDto { deviceOS!: string; } +export class SessionCreateResponseDto extends SessionResponseDto { + token!: string; +} + export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({ id: entity.id, createdAt: entity.createdAt.toISOString(), diff --git a/server/src/enum.ts b/server/src/enum.ts index fedfaa6b79..c6feb27dcc 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -144,6 +144,7 @@ export enum Permission { PERSON_MERGE = 'person.merge', PERSON_REASSIGN = 'person.reassign', + SESSION_CREATE = 'session.create', SESSION_READ = 'session.read', SESSION_UPDATE = 'session.update', SESSION_DELETE = 'session.delete', diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index c2daa2a49c..b265380a1f 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -36,6 +36,10 @@ from "sessions" where "sessions"."token" = $1 + and ( + "sessions"."expiredAt" is null + or "sessions"."expiredAt" > $2 + ) -- SessionRepository.getByUserId select diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index e471ccb031..c3136db456 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -54,7 +54,7 @@ export class CryptoRepository { }); } - newPassword(bytes: number) { + randomBytesAsText(bytes: number) { return randomBytes(bytes).toString('base64').replaceAll(/\W/g, ''); } } diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 742807dc9c..ce819470c7 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { DateTime } from 'luxon'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DB, Sessions } from 'src/db'; @@ -13,6 +14,19 @@ export type SessionSearchOptions = { updatedBefore: Date }; export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} + cleanup() { + return this.db + .deleteFrom('sessions') + .where((eb) => + eb.or([ + eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()), + eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]), + ]), + ) + .returning(['id', 'deviceOS', 'deviceType']) + .execute(); + } + @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) search(options: SessionSearchOptions) { return this.db @@ -37,6 +51,9 @@ export class SessionRepository { ).as('user'), ]) .where('sessions.token', '=', token) + .where((eb) => + eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]), + ) .executeTakeFirst(); } diff --git a/server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts b/server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts new file mode 100644 index 0000000000..d3cf8de173 --- /dev/null +++ b/server/src/schema/migrations/1747329504572-AddNewSessionColumns.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" ADD "expiredAt" timestamp with time zone;`.execute(db); + await sql`ALTER TABLE "sessions" ADD "parentId" uuid;`.execute(db); + await sql`ALTER TABLE "sessions" ADD CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8" FOREIGN KEY ("parentId") REFERENCES "sessions" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_afbbabbd7daf5b91de4dca84de" ON "sessions" ("parentId")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX "IDX_afbbabbd7daf5b91de4dca84de";`.execute(db); + await sql`ALTER TABLE "sessions" DROP CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8";`.execute(db); + await sql`ALTER TABLE "sessions" DROP COLUMN "expiredAt";`.execute(db); + await sql`ALTER TABLE "sessions" DROP COLUMN "parentId";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 090b469b54..9cc41c5bba 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -25,9 +25,15 @@ export class SessionTable { @UpdateDateColumn() updatedAt!: Date; + @Column({ type: 'timestamp with time zone', nullable: true }) + expiredAt!: Date | null; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) userId!: string; + @ForeignKeyColumn(() => SessionTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true }) + parentId!: string | null; + @Column({ default: '' }) deviceType!: string; diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 680cd38f1e..784c944146 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -18,7 +18,7 @@ describe(ApiKeyService.name, () => { const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] }); const key = 'super-secret'; - mocks.crypto.newPassword.mockReturnValue(key); + mocks.crypto.randomBytesAsText.mockReturnValue(key); mocks.apiKey.create.mockResolvedValue(apiKey); await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions }); @@ -29,7 +29,7 @@ describe(ApiKeyService.name, () => { permissions: apiKey.permissions, userId: apiKey.userId, }); - expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); @@ -38,7 +38,7 @@ describe(ApiKeyService.name, () => { const apiKey = factory.apiKey({ userId: auth.user.id }); const key = 'super-secret'; - mocks.crypto.newPassword.mockReturnValue(key); + mocks.crypto.randomBytesAsText.mockReturnValue(key); mocks.apiKey.create.mockResolvedValue(apiKey); await sut.create(auth, { permissions: [Permission.ALL] }); @@ -49,7 +49,7 @@ describe(ApiKeyService.name, () => { permissions: [Permission.ALL], userId: auth.user.id, }); - expect(mocks.crypto.newPassword).toHaveBeenCalled(); + expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled(); }); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 33861d82cd..49d4183b01 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -9,20 +9,21 @@ import { isGranted } from 'src/utils/access'; @Injectable() export class ApiKeyService extends BaseService { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise { - const secret = this.cryptoRepository.newPassword(32); + const token = this.cryptoRepository.randomBytesAsText(32); + const tokenHashed = this.cryptoRepository.hashSha256(token); if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { throw new BadRequestException('Cannot grant permissions you do not have'); } const entity = await this.apiKeyRepository.create({ - key: this.cryptoRepository.hashSha256(secret), + key: tokenHashed, name: dto.name || 'API Key', userId: auth.user.id, permissions: dto.permissions, }); - return { secret, apiKey: this.map(entity) }; + return { secret: token, apiKey: this.map(entity) }; } async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 496c252643..7bda2eeb98 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -492,17 +492,17 @@ export class AuthService extends BaseService { } private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { - const key = this.cryptoRepository.newPassword(32); - const token = this.cryptoRepository.hashSha256(key); + const token = this.cryptoRepository.randomBytesAsText(32); + const tokenHashed = this.cryptoRepository.hashSha256(token); await this.sessionRepository.create({ - token, + token: tokenHashed, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, userId: user.id, }); - return mapLoginResponse(user, key); + return mapLoginResponse(user, token); } private getClaim(profile: OAuthProfile, options: ClaimOptions): T { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 87e004845d..f6173c69f7 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -17,7 +17,7 @@ export class CliService extends BaseService { } const providedPassword = await ask(mapUserAdmin(admin)); - const password = providedPassword || this.cryptoRepository.newPassword(24); + const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 6e26b26407..7ac338da80 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -17,30 +17,9 @@ describe('SessionService', () => { }); describe('handleCleanup', () => { - it('should return skipped if nothing is to be deleted', async () => { - mocks.session.search.mockResolvedValue([]); - await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED); - expect(mocks.session.search).toHaveBeenCalled(); - }); - - it('should delete sessions', async () => { - mocks.session.search.mockResolvedValue([ - { - createdAt: new Date('1970-01-01T00:00:00.00Z'), - updatedAt: new Date('1970-01-02T00:00:00.00Z'), - deviceOS: '', - deviceType: '', - id: '123', - token: '420', - userId: '42', - updateId: 'uuid-v7', - pinExpiresAt: null, - }, - ]); - mocks.session.delete.mockResolvedValue(); - + it('should clean sessions', async () => { + mocks.session.cleanup.mockResolvedValue([]); await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); - expect(mocks.session.delete).toHaveBeenCalledWith('123'); }); }); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 6b0632cd44..9f49cda07f 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; +import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @@ -10,16 +10,8 @@ import { BaseService } from 'src/services/base.service'; export class SessionService extends BaseService { @OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK }) async handleCleanup(): Promise { - const sessions = await this.sessionRepository.search({ - updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(), - }); - - if (sessions.length === 0) { - return JobStatus.SKIPPED; - } - + const sessions = await this.sessionRepository.cleanup(); for (const session of sessions) { - await this.sessionRepository.delete(session.id); this.logger.verbose(`Deleted expired session token: ${session.deviceOS}/${session.deviceType}`); } @@ -28,6 +20,25 @@ export class SessionService extends BaseService { return JobStatus.SUCCESS; } + async create(auth: AuthDto, dto: SessionCreateDto): Promise { + if (!auth.session) { + throw new BadRequestException('This endpoint can only be used with a session token'); + } + + const token = this.cryptoRepository.randomBytesAsText(32); + const tokenHashed = this.cryptoRepository.hashSha256(token); + const session = await this.sessionRepository.create({ + parentId: auth.session.id, + userId: auth.user.id, + expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, + deviceType: dto.deviceType, + deviceOS: dto.deviceOS, + token: tokenHashed, + }); + + return { ...mapSession(session), token }; + } + async getAll(auth: AuthDto): Promise { const sessions = await this.sessionRepository.getByUserId(auth.user.id); return sessions.map((session) => mapSession(session, auth.session?.id)); diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index 9d32a88987..1167923c0c 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -12,6 +12,6 @@ export const newCryptoRepositoryMock = (): Mocked true), hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), - newPassword: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), + randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), }; }; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 01091854fa..231deeba83 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -126,6 +126,8 @@ const sessionFactory = (session: Partial = {}) => ({ deviceOS: 'android', deviceType: 'mobile', token: 'abc123', + parentId: null, + expiredAt: null, userId: newUuid(), pinExpiresAt: newDate(), ...session, 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 ad0b621921..47636fe4bf 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -7,6 +7,7 @@ mdiAndroid, mdiApple, mdiAppleSafari, + mdiCast, mdiGoogleChrome, mdiHelp, mdiLinux, @@ -46,6 +47,8 @@ {:else if device.deviceOS === 'Chrome OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium' || device.deviceType === 'Mobile Chrome'} + {:else if device.deviceOS === 'Google Cast'} + {:else} {/if} From c046651f234d09bac3fa0369eb4c06f3598638a6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 14:45:23 -0400 Subject: [PATCH 09/13] feat(web): continue after login (#18302) --- web/src/lib/utils/auth.ts | 4 ++-- web/src/routes/(user)/albums/+page.ts | 4 ++-- .../[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/buy/+page.ts | 2 +- web/src/routes/(user)/explore/+page.ts | 4 ++-- .../favorites/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- .../(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/people/+page.ts | 4 ++-- .../[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/photos/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/places/+page.ts | 4 ++-- .../(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- .../share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/shared-links/[[id=id]]/+page.ts | 4 ++-- web/src/routes/(user)/sharing/+page.ts | 4 ++-- .../(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- .../(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/(user)/user-settings/+page.ts | 4 ++-- web/src/routes/(user)/utilities/+page.ts | 4 ++-- .../duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts | 4 ++-- web/src/routes/admin/jobs-status/+page.ts | 4 ++-- web/src/routes/admin/library-management/+page.ts | 4 ++-- web/src/routes/admin/server-status/+page.ts | 4 ++-- web/src/routes/admin/system-settings/+page.ts | 4 ++-- web/src/routes/admin/users/+page.ts | 4 ++-- web/src/routes/admin/users/[id]/+page.ts | 4 ++-- web/src/routes/auth/change-password/+page.ts | 4 ++-- web/src/routes/auth/login/+page.svelte | 3 ++- web/src/routes/auth/login/+page.ts | 3 ++- web/src/routes/auth/onboarding/+page.ts | 4 ++-- 34 files changed, 65 insertions(+), 63 deletions(-) diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 9b78c345e2..c22b706631 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -50,7 +50,7 @@ const hasAuthCookie = (): boolean => { return false; }; -export const authenticate = async (options?: AuthOptions) => { +export const authenticate = async (url: URL, options?: AuthOptions) => { const { public: publicRoute, admin: adminRoute } = options || {}; const user = await loadUser(); @@ -59,7 +59,7 @@ export const authenticate = async (options?: AuthOptions) => { } if (!user) { - redirect(302, AppRoute.AUTH_LOGIN); + redirect(302, `${AppRoute.AUTH_LOGIN}?continue=${encodeURIComponent(url.pathname + url.search)}`); } if (adminRoute && !user.isAdmin) { diff --git a/web/src/routes/(user)/albums/+page.ts b/web/src/routes/(user)/albums/+page.ts index e56d0f06b7..f4527d56d2 100644 --- a/web/src/routes/(user)/albums/+page.ts +++ b/web/src/routes/(user)/albums/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllAlbums } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const sharedAlbums = await getAllAlbums({ shared: true }); const albums = await getAllAlbums({}); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts index 0143390974..f8691b5fd1 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getAlbumInfo } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const [album, asset] = await Promise.all([ getAlbumInfo({ id: params.albumId, withoutAssets: true }), getAssetInfoFromParam(params), diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts index c44ba64d5b..f5d4560505 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/buy/+page.ts b/web/src/routes/(user)/buy/+page.ts index ba55948b1e..d0180b39ff 100644 --- a/web/src/routes/(user)/buy/+page.ts +++ b/web/src/routes/(user)/buy/+page.ts @@ -5,7 +5,7 @@ import { activateProduct, getActivationKey } from '$lib/utils/license-utils'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { - await authenticate(); + await authenticate(url); const $t = await getFormatter(); const licenseKey = url.searchParams.get('licenseKey'); diff --git a/web/src/routes/(user)/explore/+page.ts b/web/src/routes/(user)/explore/+page.ts index 84ec944efe..9005f7dced 100644 --- a/web/src/routes/(user)/explore/+page.ts +++ b/web/src/routes/(user)/explore/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllPeople, getExploreData } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts index be828b69dd..0d9fe7a203 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts index d00ba238ef..7fd0a749c0 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -7,7 +7,7 @@ import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { - await authenticate(); + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts index 490e1430e6..add9882bcd 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts index e323fca182..5c030da72f 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - const user = await authenticate(); +export const load = (async ({ params, url }) => { + const user = await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts index 1395a3e8d3..1977d9a095 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -4,8 +4,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getUser } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const partner = await getUser({ id: params.userId }); const asset = await getAssetInfoFromParam(params); diff --git a/web/src/routes/(user)/people/+page.ts b/web/src/routes/(user)/people/+page.ts index 305ba31da6..35ed6c06c4 100644 --- a/web/src/routes/(user)/people/+page.ts +++ b/web/src/routes/(user)/people/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllPeople } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const people = await getAllPeople({ withHidden: true }); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts index 88e223640f..92371bd34e 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -4,8 +4,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getPerson, getPersonStatistics } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const [person, statistics, asset] = await Promise.all([ getPerson({ id: params.personId }), diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts index 6e9384f853..209b5483a8 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/places/+page.ts b/web/src/routes/(user)/places/+page.ts index a0c421ef3a..9449f416be 100644 --- a/web/src/routes/(user)/places/+page.ts +++ b/web/src/routes/(user)/places/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetsByCity } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const items = await getAssetsByCity(); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts index 23871d8bdf..82dd18acaa 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts index 66fc3552c7..c0edb5e669 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -5,9 +5,9 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getMySharedLink, isHttpError } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { +export const load = (async ({ params, url }) => { const { key } = params; - await authenticate({ public: true }); + await authenticate(url, { public: true }); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts index 920e5bdba4..f61484a910 100644 --- a/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts +++ b/web/src/routes/(user)/shared-links/[[id=id]]/+page.ts @@ -2,8 +2,8 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const $t = await getFormatter(); return { diff --git a/web/src/routes/(user)/sharing/+page.ts b/web/src/routes/(user)/sharing/+page.ts index b1872ca9f2..2bf737dfc7 100644 --- a/web/src/routes/(user)/sharing/+page.ts +++ b/web/src/routes/(user)/sharing/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { PartnerDirection, getAllAlbums, getPartners } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const sharedAlbums = await getAllAlbums({ shared: true }); const partners = await getPartners({ direction: PartnerDirection.SharedWith }); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts index 23846e57c4..6e92eda7d3 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -7,7 +7,7 @@ import { getAllTags } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { - await authenticate(); + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts index 926af322ca..79c41892c7 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/user-settings/+page.ts b/web/src/routes/(user)/user-settings/+page.ts index 15b8d8125c..bf36eeefb5 100644 --- a/web/src/routes/(user)/user-settings/+page.ts +++ b/web/src/routes/(user)/user-settings/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getApiKeys, getSessions } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); const keys = await getApiKeys(); const sessions = await getSessions(); diff --git a/web/src/routes/(user)/utilities/+page.ts b/web/src/routes/(user)/utilities/+page.ts index a0420a575b..af241d0fd7 100644 --- a/web/src/routes/(user)/utilities/+page.ts +++ b/web/src/routes/(user)/utilities/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAssetInfoFromParam } from '$lib/utils/navigation'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts index a7faaed3c3..978f50830e 100644 --- a/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/utilities/duplicates/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -4,8 +4,8 @@ import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getAssetDuplicates } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate(); +export const load = (async ({ params, url }) => { + await authenticate(url); const asset = await getAssetInfoFromParam(params); const duplicates = await getAssetDuplicates(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/jobs-status/+page.ts b/web/src/routes/admin/jobs-status/+page.ts index 8044b61861..0d4ec8b41f 100644 --- a/web/src/routes/admin/jobs-status/+page.ts +++ b/web/src/routes/admin/jobs-status/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getAllJobsStatus } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const jobs = await getAllJobsStatus(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/library-management/+page.ts b/web/src/routes/admin/library-management/+page.ts index 71bc835a6f..735c7fac92 100644 --- a/web/src/routes/admin/library-management/+page.ts +++ b/web/src/routes/admin/library-management/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { searchUsersAdmin } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); await requestServerInfo(); const allUsers = await searchUsersAdmin({ withDeleted: false }); const $t = await getFormatter(); diff --git a/web/src/routes/admin/server-status/+page.ts b/web/src/routes/admin/server-status/+page.ts index 39ce96ae41..7450550737 100644 --- a/web/src/routes/admin/server-status/+page.ts +++ b/web/src/routes/admin/server-status/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getServerStatistics } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const stats = await getServerStatistics(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/system-settings/+page.ts b/web/src/routes/admin/system-settings/+page.ts index 555835e017..294096a4be 100644 --- a/web/src/routes/admin/system-settings/+page.ts +++ b/web/src/routes/admin/system-settings/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { getConfig } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const configs = await getConfig(); const $t = await getFormatter(); diff --git a/web/src/routes/admin/users/+page.ts b/web/src/routes/admin/users/+page.ts index 0a6af40c69..521f8573e1 100644 --- a/web/src/routes/admin/users/+page.ts +++ b/web/src/routes/admin/users/+page.ts @@ -3,8 +3,8 @@ import { getFormatter } from '$lib/utils/i18n'; import { searchUsersAdmin } from '@immich/sdk'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); await requestServerInfo(); const allUsers = await searchUsersAdmin({ withDeleted: true }); const $t = await getFormatter(); diff --git a/web/src/routes/admin/users/[id]/+page.ts b/web/src/routes/admin/users/[id]/+page.ts index 7e2930c46a..c6e918d648 100644 --- a/web/src/routes/admin/users/[id]/+page.ts +++ b/web/src/routes/admin/users/[id]/+page.ts @@ -5,8 +5,8 @@ import { getUserPreferencesAdmin, getUserStatisticsAdmin, searchUsersAdmin } fro import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; -export const load = (async ({ params }) => { - await authenticate({ admin: true }); +export const load = (async ({ params, url }) => { + await authenticate(url, { admin: true }); await requestServerInfo(); const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []); if (!user) { diff --git a/web/src/routes/auth/change-password/+page.ts b/web/src/routes/auth/change-password/+page.ts index 19abb2e832..c4331b73cc 100644 --- a/web/src/routes/auth/change-password/+page.ts +++ b/web/src/routes/auth/change-password/+page.ts @@ -6,8 +6,8 @@ import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate(); +export const load = (async ({ url }) => { + await authenticate(url); if (!get(user).shouldChangePassword) { redirect(302, AppRoute.PHOTOS); } diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index fdad88e1ff..5cce88ae2c 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -26,7 +26,8 @@ let oauthLoading = $state(true); const onSuccess = async (user: LoginResponseDto) => { - await goto(AppRoute.PHOTOS, { invalidateAll: true }); + console.log(data.continueUrl); + await goto(data.continueUrl, { invalidateAll: true }); eventManager.emit('auth.login', user); }; diff --git a/web/src/routes/auth/login/+page.ts b/web/src/routes/auth/login/+page.ts index 847992ab20..54c5da716a 100644 --- a/web/src/routes/auth/login/+page.ts +++ b/web/src/routes/auth/login/+page.ts @@ -6,7 +6,7 @@ import { redirect } from '@sveltejs/kit'; import { get } from 'svelte/store'; import type { PageLoad } from './$types'; -export const load = (async ({ parent }) => { +export const load = (async ({ parent, url }) => { await parent(); const { isInitialized } = get(serverConfig); @@ -20,5 +20,6 @@ export const load = (async ({ parent }) => { meta: { title: $t('login'), }, + continueUrl: url.searchParams.get('continue') || AppRoute.PHOTOS, }; }) satisfies PageLoad; diff --git a/web/src/routes/auth/onboarding/+page.ts b/web/src/routes/auth/onboarding/+page.ts index db16c8e514..86c19c10a8 100644 --- a/web/src/routes/auth/onboarding/+page.ts +++ b/web/src/routes/auth/onboarding/+page.ts @@ -2,8 +2,8 @@ import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import type { PageLoad } from './$types'; -export const load = (async () => { - await authenticate({ admin: true }); +export const load = (async ({ url }) => { + await authenticate(url, { admin: true }); const $t = await getFormatter(); From ecb66fdb2c3aa0858dc92fadbff2946b3574c091 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 17:55:16 -0400 Subject: [PATCH 10/13] fix: check i18n are sorted (#18324) --- .github/workflows/test.yml | 43 +++++++++++ i18n/en.json | 72 +++++++++---------- web/package.json | 3 +- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 2 +- web/src/routes/auth/pin-prompt/+page.ts | 2 +- 5 files changed, 83 insertions(+), 39 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f36b01518e..91f4ffce4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: permissions: contents: read outputs: + should_run_i18n: ${{ steps.found_paths.outputs.i18n == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_web: ${{ steps.found_paths.outputs.web == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_server: ${{ steps.found_paths.outputs.server == 'true' || steps.should_force.outputs.should_force == 'true' }} should_run_cli: ${{ steps.found_paths.outputs.cli == 'true' || steps.should_force.outputs.should_force == 'true' }} @@ -36,6 +37,8 @@ jobs: uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 with: filters: | + i18n: + - 'i18n/**' web: - 'web/**' - 'i18n/**' @@ -262,6 +265,46 @@ jobs: run: npm run test:cov if: ${{ !cancelled() }} + i18n-tests: + name: Test i18n + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_i18n == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: './web/.nvmrc' + + - name: Install dependencies + run: npm --prefix=web ci + + - name: Format + run: npm --prefix=web run format:i18n + + - name: Find file changes + uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 + id: verify-changed-files + with: + files: | + i18n/** + + - name: Verify files have not changed + if: steps.verify-changed-files.outputs.files_changed == 'true' + env: + CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }} + run: | + echo "ERROR: i18n files not up to date!" + echo "Changed files: ${CHANGED_FILES}" + exit 1 + e2e-tests-lint: name: End-to-End Lint needs: pre-job diff --git a/i18n/en.json b/i18n/en.json index 05b236b33a..e4fc825cda 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,32 +1,4 @@ { - "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", - "enter_your_pin_code": "Enter your PIN code", - "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder", - "pin_verification": "PIN code verification", - "wrong_pin_code": "Wrong PIN code", - "nothing_here_yet": "Nothing here yet", - "move_to_locked_folder": "Move to Locked Folder", - "remove_from_locked_folder": "Remove from Locked Folder", - "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder", - "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library", - "move": "Move", - "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.", - "locked_folder": "Locked Folder", - "add_to_locked_folder": "Add to Locked Folder", - "move_off_locked_folder": "Move out of Locked Folder", - "user_pin_code_settings": "PIN Code", - "user_pin_code_settings_description": "Manage your PIN code", - "current_pin_code": "Current PIN code", - "new_pin_code": "New PIN code", - "setup_pin_code": "Setup a PIN code", - "confirm_new_pin_code": "Confirm new PIN code", - "change_pin_code": "Change PIN code", - "unable_to_change_pin_code": "Unable to change PIN code", - "unable_to_setup_pin_code": "Unable to setup PIN code", - "pin_code_changed_successfully": "Successfully changed PIN code", - "pin_code_setup_successfully": "Successfully setup a PIN code", - "pin_code_reset_successfully": "Successfully reset PIN code", - "reset_pin_code": "Reset PIN code", "about": "About", "account": "Account", "account_settings": "Account Settings", @@ -54,6 +26,7 @@ "add_to_album": "Add to album", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "add_to_locked_folder": "Add to Locked Folder", "add_to_shared_album": "Add to shared album", "add_url": "Add URL", "added_to_archive": "Added to archive", @@ -640,6 +613,7 @@ "change_password_form_new_password": "New Password", "change_password_form_password_mismatch": "Passwords do not match", "change_password_form_reenter_new_password": "Re-enter New Password", + "change_pin_code": "Change PIN code", "change_your_password": "Change your password", "changed_visibility_successfully": "Changed visibility successfully", "check_all": "Check All", @@ -680,6 +654,7 @@ "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", "confirm_delete_shared_link": "Are you sure you want to delete this shared link?", "confirm_keep_this_delete_others": "All other assets in the stack will be deleted except for this asset. Are you sure you want to continue?", + "confirm_new_pin_code": "Confirm new PIN code", "confirm_password": "Confirm password", "contain": "Contain", "context": "Context", @@ -722,9 +697,11 @@ "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_user": "Create user", "created": "Created", + "created_at": "Created", "crop": "Crop", "curated_object_page_title": "Things", "current_device": "Current device", + "current_pin_code": "Current PIN code", "current_server_address": "Current server address", "custom_locale": "Custom Locale", "custom_locale_description": "Format dates and numbers based on the language and the region", @@ -837,6 +814,7 @@ "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", "editor_crop_tool_h2_rotation": "Rotation", "email": "Email", + "email_notifications": "Email notifications", "empty_folder": "This folder is empty", "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", @@ -845,6 +823,8 @@ "end_date": "End date", "enqueued": "Enqueued", "enter_wifi_name": "Enter Wi-Fi name", + "enter_your_pin_code": "Enter your PIN code", + "enter_your_pin_code_subtitle": "Enter your PIN code to access the locked folder", "error": "Error", "error_change_sort_album": "Failed to change album sort order", "error_delete_face": "Error deleting face from asset", @@ -852,7 +832,6 @@ "error_saving_image": "Error: {error}", "error_title": "Error - Something went wrong", "errors": { - "unable_to_move_to_locked_folder": "Unable to move to locked folder", "cannot_navigate_next_asset": "Cannot navigate to the next asset", "cannot_navigate_previous_asset": "Cannot navigate to previous asset", "cant_apply_changes": "Can't apply changes", @@ -940,6 +919,7 @@ "unable_to_log_out_all_devices": "Unable to log out all devices", "unable_to_log_out_device": "Unable to log out device", "unable_to_login_with_oauth": "Unable to login with OAuth", + "unable_to_move_to_locked_folder": "Unable to move to locked folder", "unable_to_play_video": "Unable to play video", "unable_to_reassign_assets_existing_person": "Unable to reassign assets to {name, select, null {an existing person} other {{name}}}", "unable_to_reassign_assets_new_person": "Unable to reassign assets to a new person", @@ -1080,6 +1060,7 @@ "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "host": "Host", "hour": "Hour", + "id": "ID", "ignore_icloud_photos": "Ignore iCloud photos", "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image": "Image", @@ -1161,6 +1142,7 @@ "location_picker_latitude_hint": "Enter your latitude here", "location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_hint": "Enter your longitude here", + "locked_folder": "Locked Folder", "log_out": "Log out", "log_out_all_devices": "Log Out All Devices", "logged_out_all_devices": "Logged out all devices", @@ -1229,8 +1211,8 @@ "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", "map_zoom_to_see_photos": "Zoom out to see photos", - "mark_as_read": "Mark as read", "mark_all_as_read": "Mark all as read", + "mark_as_read": "Mark as read", "marked_all_as_read": "Marked all as read", "matches": "Matches", "media_type": "Media type", @@ -1258,6 +1240,10 @@ "month": "Month", "monthly_title_text_date_format": "MMMM y", "more": "More", + "move": "Move", + "move_off_locked_folder": "Move out of Locked Folder", + "move_to_locked_folder": "Move to Locked Folder", + "move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the Locked Folder", "moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive", "moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library", "moved_to_trash": "Moved to trash", @@ -1274,6 +1260,8 @@ "new_api_key": "New API Key", "new_password": "New password", "new_person": "New person", + "new_pin_code": "New PIN code", + "new_pin_code_subtitle": "This is your first time accessing the locked folder. Create a PIN code to securely access this page", "new_user_created": "New user created", "new_version_available": "NEW VERSION AVAILABLE", "newest_first": "Newest first", @@ -1291,23 +1279,24 @@ "no_explore_results_message": "Upload more photos to explore your collection.", "no_favorites_message": "Add favorites to quickly find your best pictures and videos", "no_libraries_message": "Create an external library to view your photos and videos", + "no_locked_photos_message": "Photos and videos in Locked Folder are hidden and won't show up as you browser your library.", "no_name": "No Name", + "no_notifications": "No notifications", "no_people_found": "No matching people found", "no_places": "No places", "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", - "no_notifications": "No notifications", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "not_in_any_album": "Not in any album", "not_selected": "Not selected", "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the", "notes": "Notes", + "nothing_here_yet": "Nothing here yet", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", "notification_toggle_setting_description": "Enable email notifications", - "email_notifications": "Email notifications", "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", @@ -1395,6 +1384,10 @@ "photos_count": "{count, plural, one {{count, number} Photo} other {{count, number} Photos}}", "photos_from_previous_years": "Photos from previous years", "pick_a_location": "Pick a location", + "pin_code_changed_successfully": "Successfully changed PIN code", + "pin_code_reset_successfully": "Successfully reset PIN code", + "pin_code_setup_successfully": "Successfully setup a PIN code", + "pin_verification": "PIN code verification", "place": "Place", "places": "Places", "places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}", @@ -1492,6 +1485,8 @@ "remove_deleted_assets": "Remove Deleted Assets", "remove_from_album": "Remove from album", "remove_from_favorites": "Remove from favorites", + "remove_from_locked_folder": "Remove from Locked Folder", + "remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of Locked Folder? They will be visible in your library", "remove_from_shared_link": "Remove from shared link", "remove_memory": "Remove memory", "remove_photo_from_memory": "Remove photo from this memory", @@ -1515,6 +1510,7 @@ "reset": "Reset", "reset_password": "Reset password", "reset_people_visibility": "Reset people visibility", + "reset_pin_code": "Reset PIN code", "reset_to_default": "Reset to default", "resolve_duplicates": "Resolve duplicates", "resolved_all_duplicates": "Resolved all duplicates", @@ -1655,6 +1651,7 @@ "settings": "Settings", "settings_require_restart": "Please restart Immich to apply this setting", "settings_saved": "Settings saved", + "setup_pin_code": "Setup a PIN code", "share": "Share", "share_add_photos": "Add photos", "share_assets_selected": "{count} selected", @@ -1771,8 +1768,8 @@ "stop_sharing_photos_with_user": "Stop sharing your photos with this user", "storage": "Storage space", "storage_label": "Storage label", - "storage_usage": "{used} of {available} used", "storage_quota": "Storage Quota", + "storage_usage": "{used} of {available} used", "submit": "Submit", "suggestions": "Suggestions", "sunrise_on_the_beach": "Sunrise on the beach", @@ -1840,6 +1837,8 @@ "trash_page_title": "Trash ({count})", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", "type": "Type", + "unable_to_change_pin_code": "Unable to change PIN code", + "unable_to_setup_pin_code": "Unable to setup PIN code", "unarchive": "Unarchive", "unarchived_count": "{count, plural, other {Unarchived #}}", "unfavorite": "Unfavorite", @@ -1863,6 +1862,7 @@ "untracked_files": "Untracked files", "untracked_files_decription": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "up_next": "Up next", + "updated_at": "Updated", "updated_password": "Updated password", "upload": "Upload", "upload_concurrency": "Upload concurrency", @@ -1877,7 +1877,6 @@ "upload_success": "Upload success, refresh the page to see new upload assets.", "upload_to_immich": "Upload to Immich ({count})", "uploading": "Uploading", - "id": "ID", "url": "URL", "usage": "Usage", "use_current_connection": "use current connection", @@ -1886,8 +1885,8 @@ "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", "user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", - "created_at": "Created", - "updated_at": "Updated", + "user_pin_code_settings": "PIN Code", + "user_pin_code_settings_description": "Manage your PIN code", "user_purchase_settings": "Purchase", "user_purchase_settings_description": "Manage your purchase", "user_role_set": "Set {user} as {role}", @@ -1937,6 +1936,7 @@ "welcome": "Welcome", "welcome_to_immich": "Welcome to Immich", "wifi_name": "Wi-Fi Name", + "wrong_pin_code": "Wrong PIN code", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", "yes": "Yes", diff --git a/web/package.json b/web/package.json index 7bf5e36189..94f48a7d97 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,8 @@ "lint:p": "eslint-p . --max-warnings 0 --concurrency=4", "lint:fix": "npm run lint -- --fix", "format": "prettier --check .", - "format:fix": "prettier --write .", + "format:fix": "prettier --write . && npm run format:i18n", + "format:i18n": "npx --yes sort-json ../i18n/*.json", "test": "vitest --run", "test:cov": "vitest --coverage", "test:watch": "vitest dev", diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts index 9b9d86a4b3..445917f0d0 100644 --- a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -7,7 +7,7 @@ import { redirect } from '@sveltejs/kit'; import type { PageLoad } from './$types'; export const load = (async ({ params, url }) => { - await authenticate(); + await authenticate(url); const { isElevated, pinCode } = await getAuthStatus(); if (!isElevated || !pinCode) { diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts index e2b79605d8..b0d248ebe6 100644 --- a/web/src/routes/auth/pin-prompt/+page.ts +++ b/web/src/routes/auth/pin-prompt/+page.ts @@ -4,7 +4,7 @@ import { getAuthStatus } from '@immich/sdk'; import type { PageLoad } from './$types'; export const load = (async ({ url }) => { - await authenticate(); + await authenticate(url); const { pinCode } = await getAuthStatus(); From c1150fe7e3cbf8fa2c2a7e41613dbeffac1cae9c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 18:08:31 -0400 Subject: [PATCH 11/13] feat: lock auth session (#18322) --- i18n/en.json | 1 + mobile/openapi/README.md | 6 +- mobile/openapi/lib/api.dart | 2 + .../openapi/lib/api/authentication_api.dart | 123 ++++++++++------- mobile/openapi/lib/api/sessions_api.dart | 40 ++++++ mobile/openapi/lib/api_client.dart | 4 + .../lib/model/auth_status_response_dto.dart | 40 +++++- mobile/openapi/lib/model/permission.dart | 3 + .../openapi/lib/model/pin_code_reset_dto.dart | 125 ++++++++++++++++++ .../model/session_create_response_dto.dart | 19 ++- .../lib/model/session_response_dto.dart | 19 ++- .../openapi/lib/model/session_unlock_dto.dart | 125 ++++++++++++++++++ open-api/immich-openapi-specs.json | 105 ++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 45 +++++-- server/src/controllers/auth.controller.ts | 17 ++- server/src/controllers/session.controller.ts | 7 + server/src/database.ts | 1 + server/src/db.d.ts | 2 +- server/src/dtos/auth.dto.ts | 4 + server/src/dtos/session.dto.ts | 2 + server/src/enum.ts | 1 + server/src/queries/access.repository.sql | 9 ++ server/src/queries/session.repository.sql | 23 +++- server/src/repositories/access.repository.ts | 21 +++ server/src/repositories/session.repository.ts | 22 ++- .../migrations/1747338664832-SessionRename.ts | 9 ++ server/src/schema/tables/session.table.ts | 2 +- server/src/services/auth.service.spec.ts | 4 +- server/src/services/auth.service.ts | 40 +++--- server/src/services/session.service.ts | 7 +- server/src/utils/access.ts | 7 + .../repositories/access.repository.mock.ts | 4 + server/test/small.factory.ts | 2 +- .../[[assetId=id]]/+page.svelte | 19 ++- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 8 +- web/src/routes/auth/pin-prompt/+page.svelte | 15 +-- web/src/routes/auth/pin-prompt/+page.ts | 5 +- 37 files changed, 765 insertions(+), 123 deletions(-) create mode 100644 mobile/openapi/lib/model/pin_code_reset_dto.dart create mode 100644 mobile/openapi/lib/model/session_unlock_dto.dart create mode 100644 server/src/schema/migrations/1747338664832-SessionRename.ts diff --git a/i18n/en.json b/i18n/en.json index e4fc825cda..578fe9a115 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1142,6 +1142,7 @@ "location_picker_latitude_hint": "Enter your latitude here", "location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_hint": "Enter your longitude here", + "lock": "Lock", "locked_folder": "Locked Folder", "log_out": "Log out", "log_out_all_devices": "Log Out All Devices", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9544b2ddab..620fc97664 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -111,13 +111,14 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | +*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code | *AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | +*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*AuthenticationApi* | [**verifyPinCode**](doc//AuthenticationApi.md#verifypincode) | **POST** /auth/pin-code/verify | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | @@ -198,6 +199,7 @@ Class | Method | HTTP request | Description *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | +*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | *SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | *SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | *SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | @@ -392,6 +394,7 @@ Class | Method | HTTP request | Description - [PersonUpdateDto](doc//PersonUpdateDto.md) - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) - [PinCodeChangeDto](doc//PinCodeChangeDto.md) + - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) - [PurchaseResponse](doc//PurchaseResponse.md) @@ -424,6 +427,7 @@ Class | Method | HTTP request | Description - [SessionCreateDto](doc//SessionCreateDto.md) - [SessionCreateResponseDto](doc//SessionCreateResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) + - [SessionUnlockDto](doc//SessionUnlockDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d0e39e0965..8710298d7d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -189,6 +189,7 @@ part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/person_with_faces_response_dto.dart'; part 'model/pin_code_change_dto.dart'; +part 'model/pin_code_reset_dto.dart'; part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; @@ -221,6 +222,7 @@ part 'model/server_version_response_dto.dart'; part 'model/session_create_dto.dart'; part 'model/session_create_response_dto.dart'; part 'model/session_response_dto.dart'; +part 'model/session_unlock_dto.dart'; part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_response_dto.dart'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 446a0616ed..5482a9fc51 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -143,6 +143,39 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'POST /auth/session/lock' operation and returns the [Response]. + Future lockAuthSessionWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/session/lock'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future lockAuthSession() async { + final response = await lockAuthSessionWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. /// Parameters: /// @@ -234,13 +267,13 @@ class AuthenticationApi { /// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response]. /// Parameters: /// - /// * [PinCodeChangeDto] pinCodeChangeDto (required): - Future resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async { + /// * [PinCodeResetDto] pinCodeResetDto (required): + Future resetPinCodeWithHttpInfo(PinCodeResetDto pinCodeResetDto,) async { // ignore: prefer_const_declarations final apiPath = r'/auth/pin-code'; // ignore: prefer_final_locals - Object? postBody = pinCodeChangeDto; + Object? postBody = pinCodeResetDto; final queryParams = []; final headerParams = {}; @@ -262,9 +295,9 @@ class AuthenticationApi { /// Parameters: /// - /// * [PinCodeChangeDto] pinCodeChangeDto (required): - Future resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async { - final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,); + /// * [PinCodeResetDto] pinCodeResetDto (required): + Future resetPinCode(PinCodeResetDto pinCodeResetDto,) async { + final response = await resetPinCodeWithHttpInfo(pinCodeResetDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -356,6 +389,45 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'POST /auth/session/unlock' operation and returns the [Response]. + /// Parameters: + /// + /// * [SessionUnlockDto] sessionUnlockDto (required): + Future unlockAuthSessionWithHttpInfo(SessionUnlockDto sessionUnlockDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/session/unlock'; + + // ignore: prefer_final_locals + Object? postBody = sessionUnlockDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SessionUnlockDto] sessionUnlockDto (required): + Future unlockAuthSession(SessionUnlockDto sessionUnlockDto,) async { + final response = await unlockAuthSessionWithHttpInfo(sessionUnlockDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response]. Future validateAccessTokenWithHttpInfo() async { // ignore: prefer_const_declarations @@ -396,43 +468,4 @@ class AuthenticationApi { } return null; } - - /// Performs an HTTP 'POST /auth/pin-code/verify' operation and returns the [Response]. - /// Parameters: - /// - /// * [PinCodeSetupDto] pinCodeSetupDto (required): - Future verifyPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async { - // ignore: prefer_const_declarations - final apiPath = r'/auth/pin-code/verify'; - - // ignore: prefer_final_locals - Object? postBody = pinCodeSetupDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - apiPath, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [PinCodeSetupDto] pinCodeSetupDto (required): - Future verifyPinCode(PinCodeSetupDto pinCodeSetupDto,) async { - final response = await verifyPinCodeWithHttpInfo(pinCodeSetupDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } } diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 9f850fb4c8..3228d31e91 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -179,4 +179,44 @@ class SessionsApi { } return null; } + + /// Performs an HTTP 'POST /sessions/{id}/lock' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future lockSessionWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sessions/{id}/lock' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future lockSession(String id,) async { + final response = await lockSessionWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f40d09ecc3..a3b1c41ca6 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -434,6 +434,8 @@ class ApiClient { return PersonWithFacesResponseDto.fromJson(value); case 'PinCodeChangeDto': return PinCodeChangeDto.fromJson(value); + case 'PinCodeResetDto': + return PinCodeResetDto.fromJson(value); case 'PinCodeSetupDto': return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': @@ -498,6 +500,8 @@ class ApiClient { return SessionCreateResponseDto.fromJson(value); case 'SessionResponseDto': return SessionResponseDto.fromJson(value); + case 'SessionUnlockDto': + return SessionUnlockDto.fromJson(value); case 'SharedLinkCreateDto': return SharedLinkCreateDto.fromJson(value); case 'SharedLinkEditDto': diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart index 0ccd87114e..4e823506ee 100644 --- a/mobile/openapi/lib/model/auth_status_response_dto.dart +++ b/mobile/openapi/lib/model/auth_status_response_dto.dart @@ -13,38 +13,70 @@ part of openapi.api; class AuthStatusResponseDto { /// Returns a new [AuthStatusResponseDto] instance. AuthStatusResponseDto({ + this.expiresAt, required this.isElevated, required this.password, required this.pinCode, + this.pinExpiresAt, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? expiresAt; + bool isElevated; bool password; bool pinCode; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? pinExpiresAt; + @override bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto && + other.expiresAt == expiresAt && other.isElevated == isElevated && other.password == password && - other.pinCode == pinCode; + other.pinCode == pinCode && + other.pinExpiresAt == pinExpiresAt; @override int get hashCode => // ignore: unnecessary_parenthesis + (expiresAt == null ? 0 : expiresAt!.hashCode) + (isElevated.hashCode) + (password.hashCode) + - (pinCode.hashCode); + (pinCode.hashCode) + + (pinExpiresAt == null ? 0 : pinExpiresAt!.hashCode); @override - String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]'; + String toString() => 'AuthStatusResponseDto[expiresAt=$expiresAt, isElevated=$isElevated, password=$password, pinCode=$pinCode, pinExpiresAt=$pinExpiresAt]'; Map toJson() { final json = {}; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'isElevated'] = this.isElevated; json[r'password'] = this.password; json[r'pinCode'] = this.pinCode; + if (this.pinExpiresAt != null) { + json[r'pinExpiresAt'] = this.pinExpiresAt; + } else { + // json[r'pinExpiresAt'] = null; + } return json; } @@ -57,9 +89,11 @@ class AuthStatusResponseDto { final json = value.cast(); return AuthStatusResponseDto( + expiresAt: mapValueOfType(json, r'expiresAt'), isElevated: mapValueOfType(json, r'isElevated')!, password: mapValueOfType(json, r'password')!, pinCode: mapValueOfType(json, r'pinCode')!, + pinExpiresAt: mapValueOfType(json, r'pinExpiresAt'), ); } return null; diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 73ecbd5868..a85b5002bf 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -85,6 +85,7 @@ class Permission { static const sessionPeriodRead = Permission._(r'session.read'); static const sessionPeriodUpdate = Permission._(r'session.update'); static const sessionPeriodDelete = Permission._(r'session.delete'); + static const sessionPeriodLock = Permission._(r'session.lock'); static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create'); static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); @@ -171,6 +172,7 @@ class Permission { sessionPeriodRead, sessionPeriodUpdate, sessionPeriodDelete, + sessionPeriodLock, sharedLinkPeriodCreate, sharedLinkPeriodRead, sharedLinkPeriodUpdate, @@ -292,6 +294,7 @@ class PermissionTypeTransformer { case r'session.read': return Permission.sessionPeriodRead; case r'session.update': return Permission.sessionPeriodUpdate; case r'session.delete': return Permission.sessionPeriodDelete; + case r'session.lock': return Permission.sessionPeriodLock; case r'sharedLink.create': return Permission.sharedLinkPeriodCreate; case r'sharedLink.read': return Permission.sharedLinkPeriodRead; case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; diff --git a/mobile/openapi/lib/model/pin_code_reset_dto.dart b/mobile/openapi/lib/model/pin_code_reset_dto.dart new file mode 100644 index 0000000000..3585348675 --- /dev/null +++ b/mobile/openapi/lib/model/pin_code_reset_dto.dart @@ -0,0 +1,125 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PinCodeResetDto { + /// Returns a new [PinCodeResetDto] instance. + PinCodeResetDto({ + this.password, + this.pinCode, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? password; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? pinCode; + + @override + bool operator ==(Object other) => identical(this, other) || other is PinCodeResetDto && + other.password == password && + other.pinCode == pinCode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (password == null ? 0 : password!.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode); + + @override + String toString() => 'PinCodeResetDto[password=$password, pinCode=$pinCode]'; + + Map toJson() { + final json = {}; + if (this.password != null) { + json[r'password'] = this.password; + } else { + // json[r'password'] = null; + } + if (this.pinCode != null) { + json[r'pinCode'] = this.pinCode; + } else { + // json[r'pinCode'] = null; + } + return json; + } + + /// Returns a new [PinCodeResetDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PinCodeResetDto? fromJson(dynamic value) { + upgradeDto(value, "PinCodeResetDto"); + if (value is Map) { + final json = value.cast(); + + return PinCodeResetDto( + password: mapValueOfType(json, r'password'), + pinCode: mapValueOfType(json, r'pinCode'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PinCodeResetDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PinCodeResetDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PinCodeResetDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PinCodeResetDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index 1ef346c96a..ab1c4ca2d8 100644 --- a/mobile/openapi/lib/model/session_create_response_dto.dart +++ b/mobile/openapi/lib/model/session_create_response_dto.dart @@ -17,6 +17,7 @@ class SessionCreateResponseDto { required this.current, required this.deviceOS, required this.deviceType, + this.expiresAt, required this.id, required this.token, required this.updatedAt, @@ -30,6 +31,14 @@ class SessionCreateResponseDto { String deviceType; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? expiresAt; + String id; String token; @@ -42,6 +51,7 @@ class SessionCreateResponseDto { other.current == current && other.deviceOS == deviceOS && other.deviceType == deviceType && + other.expiresAt == expiresAt && other.id == id && other.token == token && other.updatedAt == updatedAt; @@ -53,12 +63,13 @@ class SessionCreateResponseDto { (current.hashCode) + (deviceOS.hashCode) + (deviceType.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + (token.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, token=$token, updatedAt=$updatedAt]'; + String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -66,6 +77,11 @@ class SessionCreateResponseDto { json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; json[r'deviceType'] = this.deviceType; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'id'] = this.id; json[r'token'] = this.token; json[r'updatedAt'] = this.updatedAt; @@ -85,6 +101,7 @@ class SessionCreateResponseDto { current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, deviceType: mapValueOfType(json, r'deviceType')!, + expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, token: mapValueOfType(json, r'token')!, updatedAt: mapValueOfType(json, r'updatedAt')!, diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 92e2dc6067..cf9eb08a78 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -17,6 +17,7 @@ class SessionResponseDto { required this.current, required this.deviceOS, required this.deviceType, + this.expiresAt, required this.id, required this.updatedAt, }); @@ -29,6 +30,14 @@ class SessionResponseDto { String deviceType; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? expiresAt; + String id; String updatedAt; @@ -39,6 +48,7 @@ class SessionResponseDto { other.current == current && other.deviceOS == deviceOS && other.deviceType == deviceType && + other.expiresAt == expiresAt && other.id == id && other.updatedAt == updatedAt; @@ -49,11 +59,12 @@ class SessionResponseDto { (current.hashCode) + (deviceOS.hashCode) + (deviceType.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, updatedAt=$updatedAt]'; + String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -61,6 +72,11 @@ class SessionResponseDto { json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; json[r'deviceType'] = this.deviceType; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'id'] = this.id; json[r'updatedAt'] = this.updatedAt; return json; @@ -79,6 +95,7 @@ class SessionResponseDto { current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, deviceType: mapValueOfType(json, r'deviceType')!, + expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, updatedAt: mapValueOfType(json, r'updatedAt')!, ); diff --git a/mobile/openapi/lib/model/session_unlock_dto.dart b/mobile/openapi/lib/model/session_unlock_dto.dart new file mode 100644 index 0000000000..4cfeb14385 --- /dev/null +++ b/mobile/openapi/lib/model/session_unlock_dto.dart @@ -0,0 +1,125 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SessionUnlockDto { + /// Returns a new [SessionUnlockDto] instance. + SessionUnlockDto({ + this.password, + this.pinCode, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? password; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? pinCode; + + @override + bool operator ==(Object other) => identical(this, other) || other is SessionUnlockDto && + other.password == password && + other.pinCode == pinCode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (password == null ? 0 : password!.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode); + + @override + String toString() => 'SessionUnlockDto[password=$password, pinCode=$pinCode]'; + + Map toJson() { + final json = {}; + if (this.password != null) { + json[r'password'] = this.password; + } else { + // json[r'password'] = null; + } + if (this.pinCode != null) { + json[r'pinCode'] = this.pinCode; + } else { + // json[r'pinCode'] = null; + } + return json; + } + + /// Returns a new [SessionUnlockDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionUnlockDto? fromJson(dynamic value) { + upgradeDto(value, "SessionUnlockDto"); + if (value is Map) { + final json = value.cast(); + + return SessionUnlockDto( + password: mapValueOfType(json, r'password'), + pinCode: mapValueOfType(json, r'pinCode'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SessionUnlockDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SessionUnlockDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionUnlockDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SessionUnlockDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d4a1e219c9..89bdfef45e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2377,7 +2377,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PinCodeChangeDto" + "$ref": "#/components/schemas/PinCodeResetDto" } } }, @@ -2470,15 +2470,40 @@ ] } }, - "/auth/pin-code/verify": { + "/auth/session/lock": { "post": { - "operationId": "verifyPinCode", + "operationId": "lockAuthSession", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Authentication" + ] + } + }, + "/auth/session/unlock": { + "post": { + "operationId": "unlockAuthSession", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PinCodeSetupDto" + "$ref": "#/components/schemas/SessionUnlockDto" } } }, @@ -5695,6 +5720,41 @@ ] } }, + "/sessions/{id}/lock": { + "post": { + "operationId": "lockSession", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + } + }, "/shared-links": { "get": { "operationId": "getAllSharedLinks", @@ -9327,6 +9387,9 @@ }, "AuthStatusResponseDto": { "properties": { + "expiresAt": { + "type": "string" + }, "isElevated": { "type": "boolean" }, @@ -9335,6 +9398,9 @@ }, "pinCode": { "type": "boolean" + }, + "pinExpiresAt": { + "type": "string" } }, "required": [ @@ -11096,6 +11162,7 @@ "session.read", "session.update", "session.delete", + "session.lock", "sharedLink.create", "sharedLink.read", "sharedLink.update", @@ -11297,6 +11364,18 @@ ], "type": "object" }, + "PinCodeResetDto": { + "properties": { + "password": { + "type": "string" + }, + "pinCode": { + "example": "123456", + "type": "string" + } + }, + "type": "object" + }, "PinCodeSetupDto": { "properties": { "pinCode": { @@ -12109,6 +12188,9 @@ "deviceType": { "type": "string" }, + "expiresAt": { + "type": "string" + }, "id": { "type": "string" }, @@ -12144,6 +12226,9 @@ "deviceType": { "type": "string" }, + "expiresAt": { + "type": "string" + }, "id": { "type": "string" }, @@ -12161,6 +12246,18 @@ ], "type": "object" }, + "SessionUnlockDto": { + "properties": { + "password": { + "type": "string" + }, + "pinCode": { + "example": "123456", + "type": "string" + } + }, + "type": "object" + }, "SharedLinkCreateDto": { "properties": { "albumId": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index de0a723ffa..1d3a04da44 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -512,18 +512,28 @@ export type LogoutResponseDto = { redirectUri: string; successful: boolean; }; -export type PinCodeChangeDto = { - newPinCode: string; +export type PinCodeResetDto = { password?: string; pinCode?: string; }; export type PinCodeSetupDto = { pinCode: string; }; +export type PinCodeChangeDto = { + newPinCode: string; + password?: string; + pinCode?: string; +}; +export type SessionUnlockDto = { + password?: string; + pinCode?: string; +}; export type AuthStatusResponseDto = { + expiresAt?: string; isElevated: boolean; password: boolean; pinCode: boolean; + pinExpiresAt?: string; }; export type ValidateAccessTokenResponseDto = { authStatus: boolean; @@ -1075,6 +1085,7 @@ export type SessionResponseDto = { current: boolean; deviceOS: string; deviceType: string; + expiresAt?: string; id: string; updatedAt: string; }; @@ -1089,6 +1100,7 @@ export type SessionCreateResponseDto = { current: boolean; deviceOS: string; deviceType: string; + expiresAt?: string; id: string; token: string; updatedAt: string; @@ -2066,13 +2078,13 @@ export function logout(opts?: Oazapfts.RequestOpts) { method: "POST" })); } -export function resetPinCode({ pinCodeChangeDto }: { - pinCodeChangeDto: PinCodeChangeDto; +export function resetPinCode({ pinCodeResetDto }: { + pinCodeResetDto: PinCodeResetDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({ ...opts, method: "DELETE", - body: pinCodeChangeDto + body: pinCodeResetDto }))); } export function setupPinCode({ pinCodeSetupDto }: { @@ -2093,13 +2105,19 @@ export function changePinCode({ pinCodeChangeDto }: { body: pinCodeChangeDto }))); } -export function verifyPinCode({ pinCodeSetupDto }: { - pinCodeSetupDto: PinCodeSetupDto; +export function lockAuthSession(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/auth/session/lock", { + ...opts, + method: "POST" + })); +} +export function unlockAuthSession({ sessionUnlockDto }: { + sessionUnlockDto: SessionUnlockDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/auth/session/unlock", oazapfts.json({ ...opts, method: "POST", - body: pinCodeSetupDto + body: sessionUnlockDto }))); } export function getAuthStatus(opts?: Oazapfts.RequestOpts) { @@ -2952,6 +2970,14 @@ export function deleteSession({ id }: { method: "DELETE" })); } +export function lockSession({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}/lock`, { + ...opts, + method: "POST" + })); +} export function getAllSharedLinks({ albumId }: { albumId?: string; }, opts?: Oazapfts.RequestOpts) { @@ -3709,6 +3735,7 @@ export enum Permission { SessionRead = "session.read", SessionUpdate = "session.update", SessionDelete = "session.delete", + SessionLock = "session.lock", SharedLinkCreate = "sharedLink.create", SharedLinkRead = "sharedLink.read", SharedLinkUpdate = "sharedLink.update", diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 5d3ba8be95..78c611d761 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -9,7 +9,9 @@ import { LoginResponseDto, LogoutResponseDto, PinCodeChangeDto, + PinCodeResetDto, PinCodeSetupDto, + SessionUnlockDto, SignUpDto, ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; @@ -98,14 +100,21 @@ export class AuthController { @Delete('pin-code') @Authenticated() - async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { + async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise { return this.service.resetPinCode(auth, dto); } - @Post('pin-code/verify') + @Post('session/unlock') @HttpCode(HttpStatus.OK) @Authenticated() - async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { - return this.service.verifyPinCode(auth, dto); + async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise { + return this.service.unlockSession(auth, dto); + } + + @Post('session/lock') + @HttpCode(HttpStatus.OK) + @Authenticated() + async lockAuthSession(@Auth() auth: AuthDto): Promise { + return this.service.lockSession(auth); } } diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index addcfd8fe9..3838d5af80 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -37,4 +37,11 @@ export class SessionController { deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } + + @Post(':id/lock') + @Authenticated({ permission: Permission.SESSION_LOCK }) + @HttpCode(HttpStatus.NO_CONTENT) + lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.lock(auth, id); + } } diff --git a/server/src/database.ts b/server/src/database.ts index 29c746aa1f..cfccd70b75 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -232,6 +232,7 @@ export type Session = { id: string; createdAt: Date; updatedAt: Date; + expiresAt: Date | null; deviceOS: string; deviceType: string; pinExpiresAt: Date | null; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 6efbd5f7d7..943c9ddfa0 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -344,7 +344,7 @@ export interface Sessions { deviceType: Generated; id: Generated; parentId: string | null; - expiredAt: Date | null; + expiresAt: Date | null; token: string; updatedAt: Generated; updateId: Generated; diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 8644426ab2..2f3ae5c14b 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -93,6 +93,8 @@ export class PinCodeResetDto { password?: string; } +export class SessionUnlockDto extends PinCodeResetDto {} + export class PinCodeChangeDto extends PinCodeResetDto { @PinCode() newPinCode!: string; @@ -139,4 +141,6 @@ export class AuthStatusResponseDto { pinCode!: boolean; password!: boolean; isElevated!: boolean; + expiresAt?: string; + pinExpiresAt?: string; } diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index f109e44fa0..f15166fbf5 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -24,6 +24,7 @@ export class SessionResponseDto { id!: string; createdAt!: string; updatedAt!: string; + expiresAt?: string; current!: boolean; deviceType!: string; deviceOS!: string; @@ -37,6 +38,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse id: entity.id, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), + expiresAt: entity.expiresAt?.toISOString(), current: currentId === entity.id, deviceOS: entity.deviceOS, deviceType: entity.deviceType, diff --git a/server/src/enum.ts b/server/src/enum.ts index c6feb27dcc..a4d2d21274 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -148,6 +148,7 @@ export enum Permission { SESSION_READ = 'session.read', SESSION_UPDATE = 'session.update', SESSION_DELETE = 'session.delete', + SESSION_LOCK = 'session.lock', SHARED_LINK_CREATE = 'sharedLink.create', SHARED_LINK_READ = 'sharedLink.read', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index c73f44c19d..402bbdcfaf 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -199,6 +199,15 @@ where "partners"."sharedById" in ($1) and "partners"."sharedWithId" = $2 +-- AccessRepository.session.checkOwnerAccess +select + "sessions"."id" +from + "sessions" +where + "sessions"."id" in ($1) + and "sessions"."userId" = $2 + -- AccessRepository.stack.checkOwnerAccess select "stacks"."id" diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index b265380a1f..6a9b69c2e3 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -1,12 +1,14 @@ -- NOTE: This file is auto generated by ./sql-generator --- SessionRepository.search +-- SessionRepository.get select - * + "id", + "expiresAt", + "pinExpiresAt" from "sessions" where - "sessions"."updatedAt" <= $1 + "id" = $1 -- SessionRepository.getByToken select @@ -37,8 +39,8 @@ from where "sessions"."token" = $1 and ( - "sessions"."expiredAt" is null - or "sessions"."expiredAt" > $2 + "sessions"."expiresAt" is null + or "sessions"."expiresAt" > $2 ) -- SessionRepository.getByUserId @@ -50,6 +52,10 @@ from and "users"."deletedAt" is null where "sessions"."userId" = $1 + and ( + "sessions"."expiresAt" is null + or "sessions"."expiresAt" > $2 + ) order by "sessions"."updatedAt" desc, "sessions"."createdAt" desc @@ -58,3 +64,10 @@ order by delete from "sessions" where "id" = $1::uuid + +-- SessionRepository.lockAll +update "sessions" +set + "pinExpiresAt" = $1 +where + "userId" = $2 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index b25007c4ea..17f69c0e52 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -306,6 +306,25 @@ class NotificationAccess { } } +class SessionAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, sessionIds: Set) { + if (sessionIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('sessions') + .select('sessions.id') + .where('sessions.id', 'in', [...sessionIds]) + .where('sessions.userId', '=', userId) + .execute() + .then((sessions) => new Set(sessions.map((session) => session.id))); + } +} class StackAccess { constructor(private db: Kysely) {} @@ -456,6 +475,7 @@ export class AccessRepository { notification: NotificationAccess; person: PersonAccess; partner: PartnerAccess; + session: SessionAccess; stack: StackAccess; tag: TagAccess; timeline: TimelineAccess; @@ -469,6 +489,7 @@ export class AccessRepository { this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); + this.session = new SessionAccess(db); this.stack = new StackAccess(db); this.tag = new TagAccess(db); this.timeline = new TimelineAccess(db); diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index ce819470c7..6c3d10cb9a 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -20,20 +20,20 @@ export class SessionRepository { .where((eb) => eb.or([ eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()), - eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]), + eb.and([eb('expiresAt', 'is not', null), eb('expiresAt', '<=', DateTime.now().toJSDate())]), ]), ) .returning(['id', 'deviceOS', 'deviceType']) .execute(); } - @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) - search(options: SessionSearchOptions) { + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { return this.db .selectFrom('sessions') - .selectAll() - .where('sessions.updatedAt', '<=', options.updatedBefore) - .execute(); + .select(['id', 'expiresAt', 'pinExpiresAt']) + .where('id', '=', id) + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) @@ -52,7 +52,7 @@ export class SessionRepository { ]) .where('sessions.token', '=', token) .where((eb) => - eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]), + eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]), ) .executeTakeFirst(); } @@ -64,6 +64,9 @@ export class SessionRepository { .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null)) .selectAll('sessions') .where('sessions.userId', '=', userId) + .where((eb) => + eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]), + ) .orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.createdAt', 'desc') .execute(); @@ -86,4 +89,9 @@ export class SessionRepository { async delete(id: string) { await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async lockAll(userId: string) { + await this.db.updateTable('sessions').set({ pinExpiresAt: null }).where('userId', '=', userId).execute(); + } } diff --git a/server/src/schema/migrations/1747338664832-SessionRename.ts b/server/src/schema/migrations/1747338664832-SessionRename.ts new file mode 100644 index 0000000000..5ba532d136 --- /dev/null +++ b/server/src/schema/migrations/1747338664832-SessionRename.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" RENAME "expiredAt" TO "expiresAt";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" RENAME "expiresAt" TO "expiredAt";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 9cc41c5bba..6bd5d84cb2 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -26,7 +26,7 @@ export class SessionTable { updatedAt!: Date; @Column({ type: 'timestamp with time zone', nullable: true }) - expiredAt!: Date | null; + expiresAt!: Date | null; @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) userId!: string; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index fb1a5ae042..4bc5f1ce0b 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -924,13 +924,13 @@ describe(AuthService.name, () => { const user = factory.userAdmin(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - mocks.session.getByUserId.mockResolvedValue([currentSession]); + mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); - expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null }); + expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7bda2eeb98..e6c541a624 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -18,6 +18,7 @@ import { PinCodeChangeDto, PinCodeResetDto, PinCodeSetupDto, + SessionUnlockDto, SignUpDto, mapLoginResponse, } from 'src/dtos/auth.dto'; @@ -123,24 +124,21 @@ export class AuthService extends BaseService { async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) { const user = await this.userRepository.getForPinCode(auth.user.id); - this.resetPinChecks(user, dto); + this.validatePinCode(user, dto); await this.userRepository.update(auth.user.id, { pinCode: null }); - const sessions = await this.sessionRepository.getByUserId(auth.user.id); - for (const session of sessions) { - await this.sessionRepository.update(session.id, { pinExpiresAt: null }); - } + await this.sessionRepository.lockAll(auth.user.id); } async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { const user = await this.userRepository.getForPinCode(auth.user.id); - this.resetPinChecks(user, dto); + this.validatePinCode(user, dto); const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS); await this.userRepository.update(auth.user.id, { pinCode: hashed }); } - private resetPinChecks( + private validatePinCode( user: { pinCode: string | null; password: string | null }, dto: { pinCode?: string; password?: string }, ) { @@ -474,23 +472,27 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid user token'); } - async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise { - const user = await this.userRepository.getForPinCode(auth.user.id); - if (!user) { - throw new UnauthorizedException(); - } - - this.resetPinChecks(user, { pinCode: dto.pinCode }); - + async unlockSession(auth: AuthDto, dto: SessionUnlockDto): Promise { if (!auth.session) { - throw new BadRequestException('Session is missing'); + throw new BadRequestException('This endpoint can only be used with a session token'); } + const user = await this.userRepository.getForPinCode(auth.user.id); + this.validatePinCode(user, { pinCode: dto.pinCode }); + await this.sessionRepository.update(auth.session.id, { - pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()), + pinExpiresAt: DateTime.now().plus({ minutes: 15 }).toJSDate(), }); } + async lockSession(auth: AuthDto): Promise { + if (!auth.session) { + throw new BadRequestException('This endpoint can only be used with a session token'); + } + + await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null }); + } + private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const token = this.cryptoRepository.randomBytesAsText(32); const tokenHashed = this.cryptoRepository.hashSha256(token); @@ -526,10 +528,14 @@ export class AuthService extends BaseService { throw new UnauthorizedException(); } + const session = auth.session ? await this.sessionRepository.get(auth.session.id) : undefined; + return { pinCode: !!user.pinCode, password: !!user.password, isElevated: !!auth.session?.hasElevatedPermission, + expiresAt: session?.expiresAt?.toISOString(), + pinExpiresAt: session?.pinExpiresAt?.toISOString(), }; } } diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 9f49cda07f..059ff00e16 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -30,7 +30,7 @@ export class SessionService extends BaseService { const session = await this.sessionRepository.create({ parentId: auth.session.id, userId: auth.user.id, - expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, + expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, deviceType: dto.deviceType, deviceOS: dto.deviceOS, token: tokenHashed, @@ -49,6 +49,11 @@ export class SessionService extends BaseService { await this.sessionRepository.delete(id); } + async lock(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.SESSION_LOCK, ids: [id] }); + await this.sessionRepository.update(id, { pinExpiresAt: null }); + } + async deleteAll(auth: AuthDto): Promise { const sessions = await this.sessionRepository.getByUserId(auth.user.id); for (const session of sessions) { diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index e2fe7429f3..38697a654b 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -280,6 +280,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.partner.checkUpdateAccess(auth.user.id, ids); } + case Permission.SESSION_READ: + case Permission.SESSION_UPDATE: + case Permission.SESSION_DELETE: + case Permission.SESSION_LOCK: { + return access.session.checkOwnerAccess(auth.user.id, ids); + } + case Permission.STACK_READ: { return access.stack.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 5b98b95e27..50db983cba 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -50,6 +50,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()), }, + session: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + stack: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 231deeba83..75e36c1da2 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -127,7 +127,7 @@ const sessionFactory = (session: Partial = {}) => ({ deviceType: 'mobile', token: 'abc123', parentId: null, - expiredAt: null, + expiresAt: null, userId: newUuid(), pinExpiresAt: newDate(), ...session, diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49b40866dd..9c41a7fe59 100644 --- a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,4 +1,5 @@ @@ -62,6 +69,12 @@ {/if} + {#snippet buttons()} + + {/snippet} + { await authenticate(url); + const { isElevated, pinCode } = await getAuthStatus(); - if (!isElevated || !pinCode) { - const continuePath = encodeURIComponent(url.pathname); - const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`; - - redirect(302, redirectPath); + redirect(302, `${AppRoute.AUTH_PIN_PROMPT}?continue=${encodeURIComponent(url.pathname + url.search)}`); } + const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/auth/pin-prompt/+page.svelte b/web/src/routes/auth/pin-prompt/+page.svelte index 91480cd35c..ffed9d5de0 100644 --- a/web/src/routes/auth/pin-prompt/+page.svelte +++ b/web/src/routes/auth/pin-prompt/+page.svelte @@ -3,9 +3,8 @@ import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte'; import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte'; - import { AppRoute } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; - import { verifyPinCode } from '@immich/sdk'; + import { unlockAuthSession } from '@immich/sdk'; import { Icon } from '@immich/ui'; import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -23,17 +22,15 @@ let hasPinCode = $derived(data.hasPinCode); let pinCode = $state(''); - const onPinFilled = async (code: string, withDelay = false) => { + const handleUnlockSession = async (code: string) => { try { - await verifyPinCode({ pinCodeSetupDto: { pinCode: code } }); + await unlockAuthSession({ sessionUnlockDto: { pinCode: code } }); isVerified = true; - if (withDelay) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } + await new Promise((resolve) => setTimeout(resolve, 1000)); - void goto(data.continuePath ?? AppRoute.LOCKED); + await goto(data.continueUrl); } catch (error) { handleError(error, $t('wrong_pin_code')); isBadPinCode = true; @@ -64,7 +61,7 @@ bind:value={pinCode} tabindexStart={1} pinLength={6} - onFilled={(pinCode) => onPinFilled(pinCode, true)} + onFilled={handleUnlockSession} />
diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts index b0d248ebe6..89d59a3127 100644 --- a/web/src/routes/auth/pin-prompt/+page.ts +++ b/web/src/routes/auth/pin-prompt/+page.ts @@ -1,3 +1,4 @@ +import { AppRoute } from '$lib/constants'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAuthStatus } from '@immich/sdk'; @@ -8,8 +9,6 @@ export const load = (async ({ url }) => { const { pinCode } = await getAuthStatus(); - const continuePath = url.searchParams.get('continue'); - const $t = await getFormatter(); return { @@ -17,6 +16,6 @@ export const load = (async ({ url }) => { title: $t('pin_verification'), }, hasPinCode: !!pinCode, - continuePath, + continueUrl: url.searchParams.get('continue') || AppRoute.LOCKED, }; }) satisfies PageLoad; From 86d64f34833718dbf24ce67e234dc0b0dc8b2b3b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 18:31:33 -0400 Subject: [PATCH 12/13] refactor: buttons (#18317) * refactor: buttons * fix: woopsie --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../elements/buttons/__test__/button.spec.ts | 20 --- .../components/elements/buttons/button.svelte | 123 ------------------ .../elements/buttons/skip-link.svelte | 5 +- .../faces-page/edit-name-input.svelte | 8 +- .../manage-people-visibility.svelte | 9 +- .../faces-page/merge-face-selector.svelte | 9 +- .../faces-page/unmerge-face-selector.svelte | 32 ++--- .../forms/library-scan-settings-form.svelte | 21 +-- .../components/forms/tag-asset-form.svelte | 14 +- .../onboarding-page/onboarding-hello.svelte | 10 +- .../onboarding-page/onboarding-privacy.svelte | 20 +-- .../onboarding-storage-template.svelte | 22 ++-- .../onboarding-page/onboarding-theme.svelte | 12 +- .../navigation-bar/account-info-panel.svelte | 21 ++- .../profile-image-cropper.svelte | 10 +- .../individual-purchase-option-card.svelte | 4 +- .../purchase-activation-success.svelte | 4 +- .../purchasing/purchase-content.svelte | 5 +- .../server-purchase-option-card.svelte | 4 +- .../search-bar/search-people-section.svelte | 17 +-- .../version-announcement-box.svelte | 8 +- .../lib/components/slideshow-settings.svelte | 8 +- .../duplicates-compare-control.svelte | 22 +++- .../modals/PersonEditBirthDateModal.svelte | 11 +- .../[[assetId=id]]/+page.svelte | 6 +- .../[[assetId=id]]/+page.svelte | 2 +- 26 files changed, 148 insertions(+), 279 deletions(-) delete mode 100644 web/src/lib/components/elements/buttons/__test__/button.spec.ts delete mode 100644 web/src/lib/components/elements/buttons/button.svelte diff --git a/web/src/lib/components/elements/buttons/__test__/button.spec.ts b/web/src/lib/components/elements/buttons/__test__/button.spec.ts deleted file mode 100644 index 0539315c57..0000000000 --- a/web/src/lib/components/elements/buttons/__test__/button.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Button from '$lib/components/elements/buttons/button.svelte'; -import { render, screen } from '@testing-library/svelte'; - -describe('Button component', () => { - it('should render as a button', () => { - render(Button); - const button = screen.getByRole('button'); - expect(button).toBeInTheDocument(); - expect(button).toHaveAttribute('type', 'button'); - expect(button).not.toHaveAttribute('href'); - }); - - it('should render as a link if href prop is set', () => { - render(Button, { props: { href: '/test' } }); - const link = screen.getByRole('link'); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute('href', '/test'); - expect(link).not.toHaveAttribute('type'); - }); -}); diff --git a/web/src/lib/components/elements/buttons/button.svelte b/web/src/lib/components/elements/buttons/button.svelte deleted file mode 100644 index ac7d9808f3..0000000000 --- a/web/src/lib/components/elements/buttons/button.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - {@render children?.()} - diff --git a/web/src/lib/components/elements/buttons/skip-link.svelte b/web/src/lib/components/elements/buttons/skip-link.svelte index b8f8fcd483..65e5001f8a 100644 --- a/web/src/lib/components/elements/buttons/skip-link.svelte +++ b/web/src/lib/components/elements/buttons/skip-link.svelte @@ -1,7 +1,7 @@ @@ -39,6 +39,6 @@ - + diff --git a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte index 1b1a91d163..387f01395d 100644 --- a/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte +++ b/web/src/lib/components/shared-components/purchasing/purchase-activation-success.svelte @@ -1,9 +1,9 @@ @@ -39,6 +39,6 @@ - + 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 e4b6ae7c3b..270be62527 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 @@ -1,13 +1,12 @@
{ @@ -486,19 +486,6 @@ {/key}
-{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} - a.id)} - personAssets={person} - onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} - onConfirm={handleUnmerge} - /> -{/if} - -{#if viewMode === PersonPageViewMode.MERGE_PEOPLE} - -{/if} -
{#if assetInteraction.selectionActive} + +{#if viewMode === PersonPageViewMode.UNASSIGN_ASSETS} + a.id)} + personAssets={person} + onClose={() => (viewMode = PersonPageViewMode.VIEW_ASSETS)} + onConfirm={handleUnmerge} + /> +{/if} + +{#if viewMode === PersonPageViewMode.MERGE_PEOPLE} + +{/if}