mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
Merge remote-tracking branch 'origin/main' into feat/yucca-integration
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 KiB |
@@ -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.
|
||||
```
|
||||
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
|
||||
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
|
||||
|
||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||
final isAssetViewerOpenProvider = StateProvider<bool>((ref) => false);
|
||||
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);
|
||||
|
||||
@@ -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<void> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -81,6 +81,24 @@ void main() {
|
||||
return captured;
|
||||
}
|
||||
|
||||
List<String> captureOriginalFileNames() {
|
||||
final captured = <String>[];
|
||||
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']));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>', '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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -58,18 +58,24 @@ export class CliService extends BaseService {
|
||||
return users.map((user) => mapUserAdmin(user));
|
||||
}
|
||||
|
||||
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user