mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 13:54:05 +03:00
Compare commits
9 Commits
renovate/m
...
fix/auto-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5302d6ea0e | ||
|
|
564cda5088 | ||
|
|
a7f1d495c6 | ||
|
|
b9f6c4aaf2 | ||
|
|
23778551f7 | ||
|
|
457da17c5d | ||
|
|
12f741d911 | ||
|
|
a0c7b81142 | ||
|
|
20997823d8 |
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 KiB |
@@ -35,6 +35,7 @@ Found Admin:
|
|||||||
- Email=admin@example.com
|
- Email=admin@example.com
|
||||||
- Name=Immich Admin
|
- Name=Immich Admin
|
||||||
? Please choose a new password (optional) immich-is-cool
|
? Please choose a new password (optional) immich-is-cool
|
||||||
|
? Invalidate existing sessions? Yes
|
||||||
The admin password has been updated.
|
The admin password has been updated.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -650,7 +650,7 @@ describe(`immich upload`, () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(stdout).toBe('');
|
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);
|
expect(exitCode).not.toBe(0);
|
||||||
|
|
||||||
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ describe(`immich-admin`, () => {
|
|||||||
child.stdout.on('data', (chunk) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
data += chunk;
|
data += chunk;
|
||||||
if (data.includes('Please choose a new password (optional)')) {
|
if (data.includes('Please choose a new password (optional)')) {
|
||||||
|
child.stdin.write('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.includes('Invalidate existing sessions?')) {
|
||||||
child.stdin.end('\n');
|
child.stdin.end('\n');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
ARG DEVICE=cpu
|
ARG DEVICE=cpu
|
||||||
|
|
||||||
FROM python:3.11-bookworm@sha256:5c34b355088846dddc8afb7442c20b9433dccdc8d66192dc52c616adeaa106a3 AS builder-cpu
|
FROM python:3.11-bookworm@sha256:20ec607c68642c64c73269ce245aa0f060913f4129d15d9687850aa158e6269c AS builder-cpu
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS builder-openvino
|
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS builder-openvino
|
||||||
|
|
||||||
FROM builder-cpu AS builder-cuda
|
FROM builder-cpu AS builder-cuda
|
||||||
|
|
||||||
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
||||||
|
|
||||||
FROM python:3.11-slim-bookworm@sha256:b18992999dbe963a45a8a4da40ac2b1975be1a776d939d098c647482bcad5cba AS prod-cpu
|
FROM python:3.11-slim-bookworm@sha256:e2d3af735aff6eeee600b1933bedd99da6645fedf572cc12ef4cc1331f2ceebe AS prod-cpu
|
||||||
|
|
||||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||||
MACHINE_LEARNING_MODEL_ARENA=false
|
MACHINE_LEARNING_MODEL_ARENA=false
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie@sha256:6771159cd4fa5d9bba1258caf0b82e6b73458c694d178ad97c5e925c2d0e1a91 AS prod-openvino
|
FROM python:3.13-slim-trixie@sha256:f82c96458eedc847b233e582eb31336f4954b39cae020b6dcf5b3ed0e5cbcd74 AS prod-openvino
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
|
|||||||
725
machine-learning/uv.lock
generated
725
machine-learning/uv.lock
generated
File diff suppressed because it is too large
Load Diff
44
mise.lock
44
mise.lock
@@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
|
|||||||
version = "7.1.3-6"
|
version = "7.1.3-6"
|
||||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||||
|
|
||||||
|
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||||
|
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
||||||
|
|
||||||
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
||||||
|
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||||
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||||
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||||
|
|
||||||
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
||||||
|
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
||||||
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
||||||
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
||||||
|
|
||||||
|
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||||
|
version = "7.1.3-6"
|
||||||
|
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
|
||||||
|
|
||||||
@@ -112,17 +129,12 @@ version = "7.1.3-6"
|
|||||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
backend = "github:jellyfin/jellyfin-ffmpeg"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
||||||
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
|
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
|
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
||||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
|
|
||||||
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
|
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
|
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
|
|
||||||
|
|
||||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
||||||
version = "7.1.3-6"
|
version = "7.1.3-6"
|
||||||
@@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
|
|||||||
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 = "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"
|
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
|
||||||
|
|
||||||
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
|
|
||||||
version = "7.1.3-6"
|
|
||||||
backend = "github:jellyfin/jellyfin-ffmpeg"
|
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg".options]
|
|
||||||
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
|
|
||||||
|
|
||||||
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
|
|
||||||
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
|
|
||||||
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
|
|
||||||
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
|
|
||||||
|
|
||||||
[[tools."github:webassembly/binaryen"]]
|
[[tools."github:webassembly/binaryen"]]
|
||||||
version = "version_124"
|
version = "version_124"
|
||||||
backend = "github:webassembly/binaryen"
|
backend = "github:webassembly/binaryen"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
experimental_monorepo_root = true
|
monorepo_root = true
|
||||||
|
|
||||||
[monorepo]
|
[monorepo]
|
||||||
config_roots = [
|
config_roots = [
|
||||||
@@ -35,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
|
|||||||
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
|
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
|
||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
experimental = true
|
|
||||||
pin = true
|
pin = true
|
||||||
lockfile = true
|
lockfile = true
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const Map<String, Locale> locales = {
|
|||||||
// Additional locales
|
// Additional locales
|
||||||
'Arabic (ar)': Locale('ar'),
|
'Arabic (ar)': Locale('ar'),
|
||||||
'Basque (eu)': Locale('eu'),
|
'Basque (eu)': Locale('eu'),
|
||||||
|
'Belarusian (be)': Locale('be'),
|
||||||
'Bosnian (bl)': Locale('bn'),
|
'Bosnian (bl)': Locale('bn'),
|
||||||
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
|
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
|
||||||
'Bulgarian (bg)': Locale('bg'),
|
'Bulgarian (bg)': Locale('bg'),
|
||||||
|
|||||||
@@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget {
|
|||||||
_QuickLink(
|
_QuickLink(
|
||||||
title: context.t.recently_added,
|
title: context.t.recently_added,
|
||||||
icon: Icons.upload_outlined,
|
icon: Icons.upload_outlined,
|
||||||
isTop: true,
|
|
||||||
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
|
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
|
||||||
),
|
),
|
||||||
_QuickLink(
|
_QuickLink(
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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 currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||||
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||||
final previousRouteDataProvider = StateProvider<RouteSettings?>((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:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
|
|
||||||
class AppNavigationObserver extends AutoRouterObserver {
|
class AppNavigationObserver extends AutoRouterObserver {
|
||||||
/// Riverpod Instance
|
/// Riverpod Instance
|
||||||
@@ -12,46 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver {
|
|||||||
|
|
||||||
AppNavigationObserver({required this.ref});
|
AppNavigationObserver({required this.ref});
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async {
|
|
||||||
unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didPush(Route route, Route? previousRoute) {
|
void didPush(Route route, Route? previousRoute) {
|
||||||
_handleDriftLockedFolderState(route, previousRoute);
|
|
||||||
Future(() {
|
Future(() {
|
||||||
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
||||||
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
||||||
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||||
final hasExtension = p.extension(fileName).isNotEmpty;
|
// Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those.
|
||||||
if (!hasExtension) {
|
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
|
||||||
fileName = p.setExtension(fileName, p.extension(asset.name));
|
final originalFileName = p.setExtension(fileName, extension);
|
||||||
}
|
|
||||||
|
|
||||||
final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName;
|
|
||||||
|
|
||||||
String metadata = UploadTaskMetadata(
|
String metadata = UploadTaskMetadata(
|
||||||
localAssetId: asset.id,
|
localAssetId: asset.id,
|
||||||
|
|||||||
@@ -309,17 +309,10 @@ class ForegroundUploadService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
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.
|
||||||
/// Handle special file name from DJI or Fusion app
|
final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name);
|
||||||
/// If the file name has no extension, likely due to special renaming template by specific apps
|
final originalFileName = p.setExtension(fileName, extension);
|
||||||
/// 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 deviceId = Store.get(StoreKey.deviceId);
|
final deviceId = Store.get(StoreKey.deviceId);
|
||||||
|
|
||||||
final fields = {
|
final fields = {
|
||||||
|
|||||||
@@ -10,6 +10,24 @@ String sanitizeUrl(String url) {
|
|||||||
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates a user-entered server URL
|
||||||
|
bool isValidServerUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.contains('://')) {
|
||||||
|
// Prepend conforming scheme for URL validation
|
||||||
|
// Uri.tryParse will not parse out host without a scheme provided, even though we assume http(s) when connecting
|
||||||
|
url = 'http://$url';
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsedUrl = Uri.tryParse(url);
|
||||||
|
return parsedUrl != null &&
|
||||||
|
(parsedUrl.scheme.isEmpty || parsedUrl.scheme.startsWith(RegExp(r'https?'))) &&
|
||||||
|
parsedUrl.host.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
String? getServerUrl() {
|
String? getServerUrl() {
|
||||||
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
|
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
|
||||||
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
|
||||||
|
|||||||
@@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
|
|
||||||
final log = Logger('LoginForm');
|
final log = Logger('LoginForm');
|
||||||
|
|
||||||
String? _validateUrl(String? url) {
|
String? _validateUrl(String? url) => isValidServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
|
||||||
if (url == null || url.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final parsedUrl = Uri.tryParse(url);
|
|
||||||
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
|
|
||||||
return 'login_form_err_invalid_url'.tr();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateEmail(String? email) {
|
String? _validateEmail(String? email) {
|
||||||
if (email == null || email == '') {
|
if (email == null || email == '') {
|
||||||
|
|||||||
@@ -77,6 +77,40 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('isValidServerUrl', () {
|
||||||
|
test('should treat null as valid', () {
|
||||||
|
expect(isValidServerUrl(null), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should treat empty string as valid', () {
|
||||||
|
expect(isValidServerUrl(''), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept a bare host', () {
|
||||||
|
expect(isValidServerUrl('demo.immich.app'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept a bare host with a port', () {
|
||||||
|
expect(isValidServerUrl('192.168.1.1:2283'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept an http URL', () {
|
||||||
|
expect(isValidServerUrl('http://demo.immich.app'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept an https URL', () {
|
||||||
|
expect(isValidServerUrl('https://demo.immich.app:2283/api'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject a non-http scheme', () {
|
||||||
|
expect(isValidServerUrl('ftp://demo.immich.app'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject scheme only input', () {
|
||||||
|
expect(isValidServerUrl('https://'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('punycodeDecodeUrl', () {
|
group('punycodeDecodeUrl', () {
|
||||||
test('should return null for null input', () {
|
test('should return null for null input', () {
|
||||||
expect(punycodeDecodeUrl(null), isNull);
|
expect(punycodeDecodeUrl(null), isNull);
|
||||||
|
|||||||
@@ -136,6 +136,51 @@ void main() {
|
|||||||
expect(task, isNotNull);
|
expect(task, isNotNull);
|
||||||
expect(task!.fields.containsKey('visibility'), isFalse);
|
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', () {
|
group('getLivePhotoUploadTask', () {
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ void main() {
|
|||||||
return captured;
|
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', () {
|
group('uploadSingleAsset', () {
|
||||||
test('should upload the motion part hidden and keep the still image visible', () async {
|
test('should upload the motion part hidden and keep the still image visible', () async {
|
||||||
final asset = LocalAssetStub.image1;
|
final asset = LocalAssetStub.image1;
|
||||||
@@ -124,5 +142,59 @@ void main() {
|
|||||||
expect(captured, hasLength(1));
|
expect(captured, hasLength(1));
|
||||||
expect(captured[0].containsKey('visibility'), isFalse);
|
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]')
|
.usage('[paths...] [options]')
|
||||||
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
|
.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('-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('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option('-a, --album', 'Automatically create albums based on folder name')
|
new Option('-a, --album', 'Automatically create albums based on folder name')
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
} from 'src/commands/media-location.command';
|
} from 'src/commands/media-location.command';
|
||||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-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 { SchemaCheck } from 'src/commands/schema-check';
|
||||||
import { VersionCommand } from 'src/commands/version.command';
|
import { VersionCommand } from 'src/commands/version.command';
|
||||||
|
|
||||||
export const commandsAndQuestions = [
|
export const commandsAndQuestions = [
|
||||||
ResetAdminPasswordCommand,
|
ResetAdminPasswordCommand,
|
||||||
PromptPasswordQuestions,
|
PromptPasswordResetQuestions,
|
||||||
PromptEmailQuestion,
|
PromptEmailQuestion,
|
||||||
EnablePasswordLoginCommand,
|
EnablePasswordLoginCommand,
|
||||||
DisablePasswordLoginCommand,
|
DisablePasswordLoginCommand,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
|||||||
import { CliService } from 'src/services/cli.service';
|
import { CliService } from 'src/services/cli.service';
|
||||||
|
|
||||||
const prompt = (inquirer: InquirerService) => {
|
const prompt = (inquirer: InquirerService) => {
|
||||||
return function ask(admin: UserAdminResponseDto) {
|
return (admin: UserAdminResponseDto) => {
|
||||||
const { id, oauthId, email, name } = admin;
|
const { id, oauthId, email, name } = admin;
|
||||||
console.log(`Found Admin:
|
console.log(`Found Admin:
|
||||||
- ID=${id}
|
- ID=${id}
|
||||||
@@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => {
|
|||||||
- Email=${email}
|
- Email=${email}
|
||||||
- Name=${name}`);
|
- 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' })
|
@QuestionSet({ name: 'prompt-password-reset' })
|
||||||
export class PromptPasswordQuestions {
|
export class PromptPasswordResetQuestions {
|
||||||
@Question({
|
@Question({
|
||||||
message: 'Please choose a new password (optional)',
|
message: 'Please choose a new password (optional)',
|
||||||
name: 'password',
|
name: 'newPassword',
|
||||||
})
|
})
|
||||||
parsePassword(value: string) {
|
parsePassword(value: string) {
|
||||||
return value;
|
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);
|
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 () => {
|
it('should delete a live photo', async () => {
|
||||||
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
||||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||||
|
|||||||
@@ -316,18 +316,25 @@ export class AssetService extends BaseService {
|
|||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace the parent of the stack children with a new asset
|
if (asset.stack) {
|
||||||
if (asset.stack?.primaryAssetId === id) {
|
// asset.stack.assets only includes timeline visible assets and excludes the primary asset
|
||||||
// this only includes timeline visible assets and excludes the primary asset
|
const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id);
|
||||||
const stackAssetIds = asset.stack.assets.map((a) => a.id);
|
|
||||||
if (stackAssetIds.length >= 2) {
|
// the primary survives unless it is the asset being deleted
|
||||||
const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
|
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, {
|
await this.stackRepository.update(asset.stack.id, {
|
||||||
id: 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.getAdmin.mockResolvedValue(admin);
|
||||||
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
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);
|
const response = await sut.resetAdminPassword(ask);
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ describe(CliService.name, () => {
|
|||||||
expect(ask).toHaveBeenCalled();
|
expect(ask).toHaveBeenCalled();
|
||||||
expect(id).toEqual(admin.id);
|
expect(id).toEqual(admin.id);
|
||||||
expect(update.password).toBeDefined();
|
expect(update.password).toBeDefined();
|
||||||
|
expect(mocks.session.invalidateAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the supplied password', async () => {
|
it('should use the supplied password', async () => {
|
||||||
@@ -55,7 +56,7 @@ describe(CliService.name, () => {
|
|||||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
mocks.user.update.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);
|
const response = await sut.resetAdminPassword(ask);
|
||||||
|
|
||||||
@@ -66,6 +67,20 @@ describe(CliService.name, () => {
|
|||||||
expect(id).toEqual(admin.id);
|
expect(id).toEqual(admin.id);
|
||||||
expect(update.password).toBeDefined();
|
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', () => {
|
describe('disablePasswordLogin', () => {
|
||||||
|
|||||||
@@ -58,18 +58,24 @@ export class CliService extends BaseService {
|
|||||||
return users.map((user) => mapUserAdmin(user));
|
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();
|
const admin = await this.userRepository.getAdmin();
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
throw new Error('Admin account does not exist');
|
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 password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
|
||||||
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
|
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
|
||||||
|
|
||||||
await this.userRepository.update(admin.id, { password: hashedPassword });
|
await this.userRepository.update(admin.id, { password: hashedPassword });
|
||||||
|
|
||||||
|
if (invalidateSessions) {
|
||||||
|
await this.sessionRepository.invalidateAll({ userId: admin.id });
|
||||||
|
}
|
||||||
|
|
||||||
return { admin, password, provided: !!providedPassword };
|
return { admin, password, provided: !!providedPassword };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user