Compare commits
10
Commits
568a9d977e
...
132af2fc74
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132af2fc74 | ||
|
|
9e5bdda89c | ||
|
|
79ea67f6be | ||
|
|
79b175465e | ||
|
|
c76bd08f73 | ||
|
|
f2e05e9d98 | ||
|
|
c8a957a116 | ||
|
|
f7697264ed | ||
|
|
648dea2072 | ||
|
|
895b755f53 |
@@ -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" />
|
||||
|
||||
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.
|
||||
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
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -923,7 +923,7 @@ class SyncStreamRepository extends DatabaseAccessor<Drift> with $SyncStreamRepos
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
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) {
|
||||
|
||||
@@ -42,12 +42,16 @@ class SearchPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverFeatures = ref.watch(serverInfoProvider.select((v) => v.serverFeatures));
|
||||
final textSearchType = useState<TextSearchType>(
|
||||
serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename,
|
||||
);
|
||||
final searchHintText = useState<String>(
|
||||
serverFeatures.smartSearch ? context.t.sunrise_on_the_beach : context.t.file_name_or_extension,
|
||||
);
|
||||
// Stays null until the user picks a type, so the default follows the server features once they are loaded
|
||||
final selectedTextSearchType = useState<TextSearchType?>(null);
|
||||
final textSearchType =
|
||||
selectedTextSearchType.value ?? (serverFeatures.smartSearch ? TextSearchType.context : TextSearchType.filename);
|
||||
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 filter = useState<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.filename => filter.value.copyWith(filename: value, context: '', description: '', ocr: ''),
|
||||
TextSearchType.description => filter.value.copyWith(filename: '', context: '', description: value, ocr: ''),
|
||||
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.filename => Icons.abc_rounded,
|
||||
TextSearchType.description => Icons.text_snippet_outlined,
|
||||
@@ -543,15 +547,14 @@ class SearchPage extends HookConsumerWidget {
|
||||
context.t.search_by_context,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
|
||||
color: textSearchType == TextSearchType.context ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.context,
|
||||
selected: textSearchType == TextSearchType.context,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.context;
|
||||
searchHintText.value = context.t.sunrise_on_the_beach;
|
||||
selectedTextSearchType.value = TextSearchType.context;
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -562,15 +565,14 @@ class SearchPage extends HookConsumerWidget {
|
||||
context.t.search_filter_filename,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null,
|
||||
color: textSearchType == TextSearchType.filename ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.filename,
|
||||
selected: textSearchType == TextSearchType.filename,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.filename;
|
||||
searchHintText.value = context.t.file_name_or_extension;
|
||||
selectedTextSearchType.value = TextSearchType.filename;
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
@@ -580,15 +582,14 @@ class SearchPage extends HookConsumerWidget {
|
||||
context.t.search_by_description,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null,
|
||||
color: textSearchType == TextSearchType.description ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.description,
|
||||
selected: textSearchType == TextSearchType.description,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.description;
|
||||
searchHintText.value = context.t.search_by_description_example;
|
||||
selectedTextSearchType.value = TextSearchType.description;
|
||||
},
|
||||
),
|
||||
FeatureCheck(
|
||||
@@ -600,15 +601,14 @@ class SearchPage extends HookConsumerWidget {
|
||||
context.t.search_by_ocr,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null,
|
||||
color: textSearchType == TextSearchType.ocr ? context.colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
selectedColor: context.colorScheme.primary,
|
||||
selected: textSearchType.value == TextSearchType.ocr,
|
||||
selected: textSearchType == TextSearchType.ocr,
|
||||
),
|
||||
onPressed: () {
|
||||
textSearchType.value = TextSearchType.ocr;
|
||||
searchHintText.value = context.t.search_by_ocr_example;
|
||||
selectedTextSearchType.value = TextSearchType.ocr;
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -631,7 +631,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
child: SearchField(
|
||||
hintText: searchHintText.value,
|
||||
hintText: searchHintText,
|
||||
key: const Key('search_text_field'),
|
||||
controller: textSearchController,
|
||||
contentPadding: filter.value.assetId != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.3.5-local.13+3030505
|
||||
version: 3.3.5-local.14+3030506
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -363,4 +363,65 @@ void main() {
|
||||
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
@@ -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 \
|
||||
CI=1 \
|
||||
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 \
|
||||
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
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import z from 'zod';
|
||||
import type { UserMetadataItem } from 'src/types.js';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
|
||||
import { ImmichCookie, UserMetadataKey } from 'src/enum.js';
|
||||
import { HumanReadableSize } from 'src/utils/bytes.js';
|
||||
import { toEmail } from 'src/validation.js';
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
.object({
|
||||
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
|
||||
|
||||
@@ -15,26 +15,32 @@ import {
|
||||
randomState,
|
||||
skipSubjectCheck,
|
||||
} from 'openid-client';
|
||||
import { SystemConfig } from 'src/dtos/config.dto.js';
|
||||
import { OAuthTokenEndpointAuthMethod } from 'src/enum.js';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository.js';
|
||||
|
||||
export type OAuthConfig = {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
issuerUrl: string;
|
||||
accountManagementUrl: string;
|
||||
endSessionEndpoint: string;
|
||||
mobileOverrideEnabled: boolean;
|
||||
mobileRedirectUri: string;
|
||||
profileSigningAlgorithm: string;
|
||||
prompt: string;
|
||||
scope: string;
|
||||
signingAlgorithm: string;
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
|
||||
timeout: number;
|
||||
allowInsecureRequests: boolean;
|
||||
};
|
||||
export type OAuthProfile = UserInfoResponse;
|
||||
export type OAuthConfig = Pick<
|
||||
SystemConfig['oauth'],
|
||||
| 'defaultStorageQuota'
|
||||
| 'storageLabelClaim'
|
||||
| 'storageQuotaClaim'
|
||||
| 'roleClaim'
|
||||
| 'clientId'
|
||||
| 'clientSecret'
|
||||
| 'issuerUrl'
|
||||
| 'accountManagementUrl'
|
||||
| 'endSessionEndpoint'
|
||||
| 'mobileOverrideEnabled'
|
||||
| 'mobileRedirectUri'
|
||||
| 'profileSigningAlgorithm'
|
||||
| 'prompt'
|
||||
| 'scope'
|
||||
| 'signingAlgorithm'
|
||||
| 'tokenEndpointAuthMethod'
|
||||
| 'timeout'
|
||||
| 'allowInsecureRequests'
|
||||
>;
|
||||
export type OAuthRawProfile = UserInfoResponse;
|
||||
|
||||
@Injectable()
|
||||
export class OAuthRepository {
|
||||
@@ -84,23 +90,23 @@ export class OAuthRepository {
|
||||
url: string,
|
||||
expectedState: string,
|
||||
codeVerifier: string,
|
||||
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
|
||||
): Promise<{ rawProfile: OAuthRawProfile; sid?: string; idToken?: string }> {
|
||||
const client = await this.getClient(config);
|
||||
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||
|
||||
try {
|
||||
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
||||
|
||||
let profile: OAuthProfile;
|
||||
let rawProfile: OAuthRawProfile;
|
||||
const tokenClaims = tokens.claims();
|
||||
if (tokenClaims && 'email' in tokenClaims) {
|
||||
this.logger.debug('Using ID token claims instead of userinfo endpoint');
|
||||
profile = tokenClaims as OAuthProfile;
|
||||
rawProfile = tokenClaims;
|
||||
} 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`');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
if (error.message.includes('unexpected JWT alg received')) {
|
||||
this.logger.warn(
|
||||
@@ -138,7 +144,7 @@ export class OAuthRepository {
|
||||
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> {
|
||||
const client = await this.getClient(config);
|
||||
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', () => {
|
||||
it('should work (drain to stdout)', async () => {
|
||||
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class ProcessRepository {
|
||||
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 {
|
||||
let isStdinClosed = false;
|
||||
|
||||
@@ -719,7 +719,7 @@ describe(AuthService.name, () => {
|
||||
it('should not allow auto registering', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: OAuthProfileFactory.create() });
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -734,10 +734,10 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
const rawProfile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -749,15 +749,19 @@ describe(AuthService.name, () => {
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
const rawProfile = OAuthProfileFactory.create();
|
||||
|
||||
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.update.mockResolvedValue(user);
|
||||
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 () => {
|
||||
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.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -790,14 +794,14 @@ describe(AuthService.name, () => {
|
||||
);
|
||||
|
||||
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 () => {
|
||||
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
||||
|
||||
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.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
|
||||
@@ -818,7 +822,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
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());
|
||||
|
||||
await sut.callback(
|
||||
@@ -837,7 +841,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' } });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile: { sub: 'sub' } });
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -859,7 +863,7 @@ describe(AuthService.name, () => {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
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());
|
||||
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
@@ -877,7 +881,9 @@ describe(AuthService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
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.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
@@ -893,7 +899,7 @@ describe(AuthService.name, () => {
|
||||
it('should infer name from given and family names', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
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.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
@@ -910,10 +916,10 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
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.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
@@ -925,13 +931,13 @@ describe(AuthService.name, () => {
|
||||
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 () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
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.getByEmail.mockResolvedValue(void 0);
|
||||
@@ -950,7 +956,7 @@ describe(AuthService.name, () => {
|
||||
it('should ignore an invalid negative quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
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.getByEmail.mockResolvedValue(void 0);
|
||||
@@ -969,7 +975,7 @@ describe(AuthService.name, () => {
|
||||
it('should set unlimited quota for -1', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
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.getByEmail.mockResolvedValue(void 0);
|
||||
@@ -987,7 +993,9 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should set quota for 0 quota', async () => {
|
||||
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.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
@@ -1004,7 +1012,9 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
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.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
@@ -1023,11 +1033,11 @@ describe(AuthService.name, () => {
|
||||
it('should sync the profile picture', async () => {
|
||||
const fileId = newUuid();
|
||||
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]);
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||
mocks.oauth.getProfilePicture.mockResolvedValue(pictureBytes.buffer);
|
||||
@@ -1044,7 +1054,7 @@ describe(AuthService.name, () => {
|
||||
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
|
||||
profileChangedAt: expect.any(Date),
|
||||
});
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(rawProfile.picture);
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(
|
||||
Buffer.from(pictureBytes.buffer, pictureBytes.byteOffset, pictureBytes.byteLength),
|
||||
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 () => {
|
||||
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.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.oauth.getProfilePicture.mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]).buffer);
|
||||
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
|
||||
@@ -1085,7 +1095,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({
|
||||
rawProfile: OAuthProfileFactory.create({
|
||||
sub: oauthId,
|
||||
email: user.email,
|
||||
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 () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
|
||||
rawProfile: OAuthProfileFactory.create({ immich_role: 'foo' }),
|
||||
});
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
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 () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
|
||||
rawProfile: OAuthProfileFactory.create({ immich_role: 'admin' }),
|
||||
});
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
@@ -1149,7 +1159,7 @@ describe(AuthService.name, () => {
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
|
||||
});
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
|
||||
rawProfile: OAuthProfileFactory.create({ my_role: 'admin' }),
|
||||
});
|
||||
mocks.user.getByEmail.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 () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
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.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 () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ immich_role: ['user'] }),
|
||||
rawProfile: OAuthProfileFactory.create({ immich_role: ['user'] }),
|
||||
});
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
@@ -1209,7 +1219,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
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.update.mockResolvedValue({ ...user, quotaSizeInBytes: 5_368_709_120 });
|
||||
@@ -1233,7 +1243,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
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.update.mockResolvedValue({ ...user, quotaSizeInBytes: null });
|
||||
@@ -1257,7 +1267,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
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.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -1279,7 +1289,7 @@ describe(AuthService.name, () => {
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
|
||||
});
|
||||
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.getByStorageLabel.mockResolvedValue(void 0);
|
||||
@@ -1305,7 +1315,7 @@ describe(AuthService.name, () => {
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, storageLabelClaim: 'immich_label' },
|
||||
});
|
||||
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.getByStorageLabel.mockResolvedValue(void 0);
|
||||
@@ -1330,7 +1340,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
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.update.mockResolvedValue({ ...user, isAdmin: true });
|
||||
@@ -1351,7 +1361,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
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.update.mockResolvedValue({ ...user, isAdmin: false });
|
||||
@@ -1372,7 +1382,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ sub: oauthId }),
|
||||
rawProfile: OAuthProfileFactory.create({ sub: oauthId }),
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -1391,7 +1401,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ sub: user.oauthId! }),
|
||||
rawProfile: OAuthProfileFactory.create({ sub: user.oauthId! }),
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -1406,15 +1416,38 @@ describe(AuthService.name, () => {
|
||||
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 () => {
|
||||
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.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub });
|
||||
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub, isAdmin: true });
|
||||
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub });
|
||||
mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: rawProfile.sub, isAdmin: true });
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
@@ -1423,7 +1456,7 @@ describe(AuthService.name, () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1432,10 +1465,10 @@ describe(AuthService.name, () => {
|
||||
it('should link an account', async () => {
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
const rawProfile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ rawProfile });
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
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 () => {
|
||||
@@ -1454,7 +1487,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: { sub: 'sub' },
|
||||
rawProfile: { sub: 'sub' },
|
||||
sid: session.oauthSid ?? undefined,
|
||||
idToken: session.oauthBearerToken ?? undefined,
|
||||
});
|
||||
@@ -1480,7 +1513,7 @@ describe(AuthService.name, () => {
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
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);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
|
||||
import { parseCookie } from 'cookie';
|
||||
import { DateTime } from 'luxon';
|
||||
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 { AuthSharedLink, AuthUser, UserAdmin } from 'src/database.js';
|
||||
import {
|
||||
@@ -14,6 +13,8 @@ import {
|
||||
OAuthBackchannelLogoutDto,
|
||||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
OAuthProfile,
|
||||
OAuthProfileSchema,
|
||||
PinCodeChangeDto,
|
||||
PinCodeResetDto,
|
||||
PinCodeSetupDto,
|
||||
@@ -23,10 +24,9 @@ import {
|
||||
import { SystemConfig, defaults } from 'src/dtos/config.dto.js';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto.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 { isGranted } from 'src/utils/access.js';
|
||||
import { HumanReadableSize } from 'src/utils/bytes.js';
|
||||
import { generateProfileImage } from 'src/utils/profile-image.js';
|
||||
import { getUserAgentDetails } from 'src/utils/request.js';
|
||||
|
||||
@@ -38,18 +38,10 @@ export interface LoginDetails {
|
||||
appVersion: string | null;
|
||||
}
|
||||
|
||||
interface ClaimOptions<T> {
|
||||
key: string;
|
||||
isValid: (value: unknown) => value is T;
|
||||
parse?: (raw: unknown) => T;
|
||||
}
|
||||
|
||||
type OAuthClaimsConfig = Pick<SystemConfig['oauth'], 'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim'>;
|
||||
|
||||
interface ParsedOAuthClaims {
|
||||
storageLabel?: string | null;
|
||||
quotaSizeInBytes?: number | null;
|
||||
}
|
||||
type OAuthClaimsConfig = Pick<
|
||||
SystemConfig['oauth'],
|
||||
'defaultStorageQuota' | 'storageLabelClaim' | 'storageQuotaClaim' | 'roleClaim'
|
||||
>;
|
||||
|
||||
export type ValidateRequest = {
|
||||
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) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
@@ -312,18 +316,18 @@ export class AuthService extends BaseService {
|
||||
|
||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||
const {
|
||||
profile,
|
||||
rawProfile,
|
||||
sid: oauthSid,
|
||||
idToken: oauthBearerToken,
|
||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
|
||||
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
|
||||
const { autoRegister, roleClaim } = oauth;
|
||||
const profile = this.parseOAuthClaims(rawProfile, oauth);
|
||||
const { autoRegister } = oauth;
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
||||
|
||||
// link by email
|
||||
if (!user && normalizedEmail) {
|
||||
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
|
||||
if (!user && profile.email) {
|
||||
const emailUser = await this.userRepository.getByEmail(profile.email);
|
||||
if (emailUser) {
|
||||
if (emailUser.oauthId) {
|
||||
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 = role === 'admin';
|
||||
const isAdmin = profile.immich_role === 'admin';
|
||||
|
||||
if (user && role && isAdmin !== user.isAdmin) {
|
||||
if (user && profile.immich_role && isAdmin !== user.isAdmin) {
|
||||
user = await this.userRepository.update(user.id, { isAdmin });
|
||||
}
|
||||
|
||||
@@ -346,29 +349,28 @@ export class AuthService extends BaseService {
|
||||
} else {
|
||||
if (!autoRegister) {
|
||||
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');
|
||||
}
|
||||
|
||||
if (!normalizedEmail) {
|
||||
if (!profile.email) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`);
|
||||
|
||||
const claims = this.parseOAuthClaims(profile, oauth, true);
|
||||
this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`);
|
||||
|
||||
user = await this.createUser({
|
||||
name:
|
||||
profile.name ||
|
||||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
||||
profile.preferred_username ||
|
||||
normalizedEmail,
|
||||
email: normalizedEmail,
|
||||
profile.email,
|
||||
email: profile.email,
|
||||
oauthId: profile.sub,
|
||||
quotaSizeInBytes: claims.quotaSizeInBytes ?? null,
|
||||
storageLabel: claims.storageLabel ?? null,
|
||||
quotaSizeInBytes:
|
||||
profile.immich_quota_bytes === undefined ? profile.defaults.immich_quota_bytes : profile.immich_quota_bytes,
|
||||
storageLabel: profile.immich_storage_label ?? null,
|
||||
isAdmin,
|
||||
});
|
||||
}
|
||||
@@ -415,11 +417,13 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
const {
|
||||
profile: { sub: oauthId },
|
||||
sid,
|
||||
idToken,
|
||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
|
||||
const { rawProfile, sid, idToken } = await this.oauthRepository.getProfileAndOAuthSid(
|
||||
oauth,
|
||||
dto.url,
|
||||
expectedState,
|
||||
codeVerifier,
|
||||
);
|
||||
const { sub: oauthId } = this.parseOAuthClaims(rawProfile, oauth);
|
||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||
if (duplicate && duplicate.id !== auth.user.id) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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> {
|
||||
const claims = this.parseOAuthClaims(profile, oauth, false);
|
||||
const updates: {
|
||||
storageLabel?: string | null;
|
||||
quotaSizeInBytes?: number | null;
|
||||
} = {};
|
||||
|
||||
if (claims.storageLabel && claims.storageLabel !== user.storageLabel) {
|
||||
const duplicate = await this.userRepository.getByStorageLabel(claims.storageLabel);
|
||||
if (
|
||||
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) {
|
||||
this.logger.warn(`Unable to sync OAuth storage label for user ${user.id}: label already in use`);
|
||||
} else {
|
||||
updates.storageLabel = claims.storageLabel;
|
||||
updates.storageLabel = profile.immich_storage_label;
|
||||
}
|
||||
}
|
||||
|
||||
if (claims.quotaSizeInBytes !== undefined && claims.quotaSizeInBytes !== user.quotaSizeInBytes) {
|
||||
updates.quotaSizeInBytes = claims.quotaSizeInBytes;
|
||||
if (profile.immich_quota_bytes === undefined && user.quotaSizeInBytes !== profile.defaults.immich_quota_bytes) {
|
||||
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) {
|
||||
@@ -691,19 +652,6 @@ export class AuthService extends BaseService {
|
||||
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(
|
||||
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
|
||||
url: string,
|
||||
|
||||
@@ -16,6 +16,7 @@ describe(DatabaseBackupService.name, () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = getMocks();
|
||||
mocks.process.isCommandAvailable.mockResolvedValue(false);
|
||||
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
|
||||
args: [mocks.logger],
|
||||
strict: false,
|
||||
@@ -228,6 +229,19 @@ describe(DatabaseBackupService.name, () => {
|
||||
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 () => {
|
||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex()('pg_dump', 1, '', 'error'));
|
||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||
|
||||
@@ -236,6 +236,8 @@ export class DatabaseBackupService {
|
||||
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
|
||||
const temporaryFilePath = `${backupFilePath}.tmp`;
|
||||
|
||||
const compression = await this.getCompressionCommand();
|
||||
|
||||
let pgdump: 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);
|
||||
|
||||
await pipeline(pgdump, gzip, fileStream);
|
||||
@@ -267,6 +269,15 @@ export class DatabaseBackupService {
|
||||
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> {
|
||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||
const fn = basename(file.originalname);
|
||||
|
||||
@@ -1213,6 +1213,66 @@ describe(PersonService.name, () => {
|
||||
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 () => {
|
||||
const assetId = newUuid();
|
||||
const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif });
|
||||
|
||||
@@ -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) {
|
||||
const scaledBox = {
|
||||
x1: boundingBox.x1 * widthScale,
|
||||
y1: boundingBox.y1 * heightScale,
|
||||
x2: boundingBox.x2 * widthScale,
|
||||
y2: boundingBox.y2 * heightScale,
|
||||
};
|
||||
const match = asset.faces.find((face) => this.iou(face, scaledBox) > 0.5);
|
||||
const matches = asset.faces.filter((face) => this.iou(face, boundingBox, imageWidth, imageHeight) > 0.5);
|
||||
// prefer metadata faces over machine learning duplicates
|
||||
const match = matches.find((face) => face.sourceType !== SourceType.MachineLearning) ?? matches[0];
|
||||
|
||||
if (match && !mlFaceIds.delete(match.id)) {
|
||||
embeddings.push({ faceId: match.id, embedding });
|
||||
@@ -466,17 +460,39 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
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,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
): number {
|
||||
const x1 = Math.max(face.boundingBoxX1, newBox.x1);
|
||||
const y1 = Math.max(face.boundingBoxY1, newBox.y1);
|
||||
const x2 = Math.min(face.boundingBoxX2, newBox.x2);
|
||||
const y2 = Math.min(face.boundingBoxY2, newBox.y2);
|
||||
const box1 = {
|
||||
x1: face.boundingBoxX1 / face.imageWidth,
|
||||
y1: face.boundingBoxY1 / face.imageHeight,
|
||||
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 area1 = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1);
|
||||
const area2 = (newBox.x2 - newBox.x1) * (newBox.y2 - newBox.y1);
|
||||
const area1 = (box1.x2 - box1.x1) * (box1.y2 - box1.y1);
|
||||
const area2 = (box2.x2 - box2.x1) * (box2.y2 - box2.y1);
|
||||
const union = area1 + area2 - intersection;
|
||||
|
||||
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 { newUuid } from 'test/small.factory.js';
|
||||
|
||||
export class OAuthProfileFactory {
|
||||
private constructor(private value: OAuthProfile) {}
|
||||
private constructor(private value: OAuthRawProfile) {}
|
||||
|
||||
static create(dto: OAuthProfileLike = {}) {
|
||||
return OAuthProfileFactory.from(dto).build();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { PersonUser } from 'src/database.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 { AlbumUserTable } from 'src/schema/tables/album-user.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 PasskeyLike = Partial<Selectable<PasskeyTable>>;
|
||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||
export type OAuthProfileLike = Partial<OAuthProfile>;
|
||||
export type OAuthProfileLike = Partial<OAuthRawProfile>;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '#lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||
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 { handleError } from '#lib/utils/handle-error.ts';
|
||||
import { updateAssets } from '@immich/sdk';
|
||||
import { IconButton, toastManager } from '@immich/ui';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiHeartMinusOutline, mdiHeartOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -27,28 +26,8 @@
|
||||
|
||||
try {
|
||||
const assets = assetMultiSelectManager.ownedAssets.filter((asset) => asset.isFavorite !== isFavorite);
|
||||
|
||||
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 } }),
|
||||
);
|
||||
|
||||
await toggleFavoriteAssets(assets, isFavorite, onFavorite);
|
||||
assetMultiSelectManager.clear();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: isFavorite } }));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { shortcuts, type ShortcutOptions } from '#lib/actions/shortcut.ts';
|
||||
import {
|
||||
setFocusToAsset as setFocusAssetInit,
|
||||
@@ -14,6 +13,7 @@
|
||||
import NavigateToDateModal from '#lib/modals/NavigateToDateModal.svelte';
|
||||
import ShortcutsModal from '#lib/modals/ShortcutsModal.svelte';
|
||||
import { Route } from '#lib/route.ts';
|
||||
import { toggleFavoriteAssets } from '#lib/services/asset.service.ts';
|
||||
import { handleStack } from '#lib/services/stack.service.ts';
|
||||
import { keyboardManager } from '#lib/stores/keyboard-manager.svelte.ts';
|
||||
import { showDeleteModal } from '#lib/stores/preferences.store.ts';
|
||||
@@ -21,6 +21,7 @@
|
||||
import { handlePromiseError } from '#lib/utils.ts';
|
||||
import { deleteAssets } from '#lib/utils/actions.ts';
|
||||
import { selectAllAssets } from '#lib/utils/asset-utils.ts';
|
||||
import { goto } from '$app/navigation';
|
||||
import { isModalOpen, modalManager } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
@@ -62,6 +63,14 @@
|
||||
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) => {
|
||||
if (!searchStore.isSearchEnabled && assetInteraction.selectionActive && keyboardManager.shift) {
|
||||
e.preventDefault();
|
||||
@@ -125,6 +134,7 @@
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => assetInteraction.clear() },
|
||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
||||
{ shortcut: { key: 'f' }, onShortcut: () => toggleFavoriteSelected() },
|
||||
);
|
||||
} else {
|
||||
// conflicting shortcuts
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Route } from '#lib/route.ts';
|
||||
import { SlideshowState, slideshowStore } from '#lib/stores/slideshow.store.ts';
|
||||
import { getAssetMediaUrl, getSharedLink, sleep } 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 { getFormatter } from '#lib/utils/i18n.ts';
|
||||
import {
|
||||
@@ -63,6 +64,8 @@ import {
|
||||
mdiTune,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
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 $t = await getFormatter();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user