diff --git a/docs/docs/administration/img/reset-admin-password.webp b/docs/docs/administration/img/reset-admin-password.webp deleted file mode 100644 index 5062d24a6b..0000000000 Binary files a/docs/docs/administration/img/reset-admin-password.webp and /dev/null differ diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 104c16c0a2..49bcf17700 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -35,6 +35,7 @@ Found Admin: - Email=admin@example.com - Name=Immich Admin ? Please choose a new password (optional) immich-is-cool +? Invalidate existing sessions? Yes The admin password has been updated. ``` diff --git a/e2e/src/specs/server/cli/upload.e2e-spec.ts b/e2e/src/specs/server/cli/upload.e2e-spec.ts index 5df36746d7..6ed5030e83 100644 --- a/e2e/src/specs/server/cli/upload.e2e-spec.ts +++ b/e2e/src/specs/server/cli/upload.e2e-spec.ts @@ -650,7 +650,7 @@ describe(`immich upload`, () => { ]); expect(stdout).toBe(''); - expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`); + expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`); expect(exitCode).not.toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); diff --git a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts index 24699cda30..ad0e43ff14 100644 --- a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts +++ b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts @@ -65,6 +65,10 @@ describe(`immich-admin`, () => { child.stdout.on('data', (chunk) => { data += chunk; if (data.includes('Please choose a new password (optional)')) { + child.stdin.write('\n'); + } + + if (data.includes('Invalidate existing sessions?')) { child.stdin.end('\n'); } }); diff --git a/mise.lock b/mise.lock index e6c2f89d23..4418f81956 100644 --- a/mise.lock +++ b/mise.lock @@ -83,17 +83,12 @@ version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" [tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" +asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"] -checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"] -checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] +checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" [[tools."github:jellyfin/jellyfin-ffmpeg"]] version = "7.1.3-6" @@ -117,12 +112,17 @@ version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" [tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" +asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] -checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"] +checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" + +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"] +checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879" [[tools."github:jellyfin/jellyfin-ffmpeg"]] version = "7.1.3-6" @@ -140,10 +140,13 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"] -checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094" +[tools."github:jellyfin/jellyfin-ffmpeg".options] +asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" + +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] +checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" [[tools."github:webassembly/binaryen"]] version = "version_124" diff --git a/mise.toml b/mise.toml index f19e023297..007cc30741 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ -experimental_monorepo_root = true +monorepo_root = true [monorepo] config_roots = [ @@ -36,7 +36,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" } macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" } [settings] -experimental = true pin = true lockfile = true diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 2ae6371f6a..22546b8e50 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget { _QuickLink( title: context.t.recently_added, icon: Icons.upload_outlined, - isTop: true, onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()), ), _QuickLink( diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index a752a51a7d..6cc3d08c5d 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -1,8 +1,19 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/routing/router.dart'; + +@visibleForTesting +bool isRouteInStack(Ref ref, String routeName) { + final router = ref.watch(appRouterProvider); + void onChange() => ref.invalidateSelf(); + router.addListener(onChange); + ref.onDispose(() => router.removeListener(onChange)); + return router.stackData.any((route) => route.name == routeName); +} + +final inLockedViewProvider = Provider((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name)); +final isAssetViewerOpenProvider = Provider((ref) => isRouteInStack(ref, AssetViewerRoute.name)); -final inLockedViewProvider = StateProvider((ref) => false); -final isAssetViewerOpenProvider = StateProvider((ref) => false); final currentRouteNameProvider = StateProvider((ref) => null); final previousRouteNameProvider = StateProvider((ref) => null); final previousRouteDataProvider = StateProvider((ref) => null); diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 09d17da182..57304af44f 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; class AppNavigationObserver extends AutoRouterObserver { /// Riverpod Instance @@ -12,46 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver { AppNavigationObserver({required this.ref}); - @override - Future didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async { - unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false)); - } - @override void didPush(Route route, Route? previousRoute) { - _handleDriftLockedFolderState(route, previousRoute); Future(() { ref.read(currentRouteNameProvider.notifier).state = route.settings.name; ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name; ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings; - if (route.settings.name == AssetViewerRoute.name) { - ref.read(isAssetViewerOpenProvider.notifier).state = true; - } }); } - - @override - void didPop(Route route, Route? previousRoute) { - _handleDriftLockedFolderState(previousRoute ?? route, null); - if (route.settings.name == AssetViewerRoute.name) { - Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false); - } - } - - _handleDriftLockedFolderState(Route route, Route? previousRoute) { - final isInLockedView = ref.read(inLockedViewProvider); - final isFromLockedViewToDetailView = - route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name; - - final isFromDetailViewToInfoPanelView = - route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView; - - if (route.settings.name == DriftLockedFolderRoute.name || - isFromLockedViewToDetailView || - isFromDetailViewToInfoPanelView) { - Future(() => ref.read(inLockedViewProvider.notifier).state = true); - } else { - Future(() => ref.read(inLockedViewProvider.notifier).state = false); - } - } } diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index fa0a41f17c..ec731c6f68 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -290,13 +290,10 @@ class BackgroundUploadService { return null; } - String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; - final hasExtension = p.extension(fileName).isNotEmpty; - if (!hasExtension) { - fileName = p.setExtension(fileName, p.extension(asset.name)); - } - - final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; + // Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those. + final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); + final originalFileName = p.setExtension(fileName, extension); String metadata = UploadTaskMetadata( localAssetId: asset.id, diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 06915ad09f..cce1241473 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -309,17 +309,10 @@ class ForegroundUploadService { return; } - String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; - - /// Handle special file name from DJI or Fusion app - /// If the file name has no extension, likely due to special renaming template by specific apps - /// we append the original extension from the asset name - final hasExtension = p.extension(fileName).isNotEmpty; - if (!hasExtension) { - fileName = p.setExtension(fileName, p.extension(asset.name)); - } - - final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; + // Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those. + final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); + final originalFileName = p.setExtension(fileName, extension); final deviceId = Store.get(StoreKey.deviceId); final fields = { diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index 527da1bf1b..65f17501a0 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -136,6 +136,51 @@ void main() { expect(task, isNotNull); expect(task!.fields.containsKey('visibility'), isFalse); }); + + test('corrects the extension when iOS returns a rendered file for a .dng asset', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/IMG_6499.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('IMG_6499.jpg')); + }); + + test('keeps the .dng extension for a genuine RAW original', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/IMG_5210.dng'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('IMG_5210.dng')); + }); + + test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/DJI_0001'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('DJI_0001.jpg')); + }); }); group('getLivePhotoUploadTask', () { diff --git a/mobile/test/services/foreground_upload.service_test.dart b/mobile/test/services/foreground_upload.service_test.dart index ed48270b87..46e9a82141 100644 --- a/mobile/test/services/foreground_upload.service_test.dart +++ b/mobile/test/services/foreground_upload.service_test.dart @@ -81,6 +81,24 @@ void main() { return captured; } + List captureOriginalFileNames() { + final captured = []; + when( + () => mockUploadRepository.uploadFile( + file: any(named: 'file'), + originalFileName: any(named: 'originalFileName'), + fields: any(named: 'fields'), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + logContext: any(named: 'logContext'), + ), + ).thenAnswer((invocation) async { + captured.add(invocation.namedArguments[#originalFileName] as String); + return UploadResult.success(remoteAssetId: 'remote-${captured.length}'); + }); + return captured; + } + group('uploadSingleAsset', () { test('should upload the motion part hidden and keep the still image visible', () async { final asset = LocalAssetStub.image1; @@ -124,5 +142,59 @@ void main() { expect(captured, hasLength(1)); expect(captured[0].containsKey('visibility'), isFalse); }); + + test('corrects the extension when iOS returns a rendered file for a .dng asset', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/IMG_6499.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['IMG_6499.jpg'])); + }); + + test('keeps the .dng extension for a genuine RAW original', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/IMG_5210.dng'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['IMG_5210.dng'])); + }); + + test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/DJI_0001'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['DJI_0001.jpg'])); + }); }); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c82c7d852b..deef7d8c8c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -47,7 +47,7 @@ program .usage('[paths...] [options]') .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .addOption(new Option('-i, --ignore ', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS')) - .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) + .addOption(new Option('--skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false)) .addOption( new Option('-a, --album', 'Automatically create albums based on folder name') diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 2a2dd1857d..0d03e4cd95 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -8,13 +8,13 @@ import { } from 'src/commands/media-location.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; -import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { SchemaCheck } from 'src/commands/schema-check'; import { VersionCommand } from 'src/commands/version.command'; export const commandsAndQuestions = [ ResetAdminPasswordCommand, - PromptPasswordQuestions, + PromptPasswordResetQuestions, PromptEmailQuestion, EnablePasswordLoginCommand, DisablePasswordLoginCommand, diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index e5dee49837..703f693326 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { CliService } from 'src/services/cli.service'; const prompt = (inquirer: InquirerService) => { - return function ask(admin: UserAdminResponseDto) { + return (admin: UserAdminResponseDto) => { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} @@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => { - Email=${email} - Name=${name}`); - return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); + return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {}); }; }; @@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner { } } -@QuestionSet({ name: 'prompt-password' }) -export class PromptPasswordQuestions { +@QuestionSet({ name: 'prompt-password-reset' }) +export class PromptPasswordResetQuestions { @Question({ message: 'Please choose a new password (optional)', - name: 'password', + name: 'newPassword', }) parsePassword(value: string) { return value; } + + @Question({ + type: 'confirm', + message: 'Invalidate existing sessions?', + default: true, + name: 'invalidateSessions', + }) + parseInvalidate(value: boolean): boolean { + return value; + } } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 75e7fc5e87..0264484bfc 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -568,6 +568,34 @@ describe(AssetService.name, () => { expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId); }); + it('should delete the stack when a non-primary asset is deleted and only the primary would remain', async () => { + const asset = AssetFactory.from().build(); + const deletionAsset = { + ...getForAssetDeletion(asset), + stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }] }, + }; + mocks.stack.delete.mockResolvedValue(); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset); + + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + + expect(mocks.stack.delete).toHaveBeenCalledWith(deletionAsset.stack.id); + }); + + it('should keep the stack when a non-primary asset is deleted and the primary plus another asset remain', async () => { + const asset = AssetFactory.from().build(); + const deletionAsset = { + ...getForAssetDeletion(asset), + stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }, { id: newUuid() }] }, + }; + mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset); + + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + + expect(mocks.stack.delete).not.toHaveBeenCalled(); + expect(mocks.stack.update).not.toHaveBeenCalled(); + }); + it('should delete a live photo', async () => { const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build(); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c27bbd4b18..1edccf9483 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -316,18 +316,25 @@ export class AssetService extends BaseService { return JobStatus.Failed; } - // replace the parent of the stack children with a new asset - if (asset.stack?.primaryAssetId === id) { - // this only includes timeline visible assets and excludes the primary asset - const stackAssetIds = asset.stack.assets.map((a) => a.id); - if (stackAssetIds.length >= 2) { - const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; + if (asset.stack) { + // asset.stack.assets only includes timeline visible assets and excludes the primary asset + const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id); + + // the primary survives unless it is the asset being deleted + let remainingCount = remainingStackAssetIds.length; + if (asset.stack.primaryAssetId !== id) { + remainingCount++; + } + + if (remainingCount < 2) { + // 0 or 1 asset would remain: dissolve the stack so it does not linger as a single-asset stack + await this.stackRepository.delete(asset.stack.id); + } else if (asset.stack.primaryAssetId === id) { + // the primary is being deleted but others remain: promote a new primary await this.stackRepository.update(asset.stack.id, { id: asset.stack.id, - primaryAssetId: newPrimaryAssetId, + primaryAssetId: remainingStackAssetIds[0], }); - } else { - await this.stackRepository.delete(asset.stack.id); } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 347d9eef00..29c62d258f 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -37,7 +37,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); - const ask = vitest.fn().mockImplementation(() => {}); + const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -47,6 +47,7 @@ describe(CliService.name, () => { expect(ask).toHaveBeenCalled(); expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); + expect(mocks.session.invalidateAll).not.toHaveBeenCalled(); }); it('should use the supplied password', async () => { @@ -55,7 +56,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); - const ask = vitest.fn().mockResolvedValue('new-password'); + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -66,6 +67,20 @@ describe(CliService.name, () => { expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); + + it('should invalidate existing sessions when requested', async () => { + const admin = UserFactory.create({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(admin); + mocks.session.invalidateAll.mockResolvedValue(void 0); + + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true }); + + await sut.resetAdminPassword(ask); + + expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id }); + }); }); describe('disablePasswordLogin', () => { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 23d70b6860..f37353d7a1 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -58,18 +58,24 @@ export class CliService extends BaseService { return users.map((user) => mapUserAdmin(user)); } - async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { + async resetAdminPassword( + ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>, + ) { const admin = await this.userRepository.getAdmin(); if (!admin) { throw new Error('Admin account does not exist'); } - const providedPassword = await ask(mapUserAdmin(admin)); + const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin)); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); + if (invalidateSessions) { + await this.sessionRepository.invalidateAll({ userId: admin.id }); + } + return { admin, password, provided: !!providedPassword }; }