From 42e1e0c66afd713be785d89361266cec7bbe619e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 11 Sep 2025 14:06:27 -0400 Subject: [PATCH] feat: faster access checks --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/assets_api.dart | 13 +- mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + mobile/openapi/lib/model/access_hint.dart | 91 +++++++ open-api/immich-openapi-specs.json | 17 ++ open-api/typescript-sdk/src/fetch-client.ts | 10 +- server/src/dtos/asset-media.dto.ts | 5 +- server/src/enum.ts | 7 + server/src/utils/access.ts | 239 +++++++++++------- .../assets/thumbnail/thumbnail.svelte | 12 +- .../photos-page/asset-date-group.svelte | 1 + .../timeline-manager.svelte.ts | 7 +- web/src/lib/utils.ts | 9 +- .../[[assetId=id]]/+page.svelte | 3 +- .../[[assetId=id]]/+page.svelte | 4 +- 17 files changed, 324 insertions(+), 101 deletions(-) create mode 100644 mobile/openapi/lib/model/access_hint.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 339ae6ff5d..1921ad58c7 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -297,6 +297,7 @@ Class | Method | HTTP request | Description - [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md) - [APIKeyResponseDto](doc//APIKeyResponseDto.md) - [APIKeyUpdateDto](doc//APIKeyUpdateDto.md) + - [AccessHint](doc//AccessHint.md) - [ActivityCreateDto](doc//ActivityCreateDto.md) - [ActivityResponseDto](doc//ActivityResponseDto.md) - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e87c160d96..3e1517851d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -68,6 +68,7 @@ part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; part 'model/api_key_response_dto.dart'; part 'model/api_key_update_dto.dart'; +part 'model/access_hint.dart'; part 'model/activity_create_dto.dart'; part 'model/activity_response_dto.dart'; part 'model/activity_statistics_response_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 063f9ea43b..a8521ffc0f 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -1247,12 +1247,14 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [AccessHint] hint: + /// /// * [String] key: /// /// * [AssetMediaSize] size: /// /// * [String] slug: - Future viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, String? slug, }) async { + Future viewAssetWithHttpInfo(String id, { AccessHint? hint, String? key, AssetMediaSize? size, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/thumbnail' .replaceAll('{id}', id); @@ -1264,6 +1266,9 @@ class AssetsApi { final headerParams = {}; final formParams = {}; + if (hint != null) { + queryParams.addAll(_queryParams('', 'hint', hint)); + } if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } @@ -1294,13 +1299,15 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [AccessHint] hint: + /// /// * [String] key: /// /// * [AssetMediaSize] size: /// /// * [String] slug: - Future viewAsset(String id, { String? key, AssetMediaSize? size, String? slug, }) async { - final response = await viewAssetWithHttpInfo(id, key: key, size: size, slug: slug, ); + Future viewAsset(String id, { AccessHint? hint, String? key, AssetMediaSize? size, String? slug, }) async { + final response = await viewAssetWithHttpInfo(id, hint: hint, key: key, size: size, slug: slug, ); 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 ae5fd9227b..0d9c5defd4 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -190,6 +190,8 @@ class ApiClient { return APIKeyResponseDto.fromJson(value); case 'APIKeyUpdateDto': return APIKeyUpdateDto.fromJson(value); + case 'AccessHint': + return AccessHintTypeTransformer().decode(value); case 'ActivityCreateDto': return ActivityCreateDto.fromJson(value); case 'ActivityResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b34e9210c8..2b09e43c20 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -55,6 +55,9 @@ String parameterToString(dynamic value) { if (value is DateTime) { return value.toUtc().toIso8601String(); } + if (value is AccessHint) { + return AccessHintTypeTransformer().encode(value).toString(); + } if (value is AlbumUserRole) { return AlbumUserRoleTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/access_hint.dart b/mobile/openapi/lib/model/access_hint.dart new file mode 100644 index 0000000000..74ad58487f --- /dev/null +++ b/mobile/openapi/lib/model/access_hint.dart @@ -0,0 +1,91 @@ +// +// 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 AccessHint { + /// Instantiate a new enum with the provided [value]. + const AccessHint._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const owner = AccessHint._(r'owner'); + static const album = AccessHint._(r'album'); + static const partner = AccessHint._(r'partner'); + static const sharedLink = AccessHint._(r'sharedLink'); + + /// List of all possible values in this [enum][AccessHint]. + static const values = [ + owner, + album, + partner, + sharedLink, + ]; + + static AccessHint? fromJson(dynamic value) => AccessHintTypeTransformer().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 = AccessHint.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AccessHint] to String, +/// and [decode] dynamic data back to [AccessHint]. +class AccessHintTypeTransformer { + factory AccessHintTypeTransformer() => _instance ??= const AccessHintTypeTransformer._(); + + const AccessHintTypeTransformer._(); + + String encode(AccessHint data) => data.value; + + /// Decodes a [dynamic value][data] to a AccessHint. + /// + /// 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. + AccessHint? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'owner': return AccessHint.owner; + case r'album': return AccessHint.album; + case r'partner': return AccessHint.partner; + case r'sharedLink': return AccessHint.sharedLink; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AccessHintTypeTransformer] instance. + static AccessHintTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7caf215042..69fd32c073 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2583,6 +2583,14 @@ "get": { "operationId": "viewAsset", "parameters": [ + { + "name": "hint", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AccessHint" + } + }, { "name": "id", "required": true, @@ -9967,6 +9975,15 @@ }, "type": "object" }, + "AccessHint": { + "enum": [ + "owner", + "album", + "partner", + "sharedLink" + ], + "type": "string" + }, "ActivityCreateDto": { "properties": { "albumId": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bc38a69079..597c0ae359 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2391,7 +2391,8 @@ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: { /** * This endpoint requires the `asset.view` permission. */ -export function viewAsset({ id, key, size, slug }: { +export function viewAsset({ hint, id, key, size, slug }: { + hint?: AccessHint; id: string; key?: string; size?: AssetMediaSize; @@ -2401,6 +2402,7 @@ export function viewAsset({ id, key, size, slug }: { status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({ + hint, key, size, slug @@ -4842,6 +4844,12 @@ export enum AssetJobName { RegenerateThumbnail = "regenerate-thumbnail", TranscodeVideo = "transcode-video" } +export enum AccessHint { + Owner = "owner", + Album = "album", + Partner = "partner", + SharedLink = "sharedLink" +} export enum AssetMediaSize { Fullsize = "fullsize", Preview = "preview", diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 755069d827..b902d10620 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -3,7 +3,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { plainToInstance, Transform, Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto'; -import { AssetVisibility } from 'src/enum'; +import { AccessHint, AssetVisibility } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; export enum AssetMediaSize { @@ -19,6 +19,9 @@ export enum AssetMediaSize { export class AssetMediaOptionsDto { @ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', optional: true }) size?: AssetMediaSize; + + @ValidateEnum({ enum: AccessHint, name: 'AccessHint', optional: true }) + hint?: AccessHint; } export enum UploadFieldName { diff --git a/server/src/enum.ts b/server/src/enum.ts index 646138b060..12220f5cdc 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -753,3 +753,10 @@ export enum CronJob { LibraryScan = 'LibraryScan', NightlyJobs = 'NightlyJobs', } + +export enum AccessHint { + Owner = 'owner', + Album = 'album', + Partner = 'partner', + SharedLink = 'sharedLink', +} diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 8427da6f1b..b1127f55b5 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,7 +1,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthSharedLink } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserRole, Permission } from 'src/enum'; +import { AccessHint, AlbumUserRole, Permission } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; @@ -22,10 +22,11 @@ export type AccessRequest = { auth: AuthDto; permission: Permission; ids: Set | string[]; + hint?: AccessHint; }; type SharedLinkAccessRequest = { sharedLink: AuthSharedLink; permission: Permission; ids: Set }; -type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set }; +type OtherAccessRequest = { auth: AuthDto; permission: Permission; ids: Set; hint?: AccessHint }; export const requireUploadAccess = (auth: AuthDto | null): AuthDto => { if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) { @@ -43,7 +44,7 @@ export const requireAccess = async (access: AccessRepository, request: AccessReq export const checkAccess = async ( access: AccessRepository, - { ids, auth, permission }: AccessRequest, + { ids, auth, permission, hint }: AccessRequest, ): Promise> => { const idSet = Array.isArray(ids) ? new Set(ids) : ids; if (idSet.size === 0) { @@ -52,7 +53,7 @@ export const checkAccess = async ( return auth.sharedLink ? checkSharedLinkAccess(access, { sharedLink: auth.sharedLink, permission, ids: idSet }) - : checkOtherAccess(access, { auth, permission, ids: idSet }); + : checkOtherAccess(access, { auth, permission, ids: idSet, hint }); }; const checkSharedLinkAccess = async ( @@ -102,8 +103,38 @@ const checkSharedLinkAccess = async ( } }; +const safeMoveToFront = (array: T[], index: number) => { + if (index <= 0 || index >= array.length) { + return; + } + + const [item] = array.splice(index, 1); + array.unshift(item); +}; + +type CheckFn = (ids: Set) => Promise>; +const checkAll = async (ids: Set, checks: Partial>, hint?: AccessHint) => { + let grantedIds = new Set(); + + const items = Object.values(checks); + if (hint && checks[hint]) { + safeMoveToFront(items, items.indexOf(checks[hint])); + } + + for (const check of items) { + if (ids.size === 0) { + break; + } + const approvedIds = await check(ids); + grantedIds = setUnion(grantedIds, approvedIds); + ids = setDifference(ids, approvedIds); + } + + return grantedIds; +}; + const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRequest): Promise> => { - const { auth, permission, ids } = request; + const { auth, permission, ids, hint } = request; switch (permission) { // uses album id @@ -113,96 +144,118 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe // uses activity id case Permission.ActivityDelete: { - const isOwner = await access.activity.checkOwnerAccess(auth.user.id, ids); - const isAlbumOwner = await access.activity.checkAlbumOwnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isAlbumOwner); + return checkAll( + ids, + { + [AccessHint.Owner]: (ids) => access.activity.checkOwnerAccess(auth.user.id, ids), + [AccessHint.Album]: (ids) => access.activity.checkAlbumOwnerAccess(auth.user.id, ids), + }, + hint, + ); } case Permission.AssetRead: { - 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); + return checkAll( + ids, + { + [AccessHint.Owner]: (ids) => + access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission), + [AccessHint.Album]: (ids) => access.asset.checkAlbumAccess(auth.user.id, ids), + [AccessHint.Partner]: (ids) => access.asset.checkPartnerAccess(auth.user.id, ids), + }, + hint, + ); } case Permission.AssetShare: { - 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); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.asset.checkOwnerAccess(auth.user.id, ids, false), + [AccessHint.Partner]: (ids) => access.asset.checkPartnerAccess(auth.user.id, ids), + }); } case Permission.AssetView: { - 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); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => + access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission), + [AccessHint.Album]: (ids) => access.asset.checkAlbumAccess(auth.user.id, ids), + [AccessHint.Partner]: (ids) => access.asset.checkPartnerAccess(auth.user.id, ids), + }); } case Permission.AssetDownload: { - 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); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => + access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission), + [AccessHint.Album]: (ids) => access.asset.checkAlbumAccess(auth.user.id, ids), + [AccessHint.Partner]: (ids) => access.asset.checkPartnerAccess(auth.user.id, ids), + }); } case Permission.AssetUpdate: { - return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => + access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission), + }); } case Permission.AssetDelete: { - return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => + access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission), + }); } case Permission.AlbumRead: { - const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await access.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.Viewer, + return checkAll( + ids, + { + [AccessHint.Owner]: (ids) => access.album.checkOwnerAccess(auth.user.id, ids), + [AccessHint.Album]: (ids) => access.album.checkSharedAlbumAccess(auth.user.id, ids, AlbumUserRole.Viewer), + }, + hint, ); - return setUnion(isOwner, isShared); } case Permission.AlbumAssetCreate: { - const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await access.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.Editor, - ); - return setUnion(isOwner, isShared); - } - - case Permission.AlbumUpdate: { - return await access.album.checkOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.album.checkOwnerAccess(auth.user.id, ids), + [AccessHint.Album]: (ids) => access.album.checkSharedAlbumAccess(auth.user.id, ids, AlbumUserRole.Editor), + }); } + case Permission.AlbumShare: + case Permission.AlbumUpdate: case Permission.AlbumDelete: { - return await access.album.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.AlbumShare: { - return await access.album.checkOwnerAccess(auth.user.id, ids); + return checkAll( + ids, + { + [AccessHint.Owner]: (ids) => access.album.checkOwnerAccess(auth.user.id, ids), + }, + hint, + ); } case Permission.AlbumDownload: { - const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await access.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.Viewer, + return checkAll( + ids, + { + [AccessHint.Owner]: (ids) => access.album.checkOwnerAccess(auth.user.id, ids), + [AccessHint.Album]: (ids) => access.album.checkSharedAlbumAccess(auth.user.id, ids, AlbumUserRole.Viewer), + }, + hint, ); - return setUnion(isOwner, isShared); } case Permission.AlbumAssetDelete: { - const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); - const isShared = await access.album.checkSharedAlbumAccess( - auth.user.id, - setDifference(ids, isOwner), - AlbumUserRole.Editor, + return checkAll( + ids, + { + [AccessHint.Owner]: (ids) => access.album.checkOwnerAccess(auth.user.id, ids), + [AccessHint.Album]: (ids) => access.album.checkSharedAlbumAccess(auth.user.id, ids, AlbumUserRole.Editor), + }, + hint, ); - return setUnion(isOwner, isShared); } case Permission.AssetUpload: { @@ -214,24 +267,36 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe } case Permission.AuthDeviceDelete: { - return await access.authDevice.checkOwnerAccess(auth.user.id, ids); + return checkAll( + ids, + { + [AccessHint.Owner]: (ids) => access.authDevice.checkOwnerAccess(auth.user.id, ids), + }, + hint, + ); } case Permission.FaceDelete: { - return access.person.checkFaceOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.person.checkFaceOwnerAccess(auth.user.id, ids), + }); } case Permission.NotificationRead: case Permission.NotificationUpdate: case Permission.NotificationDelete: { - return access.notification.checkOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.notification.checkOwnerAccess(auth.user.id, ids), + }); } case Permission.TagAsset: case Permission.TagRead: case Permission.TagUpdate: case Permission.TagDelete: { - return await access.tag.checkOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.tag.checkOwnerAccess(auth.user.id, ids), + }); } case Permission.TimelineRead: { @@ -244,54 +309,56 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } - case Permission.MemoryRead: { - return access.memory.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.MemoryUpdate: { - return access.memory.checkOwnerAccess(auth.user.id, ids); - } - + case Permission.MemoryRead: + case Permission.MemoryUpdate: case Permission.MemoryDelete: { - return access.memory.checkOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.memory.checkOwnerAccess(auth.user.id, ids), + }); } case Permission.PersonCreate: { - return access.person.checkFaceOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.person.checkFaceOwnerAccess(auth.user.id, ids), + }); } case Permission.PersonRead: case Permission.PersonUpdate: case Permission.PersonDelete: case Permission.PersonMerge: { - return await access.person.checkOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.person.checkOwnerAccess(auth.user.id, ids), + }); } case Permission.PersonReassign: { - return access.person.checkFaceOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.person.checkFaceOwnerAccess(auth.user.id, ids), + }); } case Permission.PartnerUpdate: { - return await access.partner.checkUpdateAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.partner.checkUpdateAccess(auth.user.id, ids), + }); } case Permission.SessionRead: case Permission.SessionUpdate: case Permission.SessionDelete: case Permission.SessionLock: { - return access.session.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.StackRead: { - return access.stack.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.StackUpdate: { - return access.stack.checkOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.session.checkOwnerAccess(auth.user.id, ids), + }); } + case Permission.StackRead: + case Permission.StackUpdate: case Permission.StackDelete: { - return access.stack.checkOwnerAccess(auth.user.id, ids); + return checkAll(ids, { + [AccessHint.Owner]: (ids) => access.stack.checkOwnerAccess(auth.user.id, ids), + }); } default: { diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index dd7f30b981..aba43f912f 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -5,7 +5,7 @@ import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { AssetMediaSize, AssetVisibility } from '@immich/sdk'; + import { AccessHint, AssetMediaSize, AssetVisibility } from '@immich/sdk'; import { mdiArchiveArrowDownOutline, mdiCameraBurst, @@ -20,6 +20,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; + import { user } from '$lib/stores/user.store'; import { moveFocus } from '$lib/utils/focus-util'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { TUNABLES } from '$lib/utils/tunables'; @@ -45,6 +46,7 @@ imageClass?: ClassValue; brokenAssetClass?: ClassValue; dimmed?: boolean; + hint?: AccessHint; onClick?: (asset: TimelineAsset) => void; onSelect?: (asset: TimelineAsset) => void; onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void; @@ -69,6 +71,7 @@ imageClass = '', brokenAssetClass = '', dimmed = false, + hint, }: Props = $props(); let { @@ -313,7 +316,12 @@ {#if customLayout} {@render customLayout(asset)} diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 172cd07a02..3a6b14a631 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -1,4 +1,4 @@ -import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; +import { AccessHint, AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk'; import { authManager } from '$lib/managers/auth-manager.svelte'; @@ -38,6 +38,7 @@ import type { } from './types'; export class TimelineManager { + hint?: AccessHint; isInitialized = $state(false); months: MonthGroup[] = $state([]); topSectionHeight = $state(0); @@ -97,7 +98,9 @@ export class TimelineManager { monthGroup: undefined, }); - constructor() {} + constructor(options?: { hint?: AccessHint }) { + this.hint = options?.hint; + } setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) { let changed = false; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 09205a2fc9..6c3717e9a0 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -5,6 +5,7 @@ import { lang } from '$lib/stores/preferences.store'; import { serverConfig } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; import { + AccessHint, AssetJobName, AssetMediaSize, JobName, @@ -197,12 +198,14 @@ export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => { return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey }); }; -export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => { +export const getAssetThumbnailUrl = ( + options: string | (AssetUrlOptions & { size?: AssetMediaSize; hint?: AccessHint }), +) => { if (typeof options === 'string') { options = { id: options }; } - const { id, size, cacheKey } = options; - return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey }); + const { id, size, cacheKey, hint } = options; + return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey, hint }); }; export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => { diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index eba030e2d9..1904071eeb 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -59,6 +59,7 @@ type AssetGridRouteSearchParams, } from '$lib/utils/navigation'; import { + AccessHint, AlbumUserRole, AssetOrder, AssetVisibility, @@ -328,7 +329,7 @@ } }); - let timelineManager = new TimelineManager(); + let timelineManager = new TimelineManager({ hint: AccessHint.Album }); $effect(() => { if (viewMode === AlbumPageViewMode.VIEW) { diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 83962c5a90..db59939634 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -10,7 +10,7 @@ import { AppRoute } from '$lib/constants'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; - import { AssetVisibility } from '@immich/sdk'; + import { AccessHint, AssetVisibility } from '@immich/sdk'; import { mdiArrowLeft, mdiPlus } from '@mdi/js'; import { onDestroy } from 'svelte'; import { t } from 'svelte-i18n'; @@ -22,7 +22,7 @@ let { data }: Props = $props(); - const timelineManager = new TimelineManager(); + const timelineManager = new TimelineManager({ hint: AccessHint.Partner }); $effect( () => void timelineManager.updateOptions({