10 Commits
Author SHA1 Message Date
Codex 132af2fc74 Prepare local14 web and Android release
Build Mobile / pre-job (push) Canceled after 0s
CLI Build / CLI Publish (push) Canceled after 0s
CodeQL / Analyze (javascript) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
Docker / pre-job (push) Canceled after 0s
Docs build / pre-job (push) Canceled after 0s
Migration Order / Verify migration ORDER (push) Canceled after 0s
Static Code Analysis / pre-job (push) Canceled after 0s
Test / pre-job (push) Canceled after 0s
Test / ShellCheck (push) Canceled after 0s
Test / OpenAPI Clients (push) Canceled after 0s
Test / SQL Schema Checks (push) Canceled after 0s
Build Mobile / Build and sign Android (push) Canceled after 0s
Build Mobile / Build and sign iOS (push) Canceled after 0s
CLI Build / Docker (push) Canceled after 0s
Docker / Re-Tag ML (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Re-Tag ML (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Re-Tag ML (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Re-Tag ML (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Re-Tag ML (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Re-Tag ML (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Re-Tag Server (push) Canceled after 0s
Docker / Build and Push ML (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Build and Push ML (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Build and Push ML (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Build and Push ML (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Build and Push ML (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Build and Push ML (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Build and Push Server (push) Canceled after 0s
Docker / Mirror to Docker Hub (armnn, immich-machine-learning, linux/arm64, -armnn) (push) Canceled after 0s
Docker / Mirror to Docker Hub (cpu, immich-machine-learning) (push) Canceled after 0s
Docker / Mirror to Docker Hub (cuda, immich-machine-learning, linux/amd64, -cuda) (push) Canceled after 0s
Docker / Mirror to Docker Hub (immich-server) (push) Canceled after 0s
Docker / Mirror to Docker Hub (openvino, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -openvino) (push) Canceled after 0s
Docker / Mirror to Docker Hub (rknn, immich-machine-learning, linux/arm64, -rknn) (push) Canceled after 0s
Docker / Mirror to Docker Hub (rocm, immich-machine-learning, linux/amd64, {"linux/amd64": "pokedex-large"}, -rocm) (push) Canceled after 0s
Docker / Docker Build & Push Server Success (push) Canceled after 0s
Docker / Docker Build & Push ML Success (push) Canceled after 0s
Docs build / Docs Build (push) Canceled after 0s
Static Code Analysis / Run Dart Code Analysis (push) Canceled after 0s
Test / Scripts unit tests (push) Canceled after 0s
Test / Test & Lint Server (push) Canceled after 0s
Test / Unit Test CLI (push) Canceled after 0s
Test / Unit Test CLI (Windows) (push) Canceled after 0s
Test / Lint Web (push) Canceled after 0s
Test / Test Web (push) Canceled after 0s
Test / Test i18n (push) Canceled after 0s
Test / End-to-End Lint (push) Canceled after 0s
Test / Medium Tests (Server) (push) Canceled after 0s
Test / End-to-End Tests (Server & CLI) (ubuntu-24.04-arm) (push) Canceled after 0s
Test / End-to-End Tests (Server & CLI) (ubuntu-latest) (push) Canceled after 0s
Test / End-to-End Tests (Web) (ubuntu-24.04-arm) (push) Canceled after 0s
Test / End-to-End Tests (Web) (ubuntu-latest) (push) Canceled after 0s
Test / End-to-End Tests Success (push) Canceled after 0s
Test / Unit Test Mobile (push) Canceled after 0s
Test / Unit Test ML (push) Canceled after 0s
Test / .github Files Formatting (push) Canceled after 0s
2026-10-29 20:44:43 +03:00
Zack PollardandCodex 9e5bdda89c chore(deps): update base-image to v202610291416 (#32810)
(cherry picked from commit a0c5b32fb8bfd3c3660e3a5a1ee038d3997bdd5b)
2026-10-29 20:43:01 +03:00
Zack PollardandCodex 79ea67f6be feat(server): compress database backups with pigz when it is installed (#32806)
(cherry picked from commit 97af09ca3cbd7f08ea033f176e777c9a9de8fdb5)
2026-10-29 20:43:01 +03:00
BaneinaandCodex 79b175465e fix(server): normalize bounding boxes when matching detected faces to existing faces (#32486)
(cherry picked from commit 5583cd7857a414f10039a9fb5bf03867bd4390c8)
2026-10-29 20:43:01 +03:00
Bautrell12andCodex c76bd08f73 feat(web): enable the shortcut "f" to favorite/unfavorite in multiselection mode (#31411)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit aeee9be333f4faac877b99cacd355329a5001c25)
2026-10-29 20:43:01 +03:00
Daniel DietzlerandCodex f2e05e9d98 chore: migrate oauth claim validation to zod (#32373)
(cherry picked from commit 0808f24a37cf376a509f1b4046cd52fd2d72b20d)
2026-10-29 20:43:01 +03:00
DawidKrynskiandCodex c8a957a116 fix(mobile): use context search once server features are loaded (#31800)
Co-authored-by: Dawid Krynski <188586034+DawidKrynski@users.noreply.github.com>
Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit be8e42526700d9ec5e72061e33f5039d50e40bae)
2026-10-29 20:43:01 +03:00
Ethan FerrierandCodex f7697264ed fix(mobile): ocr delete by asset (#32611)
(cherry picked from commit 56ed326acda2220fc7e3b21b17b3c8d24daf99cc)
2026-10-29 20:43:01 +03:00
Jason RasmussenandCodex 648dea2072 chore: remove isar reference (#32800)
(cherry picked from commit 323e750eb50ee0728110c8a1c5902c63cbda5850)
2026-10-29 20:42:12 +03:00
renovate[bot]andCodex 895b755f53 chore(deps): update base-image to v202610271109 (#32799)
(cherry picked from commit a56303f393ebb3a16f191a6f9a442d15285398cb)
2026-10-29 20:42:12 +03:00
24 changed files with 559 additions and 259 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://f
<MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" /> <MobileArchitecture className="p-4 dark:bg-immich-dark-primary my-4" />
The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture. The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture.
Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers). Currently, it uses [Drift](https://drift.simonbinder.eu/) (SQLite) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers).
Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory. Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory.
The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes! The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes!
+1 -1
View File
@@ -1,6 +1,6 @@
# Immich Mobile Application - Flutter # Immich Mobile Application - Flutter
The Immich mobile app is a Flutter-based solution leveraging the Isar Database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance. The Immich mobile app is a Flutter-based solution leveraging the Drift (SQLite) database for local storage and Riverpod for state management. This structure optimizes functionality and maintainability, allowing for efficient development and robust performance.
## Setup ## Setup
@@ -923,7 +923,7 @@ class SyncStreamRepository extends DatabaseAccessor<Drift> with $SyncStreamRepos
try { try {
await _db.batch((batch) { await _db.batch((batch) {
for (final assetOcr in data) { for (final assetOcr in data) {
batch.deleteWhere(_db.assetOcrEntity, (row) => row.id.equals(assetOcr.id)); batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetOcr.assetId));
} }
}); });
} catch (error, stack) { } catch (error, stack) {
@@ -42,12 +42,16 @@ class SearchPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures)); final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
final textSearchType = useState<TextSearchType>( // Stays null until the user picks a type, so the default follows the server features once they are loaded
serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename, final selectedTextSearchType = useState<TextSearchType?>(null);
); final textSearchType =
final searchHintText = useState<String>( selectedTextSearchType.value ?? (serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename);
serverFeatures.smartSearch ? context.t.sunrise_on_the_beach : context.t.file_name_or_extension, final searchHintText = switch (textSearchType) {
); TextSearchType.context => context.t.sunrise_on_the_beach,
TextSearchType.filename => context.t.file_name_or_extension,
TextSearchType.description => context.t.search_by_description_example,
TextSearchType.ocr => context.t.search_by_ocr_example,
};
final textSearchController = useTextEditingController(); final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>( final filter = useState<SearchFilter>(
SearchFilter( SearchFilter(
@@ -491,14 +495,14 @@ class SearchPage extends HookConsumerWidget {
); );
} }
void handleTextSubmitted(String value) => search(switch (textSearchType.value) { void handleTextSubmitted(String value) => search(switch (textSearchType) {
TextSearchType.context => filter.value.copyWith(filename: '', context: value, description: '', ocr: ''), TextSearchType.context => filter.value.copyWith(filename: '', context: value, description: '', ocr: ''),
TextSearchType.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''), TextSearchType.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''),
TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''), TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''),
TextSearchType.ocr => filter.value.copyWith(filename: '', context: '', description: '', ocr: value), TextSearchType.ocr => filter.value.copyWith(filename: '', context: '', description: '', ocr: value),
}); });
IconData getSearchPrefixIcon() => switch (textSearchType.value) { IconData getSearchPrefixIcon() => switch (textSearchType) {
TextSearchType.context => Icons.image_search_rounded, TextSearchType.context => Icons.image_search_rounded,
TextSearchType.filename => Icons.abc_rounded, TextSearchType.filename => Icons.abc_rounded,
TextSearchType.description => Icons.text_snippet_outlined, TextSearchType.description => Icons.text_snippet_outlined,
@@ -543,15 +547,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_context, context.t.search_by_context,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.context ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.context, selected: textSearchType == TextSearchType.context,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.context; selectedTextSearchType.value = TextSearchType.context;
searchHintText.value = context.t.sunrise_on_the_beach;
}, },
), ),
), ),
@@ -562,15 +565,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_filter_filename, context.t.search_filter_filename,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.filename ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.filename, selected: textSearchType == TextSearchType.filename,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.filename; selectedTextSearchType.value = TextSearchType.filename;
searchHintText.value = context.t.file_name_or_extension;
}, },
), ),
MenuItemButton( MenuItemButton(
@@ -580,15 +582,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_description, context.t.search_by_description,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.description ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.description, selected: textSearchType == TextSearchType.description,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.description; selectedTextSearchType.value = TextSearchType.description;
searchHintText.value = context.t.search_by_description_example;
}, },
), ),
FeatureCheck( FeatureCheck(
@@ -600,15 +601,14 @@ class SearchPage extends HookConsumerWidget {
context.t.search_by_ocr, context.t.search_by_ocr,
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null, color: textSearchType == TextSearchType.ocr ? context.colorScheme.primary : null,
), ),
), ),
selectedColor: context.colorScheme.primary, selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.ocr, selected: textSearchType == TextSearchType.ocr,
), ),
onPressed: () { onPressed: () {
textSearchType.value = TextSearchType.ocr; selectedTextSearchType.value = TextSearchType.ocr;
searchHintText.value = context.t.search_by_ocr_example;
}, },
), ),
), ),
@@ -631,7 +631,7 @@ class SearchPage extends HookConsumerWidget {
), ),
), ),
child: SearchField( child: SearchField(
hintText: searchHintText.value, hintText: searchHintText,
key: const Key('search_text_field'), key: const Key('search_text_field'),
controller: textSearchController, controller: textSearchController,
contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8), contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 3.3.5-local.13+3030505 version: 3.3.5-local.14+3030506
environment: environment:
sdk: '>=3.12.0 <4.0.0' sdk: '>=3.12.0 <4.0.0'
@@ -363,4 +363,65 @@ void main() {
expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id}); expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id});
}); });
}); });
group('SyncStreamRepository - deleteAssetOcrV1 (#32337)', () {
test('removes OCR rows for deleted assets', () async {
await sut.updateUsersV1([_createUser()]);
final known1 = _createAsset(
id: 'known1',
checksum: 'AAA',
fileName: 'a.jpg',
);
final known2 = _createAsset(
id: 'known2',
checksum: 'BBB',
fileName: 'b.jpg',
);
await sut.updateAssetsV1([known1, known2]);
await sut.updateAssetOcrV1([
SyncAssetOcrV1(
assetId: known1.id,
boxScore: 0.9,
id: 'ocr-1',
isVisible: true,
text: 'this text',
textScore: 0.9,
x1: 1,
x2: 2,
x3: 3,
x4: 4,
y1: 1,
y2: 2,
y3: 3,
y4: 4,
),
SyncAssetOcrV1(
assetId: known2.id,
boxScore: 0.8,
id: 'ocr-2',
isVisible: false,
text: 'this text again',
textScore: 0.8,
x1: 1,
x2: 2,
x3: 3,
x4: 4,
y1: 1,
y2: 2,
y3: 3,
y4: 4,
),
]);
await sut.deleteAssetOcrV1([
SyncAssetOcrDeleteV1(id: 'log-id', assetId: known1.id, deletedAt: DateTime(2024, 1, 5)),
]);
final removedRows = await (db.assetOcrEntity.select()..where((t) => t.assetId.equals(known1.id))).get();
expect(removedRows, isEmpty);
final keepRows = await (db.assetOcrEntity.select()..where((t) => t.assetId.equals(known2.id))).get();
expect(keepRows, hasLength(1));
});
});
} }
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/pages/search/search.page.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import '../../unit/presentation/presentation_context.dart';
/// Server info whose features arrive after the page is built, like on a slow or late connection
class DelayedServerInfoNotifier extends ServerInfoNotifier {
DelayedServerInfoNotifier(super.serverInfoService);
void enableSmartSearch() => state = state.copyWith(serverFeatures: state.serverFeatures.copyWith(smartSearch: true));
}
void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() async => context.dispose());
final contextSearchIcon = find.byIcon(Icons.image_search_rounded);
final filenameSearchIcon = find.byIcon(Icons.abc_rounded);
Future<DelayedServerInfoNotifier> pumpSearchPage(WidgetTester tester) async {
final serverInfo = DelayedServerInfoNotifier(context.service.serverInfo);
await tester.pumpTestWidget(
context,
const SearchPage(),
overrides: [serverInfoProvider.overrideWith((ref) => serverInfo)],
);
return serverInfo;
}
testWidgets('uses context search once smart search is reported by the server', (tester) async {
final serverInfo = await pumpSearchPage(tester);
expect(filenameSearchIcon, findsOneWidget);
serverInfo.enableSmartSearch();
await tester.pumpAndSettle();
expect(contextSearchIcon, findsOneWidget);
expect(filenameSearchIcon, findsNothing);
});
testWidgets('keeps the search type selected by the user', (tester) async {
final serverInfo = await pumpSearchPage(tester);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.text_snippet_outlined));
await tester.pumpAndSettle();
serverInfo.enableSmartSearch();
await tester.pumpAndSettle();
expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget);
expect(contextSearchIcon, findsNothing);
});
}
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS builder FROM ghcr.io/immich-app/base-server-dev:202610291416@sha256:5f4f9f77059e1cd0182994419a19f4ee045c3fd48e611f5e77623ed63f7aa1e4 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \ CI=1 \
COREPACK_HOME=/tmp \ COREPACK_HOME=/tmp \
@@ -86,7 +86,7 @@ RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202610211607@sha256:e50a1b36e2638dce42981ddfa84c134246c48fb2ad290fab6acfc1bb9b2bd891 FROM ghcr.io/immich-app/base-server-prod:202610291416@sha256:6211bec52cbccc19076889b0c39ef473a1a1d02ceb635ccd2e0ecb4683e5fd4d
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS dev FROM ghcr.io/immich-app/base-server-dev:202610291416@sha256:5f4f9f77059e1cd0182994419a19f4ee045c3fd48e611f5e77623ed63f7aa1e4 AS dev
COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise
+43
View File
@@ -1,8 +1,10 @@
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import z from 'zod'; import z from 'zod';
import type { UserMetadataItem } from 'src/types.js'; import type { UserMetadataItem } from 'src/types.js';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js'; import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
import { ImmichCookie, UserMetadataKey } from 'src/enum.js'; import { ImmichCookie, UserMetadataKey } from 'src/enum.js';
import { HumanReadableSize } from 'src/utils/bytes.js';
import { toEmail } from 'src/validation.js'; import { toEmail } from 'src/validation.js';
export type CookieResponse = { export type CookieResponse = {
@@ -23,6 +25,47 @@ export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited; export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
const storageLabelClaimSchema = z.string().transform((label) => sanitize(label));
const quotaSizeInBytesSchema = z
.int()
.min(-1)
.nullable()
.transform((quota) => (quota === null || quota === -1 ? null : quota * HumanReadableSize.GiB));
const userRoleSchema = z.union([z.literal('user'), z.literal('admin')]);
export const OAuthProfileSchema = z.object({
defaults: z.object({
immich_quota_bytes: quotaSizeInBytesSchema,
}),
sub: z.string(),
email: z.string().trim().toLowerCase().optional().catch(undefined),
email_verified: z.coerce.boolean().optional().catch(undefined),
name: z.string().trim().optional().catch(undefined),
given_name: z.string().trim().optional().catch(undefined),
family_name: z.string().trim().optional().catch(undefined),
preferred_username: z.string().trim().optional().catch(undefined),
picture: z.string().optional().catch(undefined),
immich_storage_label: storageLabelClaimSchema.optional().catch(undefined),
immich_quota_bytes: quotaSizeInBytesSchema.optional().catch(undefined),
immich_role: z
.union([userRoleSchema, z.array(userRoleSchema)])
.transform((value) => {
if (!value) {
return;
}
const roles = Array.isArray(value) ? value : [value];
if (roles.includes('admin')) {
return 'admin';
}
return 'user';
})
.optional()
.catch(undefined),
});
export type OAuthProfile = z.infer<typeof OAuthProfileSchema>;
const LoginCredentialSchema = z const LoginCredentialSchema = z
.object({ .object({
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }), email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
+30 -24
View File
@@ -15,26 +15,32 @@ import {
randomState, randomState,
skipSubjectCheck, skipSubjectCheck,
} from 'openid-client'; } from 'openid-client';
import { SystemConfig } from 'src/dtos/config.dto.js';
import { OAuthTokenEndpointAuthMethod } from 'src/enum.js'; import { OAuthTokenEndpointAuthMethod } from 'src/enum.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js';
export type OAuthConfig = { export type OAuthConfig = Pick<
clientId: string; SystemConfig['oauth'],
clientSecret?: string; | 'defaultStorageQuota'
issuerUrl: string; | 'storageLabelClaim'
accountManagementUrl: string; | 'storageQuotaClaim'
endSessionEndpoint: string; | 'roleClaim'
mobileOverrideEnabled: boolean; | 'clientId'
mobileRedirectUri: string; | 'clientSecret'
profileSigningAlgorithm: string; | 'issuerUrl'
prompt: string; | 'accountManagementUrl'
scope: string; | 'endSessionEndpoint'
signingAlgorithm: string; | 'mobileOverrideEnabled'
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; | 'mobileRedirectUri'
timeout: number; | 'profileSigningAlgorithm'
allowInsecureRequests: boolean; | 'prompt'
}; | 'scope'
export type OAuthProfile = UserInfoResponse; | 'signingAlgorithm'
| 'tokenEndpointAuthMethod'
| 'timeout'
| 'allowInsecureRequests'
>;
export type OAuthRawProfile = UserInfoResponse;
@Injectable() @Injectable()
export class OAuthRepository { export class OAuthRepository {
@@ -84,23 +90,23 @@ export class OAuthRepository {
url: string, url: string,
expectedState: string, expectedState: string,
codeVerifier: string, codeVerifier: string,
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> { ): Promise<{ rawProfile: OAuthRawProfile; sid?: string; idToken?: string }> {
const client = await this.getClient(config); const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined; const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
try { try {
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier }); const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
let profile: OAuthProfile; let rawProfile: OAuthRawProfile;
const tokenClaims = tokens.claims(); const tokenClaims = tokens.claims();
if (tokenClaims && 'email' in tokenClaims) { if (tokenClaims && 'email' in tokenClaims) {
this.logger.debug('Using ID token claims instead of userinfo endpoint'); this.logger.debug('Using ID token claims instead of userinfo endpoint');
profile = tokenClaims as OAuthProfile; rawProfile = tokenClaims;
} else { } else {
profile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck); rawProfile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
} }
if (!profile.sub) { if (!rawProfile.sub) {
throw new Error('Unexpected profile response, no `sub`'); throw new Error('Unexpected profile response, no `sub`');
} }
@@ -112,7 +118,7 @@ export class OAuthRepository {
} }
} }
return { profile, sid, idToken: tokens.id_token }; return { rawProfile, sid, idToken: tokens.id_token };
} catch (error: Error | any) { } catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) { if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn( this.logger.warn(
@@ -138,7 +144,7 @@ export class OAuthRepository {
return response.arrayBuffer(); return response.arrayBuffer();
} }
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performnce private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performance
async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> { async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> {
const client = await this.getClient(config); const client = await this.getClient(config);
const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256'; const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256';
@@ -27,6 +27,16 @@ describe(ProcessRepository.name, () => {
}); });
}); });
describe('isCommandAvailable', () => {
it('should find an executable on the PATH', async () => {
await expect(sut.isCommandAvailable('bash')).resolves.toBe(true);
});
it('should not find a missing executable', async () => {
await expect(sut.isCommandAvailable('immich-missing-command')).resolves.toBe(false);
});
});
describe('createSpawnDuplexStream', () => { describe('createSpawnDuplexStream', () => {
it('should work (drain to stdout)', async () => { it('should work (drain to stdout)', async () => {
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']); const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
@@ -1,10 +1,27 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SpawnOptionsWithoutStdio, fork, spawn } from 'node:child_process'; import { SpawnOptionsWithoutStdio, fork, spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { availableParallelism } from 'node:os';
import path from 'node:path';
import { Duplex } from 'node:stream'; import { Duplex } from 'node:stream';
@Injectable() @Injectable()
export class ProcessRepository { export class ProcessRepository {
spawn = spawn; spawn = spawn;
availableParallelism = availableParallelism;
async isCommandAvailable(command: string): Promise<boolean> {
const directories = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
const found = await Promise.all(
directories.map((directory) =>
access(path.join(directory, command), constants.X_OK)
.then(() => true)
.catch(() => false),
),
);
return found.includes(true);
}
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex { spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
let isStdinClosed = false; let isStdinClosed = false;
+86 -53
View File
@@ -719,7 +719,7 @@ describe(AuthService.name, () => {
it('should not allow auto registering', async () => { it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
await expect( await expect(
sut.callback( sut.callback(
@@ -734,10 +734,10 @@ describe(AuthService.name, () => {
it('should link an existing user', async () => { it('should link an existing user', async () => {
const user = UserFactory.create(); const user = UserFactory.create();
const profile = OAuthProfileFactory.create(); const rawProfile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -749,15 +749,19 @@ describe(AuthService.name, () => {
); );
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
}); });
it('should store the OAuth bearer token on the new session', async () => { it('should store the OAuth bearer token on the new session', async () => {
const user = UserFactory.create(); const user = UserFactory.create();
const profile = OAuthProfileFactory.create(); const rawProfile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile,
sid: 'oauth-sid',
idToken: 'oauth-bearer-token',
});
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -775,10 +779,10 @@ describe(AuthService.name, () => {
it('should normalize the email from the OAuth profile before linking', async () => { it('should normalize the email from the OAuth profile before linking', async () => {
const user = UserFactory.create(); const user = UserFactory.create();
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' }); const rawProfile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -790,14 +794,14 @@ describe(AuthService.name, () => {
); );
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud'); expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
}); });
it('should not link to a user with a different oauth sub', async () => { it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ oauthId: 'existing-sub' }); const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.user.getByEmail.mockResolvedValueOnce(user); mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -818,7 +822,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' })); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback( await sut.callback(
@@ -837,7 +841,7 @@ describe(AuthService.name, () => {
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create()); mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' } }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: { sub: 'sub' } });
await expect( await expect(
sut.callback( sut.callback(
@@ -859,7 +863,7 @@ describe(AuthService.name, () => {
it(`should use the mobile redirect override for a url of ${url}`, async () => { it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create()); mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails); await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
@@ -877,7 +881,9 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create(),
});
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' })); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -893,7 +899,7 @@ describe(AuthService.name, () => {
it('should infer name from given and family names', async () => { it('should infer name from given and family names', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }), rawProfile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
}); });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -910,10 +916,10 @@ describe(AuthService.name, () => {
}); });
it('should fallback to email when no username is provided', async () => { it('should fallback to email when no username is provided', async () => {
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined }); const rawProfile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create()); mocks.user.create.mockResolvedValue(UserFactory.create());
@@ -925,13 +931,13 @@ describe(AuthService.name, () => {
loginDetails, loginDetails,
); );
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email })); expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: rawProfile.email }));
}); });
it('should ignore an invalid storage quota', async () => { it('should ignore an invalid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }), rawProfile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
}); });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
@@ -950,7 +956,7 @@ describe(AuthService.name, () => {
it('should ignore an invalid negative quota', async () => { it('should ignore an invalid negative quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -5 }), rawProfile: OAuthProfileFactory.create({ immich_quota: -5 }),
}); });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
@@ -969,7 +975,7 @@ describe(AuthService.name, () => {
it('should set unlimited quota for -1', async () => { it('should set unlimited quota for -1', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -1 }), rawProfile: OAuthProfileFactory.create({ immich_quota: -1 }),
}); });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
@@ -987,7 +993,9 @@ describe(AuthService.name, () => {
it('should set quota for 0 quota', async () => { it('should set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create({ immich_quota: 0 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' })); mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -1004,7 +1012,9 @@ describe(AuthService.name, () => {
it('should use a valid storage quota', async () => { it('should use a valid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create({ immich_quota: 5 }),
});
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1023,11 +1033,11 @@ describe(AuthService.name, () => {
it('should sync the profile picture', async () => { it('should sync the profile picture', async () => {
const fileId = newUuid(); const fileId = newUuid();
const user = UserFactory.create({ oauthId: 'oauth-id' }); const user = UserFactory.create({ oauthId: 'oauth-id' });
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' }); const rawProfile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
const pictureBytes = new Uint8Array([1, 2, 3, 4, 5]); const pictureBytes = new Uint8Array([1, 2, 3, 4, 5]);
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId); mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue(pictureBytes.buffer); mocks.oauth.getProfilePicture.mockResolvedValue(pictureBytes.buffer);
@@ -1044,7 +1054,7 @@ describe(AuthService.name, () => {
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`), profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
profileChangedAt: expect.any(Date), profileChangedAt: expect.any(Date),
}); });
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture); expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(rawProfile.picture);
expect(mocks.media.decodeImage).toHaveBeenCalledWith( expect(mocks.media.decodeImage).toHaveBeenCalledWith(
Buffer.from(pictureBytes.buffer, pictureBytes.byteOffset, pictureBytes.byteLength), Buffer.from(pictureBytes.buffer, pictureBytes.byteOffset, pictureBytes.byteLength),
expect.objectContaining({ processInvalidImages: false }), expect.objectContaining({ processInvalidImages: false }),
@@ -1058,10 +1068,10 @@ describe(AuthService.name, () => {
it('should not update the user when thumbnail processing fails on the OAuth picture', async () => { it('should not update the user when thumbnail processing fails on the OAuth picture', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' }); const user = UserFactory.create({ oauthId: 'oauth-id' });
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' }); const rawProfile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.oauth.getProfilePicture.mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]).buffer); mocks.oauth.getProfilePicture.mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]).buffer);
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image')); mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
@@ -1085,7 +1095,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ rawProfile: OAuthProfileFactory.create({
sub: oauthId, sub: oauthId,
email: user.email, email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg', picture: 'https://auth.immich.cloud/profiles/1.jpg',
@@ -1108,7 +1118,7 @@ describe(AuthService.name, () => {
it('should only allow "admin" and "user" for the role claim', async () => { it('should only allow "admin" and "user" for the role claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'foo' }), rawProfile: OAuthProfileFactory.create({ immich_role: 'foo' }),
}); });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -1128,7 +1138,7 @@ describe(AuthService.name, () => {
it('should create an admin user if the role claim is set to admin', async () => { it('should create an admin user if the role claim is set to admin', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'admin' }), rawProfile: OAuthProfileFactory.create({ immich_role: 'admin' }),
}); });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1149,7 +1159,7 @@ describe(AuthService.name, () => {
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' }, oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
}); });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ my_role: 'admin' }), rawProfile: OAuthProfileFactory.create({ my_role: 'admin' }),
}); });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1168,7 +1178,7 @@ describe(AuthService.name, () => {
it('should create an admin user if the role claim is an array containing admin', async () => { it('should create an admin user if the role claim is an array containing admin', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }), rawProfile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }),
}); });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1187,7 +1197,7 @@ describe(AuthService.name, () => {
it('should create a standard user if the role claim is an array containing only user', async () => { it('should create a standard user if the role claim is an array containing only user', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: ['user'] }), rawProfile: OAuthProfileFactory.create({ immich_role: ['user'] }),
}); });
mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0); mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -1209,7 +1219,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: 5 }), rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: 5 }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 }); mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 });
@@ -1233,7 +1243,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: -1 }), rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_quota: -1 }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: null }); mocks.user.update.mockResolvedValue({ ...user, quotaSizeInBytes: null });
@@ -1257,7 +1267,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, preferred_username: 'idp-username' }), rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, preferred_username: 'idp-username' }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -1279,7 +1289,7 @@ describe(AuthService.name, () => {
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' }, oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
}); });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced-label' }), rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced-label' }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.getByStorageLabel.mockResolvedValue(void 0); mocks.user.getByStorageLabel.mockResolvedValue(void 0);
@@ -1305,7 +1315,7 @@ describe(AuthService.name, () => {
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' }, oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
}); });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced.label' }), rawProfile: OAuthProfileFactory.create({ sub: user.oauthId!, immich_label: 'synced.label' }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.getByStorageLabel.mockResolvedValue(void 0); mocks.user.getByStorageLabel.mockResolvedValue(void 0);
@@ -1330,7 +1340,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: 'admin' }), rawProfile: OAuthProfileFactory.create({ sub: oauthId, immich_role: 'admin' }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: true }); mocks.user.update.mockResolvedValue({ ...user, isAdmin: true });
@@ -1351,7 +1361,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: oauthId, immich_role: ['user'] }), rawProfile: OAuthProfileFactory.create({ sub: oauthId, immich_role: ['user'] }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...user, isAdmin: false }); mocks.user.update.mockResolvedValue({ ...user, isAdmin: false });
@@ -1372,7 +1382,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: oauthId }), rawProfile: OAuthProfileFactory.create({ sub: oauthId }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -1391,7 +1401,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: user.oauthId! }), rawProfile: OAuthProfileFactory.create({ sub: user.oauthId! }),
}); });
mocks.user.getByOAuthId.mockResolvedValue(user); mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -1406,15 +1416,38 @@ describe(AuthService.name, () => {
expect(mocks.user.syncUsage).not.toHaveBeenCalled(); expect(mocks.user.syncUsage).not.toHaveBeenCalled();
}); });
it('should update quota to default value on subsequent logins when it is absent from the profile', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId! }),
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValueOnce({ ...user, quotaSizeInBytes: null });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(
user.id,
expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }),
);
});
it('should re-evaluate the role claim for a user linked by email', async () => { it('should re-evaluate the role claim for a user linked by email', async () => {
const user = UserFactory.create({ isAdmin: false }); const user = UserFactory.create({ isAdmin: false });
const profile = OAuthProfileFactory.create({ immich_role: 'admin' }); const rawProfile = OAuthProfileFactory.create({ immich_role: 'admin' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.getByEmail.mockResolvedValue(user); mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub }); mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub });
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub, isAdmin: true }); mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub, isAdmin: true });
mocks.session.create.mockResolvedValue(SessionFactory.create()); mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback( await sut.callback(
@@ -1423,7 +1456,7 @@ describe(AuthService.name, () => {
loginDetails, loginDetails,
); );
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: rawProfile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true });
}); });
}); });
@@ -1432,10 +1465,10 @@ describe(AuthService.name, () => {
it('should link an account', async () => { it('should link an account', async () => {
const user = UserFactory.create(); const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build(); const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create(); const rawProfile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
mocks.user.update.mockResolvedValue(user); mocks.user.update.mockResolvedValue(user);
await sut.link( await sut.link(
@@ -1444,7 +1477,7 @@ describe(AuthService.name, () => {
{}, {},
); );
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: rawProfile.sub });
}); });
it('should link an account and update the session with the oauthSid', async () => { it('should link an account and update the session with the oauthSid', async () => {
@@ -1454,7 +1487,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' }, rawProfile: { sub: 'sub' },
sid: session.oauthSid ?? undefined, sid: session.oauthSid ?? undefined,
idToken: session.oauthBearerToken ?? undefined, idToken: session.oauthBearerToken ?? undefined,
}); });
@@ -1480,7 +1513,7 @@ describe(AuthService.name, () => {
const auth = { user: authUser, apiKey: authApiKey }; const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() }); mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin); mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect( await expect(
+52 -104
View File
@@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
import { parseCookie } from 'cookie'; import { parseCookie } from 'cookie';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import sanitize from 'sanitize-filename';
import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants.js'; import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants.js';
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js'; import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
import { import {
@@ -14,6 +13,8 @@ import {
OAuthBackchannelLogoutDto, OAuthBackchannelLogoutDto,
OAuthCallbackDto, OAuthCallbackDto,
OAuthConfigDto, OAuthConfigDto,
OAuthProfile,
OAuthProfileSchema,
PinCodeChangeDto, PinCodeChangeDto,
PinCodeResetDto, PinCodeResetDto,
PinCodeSetupDto, PinCodeSetupDto,
@@ -23,10 +24,9 @@ import {
import { SystemConfig, defaults } from 'src/dtos/config.dto.js'; import { SystemConfig, defaults } from 'src/dtos/config.dto.js';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.js';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum.js'; import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum.js';
import { OAuthProfile } from 'src/repositories/oauth.repository.js'; import { OAuthConfig, OAuthRawProfile } from 'src/repositories/oauth.repository.js';
import { BaseService } from 'src/services/base.service.js'; import { BaseService } from 'src/services/base.service.js';
import { isGranted } from 'src/utils/access.js'; import { isGranted } from 'src/utils/access.js';
import { HumanReadableSize } from 'src/utils/bytes.js';
import { generateProfileImage } from 'src/utils/profile-image.js'; import { generateProfileImage } from 'src/utils/profile-image.js';
import { getUserAgentDetails } from 'src/utils/request.js'; import { getUserAgentDetails } from 'src/utils/request.js';
@@ -38,18 +38,10 @@ export interface LoginDetails {
appVersion: string | null; appVersion: string | null;
} }
interface ClaimOptions<T> { type OAuthClaimsConfig = Pick<
key: string; SystemConfig['oauth'],
isValid: (value: unknown) => value is T; 'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim' | 'roleClaim'
parse?: (raw: unknown) => T; >;
}
type OAuthClaimsConfig = Pick<SystemConfig['oauth'], 'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim'>;
interface ParsedOAuthClaims {
storageLabel?: string | null;
quotaSizeInBytes?: number | null;
}
export type ValidateRequest = { export type ValidateRequest = {
headers: IncomingHttpHeaders; headers: IncomingHttpHeaders;
@@ -294,6 +286,18 @@ export class AuthService extends BaseService {
); );
} }
private parseOAuthClaims(profile: OAuthRawProfile, oauth: OAuthConfig) {
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
return OAuthProfileSchema.parse({
...profile,
defaults: { immich_quota_bytes: defaultStorageQuota },
immich_storage_label: profile[storageLabelClaim],
immich_quota_bytes: profile[storageQuotaClaim],
immich_role: profile[roleClaim],
});
}
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) { async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
const { oauth } = await this.getConfig({ withCache: false }); const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) { if (!oauth.enabled) {
@@ -312,18 +316,18 @@ export class AuthService extends BaseService {
const url = this.resolveRedirectUri(oauth, dto.url); const url = this.resolveRedirectUri(oauth, dto.url);
const { const {
profile, rawProfile,
sid: oauthSid, sid: oauthSid,
idToken: oauthBearerToken, idToken: oauthBearerToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier); } = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined; const profile = this.parseOAuthClaims(rawProfile, oauth);
const { autoRegister, roleClaim } = oauth; const { autoRegister } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub); let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
// link by email // link by email
if (!user && normalizedEmail) { if (!user && profile.email) {
const emailUser = await this.userRepository.getByEmail(normalizedEmail); const emailUser = await this.userRepository.getByEmail(profile.email);
if (emailUser) { if (emailUser) {
if (emailUser.oauthId) { if (emailUser.oauthId) {
this.logger.debug('OAuth login conflict: email already linked to different account'); this.logger.debug('OAuth login conflict: email already linked to different account');
@@ -333,10 +337,9 @@ export class AuthService extends BaseService {
} }
} }
const role = this.getRoleClaim(profile, roleClaim); const isAdmin = profile.immich_role === 'admin';
const isAdmin = role === 'admin';
if (user && role && isAdmin !== user.isAdmin) { if (user && profile.immich_role && isAdmin !== user.isAdmin) {
user = await this.userRepository.update(user.id, { isAdmin }); user = await this.userRepository.update(user.id, { isAdmin });
} }
@@ -346,29 +349,28 @@ export class AuthService extends BaseService {
} else { } else {
if (!autoRegister) { if (!autoRegister) {
this.logger.warn( this.logger.warn(
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`, `Unable to register ${profile.sub}/${profile.email || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`,
); );
throw new BadRequestException('OAuth authentication failed'); throw new BadRequestException('OAuth authentication failed');
} }
if (!normalizedEmail) { if (!profile.email) {
throw new BadRequestException('OAuth profile does not have an email address'); throw new BadRequestException('OAuth profile does not have an email address');
} }
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`); this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`);
const claims = this.parseOAuthClaims(profile, oauth, true);
user = await this.createUser({ user = await this.createUser({
name: name:
profile.name || profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() || `${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username || profile.preferred_username ||
normalizedEmail, profile.email,
email: normalizedEmail, email: profile.email,
oauthId: profile.sub, oauthId: profile.sub,
quotaSizeInBytes: claims.quotaSizeInBytes ?? null, quotaSizeInBytes:
storageLabel: claims.storageLabel ?? null, profile.immich_quota_bytes === undefined ? profile.defaults.immich_quota_bytes : profile.immich_quota_bytes,
storageLabel: profile.immich_storage_label ?? null,
isAdmin, isAdmin,
}); });
} }
@@ -415,11 +417,13 @@ export class AuthService extends BaseService {
} }
const { oauth } = await this.getConfig({ withCache: false }); const { oauth } = await this.getConfig({ withCache: false });
const { const { rawProfile, sid, idToken } = await this.oauthRepository.getProfileAndOAuthSid(
profile: { sub: oauthId }, oauth,
sid, dto.url,
idToken, expectedState,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier); codeVerifier,
);
const { sub: oauthId } = this.parseOAuthClaims(rawProfile, oauth);
const duplicate = await this.userRepository.getByOAuthId(oauthId); const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) { if (duplicate && duplicate.id !== auth.user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`); this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
@@ -611,73 +615,30 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null }); await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
} }
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T | undefined {
const raw = profile[options.key as keyof OAuthProfile];
const value = options.parse ? options.parse(raw) : raw;
return options.isValid(value) ? value : undefined;
}
private parseOAuthClaims(profile: OAuthProfile, oauth: OAuthClaimsConfig, useDefaults: boolean): ParsedOAuthClaims {
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
const claims: ParsedOAuthClaims = {};
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
isValid: (value): value is string => typeof value === 'string',
});
if (useDefaults) {
claims.storageLabel = storageLabel || null;
}
// Default claim (preferred_username) is registration-only; login sync is opt-in.
else if (storageLabelClaim !== defaults.oauth.storageLabelClaim && storageLabel !== undefined) {
claims.storageLabel = this.formatStorageLabel(storageLabel);
}
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
parse: Number,
isValid: (value): value is number =>
typeof value === 'number' && Number.isFinite(value) && (value === -1 || value >= 0),
});
if (storageQuota !== undefined) {
claims.quotaSizeInBytes = storageQuota === -1 ? null : storageQuota * HumanReadableSize.GiB;
} else if (useDefaults) {
claims.quotaSizeInBytes =
defaultStorageQuota === null || defaultStorageQuota === -1 ? null : defaultStorageQuota * HumanReadableSize.GiB;
}
return claims;
}
private formatStorageLabel(label: string): string | null {
if (!label) {
return null;
}
return sanitize(label);
}
private async syncOAuthClaims(user: UserAdmin, profile: OAuthProfile, oauth: OAuthClaimsConfig): Promise<UserAdmin> { private async syncOAuthClaims(user: UserAdmin, profile: OAuthProfile, oauth: OAuthClaimsConfig): Promise<UserAdmin> {
const claims = this.parseOAuthClaims(profile, oauth, false);
const updates: { const updates: {
storageLabel?: string | null; storageLabel?: string | null;
quotaSizeInBytes?: number | null; quotaSizeInBytes?: number | null;
} = {}; } = {};
if (claims.storageLabel && claims.storageLabel !== user.storageLabel) { if (
const duplicate = await this.userRepository.getByStorageLabel(claims.storageLabel); oauth.storageLabelClaim !== defaults.oauth.storageLabelClaim &&
profile.immich_storage_label &&
profile.immich_storage_label !== user.storageLabel
) {
const duplicate = await this.userRepository.getByStorageLabel(profile.immich_storage_label);
if (duplicate && duplicate.id !== user.id) { if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`); this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`);
} else { } else {
updates.storageLabel = claims.storageLabel; updates.storageLabel = profile.immich_storage_label;
} }
} }
if (claims.quotaSizeInBytes !== undefined && claims.quotaSizeInBytes !== user.quotaSizeInBytes) { if (profile.immich_quota_bytes === undefined && user.quotaSizeInBytes !== profile.defaults.immich_quota_bytes) {
updates.quotaSizeInBytes = claims.quotaSizeInBytes; updates.quotaSizeInBytes = profile.defaults.immich_quota_bytes;
} else if (profile.immich_quota_bytes !== undefined && profile.immich_quota_bytes !== user.quotaSizeInBytes) {
updates.quotaSizeInBytes = profile.immich_quota_bytes;
} }
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0) {
@@ -691,19 +652,6 @@ export class AuthService extends BaseService {
return this.userRepository.update(user.id, { ...updates, updatedAt: new Date() }); return this.userRepository.update(user.id, { ...updates, updatedAt: new Date() });
} }
private getRoleClaim(profile: OAuthProfile, roleClaim: string): 'admin' | 'user' | undefined {
const value = profile[roleClaim as keyof OAuthProfile];
const roles = Array.isArray(value) ? value : [value];
const isRole = (role: string) => roles.includes(role);
if (isRole('admin')) {
return 'admin';
}
if (isRole('user')) {
return 'user';
}
}
private resolveRedirectUri( private resolveRedirectUri(
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean }, { mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
url: string, url: string,
@@ -16,6 +16,7 @@ describe(DatabaseBackupService.name, () => {
beforeEach(() => { beforeEach(() => {
mocks = getMocks(); mocks = getMocks();
mocks.process.isCommandAvailable.mockResolvedValue(false);
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, { maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
args: [mocks.logger], args: [mocks.logger],
strict: false, strict: false,
@@ -228,6 +229,19 @@ describe(DatabaseBackupService.name, () => {
expect(mocks.storage.rename).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalled();
}); });
it('should compress with gzip when pigz is not installed', async () => {
await sut.handleBackupDatabase();
expect(mocks.process.isCommandAvailable).toHaveBeenCalledWith('pigz');
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('gzip', ['--rsyncable']);
});
it('should compress with pigz on every available core when it is installed', async () => {
mocks.process.isCommandAvailable.mockResolvedValue(true);
mocks.process.availableParallelism.mockReturnValue(8);
await sut.handleBackupDatabase();
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('pigz', ['--rsyncable', '--processes', '8']);
});
it('should fail if pg_dump fails', async () => { it('should fail if pg_dump fails', async () => {
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex()('pg_dump', 1, '', 'error')); mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex()('pg_dump', 1, '', 'error'));
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)'); await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
+12 -1
View File
@@ -236,6 +236,8 @@ export class DatabaseBackupService {
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename); const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
const temporaryFilePath = `${backupFilePath}.tmp`; const temporaryFilePath = `${backupFilePath}.tmp`;
const compression = await this.getCompressionCommand();
let pgdump: Duplex | undefined; let pgdump: Duplex | undefined;
let gzip: Duplex | undefined; let gzip: Duplex | undefined;
@@ -247,7 +249,7 @@ export class DatabaseBackupService {
}, },
}); });
gzip = this.processRepository.spawnDuplexStream('gzip', ['--rsyncable']); gzip = this.processRepository.spawnDuplexStream(compression.command, compression.args);
const fileStream = this.storageRepository.createWriteStream(temporaryFilePath); const fileStream = this.storageRepository.createWriteStream(temporaryFilePath);
await pipeline(pgdump, gzip, fileStream); await pipeline(pgdump, gzip, fileStream);
@@ -267,6 +269,15 @@ export class DatabaseBackupService {
return backupFilePath; return backupFilePath;
} }
private async getCompressionCommand() {
if (await this.processRepository.isCommandAvailable('pigz')) {
const processes = this.processRepository.availableParallelism();
return { command: 'pigz', args: ['--rsyncable', '--processes', processes.toString()] };
}
return { command: 'gzip', args: ['--rsyncable'] };
}
async uploadBackup(file: Express.Multer.File): Promise<void> { async uploadBackup(file: Express.Multer.File): Promise<void> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const fn = basename(file.originalname); const fn = basename(file.originalname);
@@ -1213,6 +1213,66 @@ describe(PersonService.name, () => {
expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
}); });
it('should match metadata face stored at a different resolution', async () => {
const face = AssetFaceFactory.create({
sourceType: SourceType.Exif,
imageWidth: 2736,
imageHeight: 3648,
boundingBoxX1: 1225,
boundingBoxY1: 1453,
boundingBoxX2: 1436,
boundingBoxY2: 1732,
});
const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).exif().build();
mocks.machineLearning.detectFaces.mockResolvedValue({
imageWidth: 1440,
imageHeight: 1920,
faces: [{ boundingBox: { x1: 645, y1: 765, x2: 756, y2: 912 }, embedding: '[1, 2, 3, 4]', score: 0.9 }],
});
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
await sut.handleDetectFaces({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith([], [], [{ faceId: face.id, embedding: '[1, 2, 3, 4]' }]);
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
it('should prefer metadata face over overlapping machine learning face', async () => {
const mlFace = AssetFaceFactory.create({
imageWidth: 1440,
imageHeight: 1920,
boundingBoxX1: 645,
boundingBoxY1: 765,
boundingBoxX2: 756,
boundingBoxY2: 912,
});
const exifFace = AssetFaceFactory.create({
sourceType: SourceType.Exif,
imageWidth: 2736,
imageHeight: 3648,
boundingBoxX1: 1225,
boundingBoxY1: 1453,
boundingBoxX2: 1436,
boundingBoxY2: 1732,
});
const asset = AssetFactory.from()
.face(mlFace)
.face(exifFace)
.file({ type: AssetFileType.Preview })
.exif()
.build();
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(mlFace));
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
await sut.handleDetectFaces({ id: asset.id });
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[],
[mlFace.id],
[{ faceId: exifFace.id, embedding: '[1, 2, 3, 4]' }],
);
});
it('should not add embedding to non-matching metadata face', async () => { it('should not add embedding to non-matching metadata face', async () => {
const assetId = newUuid(); const assetId = newUuid();
const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif }); const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif });
+32 -16
View File
@@ -414,16 +414,10 @@ export class PersonService extends BaseService {
} }
} }
const heightScale = imageHeight / (asset.faces[0]?.imageHeight || 1);
const widthScale = imageWidth / (asset.faces[0]?.imageWidth || 1);
for (const { boundingBox, embedding } of faces) { for (const { boundingBox, embedding } of faces) {
const scaledBox = { const matches = asset.faces.filter((face) => this.iou(face, boundingBox, imageWidth, imageHeight) > 0.5);
x1: boundingBox.x1 * widthScale, // prefer metadata faces over machine learning duplicates
y1: boundingBox.y1 * heightScale, const match = matches.find((face) => face.sourceType !== SourceType.MachineLearning) ?? matches[0];
x2: boundingBox.x2 * widthScale,
y2: boundingBox.y2 * heightScale,
};
const match = asset.faces.find((face) => this.iou(face, scaledBox) > 0.5);
if (match && !mlFaceIds.delete(match.id)) { if (match && !mlFaceIds.delete(match.id)) {
embeddings.push({ faceId: match.id, embedding }); embeddings.push({ faceId: match.id, embedding });
@@ -466,17 +460,39 @@ export class PersonService extends BaseService {
} }
private iou( private iou(
face: { boundingBoxX1: number; boundingBoxY1: number; boundingBoxX2: number; boundingBoxY2: number }, face: {
boundingBoxX1: number;
boundingBoxY1: number;
boundingBoxX2: number;
boundingBoxY2: number;
imageWidth: number;
imageHeight: number;
},
newBox: BoundingBox, newBox: BoundingBox,
imageWidth: number,
imageHeight: number,
): number { ): number {
const x1 = Math.max(face.boundingBoxX1, newBox.x1); const box1 = {
const y1 = Math.max(face.boundingBoxY1, newBox.y1); x1: face.boundingBoxX1 / face.imageWidth,
const x2 = Math.min(face.boundingBoxX2, newBox.x2); y1: face.boundingBoxY1 / face.imageHeight,
const y2 = Math.min(face.boundingBoxY2, newBox.y2); x2: face.boundingBoxX2 / face.imageWidth,
y2: face.boundingBoxY2 / face.imageHeight,
};
const box2 = {
x1: newBox.x1 / imageWidth,
y1: newBox.y1 / imageHeight,
x2: newBox.x2 / imageWidth,
y2: newBox.y2 / imageHeight,
};
const x1 = Math.max(box1.x1, box2.x1);
const y1 = Math.max(box1.y1, box2.y1);
const x2 = Math.min(box1.x2, box2.x2);
const y2 = Math.min(box1.y2, box2.y2);
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1); const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
const area1 = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1); const area1 = (box1.x2 - box1.x1) * (box1.y2 - box1.y1);
const area2 = (newBox.x2 - newBox.x1) * (newBox.y2 - newBox.y1); const area2 = (box2.x2 - box2.x1) * (box2.y2 - box2.y1);
const union = area1 + area2 - intersection; const union = area1 + area2 - intersection;
return intersection / union; return intersection / union;
@@ -1,9 +1,9 @@
import { OAuthProfile } from 'src/repositories/oauth.repository.js'; import { OAuthRawProfile } from 'src/repositories/oauth.repository.js';
import { OAuthProfileLike } from 'test/factories/types.js'; import { OAuthProfileLike } from 'test/factories/types.js';
import { newUuid } from 'test/small.factory.js'; import { newUuid } from 'test/small.factory.js';
export class OAuthProfileFactory { export class OAuthProfileFactory {
private constructor(private value: OAuthProfile) {} private constructor(private value: OAuthRawProfile) {}
static create(dto: OAuthProfileLike = {}) { static create(dto: OAuthProfileLike = {}) {
return OAuthProfileFactory.from(dto).build(); return OAuthProfileFactory.from(dto).build();
+2 -2
View File
@@ -1,7 +1,7 @@
import { Selectable } from 'kysely'; import { Selectable } from 'kysely';
import { PersonUser } from 'src/database.js'; import { PersonUser } from 'src/database.js';
import { PersonUserRole } from 'src/dtos/person.dto.js'; import { PersonUserRole } from 'src/dtos/person.dto.js';
import { OAuthProfile } from 'src/repositories/oauth.repository.js'; import { OAuthRawProfile } from 'src/repositories/oauth.repository.js';
import { ActivityTable } from 'src/schema/tables/activity.table.js'; import { ActivityTable } from 'src/schema/tables/activity.table.js';
import { AlbumUserTable } from 'src/schema/tables/album-user.table.js'; import { AlbumUserTable } from 'src/schema/tables/album-user.table.js';
import { AlbumTable } from 'src/schema/tables/album.table.js'; import { AlbumTable } from 'src/schema/tables/album.table.js';
@@ -48,4 +48,4 @@ export type ActivityLike = Partial<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>; export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type PasskeyLike = Partial<Selectable<PasskeyTable>>; export type PasskeyLike = Partial<Selectable<PasskeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>; export type SessionLike = Partial<Selectable<SessionTable>>;
export type OAuthProfileLike = Partial<OAuthProfile>; export type OAuthProfileLike = Partial<OAuthRawProfile>;
@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import MenuOption from '#lib/components/shared-components/context-menu/MenuOption.svelte'; import MenuOption from '#lib/components/shared-components/context-menu/MenuOption.svelte';
import { assetMultiSelectManager } from '#lib/managers/asset-multi-select-manager.svelte.ts'; import { assetMultiSelectManager } from '#lib/managers/asset-multi-select-manager.svelte.ts';
import { toggleFavoriteAssets } from '#lib/services/asset.service.ts';
import type { OnFavorite } from '#lib/utils/actions.ts'; import type { OnFavorite } from '#lib/utils/actions.ts';
import { handleError } from '#lib/utils/handle-error.ts'; import { IconButton } from '@immich/ui';
import { updateAssets } from '@immich/sdk';
import { IconButton, toastManager } from '@immich/ui';
import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js'; import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -27,28 +26,8 @@
try { try {
const assets = assetMultiSelectManager.ownedAssets.filter((asset) => asset.isFavorite !== isFavorite); const assets = assetMultiSelectManager.ownedAssets.filter((asset) => asset.isFavorite !== isFavorite);
await toggleFavoriteAssets(assets, isFavorite, onFavorite);
const ids = assets.map(({ id }) => id);
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
toastManager.primary(
isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
);
assetMultiSelectManager.clear(); assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
} finally { } finally {
loading = false; loading = false;
} }
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '#lib/actions/shortcut.ts'; import { shortcuts, type ShortcutOptions } from '#lib/actions/shortcut.ts';
import { import {
setFocusToAsset as setFocusAssetInit, setFocusToAsset as setFocusAssetInit,
@@ -14,6 +13,7 @@
import NavigateToDateModal from '#lib/modals/NavigateToDateModal.svelte'; import NavigateToDateModal from '#lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '#lib/modals/ShortcutsModal.svelte'; import ShortcutsModal from '#lib/modals/ShortcutsModal.svelte';
import { Route } from '#lib/route.ts'; import { Route } from '#lib/route.ts';
import { toggleFavoriteAssets } from '#lib/services/asset.service.ts';
import { handleStack } from '#lib/services/stack.service.ts'; import { handleStack } from '#lib/services/stack.service.ts';
import { keyboardManager } from '#lib/stores/keyboard-manager.svelte.ts'; import { keyboardManager } from '#lib/stores/keyboard-manager.svelte.ts';
import { showDeleteModal } from '#lib/stores/preferences.store.ts'; import { showDeleteModal } from '#lib/stores/preferences.store.ts';
@@ -21,6 +21,7 @@
import { handlePromiseError } from '#lib/utils.ts'; import { handlePromiseError } from '#lib/utils.ts';
import { deleteAssets } from '#lib/utils/actions.ts'; import { deleteAssets } from '#lib/utils/actions.ts';
import { selectAllAssets } from '#lib/utils/asset-utils.ts'; import { selectAllAssets } from '#lib/utils/asset-utils.ts';
import { goto } from '$app/navigation';
import { isModalOpen, modalManager } from '@immich/ui'; import { isModalOpen, modalManager } from '@immich/ui';
type Props = { type Props = {
@@ -62,6 +63,14 @@
onEscape?.(); onEscape?.();
}; };
const toggleFavoriteSelected = async () => {
const allAssets = assetInteraction.ownedAssets;
const isFavorite = allAssets.some((a) => !a.isFavorite);
const assetsToUpdate = allAssets.filter((asset) => asset.isFavorite !== isFavorite);
await toggleFavoriteAssets(assetsToUpdate, isFavorite);
assetInteraction.clear();
};
const onSelectStart = (e: Event) => { const onSelectStart = (e: Event) => {
if (!searchStore.isSearchEnabled && assetInteraction.selectionActive && keyboardManager.shift) { if (!searchStore.isSearchEnabled && assetInteraction.selectionActive && keyboardManager.shift) {
e.preventDefault(); e.preventDefault();
@@ -125,6 +134,7 @@
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) }, { shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => assetInteraction.clear() }, { shortcut: { key: 'D', ctrl: true }, onShortcut: () => assetInteraction.clear() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() }, { shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'f' }, onShortcut: () => toggleFavoriteSelected() },
); );
} else { } else {
// conflicting shortcuts // conflicting shortcuts
+32
View File
@@ -15,6 +15,7 @@ import { Route } from '#lib/route.ts';
import { SlideshowState, slideshowStore } from '#lib/stores/slideshow.store.ts'; import { SlideshowState, slideshowStore } from '#lib/stores/slideshow.store.ts';
import { getAssetMediaUrl, getSharedLink, sleep } from '#lib/utils.ts'; import { getAssetMediaUrl, getSharedLink, sleep } from '#lib/utils.ts';
import { downloadUrl } from '#lib/utils.ts'; import { downloadUrl } from '#lib/utils.ts';
import type { OnFavorite } from '#lib/utils/actions.ts';
import { handleError } from '#lib/utils/handle-error.ts'; import { handleError } from '#lib/utils/handle-error.ts';
import { getFormatter } from '#lib/utils/i18n.ts'; import { getFormatter } from '#lib/utils/i18n.ts';
import { import {
@@ -63,6 +64,8 @@ import {
mdiTune, mdiTune,
} from '@mdi/js'; } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export const getAssetBulkActions = ($t: MessageFormatter, album?: AlbumResponseDto) => { export const getAssetBulkActions = ($t: MessageFormatter, album?: AlbumResponseDto) => {
@@ -496,6 +499,35 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
} }
}; };
export const toggleFavoriteAssets = async (
assets: Pick<AssetResponseDto, 'id' | 'isFavorite'>[],
isFavorite: boolean,
onFavorite?: OnFavorite,
) => {
const ids = assets.map((a) => a.id);
const $t = get(t);
try {
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
}
for (const asset of assets) {
asset.isFavorite = isFavorite;
}
onFavorite?.(ids, isFavorite);
toastManager.primary(
isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),
);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
}
};
const handleRate = async (asset: AssetResponseDto, rating: number) => { const handleRate = async (asset: AssetResponseDto, rating: number) => {
const $t = await getFormatter(); const $t = await getFormatter();