From 0344c61e4cee9cb2e8ee3ce2292177cd2b0c4129 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 12 Aug 2026 13:36:33 +0200 Subject: [PATCH 01/78] feat: use version service for docs archive switcher (#30675) --- docs/src/components/version-switcher.tsx | 9 +- docs/static/_redirects | 1 + docs/static/archived-versions.json | 265 ------------------ packages/scripts/src/commands/release.spec.ts | 78 +----- packages/scripts/src/commands/release.ts | 52 ---- 5 files changed, 6 insertions(+), 399 deletions(-) delete mode 100644 docs/static/archived-versions.json diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index 739d7bd001..967c85c780 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -2,6 +2,8 @@ import { useWindowSize } from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import React, { useEffect, useState } from 'react'; +const ARCHIVED_VERSIONS_URL = 'https://version.immich.cloud/v1/docs/versions'; + export default function VersionSwitcher(): JSX.Element { const [versions, setVersions] = useState([]); const [activeLabel, setLabel] = useState('Versions'); @@ -11,12 +13,7 @@ export default function VersionSwitcher(): JSX.Element { useEffect(() => { async function getVersions() { try { - let baseUrl = 'https://docs.immich.app'; - if (window.location.origin === 'http://localhost:3005') { - baseUrl = window.location.origin; - } - - const response = await fetch(`${baseUrl}/archived-versions.json`); + const response = await fetch(ARCHIVED_VERSIONS_URL); const archiveVersions = await response.json(); diff --git a/docs/static/_redirects b/docs/static/_redirects index 633025ac17..e218124e59 100644 --- a/docs/static/_redirects +++ b/docs/static/_redirects @@ -1,3 +1,4 @@ +/archived-versions.json https://version.immich.cloud/v1/docs/versions 307 / /overview/quick-start 307 /mobile-app-beta-program /features/mobile-app 307 /contribution-guidelines /overview/support-the-project#contributing 307 diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json deleted file mode 100644 index dbeed76b73..0000000000 --- a/docs/static/archived-versions.json +++ /dev/null @@ -1,265 +0,0 @@ -[ - { - "label": "v3.1.0", - "url": "https://docs.v3.1.0.archive.immich.app" - }, - { - "label": "v3.0.3", - "url": "https://docs.v3.0.3.archive.immich.app" - }, - { - "label": "v2.7.5", - "url": "https://docs.v2.7.5.archive.immich.app" - }, - { - "label": "v2.6.3", - "url": "https://docs.v2.6.3.archive.immich.app" - }, - { - "label": "v2.5.6", - "url": "https://docs.v2.5.6.archive.immich.app" - }, - { - "label": "v2.4.1", - "url": "https://docs.v2.4.1.archive.immich.app" - }, - { - "label": "v2.3.1", - "url": "https://docs.v2.3.1.archive.immich.app" - }, - { - "label": "v2.2.3", - "url": "https://docs.v2.2.3.archive.immich.app" - }, - { - "label": "v2.1.0", - "url": "https://docs.v2.1.0.archive.immich.app" - }, - { - "label": "v2.0.1", - "url": "https://docs.v2.0.1.archive.immich.app" - }, - { - "label": "v1.144.1", - "url": "https://docs.v1.144.1.archive.immich.app" - }, - { - "label": "v1.143.1", - "url": "https://docs.v1.143.1.archive.immich.app" - }, - { - "label": "v1.142.1", - "url": "https://v1.142.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.141.1", - "url": "https://v1.141.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.140.1", - "url": "https://v1.140.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.139.4", - "url": "https://v1.139.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.138.1", - "url": "https://v1.138.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.137.3", - "url": "https://v1.137.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.136.0", - "url": "https://v1.136.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.135.3", - "url": "https://v1.135.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.134.0", - "url": "https://v1.134.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.133.1", - "url": "https://v1.133.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.132.3", - "url": "https://v1.132.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.131.3", - "url": "https://v1.131.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.130.3", - "url": "https://v1.130.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.129.0", - "url": "https://v1.129.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.128.0", - "url": "https://v1.128.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.127.0", - "url": "https://v1.127.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.126.1", - "url": "https://v1.126.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.125.7", - "url": "https://v1.125.7.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.124.2", - "url": "https://v1.124.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.123.0", - "url": "https://v1.123.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.122.3", - "url": "https://v1.122.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.121.0", - "url": "https://v1.121.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.120.2", - "url": "https://v1.120.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.119.1", - "url": "https://v1.119.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.118.2", - "url": "https://v1.118.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.117.0", - "url": "https://v1.117.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.116.2", - "url": "https://v1.116.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.115.0", - "url": "https://v1.115.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.114.0", - "url": "https://v1.114.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.113.1", - "url": "https://v1.113.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.112.1", - "url": "https://v1.112.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.111.0", - "url": "https://v1.111.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.110.0", - "url": "https://v1.110.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.109.2", - "url": "https://v1.109.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.108.0", - "url": "https://v1.108.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.107.2", - "url": "https://v1.107.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.106.4", - "url": "https://v1.106.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.105.1", - "url": "https://v1.105.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.104.0", - "url": "https://v1.104.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.103.1", - "url": "https://v1.103.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.102.3", - "url": "https://v1.102.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.101.0", - "url": "https://v1.101.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.100.0", - "url": "https://v1.100.0.archive.immich.app", - "rootPath": "/docs" - } -] diff --git a/packages/scripts/src/commands/release.spec.ts b/packages/scripts/src/commands/release.spec.ts index 0b1be22302..dc3f82517d 100644 --- a/packages/scripts/src/commands/release.spec.ts +++ b/packages/scripts/src/commands/release.spec.ts @@ -1,23 +1,10 @@ import semver from 'semver'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - resolveArchivedVersions, - ArchivedVersion, - getMobileBuild, - getNewVersion, - ReleaseError, -} from './release'; +import { describe, expect, it } from 'vitest'; +import { getMobileBuild, getNewVersion, ReleaseError } from './release'; const mobileBuild = (version: string) => getMobileBuild(semver.parse(version) as semver.SemVer); -const archived = (label: string): ArchivedVersion => ({ - label, - url: `https://docs.${label}.archive.immich.app`, -}); - -const labels = (versions: ArchivedVersion[]) => versions.map((v) => v.label); - describe(getNewVersion.name, () => { describe('transitions', () => { const valid = [ @@ -82,67 +69,6 @@ describe(getNewVersion.name, () => { }); }); -describe(resolveArchivedVersions.name, () => { - beforeEach(() => { - // silence the "Removed ..." progress logging - vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - it('should prepend the new version to the front', () => { - const result = resolveArchivedVersions([archived('v2.9.0')], '3.0.0'); - - expect(result[0]).toEqual({ - label: 'v3.0.0', - url: 'https://docs.v3.0.0.archive.immich.app', - }); - expect(labels(result)).toEqual(['v3.0.0', 'v2.9.0']); - }); - - it('should handle an empty list', () => { - expect(labels(resolveArchivedVersions([], '3.0.0'))).toEqual(['v3.0.0']); - }); - - it('should drop older patch releases of the new version minor', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v3.0.2'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - ]); - }); - - it('should keep the latest patch of each older minor', () => { - const versions = ['v3.1.2', 'v3.1.1', 'v3.0.9', 'v2.0.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.2.0'))).toEqual([ - 'v3.2.0', - 'v3.1.2', - 'v3.0.9', - 'v2.0.0', - ]); - }); - - it('should keep an older minor when bumping a patch', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v2.9.1', 'v2.9.0', 'v1.5.0'].map( - archived, - ); - - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - 'v2.9.1', - 'v1.5.0', - ]); - }); - - it('should replace a prerelease with its release', () => { - const versions = ['v3.0.0-rc.2', 'v3.0.0-rc.1', 'v2.9.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.0.0'))).toEqual([ - 'v3.0.0', - 'v2.9.0', - ]); - }); -}); - describe(getMobileBuild.name, () => { it('should encode the version', () => { expect(mobileBuild('3.1.0')).toBe(3_010_099); diff --git a/packages/scripts/src/commands/release.ts b/packages/scripts/src/commands/release.ts index 307b66165a..2c0d82d5f6 100644 --- a/packages/scripts/src/commands/release.ts +++ b/packages/scripts/src/commands/release.ts @@ -26,7 +26,6 @@ const Files = { Docs: { Env: join(root, 'docs/docs/install/environment-variables.md'), Upgrading: join(root, 'docs/docs/install/upgrading.md'), - ArchivedVersions: join(root, 'docs/static/archived-versions.json'), }, Mobile: { Pubspec: join(root, 'mobile/pubspec.yaml'), @@ -92,13 +91,6 @@ export const handleRelease = ({ type }: ReleaseOptions) => { pump(Files.Docs.Upgrading, /:v\d+/, `:${major}`); } - // update archived versions list - const archivedFile = new JsonFile( - Files.Docs.ArchivedVersions, - ); - const versions = archivedFile.read(); - archivedFile.write(resolveArchivedVersions(versions, newVersionRaw)); - if (process.env.GITHUB_ENV) { // make available for following steps appendFileSync( @@ -222,50 +214,6 @@ const pump = (path: string, pattern: RegExp, replacement: string) => { file.write(update); }; -export interface ArchivedVersion { - label: string; - url: string; -} - -export const resolveArchivedVersions = ( - versions: ArchivedVersion[], - nextVersion: string, -): ArchivedVersion[] => { - const newVersion: ArchivedVersion = { - label: `v${nextVersion}`, - url: `https://docs.v${nextVersion}.archive.immich.app`, - }; - - let result = versions; - let lastVersion = asVersion(newVersion); - for (const item of versions) { - const version = asVersion(item); - // only keep the latest patch version for each minor release - if ( - lastVersion.major === version.major && - lastVersion.minor === version.minor && - lastVersion.patch >= version.patch - ) { - result = result.filter((item) => item.label !== version.label); - console.log( - `Removed ${version.label} (replaced with ${lastVersion.label})`, - ); - continue; - } - - lastVersion = version; - } - - return [newVersion, ...result]; -}; - -const asVersion = (item: ArchivedVersion) => { - const { label, url } = item; - const [version] = label.substring(1).split('-'); - const [major, minor, patch] = version.split('.').map(Number); - return { major, minor, patch, label, url }; -}; - const isPrerelease = (version: SemVer) => version.prerelease.length > 0; /** From 610bcfa6d09e6bfdb2bbaf16df8e77b83fc34356 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 12 Aug 2026 04:53:14 -0700 Subject: [PATCH 02/78] chore(mobile): add Cocoapods to mise (#30722) * chore(mobile): add Cocoapods to mise * Use new mise setup in CI --- .github/workflows/build-mobile.yml | 19 +++++++------------ mobile/mise.toml | 21 +++++++++++++++++++-- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b2ba244ab9..6a9a01d41d 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -225,6 +225,13 @@ jobs: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ./mobile/ios + - name: Install dependencies run: mise //mobile:install:ci @@ -235,18 +242,6 @@ jobs: working-directory: ./mobile run: flutter build ios --config-only --no-codesign - - name: Setup Ruby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 - with: - ruby-version: '3.3' - bundler-cache: true - working-directory: ./mobile/ios - - - name: Install CocoaPods dependencies - working-directory: ./mobile/ios - run: | - pod install - - name: Create API Key env: API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} diff --git a/mobile/mise.toml b/mobile/mise.toml index 08a26c0c3b..f021cd8747 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -29,7 +29,7 @@ sources = [ ] outputs = { auto = true } run = [ - "dart run build_runner build", + "dart run build_runner build", "dart format lib/routing/router.gr.dart", ] depends = ["//:open-api-dart"] @@ -99,7 +99,11 @@ run = "dart run drift_dev make-migrations" alias = "install" description = "Install flutter dependencies" depends = ["//:open-api-dart"] -run = "flutter pub get" +run = [ + # This receives passed `--enforce-lockfile` args + "flutter pub get", + { task = "install:ios" }, +] [tasks."install:ci"] description = "Install flutter dependencies in CI" @@ -132,6 +136,19 @@ run = [ ] wait_for = ["//:i18n:format-fix"] +[tasks."install:ios"] +description = "Install CocoaPods dependencies" +hide = true +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"] +outputs = ["ios/Pods/Manifest.lock"] +run = ''' +echo "Running pod install" +if [ "$(uname)" != "Darwin" ]; then + exit 0 +fi +cd ios && pod install +''' + [tasks."analyze:dart"] description = "Run Dart analysis" hide = true From ce705dcc068146fca2c6ecb050ab94ec6b54cc85 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:30:06 +0530 Subject: [PATCH 03/78] fix: riverpod reactivity regression from lint fixes (#30734) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/pages/backup/drift_backup.page.dart | 12 +-- .../pages/drift_album_options.page.dart | 10 +- .../pages/drift_slideshow.page.dart | 97 +++++++++++++------ .../pages/drift_user_selection.page.dart | 4 +- .../pages/search/drift_search.page.dart | 9 +- .../widgets/common/immich_sliver_app_bar.dart | 20 +++- 6 files changed, 100 insertions(+), 52 deletions(-) diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index ad2674b81b..a0e55e58de 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -316,12 +316,11 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final backupAlbums = ref.watch(backupAlbumProvider); + Widget buildSelectedAlbumName() { String text = context.t.backup_controller_page_backup_selected; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.selected) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.selected).toList(); if (albums.isNotEmpty) { for (var album in albums) { @@ -352,10 +351,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { Widget buildExcludedAlbumName() { String text = context.t.backup_controller_page_excluded; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.excluded) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); if (albums.isNotEmpty) { for (var album in albums) { diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 59cabf8daf..078b9cc325 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -32,6 +32,8 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.isActivityEnabled); final isOwner = album.ownerId == userId; + final owner = isOwner ? ref.watch(currentUserProvider) : null; + final allUsers = isOwner ? null : ref.watch(driftUsersProvider); void showErrorMessage() { ContextHelper(context).pop(); @@ -141,7 +143,6 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { Widget buildOwnerInfo() { if (isOwner) { - final owner = ref.read(currentUserProvider); return ListTile( leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(), title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)), @@ -149,8 +150,11 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { trailing: Text(context.t.owner, style: context.textTheme.labelLarge), ); } else { - final usersProvider = ref.read(driftUsersProvider); - return usersProvider.maybeWhen( + if (allUsers == null) { + return const SizedBox(); + } + + return allUsers.maybeWhen( data: (users) { final user = users.firstWhereOrNull((u) => u.id == album.ownerId); diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 824d7d7c6b..2662c60a30 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si color: context.colorScheme.primary, ); } else { - return LinearProgressIndicator( - color: context.colorScheme.primary, - borderRadius: BorderRadius.zero, - minHeight: 5, - value: - ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds / - asset.duration.inMilliseconds, - ); + return _VideoProgressBar(asset: asset); } } @@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState with Si builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), ); } else { - final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status)); - final position = ref.read(videoPlayerProvider(asset.id)).position; - - if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { - unawaited(_nextPage()); - } else if (status == VideoPlaybackStatus.playing) { - unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); - } - - return PhotoView.customChild( - onTapUp: (_, _, _) => _onTapUp(), - disableScaleGestures: true, - filterQuality: FilterQuality.high, - initialScale: scale, - child: NativeVideoViewer( - asset: asset, - isCurrent: isCurrent, - image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), - ), + return _VideoChild( + asset: asset, + isCurrent: isCurrent, + scale: scale, + imageProvider: imageProvider, + onTapUp: _onTapUp, + onCompleted: _nextPage, ); } } @@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState with Si } } +class _VideoChild extends ConsumerWidget { + final BaseAsset asset; + final bool isCurrent; + final PhotoViewComputedScale scale; + final ImageProvider imageProvider; + final VoidCallback onTapUp; + final VoidCallback onCompleted; + + const _VideoChild({ + required this.asset, + required this.isCurrent, + required this.scale, + required this.imageProvider, + required this.onTapUp, + required this.onCompleted, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) { + if (status == VideoPlaybackStatus.completed) { + if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) { + onCompleted(); + } + } else if (status == VideoPlaybackStatus.playing) { + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); + } + }); + + return PhotoView.customChild( + onTapUp: (_, _, _) => onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } +} + +class _VideoProgressBar extends ConsumerWidget { + final BaseAsset asset; + + const _VideoProgressBar({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position)); + + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: BorderRadius.zero, + minHeight: 5, + value: position.inMilliseconds / asset.duration.inMilliseconds, + ); + } +} + /// Progress bar for image slides, driven by an explicit [AnimationController]. /// /// [TweenAnimationBuilder] creates its controller internally with the default diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart index 215a5d1071..c11d5a1fd6 100644 --- a/mobile/lib/presentation/pages/drift_user_selection.page.dart +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue> suggestedShareUsers = ref.watch(driftUsersProvider); + final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id)); final sharedUsersList = useState>({}); void addNewUsersHandler() { @@ -136,9 +137,6 @@ class DriftUserSelectionPage extends HookConsumerWidget { ), body: suggestedShareUsers.widgetWhen( onData: (users) { - // Get shared users for this album from the database - final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id)); - return sharedUsers.when( data: (albumSharedUsers) { // Filter out users that are already shared with this album and the owner diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0c6b0d8ae4..dfcb5abd4d 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -742,9 +742,7 @@ class _SearchResultGrid extends ConsumerWidget { return false; } - Widget? _bottomWidget(BuildContext context, WidgetRef ref) { - final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading)); - + Widget? _bottomWidget(BuildContext context, {required bool isLoading, required bool hasMore}) { if (isLoading) { return const SliverFillRemaining( hasScrollBody: false, @@ -755,8 +753,6 @@ class _SearchResultGrid extends ConsumerWidget { ); } - final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null)); - if (hasMore) { return null; } @@ -778,6 +774,7 @@ class _SearchResultGrid extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); if (!hasAssets && !isLoading) { return const _SearchNoResults(); @@ -807,7 +804,7 @@ class _SearchResultGrid extends ConsumerWidget { bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, loadingWidget: const SizedBox.shrink(), - bottomSliverWidget: _bottomWidget(context, ref), + bottomSliverWidget: _bottomWidget(context, isLoading: isLoading, hasMore: hasMore), ), ), ), diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 4c6b2a7211..6a119f8ca3 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -177,7 +177,15 @@ class _BackupIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final indicatorIcon = _getBackupBadgeIcon(context, ref); + final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); + final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); + final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); + final indicatorIcon = _getBackupBadgeIcon( + context, + backupEnabled: backupEnabled, + hasError: hasError, + isUploading: isUploading, + ); return IconButton( onPressed: () => context.pushRoute(const DriftBackupRoute()), @@ -192,12 +200,14 @@ class _BackupIndicator extends ConsumerWidget { ); } - Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { - final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled)); - final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none)); + Widget? _getBackupBadgeIcon( + BuildContext context, { + required bool backupEnabled, + required bool hasError, + required bool isUploading, + }) { final isDarkTheme = context.isDarkTheme; final iconColor = isDarkTheme ? Colors.white : Colors.black; - final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); if (!backupEnabled) { return _BadgeLabel( From 943c11c0196e7424e5e488131a2fb7f7522037ed Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:32:08 +0530 Subject: [PATCH 04/78] chore: label local only delete as trash (#30726) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/presentation/actions/delete.action.dart | 3 ++- .../unit/presentation/actions/delete_action_test.dart | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index ea7423354a..23a2ef2a2f 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -37,7 +37,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); // Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting. - final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); + final trash = + ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked)); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); }, dependencies: [assetsActionProvider]); diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 29d81a5c2c..4c1bf9306c 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -170,6 +170,17 @@ void main() { verifyNever(() => assetService.trash(any())); verifyNever(() => assetService.delete(any())); }); + + testWidgets('is labelled trash', (tester) async { + await tester.pumpTestWidget( + context, + const ActionButton(action: DeleteAction(source: .timeline)), + overrides: context.selected({LocalAssetFactory.create()}), + ); + + expect(find.text(StaticTranslations.instance.trash), findsOneWidget); + expect(find.text(StaticTranslations.instance.delete), findsNothing); + }); }); group('prompt handling', () { From 52edcc0c74c2430418f88fe94700b549dac84e90 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:12:11 +0530 Subject: [PATCH 05/78] refactor: unify person model (#30659) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/person.model.dart | 29 ++----------------- .../lib/domain/services/people.service.dart | 6 ++-- .../repositories/people.repository.dart | 21 +++----------- .../models/search/search_filter.model.dart | 4 +-- .../presentation/pages/drift_person.page.dart | 4 +-- .../pages/search/drift_search.page.dart | 2 +- .../asset_details/people_details.widget.dart | 4 +-- .../person_edit_birthday_modal.widget.dart | 2 +- .../people/person_edit_name_modal.widget.dart | 2 +- .../infrastructure/people.provider.dart | 4 +-- .../lib/providers/search/people.provider.dart | 2 +- .../repositories/person_api.repository.dart | 14 +++------ mobile/lib/services/person.service.dart | 4 +-- mobile/lib/utils/people.utils.dart | 4 +-- .../widgets/common/person_sliver_app_bar.dart | 4 +-- .../search/search_filter/people_picker.dart | 6 ++-- 16 files changed, 34 insertions(+), 78 deletions(-) diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart index 56e6d3990d..94ead6a2e9 100644 --- a/mobile/lib/domain/models/person.model.dart +++ b/mobile/lib/domain/models/person.model.dart @@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'person.model.freezed.dart'; -// TODO: Remove PersonDto once Isar is removed @freezed -abstract class PersonDto with _$PersonDto { - const factory PersonDto({ - required String id, - DateTime? birthDate, - required bool isHidden, - required String name, - required String thumbnailPath, - DateTime? updatedAt, - }) = _PersonDto; -} - -// Model for a person stored in the server -@freezed -abstract class DriftPerson with _$DriftPerson { - const factory DriftPerson({ - required String id, - required DateTime createdAt, - required DateTime updatedAt, - required String ownerId, - required String name, - String? faceAssetId, - required bool isFavorite, - required bool isHidden, - required String? color, - DateTime? birthDate, - }) = _DriftPerson; +abstract class Person with _$Person { + const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person; } diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index 73972dbdc0..e86d43b283 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -10,15 +10,15 @@ class DriftPeopleService { const DriftPeopleService(this._repository, this._personApiRepository); - Future get(String personId) { + Future get(String personId) { return _repository.get(personId); } - Future> getAssetPeople(String assetId) { + Future> getAssetPeople(String assetId) { return _repository.getAssetPeople(assetId); } - Future> getAllPeople({int minFaces = 3}) { + Future> getAllPeople({int minFaces = 3}) { return _repository.getAllPeople(minFaces: minFaces); } diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 0dd73cc81d..b539c6b2fd 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -8,14 +8,14 @@ class DriftPeopleRepository extends DriftDatabaseRepository { final Drift _db; const DriftPeopleRepository(this._db) : super(_db); - Future get(String personId) async { + Future get(String personId) async { final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId)); final result = await query.getSingleOrNull(); return result?.toDto(); } - Future> getAssetPeople(String assetId) async { + Future> getAssetPeople(String assetId) async { // An asset can have multiple face records for the same person (e.g., metadata // imports alongside ML detections). Use a subquery instead of a join so each // person is returned once, regardless of how many of their faces are on the asset @@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getAllPeople({int minFaces = 3}) async { + Future> getAllPeople({int minFaces = 3}) async { final people = _db.personEntity; final faces = _db.assetFaceEntity; final assets = _db.remoteAssetEntity; @@ -76,18 +76,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } extension on PersonEntityData { - DriftPerson toDto() { - return DriftPerson( - id: id, - createdAt: createdAt, - updatedAt: updatedAt, - ownerId: ownerId, - name: name, - faceAssetId: faceAssetId, - isFavorite: isFavorite, - isHidden: isHidden, - color: color, - birthDate: birthDate, - ); - } + Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate); } diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 7e5e1b819e..a9cc13ba6a 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -194,7 +194,7 @@ class SearchFilter { String? language; String? assetId; List? tagIds; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -250,7 +250,7 @@ class SearchFilter { String? language, String? ocr, String? assetId, - Set? people, + Set? people, List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, diff --git a/mobile/lib/presentation/pages/drift_person.page.dart b/mobile/lib/presentation/pages/drift_person.page.dart index 7389f506d5..cad3b7b6a6 100644 --- a/mobile/lib/presentation/pages/drift_person.page.dart +++ b/mobile/lib/presentation/pages/drift_person.page.dart @@ -14,7 +14,7 @@ import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart'; @RoutePage() class DriftPersonPage extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonPage({super.key, required this.person}); @@ -23,7 +23,7 @@ class DriftPersonPage extends ConsumerStatefulWidget { } class _DriftPersonPageState extends ConsumerState { - late DriftPerson _person; + late Person _person; @override void initState() { diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index dfcb5abd4d..e364e0d2f8 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -129,7 +129,7 @@ class DriftSearchPage extends HookConsumerWidget { void showPeoplePicker() { var people = filter.value.people; - void handleOnSelect(Set value) { + void handleOnSelect(Set value) { people = value; } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 739fe0a0d9..00c20f35bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -30,7 +30,7 @@ class PeopleDetails extends ConsumerWidget { final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id)); - Future showNameEditModal(DriftPerson person) async { + Future showNameEditModal(Person person) async { await showDialog( context: context, useRootNavigator: false, @@ -96,7 +96,7 @@ class PeopleDetails extends ConsumerWidget { } class _Avatar extends StatelessWidget { - final DriftPerson person; + final Person person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index 0b8a08a5d8..a4903c7a29 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:scroll_date_picker/scroll_date_picker.dart'; class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonBirthdayEditForm({super.key, required this.person}); diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index 3b0e62ab1a..5e8c634181 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DriftPersonNameEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonNameEditForm({super.key, required this.person}); diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 1939940975..73a35958bd 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -14,12 +14,12 @@ final driftPeopleServiceProvider = Provider( (ref) => DriftPeopleService(ref.watch(driftPeopleRepositoryProvider), ref.watch(personApiRepositoryProvider)), ); -final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { +final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { final service = ref.watch(driftPeopleServiceProvider); return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = FutureProvider>((ref) async { +final driftGetAllPeopleProvider = FutureProvider>((ref) async { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3); diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index 1bd58509f5..f9fc1ec133 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/services/person.service.dart'; -final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { +final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { final PersonService personService = ref.read(personServiceProvider); final people = await personService.getAllPeople(); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 158e3ac333..5804e1da3b 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -11,12 +11,12 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { + Future> getAll() async { final dto = await checkNull(_api.getAllPeople()); return dto.people.map(_toPerson).toList(); } - Future update(String id, {String? name, DateTime? birthday}) async { + Future update(String id, {String? name, DateTime? birthday}) async { final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = PersonUpdateDto( name: name == null ? const Optional.absent() : Optional.present(name), @@ -26,12 +26,6 @@ class PersonApiRepository extends ApiRepository { return _toPerson(response); } - static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( - birthDate: dto.birthDate, - id: dto.id, - isHidden: dto.isHidden, - name: dto.name, - thumbnailPath: dto.thumbnailPath, - updatedAt: dto.updatedAt.orElse(null), - ); + static Person _toPerson(PersonResponseDto dto) => + .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.value); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index 0d589ea71d..6a9226a893 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -12,7 +12,7 @@ class PersonService { final PersonApiRepository _personApiRepository; PersonService(this._personApiRepository); - Future> getAllPeople() async { + Future> getAllPeople() async { try { return await _personApiRepository.getAll(); } catch (error, stack) { @@ -21,7 +21,7 @@ class PersonService { } } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { return await _personApiRepository.update(id, name: name); } catch (error, stack) { diff --git a/mobile/lib/utils/people.utils.dart b/mobile/lib/utils/people.utils.dart index 8da5a5a3eb..bd2b607e0b 100644 --- a/mobile/lib/utils/people.utils.dart +++ b/mobile/lib/utils/people.utils.dart @@ -34,7 +34,7 @@ int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) { (referenceDate.day < birthDate.day ? 1 : 0); } -Future showNameEditModal(BuildContext context, DriftPerson person) { +Future showNameEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, @@ -44,7 +44,7 @@ Future showNameEditModal(BuildContext context, DriftPerson person) { ); } -Future showBirthdayEditModal(BuildContext context, DriftPerson person) { +Future showBirthdayEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 4b159447e6..56eb3c6094 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -28,7 +28,7 @@ class PersonSliverAppBar extends ConsumerStatefulWidget { required this.onBirthdayTap, }); - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; final VoidCallback onShowOptions; @@ -137,7 +137,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState { class _ExpandedBackground extends ConsumerStatefulWidget { final double scrollProgress; - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index b4e73e97f9..1995851904 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -15,8 +15,8 @@ import 'package:immich_mobile/widgets/common/search_field.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget { const imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return Column( children: [ From 303a9f15b1a78e2aeb3c4195f6a97608152eb799 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:12:12 +0530 Subject: [PATCH 06/78] refactor: reactive driftGetAllPeopleProvider (#30660) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/services/people.service.dart | 6 +++--- .../infrastructure/repositories/people.repository.dart | 4 ++-- mobile/lib/pages/common/tab_shell.page.dart | 2 -- .../people/person_edit_birthday_modal.widget.dart | 9 ++------- .../widgets/people/person_edit_name_modal.widget.dart | 7 +------ mobile/lib/providers/infrastructure/people.provider.dart | 4 ++-- 6 files changed, 10 insertions(+), 22 deletions(-) diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index e86d43b283..196cf1edbb 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -18,8 +18,8 @@ class DriftPeopleService { return _repository.getAssetPeople(assetId); } - Future> getAllPeople({int minFaces = 3}) { - return _repository.getAllPeople(minFaces: minFaces); + Stream> watch({int minFaces = 3}) { + return _repository.watch(minFaces: minFaces); } Future updateName(String personId, String name) async { @@ -27,7 +27,7 @@ class DriftPeopleService { return _repository.updateName(personId, name); } - Future updateBrithday(String personId, DateTime birthday) async { + Future updateBirthday(String personId, DateTime birthday) async { await _personApiRepository.update(personId, birthday: birthday); return _repository.updateBirthday(personId, birthday); } diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index b539c6b2fd..c5c7bfdb9c 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -33,7 +33,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getAllPeople({int minFaces = 3}) async { + Stream> watch({int minFaces = 3}) { final people = _db.personEntity; final faces = _db.assetFaceEntity; final assets = _db.remoteAssetEntity; @@ -59,7 +59,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) { final person = row.readTable(people); return person.toDto(); - }).get(); + }).watch(); } Future updateName(String personId, String name) { diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 5b620e9c91..240c4abe7a 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/pages/search/paginated_search.provide import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; @@ -132,7 +131,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // Library page if (index == kLibraryTabIndex) { ref.invalidate(localAlbumProvider); - ref.invalidate(driftGetAllPeopleProvider); } ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index a4903c7a29..e56a21e021 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -30,14 +30,9 @@ class _DriftPersonNameEditFormState extends ConsumerState saveBirthday() async { try { - final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate); - - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } + final result = await ref.read(driftPeopleServiceProvider).updateBirthday(widget.person.id, _selectedDate); + if (result != 0 && mounted) { context.pop(_selectedDate); } } catch (error) { diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index 5e8c634181..4ae41bc640 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -29,12 +29,7 @@ class _DriftPersonNameEditFormState extends ConsumerState onEdit(String personId, String newName) async { try { final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName); - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } - + if (result != 0 && mounted) { context.pop(newName); } } catch (error) { diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 73a35958bd..26b145255c 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -19,8 +19,8 @@ final driftPeopleAssetProvider = FutureProvider.family, String>((re return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = FutureProvider>((ref) async { +final driftGetAllPeopleProvider = StreamProvider>((ref) async* { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); - return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3); + yield* service.watch(minFaces: prefs?.minimumFaces ?? 3); }); From 1c3a5cf5087182a929b16d71460e093af5b844a6 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:12:12 +0530 Subject: [PATCH 07/78] chore: remove old people provider (#30662) * chore: remove old people provider * refactor: rename driftGetAllPeopleProvider * minor fixes --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../pages/drift_library.page.dart | 2 +- .../pages/drift_people_collection.page.dart | 2 +- .../infrastructure/people.provider.dart | 2 +- .../lib/providers/search/people.provider.dart | 24 -------------- .../repositories/person_api.repository.dart | 7 +--- mobile/lib/services/person.service.dart | 32 ------------------- .../search/search_filter/people_picker.dart | 4 +-- 7 files changed, 6 insertions(+), 67 deletions(-) delete mode 100644 mobile/lib/providers/search/people.provider.dart delete mode 100644 mobile/lib/services/person.service.dart diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 3118f2c4f0..137f46659f 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -145,7 +145,7 @@ class _PeopleCollectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final people = ref.watch(driftGetAllPeopleProvider); + final people = ref.watch(getAllPeopleProvider); return LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index cb305395cc..569cb99b01 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -33,7 +33,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState, String>((re return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = StreamProvider>((ref) async* { +final getAllPeopleProvider = StreamProvider>((ref) async* { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); yield* service.watch(minFaces: prefs?.minimumFaces ?? 3); diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart deleted file mode 100644 index f9fc1ec133..0000000000 --- a/mobile/lib/providers/search/people.provider.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/services/person.service.dart'; - -final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { - final PersonService personService = ref.read(personServiceProvider); - - final people = await personService.getAllPeople(); - - return people; -}); - -final updatePersonNameProvider = FutureProvider.autoDispose( - (ref) => (String personId, String updatedName) async { - final PersonService personService = ref.read(personServiceProvider); - final person = await personService.updateName(personId, updatedName); - - if (person != null && person.name == updatedName) { - ref.invalidate(getAllPeopleProvider); - return true; - } - return false; - }, -); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 5804e1da3b..262868eea1 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -11,11 +11,6 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { - final dto = await checkNull(_api.getAllPeople()); - return dto.people.map(_toPerson).toList(); - } - Future update(String id, {String? name, DateTime? birthday}) async { final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = PersonUpdateDto( @@ -27,5 +22,5 @@ class PersonApiRepository extends ApiRepository { } static Person _toPerson(PersonResponseDto dto) => - .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.value); + .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.orElse(null)); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart deleted file mode 100644 index 6a9226a893..0000000000 --- a/mobile/lib/services/person.service.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/repositories/person_api.repository.dart'; -import 'package:logging/logging.dart'; - -final personServiceProvider = Provider.autoDispose( - (ref) => PersonService(ref.watch(personApiRepositoryProvider)), -); - -class PersonService { - final Logger _log = Logger("PersonService"); - final PersonApiRepository _personApiRepository; - PersonService(this._personApiRepository); - - Future> getAllPeople() async { - try { - return await _personApiRepository.getAll(); - } catch (error, stack) { - _log.severe("Error while fetching curated people", error, stack); - return []; - } - } - - Future updateName(String id, String name) async { - try { - return await _personApiRepository.update(id, name: name); - } catch (error, stack) { - _log.severe("Error while updating person name", error, stack); - } - return null; - } -} diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index 1995851904..ee738265b2 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; @@ -65,7 +65,7 @@ class PeoplePicker extends HookConsumerWidget { padding: const EdgeInsets.only(bottom: 2.0), child: LargeLeadingTile( title: Text( - person.name, + person.name.nullIfEmpty ?? context.t.no_name, style: context.textTheme.bodyLarge?.copyWith( fontSize: 20, fontWeight: FontWeight.w500, From 2a1691868e71aec6bd66eee82d1a6eb2e7aec636 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:01:30 +0200 Subject: [PATCH 08/78] chore: use import.meta.dirname instead of __dirname (#30738) --- e2e/eslint.config.mjs | 7 +------ packages/cli/eslint.config.mjs | 7 +------ server/eslint.config.mjs | 7 +------ web/eslint.config.js | 10 +++------- web/vite.config.ts | 4 ++-- 5 files changed, 8 insertions(+), 27 deletions(-) diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index d5da8f8445..3d69d1f503 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index bcc74c654d..cd32bf6023 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 2c11b7d3cb..949acaf139 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, diff --git a/web/eslint.config.js b/web/eslint.config.js index 5974eaf68f..fc69385c93 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -10,10 +10,6 @@ import parser from 'svelte-eslint-parser'; import typescriptEslint from 'typescript-eslint'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); export default typescriptEslint.config( ...eslintPluginSvelte.configs.recommended, @@ -29,7 +25,7 @@ export default typescriptEslint.config( 'error', { browserslist: fs - .readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8') + .readFileSync(path.join(import.meta.dirname, '.browserslistrc'), 'utf8') .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')), @@ -40,7 +36,7 @@ export default typescriptEslint.config( parser, parserOptions: { project: ['./tsconfig.json'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, // ignores: ['**/service-worker/**'], @@ -96,7 +92,7 @@ export default typescriptEslint.config( parserOptions: { extraFileExtensions: ['.svelte'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, project: ['./tsconfig.json'], }, }, diff --git a/web/vite.config.ts b/web/vite.config.ts index c2b2b5bf25..07e7efaf2a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -28,8 +28,8 @@ export default defineConfig({ alias: { 'xmlhttprequest-ssl': './node_modules/engine.io-client/lib/xmlhttprequest.js', // eslint-disable-next-line unicorn/prefer-module - '@test-data': path.resolve(__dirname, './src/test-data'), - // '@immich/ui': path.resolve(__dirname, '../../ui/packages/ui'), + '@test-data': path.resolve(import.meta.dirname, './src/test-data'), + // '@immich/ui': path.resolve(import.meta.dirname, '../../ui/packages/ui'), }, }, server: { From ff5da0f84fc79d3167fbb6a6a193ed53dc36c987 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:00:08 +0530 Subject: [PATCH 09/78] chore: immich_mobile path in openapi pubspec (#30643) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- open-api/patch/pubspec_immich_mobile.yaml.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-api/patch/pubspec_immich_mobile.yaml.patch b/open-api/patch/pubspec_immich_mobile.yaml.patch index ad3bbc2edd..be210f864f 100644 --- a/open-api/patch/pubspec_immich_mobile.yaml.patch +++ b/open-api/patch/pubspec_immich_mobile.yaml.patch @@ -6,4 +6,4 @@ -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: -+ path: ../ ++ path: ../../ From db9e7c20d71043a5a5a438781e3d9bebcf35bf97 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 12 Aug 2026 11:30:05 -0700 Subject: [PATCH 10/78] chore(mobile): add a single mise checkout command for reoccuring tasks (#30737) * chore(mobile): add a single mise checkout command for reoccuring tasks * Fix .flutter-plugins-dependencies path Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --- mobile/mise.toml | 14 ++++++++- mobile/scripts/checkout_ios.sh | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 mobile/scripts/checkout_ios.sh diff --git a/mobile/mise.toml b/mobile/mise.toml index f021cd8747..d8ddc8ab21 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -111,6 +111,18 @@ run = [ { task = "install", args = ["--enforce-lockfile"] }, ] +[tasks.checkout] +description = "Bring a checkout up to date after switching branches" +run = [ + { task = "install" }, + { task = "checkout:ios" }, + { task = "codegen" }, +] + +[tasks."checkout:ios"] +description = "Bring the iOS project up to date after switching branches" +run = "bash ./scripts/checkout_ios.sh" + [tasks.start] alias = "start" description = "Start flutter app" @@ -139,7 +151,7 @@ wait_for = ["//:i18n:format-fix"] [tasks."install:ios"] description = "Install CocoaPods dependencies" hide = true -sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugin-dependencies"] +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugins-dependencies"] outputs = ["ios/Pods/Manifest.lock"] run = ''' echo "Running pod install" diff --git a/mobile/scripts/checkout_ios.sh b/mobile/scripts/checkout_ios.sh new file mode 100644 index 0000000000..74706e0d23 --- /dev/null +++ b/mobile/scripts/checkout_ios.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Reconciles the iOS project with the current Flutter setup +# +# `flutter build ios --config-only` is relatively expensive, so we attempt to avoid running it when possible + +set -euo pipefail + +stamp="build/ios_checkout.stamp" + +# All tracked files that can invalidate the Xcode + Flutter build state +inputs_digest() { + git ls-files -z ios pubspec.yaml pubspec.lock mise.toml mise.lock \ + | xargs -0 shasum \ + | shasum \ + | cut -d ' ' -f 1 +} + +# Echoes why the iOS project needs reconciling, or returns 1 if it does not. +# The first three cases can happen with no tracked file having changed at all, +# so comparing the digest alone is not enough. +staleness() { + local flutter_root + flutter_root="$(sed -n 's/^FLUTTER_ROOT=//p' ios/Flutter/Generated.xcconfig 2>/dev/null || true)" + + if [[ -z $flutter_root ]]; then + echo "misconfigured FLUTTER_ROOT" + elif [[ ! -d $flutter_root ]]; then + echo "the Flutter SDK has changed" + elif ! cmp -s ios/Podfile.lock ios/Pods/Manifest.lock; then + # This is also done by Xcode's "Check Pods Manifest.lock" build phase + echo "Cocoapods out of sync" + elif [[ ! -f $stamp ]]; then + echo "first checkout" + elif [[ $(<"$stamp") != "$(inputs_digest)" ]]; then + echo "tracked build state has changed" + else + return 1 + fi +} + +[[ $(uname) == Darwin ]] || exit 0 + +if ! reason="$(staleness)"; then + exit 0 +fi + +echo "Reconciling iOS project: $reason" +flutter build ios --config-only --debug + +# Save tracked files manifest +mkdir -p "$(dirname "$stamp")" +inputs_digest > "$stamp" From b82d4805525f68dbb07e160163e2a582e9dfab91 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Wed, 12 Aug 2026 12:57:45 -0700 Subject: [PATCH 11/78] fix(mobile): prevent iOS status bar scroll to top during transitions (#30717) --- mobile/lib/main.dart | 2 +- .../widgets/timeline/timeline.widget.dart | 10 +++++++++- .../lib/routing/app_navigation_observer.dart | 19 +++++++++++++++++++ .../timeline/timeline_scroll_to_top_test.dart | 14 +++++++++++--- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d2fd945cbf..2f1cfdaf6a 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -277,7 +277,7 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve ), routerConfig: router.config( deepLinkBuilder: _deepLinkBuilder, - navigatorObservers: () => [AppNavigationObserver(ref: ref)], + navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()], ), ), ); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9e65dcb72c..c41f88fcc0 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; @@ -190,7 +191,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi // may be in a background tab. In either case, `handleStatusBarTap()` still fires // Make sure the timeline is the primary route before scrolling to the top final routeData = context.findAncestorWidgetOfExactType()?.routeData; - if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) { + // The tap is generated async, so it can arrive after a route pop has started (due to a back button or similar) + // Check if route is alive and not exiting before taking action + final observers = Navigator.maybeOf(context)?.widget.observers ?? const []; + final isRouteTransitioning = observers.whereType().any( + (observer) => observer.hasTransitioningRoute, + ); + + if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true && !isRouteTransitioning) { _scrollToTop(); } } diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index a7f6d532e9..90381cf49c 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -30,3 +30,22 @@ class AppNavigationObserver extends AutoRouterObserver { ref.invalidate(isAssetViewerOpenProvider); } } + +/// Tracks routes that are undergoing a pop transition +class TransitioningRouteObserver extends NavigatorObserver { + int _transitioningRoutes = 0; + + /// Whether a "popping" route is still on screen + bool get hasTransitioningRoute => _transitioningRoutes > 0; + + @override + void didPop(Route route, Route? previousRoute) { + if (route is! TransitionRoute) { + return; + } + + _transitioningRoutes += 1; + // Transition completed and route disposed + unawaited(route.completed.whenComplete(() => _transitioningRoutes -= 1)); + } +} diff --git a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart index 28644d6702..f72af61a0a 100644 --- a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart +++ b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import '../../../fixtures/asset.stub.dart'; @@ -62,7 +63,9 @@ void main() { timelineServiceProvider.overrideWithValue(service), appConfigProvider.overrideWithValue(const AppConfig()), ], - child: MaterialApp.router(routerConfig: router.config()), + child: MaterialApp.router( + routerConfig: router.config(navigatorObservers: () => [TransitioningRouteObserver()]), + ), ), ); // Segment stream resolves @@ -96,9 +99,14 @@ void main() { await tester.pumpAndSettle(); expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route'); - // Once the timeline is visible, taps on the status bar should scroll to the top - await router.maybePop(); + // The tap is async, so it can arrive while a pushed route is transitioning to popped + unawaited(router.maybePop()); + await tester.pump(); + await tapStatusBar(); await tester.pumpAndSettle(); + expect(position.pixels, initialScrolledPosition, reason: 'ignored while the pushed route pops'); + + // Once the timeline is visible, taps on the status bar should scroll to the top await tapStatusBar(); await tester.pumpAndSettle(); expect(position.pixels, 0, reason: 'scrolls the foreground timeline'); From a939561e70f437dc7ab3ee2d8b859cc654d6cb3e Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 12 Aug 2026 16:26:03 -0700 Subject: [PATCH 12/78] feat: workflow asset tag trigger/filter/action (#29043) * feat: workflow asset tag trigger and filter * feat(web): show tag names in workflow editor * fix(web): tag picker in schema config editor * fix: invalid plugin manifest * chore: update workflow method wrapper type * chore: update tag filter method declaration * feat: workflow action to add tags to assets --- i18n/en.json | 2 + open-api/immich-openapi-specs.json | 3 +- packages/plugin-core/manifest.json | 50 +++++++++++++++++++ packages/plugin-core/src/index.ts | 27 ++++++++++ packages/plugin-sdk/src/host-functions.ts | 9 ++++ packages/plugin-sdk/src/types.ts | 9 +++- packages/sdk/src/fetch-client.ts | 3 +- server/src/repositories/event.repository.ts | 2 +- .../src/repositories/workflow.repository.ts | 2 + server/src/services/tag.service.ts | 4 +- .../services/workflow-execution.service.ts | 13 +++++ server/src/utils/workflow.ts | 1 + .../lib/components/SchemaConfiguration.svelte | 3 ++ web/src/lib/components/SchemaTagPicker.svelte | 32 ++++++++++++ web/src/lib/types.ts | 2 +- web/src/lib/utils/workflow.ts | 9 ++++ .../[workflowId]/WorkflowStepCard.svelte | 12 ++++- 17 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/components/SchemaTagPicker.svelte diff --git a/i18n/en.json b/i18n/en.json index 536b7677ac..1022a6f851 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2139,6 +2139,8 @@ "trigger": "Trigger", "trigger_asset_metadata_extraction": "Asset Metadata Extraction", "trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted", + "trigger_asset_tagged": "Asset Tagged", + "trigger_asset_tagged_description": "Triggered when a tag is added to an asset", "trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_person_recognized": "Person Recognized", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc3bf82094..3596eaf4b1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -28066,7 +28066,8 @@ "description": "Plugin trigger type", "enum": [ "AssetCreate", - "AssetMetadataExtraction" + "AssetMetadataExtraction", + "AssetTagged" ], "type": "string" }, diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index a72d60c04d..d7f7830a0e 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -308,6 +308,56 @@ }, "uiHints": ["Filter"] }, + { + "name": "assetAddTags", + "title": "Add Tags", + "description": "Add tags to an asset", + "types": ["AssetV1"], + "hostFunctions": true, + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Tags to add to the asset", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + } + }, + "required": ["tags"] + } + }, + { + "name": "assetTagFilter", + "title": "Filter by tags", + "description": "Filter assets by which tags they have", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Which tags the asset must have", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + }, + "matching": { + "title": "Matching", + "description": "Whether assets must have all, any, or none of the listed tags", + "type": "string", + "enum": ["all", "any", "none"] + } + }, + "required": ["tags", "matching"] + }, + "uiHints": ["Filter"] + }, { "name": "assetExifFilter", "title": "Filter by EXIF metadata", diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 7b91ed7111..251393ace5 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -39,6 +39,11 @@ const matchValueResult = (value: string, config: MatchValueConfig) => { }; const methods = wrapper({ + assetAddTags: ({ config, data, functions }) => { + functions.bulkTagAssets({ assetIds: [data.asset.id], tagIds: config.tags }); + return {}; + }, + assetAddToAlbums: ({ config, data, functions }) => { const assetId = data.asset.id; @@ -176,6 +181,24 @@ const methods = wrapper({ return { workflow: { continue: hasTimeZone === needsTimeZone } }; }, + assetTagFilter: ({ config, data }) => { + const assetTags = data.asset.tags.map((tag) => tag.id); + + for (const tag of config.tags) { + if (assetTags.includes(tag)) { + if (config.matching === 'any') { + break; + } else if (config.matching === 'none') { + return { workflow: { continue: false } }; + } + } else if (config.matching === 'all') { + return { workflow: { continue: false } }; + } + } + + return { workflow: { continue: true } }; + }, + assetTypeFilter: ({ config, data }) => { return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; }, @@ -208,6 +231,7 @@ const methods = wrapper({ }); const { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -217,6 +241,7 @@ const { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, @@ -226,6 +251,7 @@ const { } = methods; export { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -235,6 +261,7 @@ export { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 9d52630438..e0b6e34da9 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -4,6 +4,8 @@ import { type BulkIdResponseDto, type BulkIdsDto, type CreateAlbumDto, + type TagBulkAssetsDto, + type TagBulkAssetsResponseDto, } from '@immich/sdk'; declare module 'extism:host' { @@ -47,6 +49,7 @@ export const availableFunctions = [ 'addAssetsToAlbum', 'addAssetsToAlbums', 'httpRequest', + 'bulkTagAssets', ] as const; export const hostFunctions = (authToken: string) => { @@ -96,5 +99,11 @@ export const hostFunctions = (authToken: string) => { authToken, [url, options], ), + bulkTagAssets: (dto: TagBulkAssetsDto) => + call<[TagBulkAssetsDto], TagBulkAssetsResponseDto>( + 'bulkTagAssets', + authToken, + [dto], + ), } satisfies Record<(typeof availableFunctions)[number], unknown>; }; diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 9756d652c3..94c968978d 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -1,4 +1,9 @@ -import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; +import type { + AssetTypeEnum, + AssetVisibility, + TagResponseDto, + WorkflowType, +} from '@immich/sdk'; type DeepPartial = T extends Date ? T @@ -18,6 +23,7 @@ export type WorkflowEventData = WorkflowEventMap[T]; export enum WorkflowTrigger { AssetCreate = 'AssetCreate', AssetMetadataExtraction = 'AssetMetadataExtraction', + AssetTagged = 'AssetTagged', // PersonRecognized = 'PersonRecognized', } @@ -88,6 +94,7 @@ export type AssetV1 = { duplicateId: string | null; visibility: AssetVisibility; isEdited: boolean; + tags: TagResponseDto[]; exifInfo: { make: string | null; model: string | null; diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3ac958ce2d..af24b2d21b 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -7423,7 +7423,8 @@ export enum WorkflowType { } export enum WorkflowTrigger { AssetCreate = "AssetCreate", - AssetMetadataExtraction = "AssetMetadataExtraction" + AssetMetadataExtraction = "AssetMetadataExtraction", + AssetTagged = "AssetTagged" } export enum QueueJobStatus { Active = "active", diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 416f823952..7fedc4eb3a 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -43,7 +43,7 @@ type EventMap = { // asset events AssetCreate: [{ asset: Pick; file?: UploadFile }]; - AssetTag: [{ assetId: string }]; + AssetTag: [{ assetId: string; userId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; AssetShow: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 9c946fe583..888ca94440 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -8,6 +8,7 @@ import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; +import { withTags } from 'src/utils/database'; export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @@ -143,6 +144,7 @@ export class WorkflowRepository { .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select((eb) => [ ...columns.workflowAssetV1, + withTags, jsonObjectFrom( eb .selectFrom('asset_exif') diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 3cd442b6a7..08a9b00104 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -93,7 +93,7 @@ export class TagService extends BaseService { const results = await this.tagRepository.upsertAssetIds(items); for (const assetId of new Set(results.map((item) => item.assetId))) { await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return { count: results.length }; @@ -114,7 +114,7 @@ export class TagService extends BaseService { } await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return results; diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 318f36a4b6..4995eada9c 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -13,6 +13,7 @@ import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { TagBulkAssetsDto } from 'src/dtos/tag.dto'; import { BootstrapEventPriority, DatabaseLock, @@ -27,6 +28,7 @@ import { ArgOf } from 'src/repositories/event.repository'; import { AlbumService } from 'src/services/album.service'; import { AssetService } from 'src/services/asset.service'; import { BaseService } from 'src/services/base.service'; +import { TagService } from 'src/services/tag.service'; import { JobOf } from 'src/types'; const dummy = () => { @@ -69,6 +71,7 @@ export class WorkflowExecutionService extends BaseService { this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); const albumService = BaseService.create(AlbumService, this); + const tagService = BaseService.create(TagService, this); const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args)); const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args)); @@ -106,6 +109,9 @@ export class WorkflowExecutionService extends BaseService { throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); }); + const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) => + tagService.bulkTagAssets(authDto, ...args), + ); const functions = { searchAlbums, @@ -113,6 +119,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum, addAssetsToAlbums, httpRequest, + bulkTagAssets, }; const stubs: typeof functions = { @@ -121,6 +128,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum: dummy, addAssetsToAlbums: dummy, httpRequest: dummy, + bulkTagAssets: dummy, }; const plugins = await this.pluginRepository.getForLoad(); @@ -309,6 +317,11 @@ export class WorkflowExecutionService extends BaseService { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); } + @OnEvent({ name: 'AssetTag' }) + onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) { + return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged }); + } + private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { const items = await this.workflowRepository.search({ userId, trigger }); await this.jobRepository.queueAll( diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index c892239c80..a3a9d56c42 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -6,6 +6,7 @@ export const triggerMap: Record = { [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], // [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], + [WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1], }; export const getWorkflowTriggers = () => diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte index 65d115179e..c4e20081dd 100644 --- a/web/src/lib/components/SchemaConfiguration.svelte +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -1,6 +1,7 @@ + + { + if (option && !tagIds.includes(option.value)) { + tagIds.push(option.value); + } + }} + label={$t('tags')} + defaultFirstOption + options={tags.map((tag) => ({ label: tag.value, value: tag.id }))} + placeholder={$t('search_tags')} +/> + +
+ {#each tagIds as id, index (id)} + t.id === id)?.name ?? id} onRemove={() => tagIds.splice(index, 1)} /> + {/each} +
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 09d882dd7d..f2451b1bc5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -100,7 +100,7 @@ export type JSONSchemaProperty = { properties?: Record; required?: string[]; uiHint?: { - type?: 'AlbumId' | 'AssetId' | 'PersonId'; + type?: 'AlbumId' | 'AssetId' | 'PersonId' | 'TagId'; order?: number; }; }; diff --git a/web/src/lib/utils/workflow.ts b/web/src/lib/utils/workflow.ts index b8dc44cc67..3a2100e3a4 100644 --- a/web/src/lib/utils/workflow.ts +++ b/web/src/lib/utils/workflow.ts @@ -13,6 +13,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => { case WorkflowTrigger.AssetMetadataExtraction: { return $t('trigger_asset_metadata_extraction'); } + case WorkflowTrigger.AssetTagged: { + return $t('trigger_asset_tagged'); + } default: { return type; } @@ -30,6 +33,12 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge case WorkflowTrigger.AssetMetadataExtraction: { return $t('trigger_asset_metadata_extraction_description'); } + case WorkflowTrigger.AssetTagged: { + return $t('trigger_asset_tagged_description'); + } + default: { + return type; + } } }; diff --git a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte index d3dec41f48..2b8c0d7ffe 100644 --- a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte +++ b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte @@ -1,6 +1,6 @@ + + + + {#if workflow.logging} + + + {$t('date')} + {$t('result')} + + + {#each entries as entry (entry.id)} + + + +

+ {DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, { + locale: $locale, + })} +

+ {#if entry.triggerDataId} + + + + {/if} +
+
+ + + {#if entry.result === WorkflowResult.Completed} +

+ {$t('workflow_logging_completed')} +

+ {:else if entry.result === WorkflowResult.Halted} +

+ {#if entry.lastStep} + {$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('workflow_logging_halted')} + {/if} +

+ {:else} +

+ {#if entry.lastStep} + {$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('error')} + {/if} +

+ {/if} +
+
+
+ {/each} + {#if hasNext} +
...
+ {/if} +
+
+
+ - - {:else if queryType === 'metadata'} - - - - {:else if queryType === 'description'} - - - - {:else if queryType === 'fullPath'} - - - - {:else if queryType === 'ocr'} - - - - {/if} diff --git a/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts new file mode 100644 index 0000000000..4dd99edbba --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts @@ -0,0 +1,157 @@ +import { getAllPeople, type PersonResponseDto, type TagResponseDto } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { t } from 'svelte-i18n'; +import type { SvelteSet } from 'svelte/reactivity'; +import { get } from 'svelte/store'; +import { MediaType } from '$lib/constants'; +import { handleError } from '$lib/utils/handle-error'; + +export enum SearchDatePreset { + ThisYear, + LastYear, + Last30Days, + Custom, +} + +export const getSearchDatePreset = (after: DateTime | undefined, before: DateTime | undefined) => { + if (!after && !before) { + return; + } + + const start = after?.toMillis(); + const end = before?.endOf('day').toMillis(); + + if (start === DateTime.utc().startOf('year').toMillis() && end === DateTime.utc().endOf('year').toMillis()) { + return SearchDatePreset.ThisYear; + } + + if ( + start === DateTime.utc().minus({ years: 1 }).startOf('year').toMillis() && + end === DateTime.utc().minus({ years: 1 }).endOf('year').toMillis() + ) { + return SearchDatePreset.LastYear; + } + + if ( + start === DateTime.utc().minus({ days: 30 }).startOf('day').toMillis() && + end === DateTime.utc().endOf('day').toMillis() + ) { + return SearchDatePreset.Last30Days; + } + + return SearchDatePreset.Custom; +}; + +export const getSearchDateRange = (after: DateTime | undefined, before: DateTime | undefined) => { + const $t = get(t); + const start = after?.toLocaleString(DateTime.DATE_MED); + const end = before?.toLocaleString(DateTime.DATE_MED); + return start && end ? $t('search_filter_date_interval', { values: { start, end } }) : (start ?? end); +}; + +export const getSearchDateTitle = ( + preset: SearchDatePreset | undefined, + before: DateTime | undefined, + after: DateTime | undefined, +): string | undefined => { + const $t = get(t); + switch (preset) { + case SearchDatePreset.ThisYear: { + return $t('search_filter_date_this_year'); + } + case SearchDatePreset.LastYear: { + return $t('search_filter_date_last_year'); + } + case SearchDatePreset.Last30Days: { + return $t('search_filter_date_last_30_days'); + } + case SearchDatePreset.Custom: { + return getSearchDateRange(before, after); + } + default: { + return; + } + } +}; + +export const getSearchTypeTitle = (type: string) => { + const $t = get(t); + switch (type) { + case 'metadata': { + return $t('file_name_text'); + } + case 'description': { + return $t('description'); + } + case 'fullPath': { + return $t('full_path_or_folder'); + } + case 'ocr': { + return $t('ocr'); + } + default: { + return; + } + } +}; + +export const getSearchPlacesTitle = (city?: string, state?: string, country?: string) => + [city, state, country].filter(Boolean).join(', ') || undefined; + +export const getSearchMediaTitle = (mediaType: MediaType) => { + const $t = get(t); + switch (mediaType) { + case MediaType.Image: { + return $t('image'); + } + case MediaType.Video: { + return $t('video'); + } + default: { + return; + } + } +}; + +export const getPeople = async (selected: SvelteSet): Promise => { + const $t = get(t); + try { + const res = await getAllPeople({ withHidden: false }); + res.people.sort((a, b) => (selected.has(a.id) ? -1 : selected.has(b.id) ? 1 : 0)); + return res.people; + } catch (error) { + handleError(error, $t('errors.failed_to_get_people')); + } + return []; +}; + +export const getSearchPeopleTitle = (people: PersonResponseDto[], selected: SvelteSet) => { + if (selected.size === 0) { + return; + } + + const $t = get(t); + + const name = people.find(({ id, name }) => name && selected.has(id))?.name; + if (name) { + return selected.size === 1 ? name : $t('name_plus_more_people', { values: { name, count: selected.size - 1 } }); + } + + return $t('people_count', { values: { count: selected.size } }); +}; + +export const getSearchTagsTitle = (tags: TagResponseDto[], selected: SvelteSet) => { + const $t = get(t); + + const id = selected.values().next().value; + if (!id) { + return undefined; + } + + const tag = tags.find((t) => t.id === id)?.name; + if (!tag) { + return undefined; + } + + return selected.size === 1 ? tag : $t('tag_plus_more_tags', { values: { tag, count: selected.size - 1 } }); +}; diff --git a/web/src/lib/elements/SearchBar.svelte b/web/src/lib/elements/SearchBar.svelte index c20eda2928..c380e7667b 100644 --- a/web/src/lib/elements/SearchBar.svelte +++ b/web/src/lib/elements/SearchBar.svelte @@ -39,7 +39,7 @@
onSearch({ force: true })} /> (this.#fromQuery({})); + + get filter() { + return this.#filter; + } + + reset() { + this.#filter = this.#fromQuery({}); + } + + setQuery(query: MetadataSearchDto | SmartSearchDto) { + this.#filter = this.#fromQuery(query); + } + + async submit() { + await goto(Route.search(this.#toQuery())); + } + + #fromQuery(searchQuery: MetadataSearchDto | SmartSearchDto): SearchFilter { + let query = 'query' in searchQuery && searchQuery.query ? searchQuery.query : ''; + + if ('originalFileName' in searchQuery && searchQuery.originalFileName) { + query = searchQuery.originalFileName; + } + + if ('originalPath' in searchQuery && searchQuery.originalPath) { + query = searchQuery.originalPath; + } + + return { + query, + ocr: searchQuery.ocr, + queryType: this.#defaultQueryType(), + queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined, + personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), + tagIds: + 'tagIds' in searchQuery + ? searchQuery.tagIds === null + ? null + : new SvelteSet(searchQuery.tagIds) + : new SvelteSet(), + location: { + country: this.#withNullAsEmptyString(searchQuery.country), + state: this.#withNullAsEmptyString(searchQuery.state), + city: this.#withNullAsEmptyString(searchQuery.city), + }, + camera: { + make: this.#withNullAsEmptyString(searchQuery.make), + model: this.#withNullAsEmptyString(searchQuery.model), + lensModel: this.#withNullAsEmptyString(searchQuery.lensModel), + }, + date: { + takenAfter: searchQuery.takenAfter ? this.#toStartOfDayDate(searchQuery.takenAfter) : undefined, + takenBefore: searchQuery.takenBefore ? this.#toStartOfDayDate(searchQuery.takenBefore) : undefined, + }, + display: { + isArchive: searchQuery.visibility === AssetVisibility.Archive, + isFavorite: searchQuery.isFavorite ?? false, + isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false, + }, + mediaType: + searchQuery.type === AssetTypeEnum.Image + ? MediaType.Image + : searchQuery.type === AssetTypeEnum.Video + ? MediaType.Video + : MediaType.All, + rating: searchQuery.rating, + }; + } + + #toQuery(): MetadataSearchDto | SmartSearchDto { + let type: AssetTypeEnum | undefined = undefined; + if (this.filter.mediaType === MediaType.Image) { + type = AssetTypeEnum.Image; + } else if (this.filter.mediaType === MediaType.Video) { + type = AssetTypeEnum.Video; + } + + const query = this.filter.query || undefined; + + return { + query: this.filter.queryType === 'smart' ? query : undefined, + queryAssetId: this.filter.queryAssetId || undefined, + ocr: this.filter.queryType === 'ocr' ? query : undefined, + originalFileName: this.filter.queryType === 'metadata' ? query : undefined, + description: this.filter.queryType === 'description' ? query : undefined, + originalPath: this.filter.queryType === 'fullPath' ? this.filter.query.trim() || undefined : undefined, + country: this.#emptyStringToNull(this.filter.location.country), + state: this.#emptyStringToNull(this.filter.location.state), + city: this.#emptyStringToNull(this.filter.location.city), + make: this.#emptyStringToNull(this.filter.camera.make), + model: this.#emptyStringToNull(this.filter.camera.model), + lensModel: this.#emptyStringToNull(this.filter.camera.lensModel), + takenAfter: this.filter.date.takenAfter + ? asLocalTimeISO(this.filter.date.takenAfter.startOf('day') as DateTime) + : undefined, + takenBefore: this.filter.date.takenBefore + ? asLocalTimeISO(this.filter.date.takenBefore.endOf('day') as DateTime) + : undefined, + visibility: this.filter.display.isArchive ? AssetVisibility.Archive : undefined, + isFavorite: this.filter.display.isFavorite || undefined, + isNotInAlbum: this.filter.display.isNotInAlbum || undefined, + personIds: this.filter.personIds.size > 0 ? [...this.filter.personIds] : undefined, + tagIds: this.filter.tagIds === null ? null : this.filter.tagIds.size > 0 ? [...this.filter.tagIds] : undefined, + type, + rating: this.filter.rating, + }; + } + + #withNullAsEmptyString(value: T | null) { + return value === null ? '' : value; + } + + #emptyStringToNull(value: string | undefined) { + return value === '' ? null : value; + } + + #toStartOfDayDate(dateString: string) { + return parseUtcDate(dateString)?.startOf('day') || undefined; + } + + #defaultQueryType(): QueryType { + const storedQueryType = localStorage.getItem('searchQueryType') as QueryType; + return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART; + } +} + +export const searchManager = new SearchManager(); diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte deleted file mode 100644 index 2acb885d4a..0000000000 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ /dev/null @@ -1,225 +0,0 @@ - - - - -
-
- - - - - - - - - - - - - - - - - - - - {#if authManager.authenticated && authManager.preferences.ratings.enabled} - - {/if} - -
- - - - - -
-
-
-
- - - - - - - -
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6683dbfe2e..649527af9b 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -22,6 +22,7 @@ import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { searchManager } from '$lib/managers/search-manager.svelte'; import type { Viewport } from '$lib/managers/timeline-manager/types'; import { Route } from '$lib/route'; import { getAssetBulkActions } from '$lib/services/asset.service'; @@ -44,7 +45,7 @@ } from '@immich/sdk'; import { ActionButton, CommandPaletteDefaultProvider, Icon, IconButton, LoadingSpinner } from '@immich/ui'; import { mdiArrowLeft, mdiClose, mdiDotsVertical, mdiImageOffOutline, mdiSelectAll } from '@mdi/js'; - import { tick, untrack } from 'svelte'; + import { onMount, tick, untrack } from 'svelte'; import { t } from 'svelte-i18n'; const viewport: Viewport = $state({ width: 0, height: 0 }); @@ -244,7 +245,10 @@ delete terms[key]; assetMultiSelectManager.clear(); void goto(Route.search(terms)); + searchManager.setQuery(terms); } + + onMount(() => searchManager.setQuery(terms)); @@ -392,7 +396,7 @@
goto(previousRoute)} backIcon={mdiArrowLeft}>
- +
From 2018e4f8c4382e7428c05b8ee7842a74f1166fd5 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 19 Aug 2026 15:14:40 +0200 Subject: [PATCH 53/78] chore(server): migrate library e2e tests (#27277) * migrate library e2e tests * fix: real storage repo --------- Co-authored-by: Jason Rasmussen --- e2e/src/specs/server/api/library.e2e-spec.ts | 549 +----------------- server/test/medium.factory.ts | 3 + .../specs/services/library.service.spec.ts | 455 +++++++++++++++ 3 files changed, 459 insertions(+), 548 deletions(-) create mode 100644 server/test/medium/specs/services/library.service.spec.ts diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index ccb594610c..47a6da0648 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -1,5 +1,5 @@ import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; -import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs'; +import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; @@ -782,553 +782,6 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); - - it('should set an asset offline if its file is missing', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(trashedAsset.isOffline).toEqual(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(newAssets.items).toEqual([]); - }); - - it('should set an asset offline if its file is not in any import path', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - - utils.createDirectory(`${testAssetDir}/temp/another-path/`); - - await utils.updateLibrary(admin.accessToken, library.id, { - importPaths: [`${testAssetDirInternal}/temp/another-path/`], - }); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(trashedAsset.isOffline).toBe(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([]); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - utils.removeDirectory(`${testAssetDir}/temp/another-path/`); - }); - - it('should set an asset offline if its file is covered by an exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { - libraryId: library.id, - originalFileName: 'assetB.png', - }); - expect(assets.count).toBe(1); - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] }); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.isTrashed).toBe(true); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); - expect(trashedAsset.isOffline).toBe(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'assetA.png', - }), - ]); - }); - - it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assetsBefore.count).toBeGreaterThan(1); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets).toEqual(assetsBefore); - }); - - describe('xmp metadata', async () => { - it('should import metadata from file.xmp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should import metadata from file.ext.xmp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.ext.xmp to file metadata', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-07-20T17:27:12.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.xmp to file metadata', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-07-20T17:27:12.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - }); - - it('should set an offline asset to online if its file exists, is in an import path, and not covered by an exclusion pattern', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(backOnlineAsset.isTrashed).toBe(false); - expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(backOnlineAsset.isOffline).toBe(false); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - } - }); - - it('should set a trashed offline asset to online but keep it in trash', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - await utils.deleteAssets(admin.accessToken, [assets.items[0].id]); - - { - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(trashedAsset.isTrashed).toBe(true); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(backOnlineAsset.isOffline).toBe(false); - expect(backOnlineAsset.isTrashed).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - }); - - it('should not set an offline asset to online if its file exists, is not covered by an exclusion pattern, but is outside of all import paths', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - utils.createDirectory(`${testAssetDir}/temp/another-path/`); - - await utils.updateLibrary(admin.accessToken, library.id, { - importPaths: [`${testAssetDirInternal}/temp/another-path`], - }); - - await utils.scan(admin.accessToken, library.id); - - const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(stillOfflineAsset.isTrashed).toBe(true); - expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(stillOfflineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.removeDirectory(`${testAssetDir}/temp/another-path/`); - }); - - it('should not set an offline asset to online if its file exists, is in an import path, but is covered by an exclusion pattern', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assetsBefore.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - - await utils.scan(admin.accessToken, library.id); - - const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(stillOfflineAsset.isTrashed).toBe(true); - expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(stillOfflineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - }); }); describe('POST /libraries/:id/validate', () => { diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index dc3bc34e3c..1fbe41d964 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -35,6 +35,7 @@ import { EmailRepository } from 'src/repositories/email.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { IntegrityRepository } from 'src/repositories/integrity.repository'; import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; @@ -451,6 +452,7 @@ const newRealRepository = (key: T, db: Kysely case DuplicateRepository: case IntegrityRepository: case MemoryRepository: + case LibraryRepository: case NotificationRepository: case OcrRepository: case PartnerRepository: @@ -517,6 +519,7 @@ const newMockRepository = (key: ClassConstructor) => { case AssetJobRepository: case ConfigRepository: case CryptoRepository: + case LibraryRepository: case MemoryRepository: case IntegrityRepository: case NotificationRepository: diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts new file mode 100644 index 0000000000..e551869064 --- /dev/null +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -0,0 +1,455 @@ +import { Kysely } from 'kysely'; +import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { AssetStatus, JobName, JobStatus } from 'src/enum'; +import { AssetJobRepository } from 'src/repositories/asset-job.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { DB } from 'src/schema'; +import { LibraryService } from 'src/services/library.service'; +import { newMediumService, testAssetsDir } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const fileModifiedAt = new Date(1_700_000_000_000); + +const createFile = async (filePath: string, modifiedAt: Date = fileModifiedAt) => { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, 'test'); + await utimes(filePath, modifiedAt, modifiedAt); + return filePath; +}; + +const copyTestAsset = async (source: string, filePath: string, modifiedAt: Date = fileModifiedAt) => { + await mkdir(dirname(filePath), { recursive: true }); + await copyFile(join(testAssetsDir, source), filePath); + await utimes(filePath, modifiedAt, modifiedAt); + return filePath; +}; + +const setup = (db?: Kysely) => { + const context = newMediumService(LibraryService, { + database: db || defaultDatabase, + real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], + mock: [EventRepository, JobRepository, LoggingRepository], + }); + + const jobs = context.ctx.getMock(JobRepository); + jobs.queue.mockResolvedValue(); + jobs.queueAll.mockResolvedValue(); + + context.ctx.getMock(EventRepository).emit.mockResolvedValue(); + + return context; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(LibraryService.name, () => { + let tempDir: string; + let importRoot: string; + let importPath: string; + let excludedPath: string; + let outsidePath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'immich-library-')); + importRoot = join(tempDir, 'libraries/offline'); + importPath = join(importRoot, 'in-path'); + excludedPath = join(importRoot, 'excluded'); + outsidePath = join(tempDir, 'libraries/outside'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + const createLibrary = async ( + ctx: ReturnType['ctx'], + options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}, + ) => { + const { user } = await ctx.newUser(); + return ctx.get(LibraryRepository).create({ + ownerId: user.id, + name: 'Medium test library', + importPaths: options.importPaths ?? [importPath], + exclusionPatterns: options.exclusionPatterns ?? [], + }); + }; + + describe('offline asset handling', () => { + it('should set an asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + // the file is intentionally never created on disk + originalPath: join(importPath, 'offline.png'), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set an asset offline if its file is not in any import path', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(outsidePath, 'offline.png')), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(excludedPath, 'offline.png')), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should not set an asset offline if file exists in import path and is not excluded', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(importPath, 'online.png')), + fileModifiedAt, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeNull(); + }); + + it('should set an offline asset to online if its file exists in an import path and is not excluded', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(importPath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeNull(); + }); + + it('should not set an offline asset to online if its file exists in an import path but is excluded', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(excludedPath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should keep an offline asset offline if it is outside import paths', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(outsidePath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set a trashed asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + // the file is intentionally never created on disk + originalPath: join(importPath, 'offline.png'), + isExternal: true, + isOffline: false, + deletedAt: new Date(), + status: AssetStatus.Trashed, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set a trashed offline asset to online but keep it in trash', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(importPath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Trashed, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + }); + + describe('xmp scan behavior', () => { + it('should queue sidecar checks for newly imported assets', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); + + await expect( + sut.handleSyncFiles({ + libraryId: library.id, + paths: [rawPath], + progressCounter: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).toHaveBeenCalledWith([ + expect.objectContaining({ + name: JobName.SidecarCheck, + data: expect.objectContaining({ id: expect.any(String) }), + }), + ]); + }); + + it('should queue sidecar checks for assets whose file changed', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: rawPath, + // the file on disk has a newer modified time + fileModifiedAt: new Date(fileModifiedAt.valueOf() - 1000), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).toHaveBeenCalledWith([ + { + name: JobName.SidecarCheck, + data: { id: asset.id, source: 'upload' }, + }, + ]); + }); + + it('should not queue sidecar checks for unchanged assets', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); + + const library = await createLibrary(ctx, { importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: rawPath, + fileModifiedAt, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).not.toHaveBeenCalled(); + }); + }); +}); From db3b31709de80715137df05d89b0d91de4e275d4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 09:54:08 -0400 Subject: [PATCH 54/78] refactor: library controller tests (#30863) --- e2e/src/specs/server/api/library.e2e-spec.ts | 173 --------------- .../controllers/library.controller.spec.ts | 203 ++++++++++++++++++ 2 files changed, 203 insertions(+), 173 deletions(-) create mode 100644 server/src/controllers/library.controller.spec.ts diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index 47a6da0648..b508a52f3a 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -1,8 +1,6 @@ import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { userDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { utimes } from 'utimes'; @@ -10,7 +8,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/libraries', () => { let admin: LoginResponseDto; - let user: LoginResponseDto; let library: LibraryResponseDto; let websocket: Socket; @@ -18,7 +15,6 @@ describe('/libraries', () => { await utils.resetDatabase(); admin = await utils.adminSetup(); await utils.resetAdminConfig(admin.accessToken); - user = await utils.userSetup(admin.accessToken, userDto.user1); library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); websocket = await utils.connectWebsocket(admin.accessToken); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); @@ -34,31 +30,7 @@ describe('/libraries', () => { utils.resetEvents(); }); - describe('GET /libraries', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/libraries'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - }); - describe('POST /libraries', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/libraries').send({}); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require admin authentication', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${user.accessToken}`) - .send({ ownerId: admin.userId }); - - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should create an external library with defaults', async () => { const { status, body } = await request(app) .post('/libraries') @@ -97,49 +69,9 @@ describe('/libraries', () => { }), ); }); - - it('should not create an external library with duplicate import paths', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path', '/path'], - exclusionPatterns: ['**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), - ); - }); - - it('should not create an external library with duplicate exclusion patterns', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - exclusionPatterns: ['**/Raw/**', '**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), - ); - }); }); describe('PUT /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/libraries/${uuidDto.notFound}`).send({}); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should change the library name', async () => { const { status, body } = await request(app) .put(`/libraries/${library.id}`) @@ -154,18 +86,6 @@ describe('/libraries', () => { ); }); - it('should not set an empty name', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ name: '' }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]), - ); - }); - it('should change the import paths', async () => { const { status, body } = await request(app) .put(`/libraries/${library.id}`) @@ -180,30 +100,6 @@ describe('/libraries', () => { ); }); - it('should reject an empty import path', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [''] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), - ); - }); - - it('should reject duplicate import paths', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: ['/path', '/path'] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), - ); - }); - it('should change the exclusion pattern', async () => { const { status, body } = await request(app) .put(`/libraries/${library.id}`) @@ -217,48 +113,9 @@ describe('/libraries', () => { }), ); }); - - it('should reject duplicate exclusion patterns', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), - ); - }); - - it('should reject an empty exclusion pattern', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: [''] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), - ); - }); }); describe('GET /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require admin access', async () => { - const { status, body } = await request(app) - .get(`/libraries/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should get library by id', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); @@ -280,23 +137,7 @@ describe('/libraries', () => { }); }); - describe('GET /libraries/:id/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}/statistics`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - }); - describe('POST /libraries/:id/scan', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/scan`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should import new asset when scanning external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -785,13 +626,6 @@ describe('/libraries', () => { }); describe('POST /libraries/:id/validate', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/validate`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should pass with no import paths', async () => { const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); expect(response.importPaths).toEqual([]); @@ -856,13 +690,6 @@ describe('/libraries', () => { }); describe('DELETE /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/libraries/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should delete an external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); diff --git a/server/src/controllers/library.controller.spec.ts b/server/src/controllers/library.controller.spec.ts new file mode 100644 index 0000000000..c6f1b21ccc --- /dev/null +++ b/server/src/controllers/library.controller.spec.ts @@ -0,0 +1,203 @@ +import { LibraryController } from 'src/controllers/library.controller'; +import { LibraryService } from 'src/services/library.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(LibraryController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(LibraryService); + + beforeAll(async () => { + ctx = await controllerSetup(LibraryController, [{ provide: LibraryService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + const id = factory.uuid(); + + describe('authentication', () => { + const routes = [ + { method: 'get', path: '/libraries' }, + { method: 'post', path: '/libraries' }, + { method: 'get', path: `/libraries/${id}` }, + { method: 'put', path: `/libraries/${id}` }, + { method: 'patch', path: `/libraries/${id}` }, + { method: 'delete', path: `/libraries/${id}` }, + { method: 'post', path: `/libraries/${id}/validate` }, + { method: 'get', path: `/libraries/${id}/statistics` }, + { method: 'post', path: `/libraries/${id}/scan` }, + ] as const; + + it.each(routes)('$method $path should be an admin route', async ({ method, path }) => { + await request(ctx.getHttpServer())[method](path).send({}); + + expect(ctx.authenticate).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ adminRoute: true }) }), + ); + }); + }); + + describe('POST /libraries', () => { + it('should require an owner id', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['ownerId'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should reject duplicate import paths', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/libraries') + .send({ ownerId: id, importPaths: ['/path', '/path'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should reject duplicate exclusion patterns', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/libraries') + .send({ ownerId: id, exclusionPatterns: ['**/Raw/**', '**/Raw/**'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /libraries/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/libraries/invalid') + .send({ name: 'New Library Name' }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty name', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/libraries/${id}`).send({ name: '' }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['name'], message: 'Too small: expected string to have >=1 characters' }, + ]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty import path', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ importPaths: [''] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject duplicate import paths', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ importPaths: ['/path', '/path'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty exclusion pattern', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ exclusionPatterns: [''] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject duplicate exclusion patterns', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + }); + + describe('POST /libraries/:id/validate', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries/invalid/validate').send({}); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.validate).not.toHaveBeenCalled(); + }); + + it('should not require import paths', async () => { + service.validate.mockResolvedValue({ importPaths: [] }); + + const { status } = await request(ctx.getHttpServer()).post(`/libraries/${id}/validate`).send({}); + + expect(status).toBe(200); + expect(service.validate).toHaveBeenCalledWith(id, {}); + }); + }); + + describe('GET /libraries/:id/statistics', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/libraries/invalid/statistics'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.getStatistics).not.toHaveBeenCalled(); + }); + }); + + describe('POST /libraries/:id/scan', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries/invalid/scan'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.queueScan).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /libraries/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete('/libraries/invalid'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.delete).not.toHaveBeenCalled(); + }); + }); +}); From d2759ec41e6e4f59247641f5444fd8702536820e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 10:36:35 -0400 Subject: [PATCH 55/78] refactor: library e2e (#30865) --- e2e/src/specs/server/api/library.e2e-spec.ts | 418 ------------------ .../specs/services/library.service.spec.ts | 371 +++++++++++++--- 2 files changed, 319 insertions(+), 470 deletions(-) diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index b508a52f3a..5c98332a1e 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -138,29 +138,6 @@ describe('/libraries', () => { }); describe('POST /libraries/:id/scan', () => { - it('should import new asset when scanning external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/directoryA`], - }); - - const { status } = await request(app) - .post(`/libraries/${library.id}/scan`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - - const { assets } = await utils.searchAssets(admin.accessToken, { - originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, - libraryId: library.id, - }); - expect(assets.count).toBe(1); - }); - it('should process metadata and thumbnails for external asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -180,337 +157,6 @@ describe('/libraries', () => { expect(asset.thumbhash).not.toBe(null); }); - it('should scan external library with exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - exclusionPatterns: ['**/directoryA/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - }); - - it('should scan external library with multiple exclusion patterns', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(0); - - expect(assets.items).toEqual([]); - }); - - it('should remove assets covered by a new exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryA/assetA.png') }), - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { - exclusionPatterns: ['**/directoryA/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { - exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(0); - - expect(assets.items).toEqual([]); - } - }); - - it('should scan multiple import paths', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined(); - expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); - }); - - it('should scan multiple import paths with commas', async () => { - // https://github.com/immich-app/immich/issues/10699 - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`], - }); - - utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); - utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined(); - expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined(); - - utils.removeImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); - utils.removeImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); - }); - - it('should scan multiple import paths with braces', async () => { - // https://github.com/immich-app/immich/issues/10699 - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`], - }); - - utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); - utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined(); - expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined(); - - utils.removeImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); - utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); - }); - - const annoyingChars = [ - "'", - '"', - '`', - '*', - '{', - '}', - ',', - '(', - ')', - '[', - ']', - '?', - '!', - '@', - '#', - '$', - '%', - '^', - '&', - '=', - '+', - '~', - '|', - '<', - '>', - ';', - ':', - '/', // We never got backslashes to work - ]; - - it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/folder${char}1`, `${testAssetDirInternal}/temp/folder${char}2`], - }); - - utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}1/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}2/asset2.png`) }), - ]), - ); - - utils.removeImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); - }); - - it('should respect exclusion patterns when using multiple import paths', async () => { - // https://github.com/immich-app/immich/issues/17121 - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/exclusion/`, `${testAssetDirInternal}/temp/exclusion2/`], - }); - - const excludedFolder = `Raw`; - - utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - }); - - const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; - - it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/exclusion/`], - }); - - const excludedFolder = `${char}folder`; - - utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - }); - it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -625,70 +271,6 @@ describe('/libraries', () => { }); }); - describe('POST /libraries/:id/validate', () => { - it('should pass with no import paths', async () => { - const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); - expect(response.importPaths).toEqual([]); - }); - - it('should fail if path does not exist', async () => { - const pathToTest = `${testAssetDirInternal}/does/not/exist`; - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Path does not exist (ENOENT)`, - }); - }); - - it("should fail if path isn't absolute", async () => { - const pathToTest = `relative/path`; - - const cwd = process.cwd(); - // Create directory in cwd - utils.createDirectory(`${cwd}/${pathToTest}`); - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - utils.removeDirectory(`${cwd}/${pathToTest}`); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: expect.stringMatching('Import path must be absolute, try /usr/src/app/relative/path'), - }); - }); - - it('should fail if path is a file', async () => { - const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`; - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Not a directory`, - }); - }); - }); - describe('DELETE /libraries/:id', () => { it('should delete an external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts index e551869064..6b1d2fff35 100644 --- a/server/test/medium/specs/services/library.service.spec.ts +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -1,7 +1,8 @@ import { Kysely } from 'kysely'; import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetStatus, JobName, JobStatus } from 'src/enum'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -13,9 +14,13 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { DB } from 'src/schema'; import { LibraryService } from 'src/services/library.service'; -import { newMediumService, testAssetsDir } from 'test/medium.factory'; +import { MediumTestContext, testAssetsDir } from 'test/medium.factory'; +import { newUuid } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; +// `validateImportPath` checks candidate paths against the media location +StorageCore.setMediaLocation('/photos'); + let defaultDatabase: Kysely; const fileModifiedAt = new Date(1_700_000_000_000); @@ -34,20 +39,68 @@ const copyTestAsset = async (source: string, filePath: string, modifiedAt: Date return filePath; }; +class LibraryTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(LibraryService, { + database, + real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], + mock: [EventRepository, JobRepository, LoggingRepository], + }); + + const jobs = this.getMock(JobRepository); + jobs.queue.mockResolvedValue(); + jobs.queueAll.mockResolvedValue(); + + this.getMock(EventRepository).emit.mockResolvedValue(); + } + + async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) { + const { user } = await this.newUser(); + return this.get(LibraryRepository).create({ + ownerId: user.id, + name: 'Medium test library', + importPaths: options.importPaths ?? [], + exclusionPatterns: options.exclusionPatterns ?? [], + }); + } + + /** Runs a full library scan, manually routing jobs to their handlers */ + async scan(libraryId: string) { + const jobs = this.getMock(JobRepository); + + jobs.queue.mockClear(); + await this.sut.handleQueueSyncFiles({ id: libraryId }); + for (const [job] of jobs.queue.mock.calls) { + if (job.name === JobName.LibrarySyncFiles) { + await this.sut.handleSyncFiles(job.data); + } + } + + jobs.queue.mockClear(); + await this.sut.handleQueueSyncAssets({ id: libraryId }); + for (const [job] of jobs.queue.mock.calls) { + if (job.name === JobName.LibrarySyncAssets) { + await this.sut.handleSyncAssets(job.data); + } + } + } + + /** The paths a library scan left visible, i.e. neither offline nor trashed */ + async getAssetPaths(libraryId: string) { + const assets = await this.database + .selectFrom('asset') + .select('originalPath') + .where('libraryId', '=', libraryId) + .where('deletedAt', 'is', null) + .execute(); + + return assets.map(({ originalPath }) => originalPath).sort(); + } +} + const setup = (db?: Kysely) => { - const context = newMediumService(LibraryService, { - database: db || defaultDatabase, - real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], - mock: [EventRepository, JobRepository, LoggingRepository], - }); - - const jobs = context.ctx.getMock(JobRepository); - jobs.queue.mockResolvedValue(); - jobs.queueAll.mockResolvedValue(); - - context.ctx.getMock(EventRepository).emit.mockResolvedValue(); - - return context; + const ctx = new LibraryTestContext(db || defaultDatabase); + return { sut: ctx.sut, ctx }; }; beforeAll(async () => { @@ -73,25 +126,11 @@ describe(LibraryService.name, () => { await rm(tempDir, { recursive: true, force: true }); }); - const createLibrary = async ( - ctx: ReturnType['ctx'], - options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}, - ) => { - const { user } = await ctx.newUser(); - return ctx.get(LibraryRepository).create({ - ownerId: user.id, - name: 'Medium test library', - importPaths: options.importPaths ?? [importPath], - exclusionPatterns: options.exclusionPatterns ?? [], - }); - }; - describe('offline asset handling', () => { it('should set an asset offline if its file is missing', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -121,8 +160,7 @@ describe(LibraryService.name, () => { it('should set an asset offline if its file is not in any import path', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -142,8 +180,7 @@ describe(LibraryService.name, () => { it('should set an asset offline if its file is covered by an exclusion pattern', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { + const library = await ctx.createLibrary({ importPaths: [importRoot], exclusionPatterns: ['**/excluded/**'], }); @@ -167,8 +204,7 @@ describe(LibraryService.name, () => { it('should not set an asset offline if file exists in import path and is not excluded', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { + const library = await ctx.createLibrary({ importPaths: [importRoot], exclusionPatterns: ['**/excluded/**'], }); @@ -202,8 +238,7 @@ describe(LibraryService.name, () => { it('should set an offline asset to online if its file exists in an import path and is not excluded', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -233,8 +268,7 @@ describe(LibraryService.name, () => { it('should not set an offline asset to online if its file exists in an import path but is excluded', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { + const library = await ctx.createLibrary({ importPaths: [importRoot], exclusionPatterns: ['**/excluded/**'], }); @@ -268,8 +302,7 @@ describe(LibraryService.name, () => { it('should keep an offline asset offline if it is outside import paths', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -299,8 +332,7 @@ describe(LibraryService.name, () => { it('should set a trashed asset offline if its file is missing', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -331,8 +363,7 @@ describe(LibraryService.name, () => { it('should set a trashed offline asset to online but keep it in trash', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const { asset } = await ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, @@ -364,8 +395,7 @@ describe(LibraryService.name, () => { it('should queue sidecar checks for newly imported assets', async () => { const { sut, ctx } = setup(); const jobs = ctx.getMock(JobRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); await expect( @@ -387,8 +417,7 @@ describe(LibraryService.name, () => { it('should queue sidecar checks for assets whose file changed', async () => { const { sut, ctx } = setup(); const jobs = ctx.getMock(JobRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); const { asset } = await ctx.newAsset({ @@ -424,8 +453,7 @@ describe(LibraryService.name, () => { it('should not queue sidecar checks for unchanged assets', async () => { const { sut, ctx } = setup(); const jobs = ctx.getMock(JobRepository); - - const library = await createLibrary(ctx, { importPaths: [importPath] }); + const library = await ctx.createLibrary({ importPaths: [importPath] }); const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); const { asset } = await ctx.newAsset({ @@ -452,4 +480,243 @@ describe(LibraryService.name, () => { expect(jobs.queueAll).not.toHaveBeenCalled(); }); }); + + describe('scanning', () => { + it('should import a new asset', async () => { + const { ctx } = setup(); + + const assetPath = await createFile(join(importPath, 'assetA.png')); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetPath]); + }); + + it('should scan multiple import paths', async () => { + const { ctx } = setup(); + + const directoryA = join(importRoot, 'directoryA'); + const directoryB = join(importRoot, 'directoryB'); + const assetA = await createFile(join(directoryA, 'assetA.png')); + const assetB = await createFile(join(directoryB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [directoryA, directoryB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with commas', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder, a'); + const folderB = join(importRoot, 'folder, b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with braces', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder{ a'); + const folderB = join(importRoot, 'folder} b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // We never got backslashes to work + const annoyingChars = [ + "'", + '"', + '`', + '*', + '{', + '}', + ',', + '(', + ')', + '[', + ']', + '?', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '=', + '+', + '~', + '|', + '<', + '>', + ';', + ':', + '/', + ]; + + it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { + const { ctx } = setup(); + + const folderA = join(importRoot, `folder${char}1`); + const folderB = join(importRoot, `folder${char}2`); + const asset1 = await createFile(join(folderA, 'asset1.png')); + const asset2 = await createFile(join(folderB, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + }); + }); + + describe('exclusion patterns', () => { + it('should not import assets covered by an exclusion pattern', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + }); + + it('should not import assets covered by multiple exclusion patterns', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + it('should offline assets covered by a new exclusion pattern', async () => { + const { sut, ctx } = setup(); + + const assetA = await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ importPaths: [importRoot] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + // https://github.com/immich-app/immich/issues/17121 + it('should respect exclusion patterns when using multiple import paths', async () => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + // a second import path that never exists on disk, as in the original report + const missingPath = join(importRoot, 'exclusion2'); + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, 'Raw/asset2.png')); + const library = await ctx.createLibrary({ importPaths: [`${inPath}/`, `${missingPath}/`] }); + + // scanning twice must be idempotent + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + + const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; + + it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + const excludedFolder = `${char}folder`; + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, excludedFolder, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [inPath] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + }); + + describe('validate', () => { + it('should pass with no import paths', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: [] })).resolves.toEqual({ importPaths: [] }); + }); + + it('should fail if the path does not exist', async () => { + const { sut } = setup(); + const missingPath = join(tempDir, 'does/not/exist'); + + await expect(sut.validate(newUuid(), { importPaths: [missingPath] })).resolves.toEqual({ + importPaths: [{ importPath: missingPath, isValid: false, message: 'Path does not exist (ENOENT)' }], + }); + }); + + it('should fail if the path is not absolute', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: ['relative/path'] })).resolves.toEqual({ + importPaths: [ + { + importPath: 'relative/path', + isValid: false, + message: `Import path must be absolute, try ${resolve('relative/path')}`, + }, + ], + }); + }); + + it('should fail if the path is a file', async () => { + const { sut } = setup(); + const filePath = await createFile(join(importPath, 'assetA.png')); + + await expect(sut.validate(newUuid(), { importPaths: [filePath] })).resolves.toEqual({ + importPaths: [{ importPath: filePath, isValid: false, message: 'Not a directory' }], + }); + }); + }); }); From c63824bcea735d5015e985aaf6da03576ad37ec6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 11:49:41 -0400 Subject: [PATCH 56/78] refactor: library e2e (#30867) --- e2e/src/specs/server/api/library.e2e-spec.ts | 204 +---- .../specs/services/library.service.spec.ts | 721 +++++++++++------- 2 files changed, 441 insertions(+), 484 deletions(-) diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index 5c98332a1e..c3b83cf4e4 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -1,24 +1,20 @@ -import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; -import { cpSync, existsSync } from 'node:fs'; +import { LoginResponseDto } from '@immich/sdk'; +import { cpSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; -import request from 'supertest'; +import { testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/libraries', () => { let admin: LoginResponseDto; - let library: LibraryResponseDto; let websocket: Socket; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); await utils.resetAdminConfig(admin.accessToken); - library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); websocket = await utils.connectWebsocket(admin.accessToken); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); - utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`); }); afterAll(() => { @@ -30,113 +26,6 @@ describe('/libraries', () => { utils.resetEvents(); }); - describe('POST /libraries', () => { - it('should create an external library with defaults', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - ownerId: admin.userId, - name: 'New External Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: expect.any(Array), - }), - ); - }); - - it('should create an external library with options', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - exclusionPatterns: ['**/Raw/**'], - }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - }), - ); - }); - }); - - describe('PUT /libraries/:id', () => { - it('should change the library name', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ name: 'New Library Name' }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - name: 'New Library Name', - }), - ); - }); - - it('should change the import paths', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [testAssetDirInternal] }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - importPaths: [testAssetDirInternal], - }), - ); - }); - - it('should change the exclusion pattern', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/Raw/**'] }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - exclusionPatterns: ['**/Raw/**'], - }), - ); - }); - }); - - describe('GET /libraries/:id', () => { - it('should get library by id', async () => { - const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); - - const { status, body } = await request(app) - .get(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - ownerId: admin.userId, - name: 'New External Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: expect.any(Array), - }), - ); - }); - }); - describe('POST /libraries/:id/scan', () => { it('should process metadata and thumbnails for external asset', async () => { const library = await utils.createLibrary(admin.accessToken, { @@ -193,42 +82,6 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); - it('should not reimport a file with unchanged timestamp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/reimport`], - }); - - utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); - await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); - await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { - libraryId: library.id, - }); - - expect(assets.count).toEqual(1); - - const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(asset).toEqual( - expect.objectContaining({ - originalFileName: 'asset.jpg', - exifInfo: expect.not.objectContaining({ - model: 'NIKON D750', - }), - }), - ); - - utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); - }); - it('should not reimport a modified file more than once', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -270,55 +123,4 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); }); - - describe('DELETE /libraries/:id', () => { - it('should delete an external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); - - const { status, body } = await request(app) - .delete(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(204); - expect(body).toEqual({}); - - const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); - expect(libraries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: library.id, - }), - ]), - ); - }); - - it('should delete an external library with assets', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { status, body } = await request(app) - .delete(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(204); - expect(body).toEqual({}); - - const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); - expect(libraries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: library.id, - }), - ]), - ); - - // ensure no files get deleted - expect(existsSync(`${testAssetDir}/temp/directoryA/assetA.png`)).toBe(true); - expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true); - }); - }); }); diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts index 6b1d2fff35..4284e47fee 100644 --- a/server/test/medium/specs/services/library.service.spec.ts +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -1,4 +1,5 @@ import { Kysely } from 'kysely'; +import { existsSync } from 'node:fs'; import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -126,37 +127,398 @@ describe(LibraryService.name, () => { await rm(tempDir, { recursive: true, force: true }); }); - describe('offline asset handling', () => { - it('should set an asset offline if its file is missing', async () => { + describe('create', () => { + it('should create an external library with defaults', async () => { const { sut, ctx } = setup(); - const assetRepo = ctx.get(AssetRepository); - const library = await ctx.createLibrary({ importPaths: [importPath] }); - const { asset } = await ctx.newAsset({ - ownerId: library.ownerId, - libraryId: library.id, - // the file is intentionally never created on disk - originalPath: join(importPath, 'offline.png'), - isExternal: true, - isOffline: false, - status: AssetStatus.Active, - }); + const { user } = await ctx.newUser(); - await expect( - sut.handleSyncAssets({ - libraryId: library.id, - importPaths: library.importPaths, - exclusionPatterns: library.exclusionPatterns, - assetIds: [asset.id], - progressCounter: 1, - totalAssets: 1, + await expect(sut.create({ ownerId: user.id })).resolves.toEqual( + expect.objectContaining({ + ownerId: user.id, + name: 'New External Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: expect.any(Array), }), - ).resolves.toBe(JobStatus.Success); - - const updated = await assetRepo.getById(asset.id); - expect(updated).toEqual(expect.objectContaining({ isOffline: true })); - expect(updated?.deletedAt).toBeInstanceOf(Date); + ); }); + it('should create an external library with options', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect( + sut.create({ + ownerId: user.id, + name: 'My Awesome Library', + importPaths: [importPath], + exclusionPatterns: ['**/Raw/**'], + }), + ).resolves.toEqual( + expect.objectContaining({ + name: 'My Awesome Library', + importPaths: [importPath], + exclusionPatterns: ['**/Raw/**'], + }), + ); + }); + }); + + describe('get', () => { + it('should get a library by id', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.get(library.id)).resolves.toEqual( + expect.objectContaining({ + id: library.id, + ownerId: library.ownerId, + refreshedAt: null, + assetCount: 0, + importPaths: [importPath], + exclusionPatterns: [], + }), + ); + }); + + it('should throw an error when the library does not exist', async () => { + const { sut } = setup(); + + await expect(sut.get(newUuid())).rejects.toThrow('Library not found'); + }); + }); + + describe('update', () => { + it('should change the library name', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.update(library.id, { name: 'New Library Name' })).resolves.toEqual( + expect.objectContaining({ name: 'New Library Name' }), + ); + }); + + it('should change the import paths', async () => { + const { sut, ctx } = setup(); + await mkdir(importPath, { recursive: true }); + const library = await ctx.createLibrary(); + + await expect(sut.update(library.id, { importPaths: [importPath] })).resolves.toEqual( + expect.objectContaining({ importPaths: [importPath] }), + ); + }); + + it('should reject an import path that does not exist', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary(); + + await expect(sut.update(library.id, { importPaths: [join(tempDir, 'missing')] })).rejects.toThrow( + 'Invalid import path: Path does not exist (ENOENT)', + ); + }); + + it('should change the exclusion patterns', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] })).resolves.toEqual( + expect.objectContaining({ exclusionPatterns: ['**/Raw/**'] }), + ); + }); + }); + + describe('validate', () => { + it('should pass with no import paths', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: [] })).resolves.toEqual({ importPaths: [] }); + }); + + it('should fail if the path does not exist', async () => { + const { sut } = setup(); + const missingPath = join(tempDir, 'does/not/exist'); + + await expect(sut.validate(newUuid(), { importPaths: [missingPath] })).resolves.toEqual({ + importPaths: [{ importPath: missingPath, isValid: false, message: 'Path does not exist (ENOENT)' }], + }); + }); + + it('should fail if the path is not absolute', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: ['relative/path'] })).resolves.toEqual({ + importPaths: [ + { + importPath: 'relative/path', + isValid: false, + message: `Import path must be absolute, try ${resolve('relative/path')}`, + }, + ], + }); + }); + + it('should fail if the path is a file', async () => { + const { sut } = setup(); + const filePath = await createFile(join(importPath, 'assetA.png')); + + await expect(sut.validate(newUuid(), { importPaths: [filePath] })).resolves.toEqual({ + importPaths: [{ importPath: filePath, isValid: false, message: 'Not a directory' }], + }); + }); + }); + + describe('handleDeleteLibrary', () => { + it('should delete an empty library', async () => { + const { sut, ctx } = setup(); + const libraryRepo = ctx.get(LibraryRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await sut.delete(library.id); + + // the library is hidden right away, but the row survives until the job runs + await expect(libraryRepo.get(library.id)).resolves.toBeUndefined(); + await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id })); + expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({ + name: JobName.LibraryDelete, + data: { id: library.id }, + }); + + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); + + await expect(libraryRepo.get(library.id, true)).resolves.toBeUndefined(); + }); + + it('should delete a library with assets without deleting the files', async () => { + const { sut, ctx } = setup(); + const libraryRepo = ctx.get(LibraryRepository); + const jobs = ctx.getMock(JobRepository); + const assetA = await createFile(join(importPath, 'assetA.png')); + const assetB = await createFile(join(importPath, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + + await sut.delete(library.id); + await expect(libraryRepo.get(library.id)).resolves.toBeUndefined(); + + jobs.queueAll.mockClear(); + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); + + // the assets are trashed and queued for removal, so the library row stays until they are gone + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id })); + // deleteOnDisk is what keeps the files of an external library, so assert it explicitly + expect(jobs.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } }, + { name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } }, + ]); + + // the asset delete jobs are only queued here, so this just proves the handler itself unlinks nothing + expect(existsSync(assetA)).toBe(true); + expect(existsSync(assetB)).toBe(true); + }); + }); + + describe('queueScan', () => { + it('should import a new asset', async () => { + const { ctx } = setup(); + + const assetPath = await createFile(join(importPath, 'assetA.png')); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetPath]); + }); + + it('should scan multiple import paths', async () => { + const { ctx } = setup(); + + const directoryA = join(importRoot, 'directoryA'); + const directoryB = join(importRoot, 'directoryB'); + const assetA = await createFile(join(directoryA, 'assetA.png')); + const assetB = await createFile(join(directoryB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [directoryA, directoryB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with commas', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder, a'); + const folderB = join(importRoot, 'folder, b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with braces', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder{ a'); + const folderB = join(importRoot, 'folder} b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // We never got backslashes to work + const annoyingChars = [ + "'", + '"', + '`', + '*', + '{', + '}', + ',', + '(', + ')', + '[', + ']', + '?', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '=', + '+', + '~', + '|', + '<', + '>', + ';', + ':', + '/', + ]; + + it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { + const { ctx } = setup(); + + const folderA = join(importRoot, `folder${char}1`); + const folderB = join(importRoot, `folder${char}2`); + const asset1 = await createFile(join(folderA, 'asset1.png')); + const asset2 = await createFile(join(folderB, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + }); + + it('should not import assets covered by an exclusion pattern', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + }); + + it('should not import assets covered by multiple exclusion patterns', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + it('should offline assets covered by a new exclusion pattern', async () => { + const { sut, ctx } = setup(); + + const assetA = await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ importPaths: [importRoot] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + // https://github.com/immich-app/immich/issues/17121 + it('should respect exclusion patterns when using multiple import paths', async () => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + // a second import path that never exists on disk, as in the original report + const missingPath = join(importRoot, 'exclusion2'); + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, 'Raw/asset2.png')); + const library = await ctx.createLibrary({ importPaths: [`${inPath}/`, `${missingPath}/`] }); + + // scanning twice must be idempotent + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + + const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; + + it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + const excludedFolder = `${char}folder`; + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, excludedFolder, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [inPath] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + }); + + describe('handleQueueSyncAssets', () => { it('should set an asset offline if its file is not in any import path', async () => { const { sut, ctx } = setup(); const assetRepo = ctx.get(AssetRepository); @@ -200,6 +562,38 @@ describe(LibraryService.name, () => { expect(updated).toEqual(expect.objectContaining({ isOffline: true })); expect(updated?.deletedAt).toBeInstanceOf(Date); }); + }); + + describe('handleSyncAssets', () => { + it('should set an asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + // the file is intentionally never created on disk + originalPath: join(importPath, 'offline.png'), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); it('should not set an asset offline if file exists in import path and is not excluded', async () => { const { sut, ctx } = setup(); @@ -389,30 +783,6 @@ describe(LibraryService.name, () => { expect(updated).toEqual(expect.objectContaining({ isOffline: false })); expect(updated?.deletedAt).toBeInstanceOf(Date); }); - }); - - describe('xmp scan behavior', () => { - it('should queue sidecar checks for newly imported assets', async () => { - const { sut, ctx } = setup(); - const jobs = ctx.getMock(JobRepository); - const library = await ctx.createLibrary({ importPaths: [importPath] }); - const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); - - await expect( - sut.handleSyncFiles({ - libraryId: library.id, - paths: [rawPath], - progressCounter: 1, - }), - ).resolves.toBe(JobStatus.Success); - - expect(jobs.queueAll).toHaveBeenCalledWith([ - expect.objectContaining({ - name: JobName.SidecarCheck, - data: expect.objectContaining({ id: expect.any(String) }), - }), - ]); - }); it('should queue sidecar checks for assets whose file changed', async () => { const { sut, ctx } = setup(); @@ -481,242 +851,27 @@ describe(LibraryService.name, () => { }); }); - describe('scanning', () => { - it('should import a new asset', async () => { - const { ctx } = setup(); - - const assetPath = await createFile(join(importPath, 'assetA.png')); + describe('handleSyncFiles', () => { + it('should queue sidecar checks for newly imported assets', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); const library = await ctx.createLibrary({ importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); - await ctx.scan(library.id); + await expect( + sut.handleSyncFiles({ + libraryId: library.id, + paths: [rawPath], + progressCounter: 1, + }), + ).resolves.toBe(JobStatus.Success); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetPath]); - }); - - it('should scan multiple import paths', async () => { - const { ctx } = setup(); - - const directoryA = join(importRoot, 'directoryA'); - const directoryB = join(importRoot, 'directoryB'); - const assetA = await createFile(join(directoryA, 'assetA.png')); - const assetB = await createFile(join(directoryB, 'assetB.png')); - const library = await ctx.createLibrary({ importPaths: [directoryA, directoryB] }); - - await ctx.scan(library.id); - - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); - }); - - // https://github.com/immich-app/immich/issues/10699 - it('should scan multiple import paths with commas', async () => { - const { ctx } = setup(); - - const folderA = join(importRoot, 'folder, a'); - const folderB = join(importRoot, 'folder, b'); - const assetA = await createFile(join(folderA, 'assetA.png')); - const assetB = await createFile(join(folderB, 'assetB.png')); - const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); - - await ctx.scan(library.id); - - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); - }); - - // https://github.com/immich-app/immich/issues/10699 - it('should scan multiple import paths with braces', async () => { - const { ctx } = setup(); - - const folderA = join(importRoot, 'folder{ a'); - const folderB = join(importRoot, 'folder} b'); - const assetA = await createFile(join(folderA, 'assetA.png')); - const assetB = await createFile(join(folderB, 'assetB.png')); - const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); - - await ctx.scan(library.id); - - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); - }); - - // We never got backslashes to work - const annoyingChars = [ - "'", - '"', - '`', - '*', - '{', - '}', - ',', - '(', - ')', - '[', - ']', - '?', - '!', - '@', - '#', - '$', - '%', - '^', - '&', - '=', - '+', - '~', - '|', - '<', - '>', - ';', - ':', - '/', - ]; - - it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { - const { ctx } = setup(); - - const folderA = join(importRoot, `folder${char}1`); - const folderB = join(importRoot, `folder${char}2`); - const asset1 = await createFile(join(folderA, 'asset1.png')); - const asset2 = await createFile(join(folderB, 'asset2.png')); - const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); - - await ctx.scan(library.id); - - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); - }); - }); - - describe('exclusion patterns', () => { - it('should not import assets covered by an exclusion pattern', async () => { - const { ctx } = setup(); - - await createFile(join(importRoot, 'directoryA/assetA.png')); - const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); - const library = await ctx.createLibrary({ - importPaths: [importRoot], - exclusionPatterns: ['**/directoryA/**'], - }); - - await ctx.scan(library.id); - - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); - }); - - it('should not import assets covered by multiple exclusion patterns', async () => { - const { ctx } = setup(); - - await createFile(join(importRoot, 'directoryA/assetA.png')); - await createFile(join(importRoot, 'directoryB/assetB.png')); - const library = await ctx.createLibrary({ - importPaths: [importRoot], - exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], - }); - - await ctx.scan(library.id); - - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); - }); - - it('should offline assets covered by a new exclusion pattern', async () => { - const { sut, ctx } = setup(); - - const assetA = await createFile(join(importRoot, 'directoryA/assetA.png')); - const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); - const library = await ctx.createLibrary({ importPaths: [importRoot] }); - - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); - - await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**'] }); - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); - - await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'] }); - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); - }); - - // https://github.com/immich-app/immich/issues/17121 - it('should respect exclusion patterns when using multiple import paths', async () => { - const { sut, ctx } = setup(); - - const inPath = join(importRoot, 'exclusion'); - // a second import path that never exists on disk, as in the original report - const missingPath = join(importRoot, 'exclusion2'); - const asset1 = await createFile(join(inPath, 'asset1.png')); - const asset2 = await createFile(join(inPath, 'Raw/asset2.png')); - const library = await ctx.createLibrary({ importPaths: [`${inPath}/`, `${missingPath}/`] }); - - // scanning twice must be idempotent - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); - - await sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] }); - - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); - }); - - const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; - - it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { - const { sut, ctx } = setup(); - - const inPath = join(importRoot, 'exclusion'); - const excludedFolder = `${char}folder`; - const asset1 = await createFile(join(inPath, 'asset1.png')); - const asset2 = await createFile(join(inPath, excludedFolder, 'asset2.png')); - const library = await ctx.createLibrary({ importPaths: [inPath] }); - - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); - - await sut.update(library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); - - await ctx.scan(library.id); - await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); - }); - }); - - describe('validate', () => { - it('should pass with no import paths', async () => { - const { sut } = setup(); - - await expect(sut.validate(newUuid(), { importPaths: [] })).resolves.toEqual({ importPaths: [] }); - }); - - it('should fail if the path does not exist', async () => { - const { sut } = setup(); - const missingPath = join(tempDir, 'does/not/exist'); - - await expect(sut.validate(newUuid(), { importPaths: [missingPath] })).resolves.toEqual({ - importPaths: [{ importPath: missingPath, isValid: false, message: 'Path does not exist (ENOENT)' }], - }); - }); - - it('should fail if the path is not absolute', async () => { - const { sut } = setup(); - - await expect(sut.validate(newUuid(), { importPaths: ['relative/path'] })).resolves.toEqual({ - importPaths: [ - { - importPath: 'relative/path', - isValid: false, - message: `Import path must be absolute, try ${resolve('relative/path')}`, - }, - ], - }); - }); - - it('should fail if the path is a file', async () => { - const { sut } = setup(); - const filePath = await createFile(join(importPath, 'assetA.png')); - - await expect(sut.validate(newUuid(), { importPaths: [filePath] })).resolves.toEqual({ - importPaths: [{ importPath: filePath, isValid: false, message: 'Not a directory' }], - }); + expect(jobs.queueAll).toHaveBeenCalledWith([ + expect.objectContaining({ + name: JobName.SidecarCheck, + data: expect.objectContaining({ id: expect.any(String) }), + }), + ]); }); }); }); From 7cd0a7d30c19b4eae53d1bd0cd6ff414dfd5a98e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 14:31:57 -0400 Subject: [PATCH 57/78] refactor: e2e tests (#30870) --- e2e/src/responses.ts | 47 ------ .../server/maintenance.e2e-spec.ts | 25 +--- e2e/src/specs/server/api/album.e2e-spec.ts | 10 -- e2e/src/specs/server/api/api-key.e2e-spec.ts | 45 ------ e2e/src/specs/server/api/asset.e2e-spec.ts | 17 --- e2e/src/specs/server/api/jobs.e2e-spec.ts | 10 +- e2e/src/specs/server/api/map.e2e-spec.ts | 53 ------- e2e/src/specs/server/api/memory.e2e-spec.ts | 44 ------ e2e/src/specs/server/api/oauth.e2e-spec.ts | 36 ----- e2e/src/specs/server/api/server.e2e-spec.ts | 33 ----- e2e/src/specs/server/api/session.e2e-spec.ts | 17 ++- .../specs/server/api/shared-link.e2e-spec.ts | 26 +--- e2e/src/specs/server/api/stack.e2e-spec.ts | 23 +-- .../server/api/system-metadata.e2e-spec.ts | 21 --- e2e/src/specs/server/api/tag.e2e-spec.ts | 137 +----------------- e2e/src/specs/server/api/trash.e2e-spec.ts | 22 --- .../specs/server/api/user-admin.e2e-spec.ts | 64 +------- e2e/src/specs/server/api/user.e2e-spec.ts | 16 +- server/src/controllers/index.spec.ts | 71 ++++++++- server/src/controllers/job.controller.spec.ts | 45 ++++++ .../controllers/library.controller.spec.ts | 22 --- server/src/controllers/map.controller.spec.ts | 66 +++++++++ .../src/controllers/oauth.controller.spec.ts | 71 +++++++++ .../shared-link.controller.spec.ts | 9 ++ .../system-metadata.controller.spec.ts | 28 ++++ .../src/controllers/trash.controller.spec.ts | 44 ++++++ .../specs/services/album.service.spec.ts | 30 +++- .../specs/services/api-key.service.spec.ts | 38 +++++ .../specs/services/asset.service.spec.ts | 24 +++ .../specs/services/memory.service.spec.ts | 63 ++++++++ .../specs/services/stack.service.spec.ts | 44 ++++++ .../medium/specs/services/tag.service.spec.ts | 65 +++++++++ 32 files changed, 618 insertions(+), 648 deletions(-) create mode 100644 server/src/controllers/job.controller.spec.ts create mode 100644 server/src/controllers/map.controller.spec.ts create mode 100644 server/src/controllers/oauth.controller.spec.ts create mode 100644 server/src/controllers/system-metadata.controller.spec.ts create mode 100644 server/src/controllers/trash.controller.spec.ts create mode 100644 server/test/medium/specs/services/stack.service.spec.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 337d101cb4..54cfb814e8 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -1,54 +1,7 @@ import { expect } from 'vitest'; export const errorDto = { - unauthorized: { - message: 'Authentication required', - }, - unauthorizedWithMessage: (message: string) => ({ - message, - }), - forbidden: { - message: expect.any(String), - }, - missingPermission: (permission: string) => ({ - message: `Missing required permission: ${permission}`, - }), - wrongPassword: { - message: 'Wrong password', - }, - invalidToken: { - message: 'Invalid user token', - }, - invalidShareKey: { - message: 'Invalid share key', - }, - passwordRequired: { - message: 'Password required', - }, badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), - validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ - message: 'Validation failed', - errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), - }), - noPermission: { - message: expect.stringContaining('Not found or no'), - }, - incorrectLogin: { - message: 'Incorrect email or password', - }, -}; - -export const deviceDto = { - current: { - id: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - current: true, - isPendingSyncReset: false, - deviceOS: '', - deviceType: '', - appVersion: null, - }, }; diff --git a/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts index f3833ffc00..bea5f58557 100644 --- a/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts @@ -1,5 +1,4 @@ import { LoginResponseDto } from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; @@ -8,18 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/admin/maintenance', () => { let cookie: string | undefined; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); await utils.resetBackups(admin.accessToken); }); // => outside of maintenance mode - describe('GET ~/server/config', async () => { + describe('GET /server/config', async () => { it('should indicate we are out of maintenance mode', async () => { const { status, body } = await request(app).get('/server/config'); expect(status).toBe(200); @@ -49,24 +46,6 @@ describe('/admin/maintenance', () => { // => enter maintenance mode describe.sequential('POST /', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/admin/maintenance').send({ - active: false, - action: 'end', - }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should only work for admins', async () => { - const { status, body } = await request(app) - .post('/admin/maintenance') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ action: 'end' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should be a no-op if try to exit maintenance mode', async () => { const { status } = await request(app) .post('/admin/maintenance') @@ -132,7 +111,7 @@ describe('/admin/maintenance', () => { it('should fail without cookie or token in body', async () => { const { status, body } = await request(app).post('/admin/maintenance/login').send({}); expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token')); + expect(body).toEqual({ message: 'Missing JWT Token' }); }); it('should succeed with cookie', async () => { diff --git a/e2e/src/specs/server/api/album.e2e-spec.ts b/e2e/src/specs/server/api/album.e2e-spec.ts index f252e5d321..2969d606fe 100644 --- a/e2e/src/specs/server/api/album.e2e-spec.ts +++ b/e2e/src/specs/server/api/album.e2e-spec.ts @@ -613,16 +613,6 @@ describe('/albums', () => { }); describe('DELETE /albums/:id/assets', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .delete(`/albums/${user1Albums[1].id}/assets`) - .set('Authorization', `Bearer ${user2.accessToken}`) - .send({ ids: [user1Asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should be able to remove foreign asset from owned album', async () => { const { status, body } = await request(app) .delete(`/albums/${user2Albums[0].id}/assets`) diff --git a/e2e/src/specs/server/api/api-key.e2e-spec.ts b/e2e/src/specs/server/api/api-key.e2e-spec.ts index 2339889afe..8ffcf1f525 100644 --- a/e2e/src/specs/server/api/api-key.e2e-spec.ts +++ b/e2e/src/specs/server/api/api-key.e2e-spec.ts @@ -24,13 +24,6 @@ describe('/api-keys', () => { }); describe('POST /api-keys', () => { - it('should not work without permission', async () => { - const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]); - const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('apiKey.create')); - }); - it('should work with apiKey.create', async () => { const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]); const { status, body } = await request(app) @@ -113,15 +106,6 @@ describe('/api-keys', () => { }); describe('GET /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .get(`/api-keys/${apiKey.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should get api key details', async () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) @@ -139,16 +123,6 @@ describe('/api-keys', () => { }); describe('PUT /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .put(`/api-keys/${apiKey.id}`) - .send({ name: 'new name', permissions: [Permission.All] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should update api key details', async () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) @@ -169,26 +143,7 @@ describe('/api-keys', () => { }); }); - describe('POST /api-keys/:id/rotate', () => { - it('should not work without permission', async () => { - const { apiKey } = await create(user.accessToken, [Permission.ApiKeyUpdate]); - const { secret } = await create(user.accessToken, [Permission.ApiKeyUpdate]); - const { status, body } = await request(app).post(`/api-keys/${apiKey.id}/rotate`).set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('apiKey.rotate')); - }); - }); - describe('DELETE /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .delete(`/api-keys/${apiKey.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should delete an api key', async () => { const { apiKey } = await create(user.accessToken, [Permission.All]); const { status } = await request(app) diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index ccebd1ca77..f8e49a5813 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -151,14 +151,6 @@ describe('/asset', () => { }); describe('GET /assets/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .get(`/assets/${user2Assets[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should get the asset info', async () => { const { status, body } = await request(app) .get(`/assets/${user1Assets[0].id}`) @@ -306,15 +298,6 @@ describe('/asset', () => { }); describe('PUT /assets/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/assets/${user2Assets[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should favorite an asset', async () => { const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id); expect(before.isFavorite).toBe(false); diff --git a/e2e/src/specs/server/api/jobs.e2e-spec.ts b/e2e/src/specs/server/api/jobs.e2e-spec.ts index be7984404b..f9d8b75c46 100644 --- a/e2e/src/specs/server/api/jobs.e2e-spec.ts +++ b/e2e/src/specs/server/api/jobs.e2e-spec.ts @@ -2,9 +2,7 @@ import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich import { cpSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; -import { errorDto } from 'src/responses'; -import { app, asBearerAuth, testAssetDir, utils } from 'src/utils'; -import request from 'supertest'; +import { asBearerAuth, testAssetDir, utils } from 'src/utils'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; describe('/jobs', () => { @@ -50,12 +48,6 @@ describe('/jobs', () => { await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); }); - it('should require authentication', async () => { - const { status, body } = await request(app).put('/jobs/metadataExtraction'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should queue metadata extraction for missing assets', async () => { const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`; diff --git a/e2e/src/specs/server/api/map.e2e-spec.ts b/e2e/src/specs/server/api/map.e2e-spec.ts index c177fa740b..c7c504b3c5 100644 --- a/e2e/src/specs/server/api/map.e2e-spec.ts +++ b/e2e/src/specs/server/api/map.e2e-spec.ts @@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -55,12 +54,6 @@ describe('/map', () => { }); describe('GET /map/markers', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/markers'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should get map markers for all non-archived assets', async () => { const { status, body } = await request(app) .get('/map/markers') @@ -139,52 +132,6 @@ describe('/map', () => { }); describe('GET /map/reverse-geocode', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/reverse-geocode'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should throw an error if a lat is not provided', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lon=123') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - - it('should throw an error if a lat is not a number', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=abc&lon=123.456') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - - it('should throw an error if a lat is out of range', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=91&lon=123.456') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), - ); - }); - - it('should throw an error if a lon is not provided', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=75') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - const reverseGeocodeTestCases = [ { name: 'Vaucluse', diff --git a/e2e/src/specs/server/api/memory.e2e-spec.ts b/e2e/src/specs/server/api/memory.e2e-spec.ts index e5e2351738..38c6ba875a 100644 --- a/e2e/src/specs/server/api/memory.e2e-spec.ts +++ b/e2e/src/specs/server/api/memory.e2e-spec.ts @@ -7,7 +7,6 @@ import { getMemory, } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -42,14 +41,6 @@ describe('/memories', () => { }); describe('GET /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .get(`/memories/${userMemory.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should get the memory', async () => { const { status, body } = await request(app) .get(`/memories/${userMemory.id}`) @@ -60,15 +51,6 @@ describe('/memories', () => { }); describe('PUT /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}`) - .send({ isSaved: true }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should update the memory', async () => { const before = await getMemory({ id: userMemory.id }, { headers: asBearerAuth(user.accessToken) }); expect(before.isSaved).toBe(false); @@ -86,15 +68,6 @@ describe('/memories', () => { }); describe('PUT /memories/:id/assets', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should require asset access', async () => { const { status, body } = await request(app) .put(`/memories/${userMemory.id}/assets`) @@ -121,15 +94,6 @@ describe('/memories', () => { }); describe('DELETE /memories/:id/assets', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should only remove assets in the memory', async () => { const { status, body } = await request(app) .delete(`/memories/${userMemory.id}/assets`) @@ -156,14 +120,6 @@ describe('/memories', () => { }); describe('DELETE /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should delete the memory', async () => { const { status } = await request(app) .delete(`/memories/${userMemory.id}`) diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index ade6475519..3b85e9e4c8 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -102,16 +102,6 @@ describe(`/oauth`, () => { }); }); - it(`should throw an error if a redirect uri is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/authorize').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' }, - ]), - ); - }); - it('should return a redirect uri', async () => { const { status, body } = await request(app) .post('/oauth/authorize') @@ -165,22 +155,6 @@ describe(`/oauth`, () => { }); }); - it(`should throw an error if a url is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/callback').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]), - ); - }); - - it(`should throw an error if the url is empty`, async () => { - const { status, body } = await request(app).post('/oauth/callback').send({ url: '' }); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]), - ); - }); - it(`should throw an error if the state is not provided`, async () => { const { url } = await loginWithOAuth('oauth-auto-register'); const { status, body } = await request(app).post('/oauth/callback').send({ url }); @@ -380,16 +354,6 @@ describe(`/oauth`, () => { }); describe(`POST /oauth/backchannel-logout`, () => { - it(`should throw an error if the logout_token is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/backchannel-logout').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['logout_token'], message: 'Invalid input: expected string, received undefined' }, - ]), - ); - }); - it(`should throw an error if an invalid logout token is provided`, async () => { const { status, body } = await request(app) .post('/oauth/backchannel-logout') diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index f0eed82f7a..c6ab9a6704 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -1,6 +1,5 @@ import { LoginResponseDto } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -137,14 +136,6 @@ describe('/server', () => { }); describe('GET /server/statistics', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server/statistics') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should return the server stats', async () => { const { status, body } = await request(app) .get('/server/statistics') @@ -195,14 +186,6 @@ describe('/server', () => { }); describe('GET /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should return the server license', async () => { await request(app).put('/server/license').set('Authorization', `Bearer ${admin.accessToken}`).send(serverLicense); const { status, body } = await request(app) @@ -217,14 +200,6 @@ describe('/server', () => { }); describe('DELETE /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .delete('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should delete the server license', async () => { await request(app) .delete('/server/license') @@ -236,14 +211,6 @@ describe('/server', () => { }); describe('PUT /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .put('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should set the server license', async () => { const { status, body } = await request(app) .put('/server/license') diff --git a/e2e/src/specs/server/api/session.e2e-spec.ts b/e2e/src/specs/server/api/session.e2e-spec.ts index 1766b732cd..6f1599c65c 100644 --- a/e2e/src/specs/server/api/session.e2e-spec.ts +++ b/e2e/src/specs/server/api/session.e2e-spec.ts @@ -1,6 +1,6 @@ import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk'; import { loginDto, signupDto, uuidDto } from 'src/fixtures'; -import { deviceDto, errorDto } from 'src/responses'; +import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -18,7 +18,18 @@ describe('/sessions', () => { it('should get a list of authorized devices', async () => { const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([deviceDto.current]); + expect(body).toEqual([ + { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + current: true, + isPendingSyncReset: false, + deviceOS: '', + deviceType: '', + appVersion: null, + }, + ]); }); }); @@ -56,7 +67,7 @@ describe('/sessions', () => { const response = await request(app) .post('/auth/validateToken') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(response.body).toEqual(errorDto.invalidToken); + expect(response.body).toEqual({ message: 'Invalid user token' }); expect(response.status).toBe(401); }); }); diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index af189f49ac..4e5988a167 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -212,21 +212,21 @@ describe('/shared-links', () => { .query({ key: linkWithAlbum.key + 'foo' }); expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidShareKey); + expect(body).toEqual({ message: 'Invalid share key' }); }); it('should return unauthorized if target has been soft deleted', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithDeletedAlbum.key }); expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidShareKey); + expect(body).toEqual({ message: 'Invalid share key' }); }); it('should return unauthorized for password protected link', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key }); expect(status).toBe(401); - expect(body).toEqual(errorDto.passwordRequired); + expect(body).toEqual({ message: 'Password required' }); }); it('should get data for correct password protected link', async () => { @@ -306,16 +306,6 @@ describe('/shared-links', () => { }); describe('POST /shared-links', () => { - it('should require an asset/album id', async () => { - const { status, body } = await request(app) - .post('/shared-links') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ type: SharedLinkType.Album }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }])); - }); - it('should require a valid asset id', async () => { const { status, body } = await request(app) .post('/shared-links') @@ -403,16 +393,6 @@ describe('/shared-links', () => { expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); }); - it('should reject guests removing assets from an individual shared link', async () => { - const { status, body } = await request(app) - .delete(`/shared-links/${linkWithAssets.id}/assets`) - .query({ key: linkWithAssets.key }) - .send({ assetIds: [asset1.id] }); - - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) .delete(`/shared-links/${linkWithAssets.id}/assets`) diff --git a/e2e/src/specs/server/api/stack.e2e-spec.ts b/e2e/src/specs/server/api/stack.e2e-spec.ts index 3b8f642334..21284dcf78 100644 --- a/e2e/src/specs/server/api/stack.e2e-spec.ts +++ b/e2e/src/specs/server/api/stack.e2e-spec.ts @@ -1,6 +1,5 @@ -import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; +import { LoginResponseDto, searchStacks } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -8,34 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/stacks', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; - let user2: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - [user1, user2] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - ]); - - asset = await utils.createAsset(user1.accessToken); + user1 = await utils.userSetup(admin.accessToken, createUserDto.user1); }); describe('POST /stacks', () => { - it('should require access', async () => { - const user2Asset = await utils.createAsset(user2.accessToken); - const { status, body } = await request(app) - .post('/stacks') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ assetIds: [asset.id, user2Asset.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should create a stack', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), diff --git a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts index 114ed2f995..18589ed1d7 100644 --- a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts +++ b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts @@ -1,30 +1,17 @@ import { LoginResponseDto, getServerConfig } from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; describe('/server-info', () => { let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); describe('POST /system-metadata/admin-onboarding', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .post('/system-metadata/admin-onboarding') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ isOnboarded: true }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should set admin onboarding', async () => { const config = await getServerConfig({}); expect(config.isOnboarded).toBe(false); @@ -41,14 +28,6 @@ describe('/server-info', () => { }); describe('GET /system-metadata/reverse-geocoding-state', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/system-metadata/reverse-geocoding-state') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should get the reverse geocoding state', async () => { const { status, body } = await request(app) .get('/system-metadata/reverse-geocoding-state') diff --git a/e2e/src/specs/server/api/tag.e2e-spec.ts b/e2e/src/specs/server/api/tag.e2e-spec.ts index 98d66f6350..40c8cd5114 100644 --- a/e2e/src/specs/server/api/tag.e2e-spec.ts +++ b/e2e/src/specs/server/api/tag.e2e-spec.ts @@ -9,7 +9,7 @@ import { tagAssets, upsertTags, } from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; +import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; @@ -41,13 +41,6 @@ describe('/tags', () => { }); describe('POST /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.create')); - }); - it('should work with tag.create', async () => { const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]); const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); @@ -111,13 +104,6 @@ describe('/tags', () => { }); describe('GET /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).get('/tags').set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.read')); - }); - it('should start off empty', async () => { const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([]); @@ -157,13 +143,6 @@ describe('/tags', () => { }); describe('PUT /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.create')); - }); - it('should upsert tags', async () => { const { status, body } = await request(app) .put(`/tags`) @@ -195,16 +174,6 @@ describe('/tags', () => { }); describe('PUT /tags/assets', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put('/tags/assets') - .set('x-api-key', secret) - .send({ assetIds: [], tagIds: [] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should skip assets that are not owned by the user', async () => { const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ create(user.accessToken, { name: 'TagA' }), @@ -255,25 +224,6 @@ describe('/tags', () => { }); describe('GET /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .get(`/tags/${tag.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .get(`/tags/${uuidDto.notFound}`) - .set('x-api-key', secret) - .send({ assetIds: [], tagIds: [] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.read')); - }); - it('should get tag details', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -311,27 +261,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(admin.accessToken, { name: 'tagA' }); - const { status, body } = await request(app) - .put(`/tags/${tag.id}`) - .send({ color: '#000000' }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put(`/tags/${tag.id}`) - .set('x-api-key', secret) - .send({ color: '#000000' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.update')); - }); - it('should update a tag', async () => { const tag = await create(user.accessToken, { name: 'tagA' }); const { status, body } = await request(app) @@ -354,23 +283,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .delete(`/tags/${tag.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.delete')); - }); - it('should delete a tag', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status } = await request(app) @@ -404,27 +316,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id/assets', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .put(`/tags/${tag.id}/assets`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ids: [userAsset.id] }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put(`/tags/${tag.id}/assets`) - .set('x-api-key', secret) - .send({ ids: [userAsset.id] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should be able to tag own asset', async () => { const tagA = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -463,32 +354,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id/assets', () => { - it('should require authorization', async () => { - const tagA = await create(user.accessToken, { name: 'TagA' }); - await tagAssets( - { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, - { headers: asBearerAuth(user.accessToken) }, - ); - const { status, body } = await request(app) - .delete(`/tags/${tagA.id}/assets`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ids: [userAsset.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .delete(`/tags/${tag.id}/assets`) - .set('x-api-key', secret) - .send({ ids: [userAsset.id] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should be able to remove own asset from own tag', async () => { const tagA = await create(user.accessToken, { name: 'TagA' }); await tagAssets( diff --git a/e2e/src/specs/server/api/trash.e2e-spec.ts b/e2e/src/specs/server/api/trash.e2e-spec.ts index 7a1a61f946..e3ea72fdd2 100644 --- a/e2e/src/specs/server/api/trash.e2e-spec.ts +++ b/e2e/src/specs/server/api/trash.e2e-spec.ts @@ -1,7 +1,6 @@ import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -21,13 +20,6 @@ describe('/trash', () => { }); describe('POST /trash/empty', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/empty'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should empty the trash', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); @@ -142,13 +134,6 @@ describe('/trash', () => { }); describe('POST /trash/restore', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/restore'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should restore all trashed assets', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); @@ -198,13 +183,6 @@ describe('/trash', () => { }); describe('POST /trash/restore/assets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/restore/assets'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should restore a trashed asset by id', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); diff --git a/e2e/src/specs/server/api/user-admin.e2e-spec.ts b/e2e/src/specs/server/api/user-admin.e2e-spec.ts index 160c929591..37f3f2ea99 100644 --- a/e2e/src/specs/server/api/user-admin.e2e-spec.ts +++ b/e2e/src/specs/server/api/user-admin.e2e-spec.ts @@ -1,6 +1,5 @@ import { LoginResponseDto, - Permission, QueueName, createStack, deleteUserAdmin, @@ -10,8 +9,7 @@ import { login, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; +import { createUserDto } from 'src/fixtures'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -46,14 +44,6 @@ describe('/admin/users', () => { }); describe('GET /admin/users', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .get(`/admin/users`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should hide deleted users by default', async () => { const { status, body } = await request(app) .get(`/admin/users`) @@ -88,15 +78,6 @@ describe('/admin/users', () => { }); describe('POST /admin/users', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .post(`/admin/users`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send(createUserDto.user1); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should accept `isAdmin`', async () => { const { status, body } = await request(app) .post(`/admin/users`) @@ -117,14 +98,6 @@ describe('/admin/users', () => { }); describe('PUT /admin/users/:id', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .put(`/admin/users/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should allow a non-admin to become an admin', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2')); const { status, body } = await request(app) @@ -225,14 +198,6 @@ describe('/admin/users', () => { }); describe('DELETE /admin/users/:id', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .delete(`/admin/users/${userToDelete.userId}`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should delete user', async () => { const { status, body } = await request(app) .delete(`/admin/users/${userToDelete.userId}`) @@ -296,34 +261,7 @@ describe('/admin/users', () => { }); }); - describe('GET /admin/users/:id/calendar-heatmap', () => { - it('should require admin permissions', async () => { - const { status, body } = await request(app) - .get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - - it('should require the AdminUserRead permission', async () => { - const { secret } = await utils.createApiKey(admin.accessToken, [Permission.UserRead]); - const { status, body } = await request(app) - .get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`) - .set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - }); - describe('POST /admin/users/:id/restore', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .post(`/admin/users/${userToDelete.userId}/restore`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should restore a user', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('restore')); diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index 0e62672f36..ce6236eabc 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -1,6 +1,5 @@ -import { LoginResponseDto, SharedLinkType, getMyUser, login } from '@immich/sdk'; +import { LoginResponseDto, getMyUser, login } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -15,19 +14,6 @@ describe('/users', () => { nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2); }); - describe('GET /users/me', () => { - it('should not work for shared links', async () => { - const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' }); - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Album, - albumId: album.id, - }); - const { status, body } = await request(app).get(`/users/me?key=${sharedLink.key}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - }); - describe('PUT /users/me', () => { /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { diff --git a/server/src/controllers/index.spec.ts b/server/src/controllers/index.spec.ts index 3d39a4dd3d..69a3f1b0c8 100644 --- a/server/src/controllers/index.spec.ts +++ b/server/src/controllers/index.spec.ts @@ -10,6 +10,61 @@ const UNAUTHENTICATED_ADMIN_ROUTES = new Set([ 'POST admin/database-backups/start-restore', ]); +/** Admin-only routes that live outside `admin/`, i.e. `@Authenticated({ admin: true })` */ +const ADMIN_ROUTES = new Set([ + 'DELETE libraries/:id', + 'DELETE queues/:name/jobs', + 'DELETE server/license', + 'GET jobs', + 'GET libraries', + 'GET libraries/:id', + 'GET libraries/:id/statistics', + 'GET queues', + 'GET queues/:name', + 'GET queues/:name/jobs', + 'GET server/license', + 'GET server/statistics', + 'GET system-config', + 'GET system-config/defaults', + 'GET system-config/storage-template-options', + 'GET system-metadata/admin-onboarding', + 'GET system-metadata/reverse-geocoding-state', + 'GET system-metadata/version-check-state', + 'PATCH libraries/:id', + 'POST jobs', + 'POST libraries', + 'POST libraries/:id/scan', + 'POST libraries/:id/validate', + 'POST system-metadata/admin-onboarding', + 'PUT jobs/:name', + 'PUT libraries/:id', + 'PUT queues/:name', + 'PUT server/license', + 'PUT system-config', +]); + +/** Routes a shared link (`?key=`) is allowed to reach, i.e. `@Authenticated({ sharedLink: true })` */ +const SHARED_LINK_ROUTES = new Set([ + 'DELETE assets/:id/video/stream/:sessionId', + 'GET albums/:id', + 'GET albums/:id/map-markers', + 'GET assets/:id', + 'GET assets/:id/original', + 'GET assets/:id/thumbnail', + 'GET assets/:id/video/playback', + 'GET assets/:id/video/stream/:sessionId/:variantIndex/:filename', + 'GET assets/:id/video/stream/:sessionId/:variantIndex/playlist.m3u8', + 'GET assets/:id/video/stream/main.m3u8', + 'GET shared-links/me', + 'GET timeline/bucket', + 'GET timeline/buckets', + 'POST assets', + 'POST download/archive', + 'POST download/info', + 'POST search/metadata', + 'POST shared-links/login', +]); + const isAdminPermission = (permission: AuthenticatedOptions['permission']) => typeof permission === 'string' && permission.startsWith('admin'); @@ -45,9 +100,9 @@ const getRoutes = () => { describe('controllers', () => { const routes = getRoutes(); - const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/')); it('should only allow non-admin access to bootstrap routes under admin/', () => { + const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/')); const reachableByNonAdmins = adminRoutes.filter((route) => !route.auth?.admin).map((route) => route.id); expect(new Set(reachableByNonAdmins)).toEqual(UNAUTHENTICATED_ADMIN_ROUTES); @@ -59,6 +114,20 @@ describe('controllers', () => { expect(undeclared).toEqual([]); }); + it('should only allow shared link access to expected routes', () => { + const sharedLinkRoutes = routes.filter((route) => route.auth?.sharedLink).map((route) => route.id); + + expect(new Set(sharedLinkRoutes)).toEqual(SHARED_LINK_ROUTES); + }); + + it('should only require admin access on expected routes outside /admin', () => { + const adminRoutes = routes + .filter((route) => route.auth?.admin && route.path !== 'admin' && !route.path.startsWith('admin/')) + .map((route) => route.id); + + expect(new Set(adminRoutes)).toEqual(ADMIN_ROUTES); + }); + it('should require admin access for routes with an admin permission', () => { const offenders = routes .filter((route) => isAdminPermission(route.auth?.permission) && !route.auth?.admin) diff --git a/server/src/controllers/job.controller.spec.ts b/server/src/controllers/job.controller.spec.ts new file mode 100644 index 0000000000..4f6f9337c8 --- /dev/null +++ b/server/src/controllers/job.controller.spec.ts @@ -0,0 +1,45 @@ +import { JobController } from 'src/controllers/job.controller'; +import { JobService } from 'src/services/job.service'; +import { QueueService } from 'src/services/queue.service'; +import request from 'supertest'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(JobController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(JobService); + const queueService = mockBaseService(QueueService); + + beforeAll(async () => { + ctx = await controllerSetup(JobController, [ + { provide: JobService, useValue: service }, + { provide: QueueService, useValue: queueService }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + queueService.resetAllMocks(); + ctx.reset(); + }); + + describe('PUT /jobs/:name', () => { + it('should require a valid queue name', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/jobs/invalid') + .send({ command: 'start', force: false }); + + expect(status).toBe(400); + expect(queueService.runCommandLegacy).not.toHaveBeenCalled(); + }); + + it('should require a valid command', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/jobs/metadataExtraction') + .send({ command: 'invalid', force: false }); + + expect(status).toBe(400); + expect(queueService.runCommandLegacy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/library.controller.spec.ts b/server/src/controllers/library.controller.spec.ts index c6f1b21ccc..2b606f9835 100644 --- a/server/src/controllers/library.controller.spec.ts +++ b/server/src/controllers/library.controller.spec.ts @@ -20,28 +20,6 @@ describe(LibraryController.name, () => { const id = factory.uuid(); - describe('authentication', () => { - const routes = [ - { method: 'get', path: '/libraries' }, - { method: 'post', path: '/libraries' }, - { method: 'get', path: `/libraries/${id}` }, - { method: 'put', path: `/libraries/${id}` }, - { method: 'patch', path: `/libraries/${id}` }, - { method: 'delete', path: `/libraries/${id}` }, - { method: 'post', path: `/libraries/${id}/validate` }, - { method: 'get', path: `/libraries/${id}/statistics` }, - { method: 'post', path: `/libraries/${id}/scan` }, - ] as const; - - it.each(routes)('$method $path should be an admin route', async ({ method, path }) => { - await request(ctx.getHttpServer())[method](path).send({}); - - expect(ctx.authenticate).toHaveBeenCalledWith( - expect.objectContaining({ metadata: expect.objectContaining({ adminRoute: true }) }), - ); - }); - }); - describe('POST /libraries', () => { it('should require an owner id', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/libraries').send({}); diff --git a/server/src/controllers/map.controller.spec.ts b/server/src/controllers/map.controller.spec.ts new file mode 100644 index 0000000000..4c5f16bc77 --- /dev/null +++ b/server/src/controllers/map.controller.spec.ts @@ -0,0 +1,66 @@ +import { MapController } from 'src/controllers/map.controller'; +import { MapService } from 'src/services/map.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(MapController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(MapService); + + beforeAll(async () => { + ctx = await controllerSetup(MapController, [{ provide: MapService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /map/reverse-geocode', () => { + it('should require a lat', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lon: 123 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + + it('should require a lat that is a number', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get('/map/reverse-geocode') + .query({ lat: 'abc', lon: 123.456 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + + it('should require a lat that is in range', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get('/map/reverse-geocode') + .query({ lat: 91, lon: 123.456 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + + it('should require a lon', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lat: 75 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/oauth.controller.spec.ts b/server/src/controllers/oauth.controller.spec.ts new file mode 100644 index 0000000000..a8aa33a4f7 --- /dev/null +++ b/server/src/controllers/oauth.controller.spec.ts @@ -0,0 +1,71 @@ +import { OAuthController } from 'src/controllers/oauth.controller'; +import { AuthService } from 'src/services/auth.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(OAuthController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(AuthService); + + beforeAll(async () => { + ctx = await controllerSetup(OAuthController, [{ provide: AuthService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /oauth/authorize', () => { + it('should require a redirect uri', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/authorize').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + expect(service.authorize).not.toHaveBeenCalled(); + }); + }); + + describe('POST /oauth/callback', () => { + it('should require a url', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['url'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + }); + + it('should not allow an empty url', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({ url: '' }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['url'], message: 'Too small: expected string to have >=1 characters' }, + ]), + ); + }); + }); + + describe('POST /oauth/backchannel-logout', () => { + it('should require a logout token', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/backchannel-logout').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['logout_token'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + }); + }); +}); diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index 731baa673f..856a74e340 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -47,6 +47,15 @@ describe(SharedLinkController.name, () => { expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); + it('should require an albumId for share type Album', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/shared-links') + .send({ type: SharedLinkType.Album }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }])); + expect(service.create).not.toHaveBeenCalled(); + }); + it('should not allow an albumId for share type Individual', async () => { const { status, body } = await request(ctx.getHttpServer()) .post('/shared-links') diff --git a/server/src/controllers/system-metadata.controller.spec.ts b/server/src/controllers/system-metadata.controller.spec.ts new file mode 100644 index 0000000000..0f5fba08a9 --- /dev/null +++ b/server/src/controllers/system-metadata.controller.spec.ts @@ -0,0 +1,28 @@ +import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; +import request from 'supertest'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(SystemMetadataController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(SystemMetadataService); + + beforeAll(async () => { + ctx = await controllerSetup(SystemMetadataController, [{ provide: SystemMetadataService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /system-metadata/admin-onboarding', () => { + it('should require isOnboarded', async () => { + const { status } = await request(ctx.getHttpServer()).post('/system-metadata/admin-onboarding').send({}); + + expect(status).toBe(400); + expect(service.updateAdminOnboarding).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/trash.controller.spec.ts b/server/src/controllers/trash.controller.spec.ts new file mode 100644 index 0000000000..1b923692b1 --- /dev/null +++ b/server/src/controllers/trash.controller.spec.ts @@ -0,0 +1,44 @@ +import { TrashController } from 'src/controllers/trash.controller'; +import { TrashService } from 'src/services/trash.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(TrashController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(TrashService); + + beforeAll(async () => { + ctx = await controllerSetup(TrashController, [{ provide: TrashService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /trash/restore/assets', () => { + it('should require asset ids', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/trash/restore/assets').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['ids'], message: 'Invalid input: expected array, received undefined' }, + ]), + ); + expect(service.restoreAssets).not.toHaveBeenCalled(); + }); + + it('should require valid asset ids', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/trash/restore/assets') + .send({ ids: ['invalid'] }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); + expect(service.restoreAssets).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/medium/specs/services/album.service.spec.ts b/server/test/medium/specs/services/album.service.spec.ts index bc73007593..9670f4c5ca 100644 --- a/server/test/medium/specs/services/album.service.spec.ts +++ b/server/test/medium/specs/services/album.service.spec.ts @@ -1,10 +1,13 @@ import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; import { AlbumService } from 'src/services/album.service'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -12,7 +15,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(AlbumService, { database: db || defaultDatabase, - real: [AlbumRepository, UserRepository], + real: [AccessRepository, AlbumRepository, AssetRepository, UserRepository], mock: [LoggingRepository], }); }; @@ -22,16 +25,35 @@ beforeAll(async () => { }); describe(AlbumService.name, () => { + describe('removeAssets', () => { + it('should not remove assets from an album of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]); + + await expect(sut.removeAssets(factory.auth({ user: otherUser }), album.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no albumAsset.delete access', + ); + await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id); + }); + }); + describe('database triggers', () => { it('should cascade delete an album when the owner is deleted', async () => { const { ctx } = setup(); const { user } = await ctx.newUser(); - await ctx.newAlbum({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); await ctx.get(UserRepository).delete({ id: user.id }, true); - await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]); - await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]); + await expect(ctx.database.selectFrom('album').selectAll().where('id', '=', album.id).execute()).resolves.toEqual( + [], + ); + await expect( + ctx.database.selectFrom('album_user').selectAll().where('albumId', '=', album.id).execute(), + ).resolves.toEqual([]); }); }); }); diff --git a/server/test/medium/specs/services/api-key.service.spec.ts b/server/test/medium/specs/services/api-key.service.spec.ts index 1a5842dab4..32fbf7b9d9 100644 --- a/server/test/medium/specs/services/api-key.service.spec.ts +++ b/server/test/medium/specs/services/api-key.service.spec.ts @@ -24,6 +24,44 @@ beforeAll(async () => { }); describe(ApiKeyService.name, () => { + describe('getById', () => { + it('should not return an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.getById(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + }); + }); + + describe('update', () => { + it('should not update an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.update(factory.auth({ user: otherUser }), apiKey.id, { name: 'new name' })).rejects.toThrow( + 'API Key not found', + ); + }); + }); + + describe('delete', () => { + it('should not delete an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.delete(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + await expect(sut.getById(factory.auth({ user }), apiKey.id)).resolves.toEqual( + expect.objectContaining({ id: apiKey.id }), + ); + }); + }); + describe('rotate', () => { it('should not rotate an api key of another user', async () => { const { sut, ctx } = setup(); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 2569b29353..cb6e1d744d 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -45,6 +45,19 @@ beforeAll(async () => { }); describe(AssetService.name, () => { + describe('get', () => { + it('should not return an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect(sut.get(factory.auth({ user: otherUser }), asset.id)).rejects.toThrow( + 'Not found or no asset.read access', + ); + }); + }); + describe('getStatistics', () => { it('should return stats as numbers, not strings', async () => { const { sut, ctx } = setup(); @@ -334,6 +347,17 @@ describe(AssetService.name, () => { }); describe('update', () => { + it('should not update an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect(sut.update(factory.auth({ user: otherUser }), asset.id, {})).rejects.toThrow( + 'Not found or no asset.update access', + ); + }); + it('should automatically lock lockable columns', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queue.mockResolvedValue(); diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index b3a3da6010..5a9217ea60 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -34,7 +34,70 @@ const setup = (db?: Kysely) => { }); }; +/** A memory owned by one user, plus another user's auth to attempt access with */ +const newMemoryOfAnotherUser = async (ctx: ReturnType['ctx']) => { + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { memory } = await ctx.newMemory({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + return { memory, asset, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) }; +}; + describe(MemoryService.name, () => { + describe('get', () => { + it('should not return a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.get(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.read access'); + }); + }); + + describe('update', () => { + it('should not update a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.update(otherAuth, memory.id, { isSaved: true })).rejects.toThrow( + 'Not found or no memory.update access', + ); + }); + }); + + describe('remove', () => { + it('should not remove a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, auth, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.remove(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.delete access'); + await expect(sut.get(auth, memory.id)).resolves.toEqual(expect.objectContaining({ id: memory.id })); + }); + }); + + describe('addAssets', () => { + it('should not add assets to a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.addAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no memory.read access', + ); + }); + }); + + describe('removeAssets', () => { + it('should not remove assets from a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + await expect(sut.removeAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no memory.update access', + ); + }); + }); + beforeEach(async () => { defaultDatabase = await getKyselyDB(); }); diff --git a/server/test/medium/specs/services/stack.service.spec.ts b/server/test/medium/specs/services/stack.service.spec.ts new file mode 100644 index 0000000000..68c06465b7 --- /dev/null +++ b/server/test/medium/specs/services/stack.service.spec.ts @@ -0,0 +1,44 @@ +import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { DB } from 'src/schema'; +import { StackService } from 'src/services/stack.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(StackService, { + database: db || defaultDatabase, + real: [AccessRepository, AssetRepository, StackRepository], + mock: [EventRepository, LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(StackService.name, () => { + describe('create', () => { + it('should not stack an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: otherAsset } = await ctx.newAsset({ ownerId: otherUser.id }); + + await expect(sut.create(factory.auth({ user }), { assetIds: [asset.id, otherAsset.id] })).rejects.toThrow( + 'Not found or no asset.update access', + ); + await expect( + ctx.database.selectFrom('stack').selectAll().where('ownerId', '=', user.id).execute(), + ).resolves.toEqual([]); + }); + }); +}); diff --git a/server/test/medium/specs/services/tag.service.spec.ts b/server/test/medium/specs/services/tag.service.spec.ts index 989e4f535f..52d002eed2 100644 --- a/server/test/medium/specs/services/tag.service.spec.ts +++ b/server/test/medium/specs/services/tag.service.spec.ts @@ -22,12 +22,61 @@ const setup = (db?: Kysely) => { }); }; +/** A tag owned by one user, plus another user's auth to attempt access with */ +const newTagOfAnotherUser = async (ctx: ReturnType['ctx']) => { + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] }); + + return { tag, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) }; +}; + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); describe(TagService.name, () => { + describe('get', () => { + it('should not return a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.get(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.read access'); + }); + }); + + describe('update', () => { + it('should not update a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.update(otherAuth, tag.id, { color: '#000000' })).rejects.toThrow( + 'Not found or no tag.update access', + ); + }); + }); + + describe('remove', () => { + it('should not remove a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.remove(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.delete access'); + await expect(sut.get(auth, tag.id)).resolves.toEqual(expect.objectContaining({ id: tag.id })); + }); + }); + describe('addAssets', () => { + it('should not add assets to a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + const { asset } = await ctx.newAsset({ ownerId: otherAuth.user.id }); + + await expect(sut.addAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no tag.asset access', + ); + }); + it('should lock exif column', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); @@ -53,6 +102,22 @@ describe(TagService.name, () => { await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id); }); }); + + describe('removeAssets', () => { + it('should not remove assets from a tag of another user', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + await sut.addAssets(auth, tag.id, { ids: [asset.id] }); + + await expect(sut.removeAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no tag.asset access', + ); + await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id); + }); + }); + describe('deleteEmptyTags', () => { it('single tag exists, not connected to any assets, and is deleted', async () => { const { sut, ctx } = setup(); From bd2cbb751bd90da06ec3b7d6747e5b659107de8f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:49:10 -0400 Subject: [PATCH 58/78] fix(deps): update sharp to ^0.35.3 (#29132) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 +- e2e/package.json | 2 +- mise.toml | 1 - pnpm-lock.yaml | 298 +++++++++++--------- pnpm-workspace.yaml | 2 +- server/Dockerfile | 7 +- server/package.json | 4 +- server/src/repositories/media.repository.ts | 1 + 8 files changed, 174 insertions(+), 145 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb3448e870..8a4ecceb14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -734,7 +734,7 @@ jobs: github_token: ${{ steps.token.outputs.token }} - name: Install server dependencies - run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile + run: pnpm --filter immich install --frozen-lockfile - name: Run API generation run: mise //:open-api working-directory: open-api @@ -792,7 +792,7 @@ jobs: github_token: ${{ steps.token.outputs.token }} - name: Install server dependencies - run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Build plugins run: mise //:plugins diff --git a/e2e/package.json b/e2e/package.json index 88183f89db..135e6c5f16 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -48,7 +48,7 @@ "pngjs": "^7.0.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.0.0", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "socket.io-client": "^4.7.4", "supertest": "^7.0.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/mise.toml b/mise.toml index 6114bfe1b8..3ff8c22035 100644 --- a/mise.toml +++ b/mise.toml @@ -73,7 +73,6 @@ outputs = ["../mobile/generated/openapi/"] run = "bash ./bin/generate-dart-sdk.sh" [tasks.open-api] -env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true } run = [ { task = "//:plugins" }, { task = "//server:install" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 919ee04b99..6d7cbe9c2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: canvas: 3.2.3 - sharp: ^0.34.5 + sharp: ^0.35.3 packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k= @@ -181,8 +181,8 @@ importers: specifier: ^4.0.0 version: 4.3.0(@typescript/typescript6@6.0.2)(prettier@3.9.6) sharp: - specifier: ^0.34.5 - version: 0.34.5 + specifier: ^0.35.3 + version: 0.35.3(@types/node@24.13.3) socket.io-client: specifier: ^4.7.4 version: 4.8.3(supports-color@8.1.1) @@ -614,8 +614,8 @@ importers: specifier: ^7.8.1 version: 7.8.5 sharp: - specifier: ^0.34.5 - version: 0.34.5 + specifier: ^0.35.3 + version: 0.35.3(@types/node@24.13.3) sirv: specifier: ^3.0.0 version: 3.0.2 @@ -944,7 +944,7 @@ importers: version: 3.0.10(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) '@sveltejs/enhanced-img': specifier: ^0.11.0 - version: 0.11.0(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 0.11.0(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@types/node@24.13.3)(rollup@4.62.0)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@sveltejs/kit': specifier: ^2.56.1 version: 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) @@ -2951,152 +2951,161 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -11443,9 +11452,14 @@ packages: resolution: {integrity: sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw==} hasBin: true - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -15781,98 +15795,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.2 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.2 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.2': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.2': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.2': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.2 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.2 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.2 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.2 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.2 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.2 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.3': dependencies: '@emnapi/runtime': 1.11.1 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.3': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': optional: true '@immich/justified-layout-wasm@0.4.3': {} @@ -17433,17 +17457,18 @@ snapshots: dependencies: '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) - '@sveltejs/enhanced-img@0.11.0(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': + '@sveltejs/enhanced-img@0.11.0(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@types/node@24.13.3)(rollup@4.62.0)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@sveltejs/vite-plugin-svelte': 7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) magic-string: 0.30.21 - sharp: 0.34.5 + sharp: 0.35.3(@types/node@24.13.3) svelte: 5.56.9(@typescript-eslint/types@8.67.0) svelte-parse-markup: 0.1.5(svelte@5.56.9(@typescript-eslint/types@8.67.0)) vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) - vite-imagetools: 9.0.3(rollup@4.62.0) + vite-imagetools: 9.0.3(@types/node@24.13.3)(rollup@4.62.0) zimmerframe: 1.1.4 transitivePeerDependencies: + - '@types/node' - rollup '@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': @@ -25200,7 +25225,7 @@ snapshots: stream-source: 0.3.5 text-encoding: 0.6.4 - sharp@0.34.5: + sharp@0.35.3(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -25208,30 +25233,32 @@ snapshots: node-gyp: 13.0.0 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 24.13.3 shebang-command@2.0.0: dependencies: @@ -26460,12 +26487,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-imagetools@9.0.3(rollup@4.62.0): + vite-imagetools@9.0.3(@types/node@24.13.3)(rollup@4.62.0): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.62.0) imagetools-core: 9.1.0 - sharp: 0.34.5 + sharp: 0.35.3(@types/node@24.13.3) transitivePeerDependencies: + - '@types/node' - rollup vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 884a4b5b7f..71a80eb969 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,7 +29,7 @@ allowBuilds: postman-code-generators: false overrides: canvas: 3.2.3 - sharp: ^0.34.5 + sharp: ^0.35.3 packageExtensions: nestjs-kysely: dependencies: diff --git a/server/Dockerfile b/server/Dockerfile index d5c2939630..5d604f6d23 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -20,8 +20,9 @@ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \ - SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --prod --no-optional deploy /output/server-pruned + pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \ + pnpm --filter immich --prod --no-optional deploy /output/server-pruned && \ + SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --dir /output/server-pruned/node_modules/sharp exec npm run build FROM builder AS web @@ -37,7 +38,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \ + pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \ pnpm --filter @immich/sdk --filter immich-web build FROM builder AS cli diff --git a/server/package.json b/server/package.json index 236a696afc..facce43aa4 100644 --- a/server/package.json +++ b/server/package.json @@ -107,7 +107,7 @@ "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "semver": "^7.8.1", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "sirv": "^3.0.0", "socket.io": "^4.8.1", "tailwindcss-preset-email": "^1.4.0", @@ -169,6 +169,6 @@ "vitest": "^3.0.0" }, "overrides": { - "sharp": "^0.34.5" + "sharp": "^0.35.3" } } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index c1e8cfd234..1100e856b5 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -187,6 +187,7 @@ export class MediaRepository { let pipeline = sharp(input, { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes failOn: options.processInvalidImages ? 'none' : 'error', + limitInputChannels: false, limitInputPixels: false, raw: options.raw, unlimited: true, From 7165eb61ebc1330b91ad35126a532eed5b7344fa Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 19 Aug 2026 22:40:10 +0200 Subject: [PATCH 59/78] fix(server): unicode email validation (#30871) --- open-api/immich-openapi-specs.json | 18 +++++++++--------- server/src/validation.spec.ts | 30 +++++++++++++++++++++++++++++- server/src/validation.ts | 12 ++++++++++-- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 02d7fc80ef..60623a4a8d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -19664,7 +19664,7 @@ "description": "User email", "example": "testuser@email.com", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "password": { @@ -19708,7 +19708,7 @@ "userEmail": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "userId": { @@ -20861,7 +20861,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "id": { @@ -23429,7 +23429,7 @@ "description": "User email", "example": "testuser@email.com", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "name": { @@ -27488,7 +27488,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "isAdmin": { @@ -27570,7 +27570,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "id": { @@ -27676,7 +27676,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "isAdmin": { @@ -27875,7 +27875,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "id": { @@ -27930,7 +27930,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "name": { diff --git a/server/src/validation.spec.ts b/server/src/validation.spec.ts index 434ac89cee..7305c4d5cf 100644 --- a/server/src/validation.spec.ts +++ b/server/src/validation.spec.ts @@ -1,4 +1,4 @@ -import { IsNotSiblingOf } from 'src/validation'; +import { IsNotSiblingOf, toEmail } from 'src/validation'; import { describe, expect, it } from 'vitest'; import z from 'zod'; @@ -42,4 +42,32 @@ describe('Validation', () => { expect(result.success).toBe(true); }); }); + + describe('toEmail', () => { + it.each([ + 'test@immich.cloud', + 'test@immich', + 'first.last+tag@immich.cloud', + // unicode local parts and internationalized domain names + 'tëst@immich.cloud', + 'leoñ@immich.cloud', + 'test@яндекс.рф', + 'test@xn--d1acpjx3f.xn--p1ai', + '用户@例子.广告', + 'test@ท่องเที่ยว.ไทย', + ])('should accept %s', (email) => { + expect(toEmail.safeParse(email).success).toBe(true); + }); + + it.each(['immich', 'test@@immich.cloud', 'test user@immich.cloud', 'test@immich..cloud', 'test@-immich.cloud'])( + 'should reject %s', + (email) => { + expect(toEmail.safeParse(email).success).toBe(false); + }, + ); + + it('should convert the email to lower case', () => { + expect(toEmail.parse('tÉst@Immich.Cloud')).toBe('tést@immich.cloud'); + }); + }); }); diff --git a/server/src/validation.ts b/server/src/validation.ts index f8de2a68ff..467a87b676 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -119,14 +119,22 @@ const FilenameParamSchema = z.object({ export class FilenameParamDto extends createZodDto(FilenameParamSchema) {} +/** + * The HTML5 email regex, but with unicode support, so that international email addresses + * (unicode local parts and internationalized domain names) are accepted. + * @see {@link z.regexes.html5Email} + */ +const unicodeEmail = + /^[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; + /** * Unified email validation - * Converts email strings to lowercase and validates against HTML5 email regex + * Converts email strings to lowercase and validates against a unicode aware email regex * @docs https://zod.dev/api?id=email */ export const toEmail = z .email({ - pattern: z.regexes.html5Email, + pattern: unicodeEmail, error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`, }) .transform((val) => val.toLowerCase()); From 47dccf72834b7fe121893ea0f0a7521fed2d75ea Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 19 Aug 2026 16:52:50 -0400 Subject: [PATCH 60/78] refactor: dockerfile caching (#30874) --- .github/workflows/test.yml | 42 ++++++++++++++++++++++++++++++++++++++ e2e/docker-compose.yml | 5 +++-- server/Dockerfile | 36 ++++++++++---------------------- 3 files changed, 56 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a4ecceb14..dfb31f064a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -397,6 +397,7 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read + packages: write defaults: run: working-directory: ./e2e @@ -440,8 +441,33 @@ jobs: run: pnpm install --frozen-lockfile if: ${{ !cancelled() }} + - name: Setup Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + if: ${{ github.event_name == 'push' && !github.event.pull_request.head.repo.fork }} + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve build cache + id: cache + run: | + ref=ghcr.io/immich-app/immich-server-build-cache:e2e-main-${{ runner.arch }} + echo "ref=$ref" >> "$GITHUB_OUTPUT" + if docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then + echo "from=type=registry,ref=$ref" >> "$GITHUB_OUTPUT" + else + echo "from=" >> "$GITHUB_OUTPUT" + fi + - name: Start Docker Compose id: docker + env: + E2E_CACHE_FROM: ${{ steps.cache.outputs.from }} + E2E_CACHE_TO: ${{ github.event_name == 'push' && !github.event.pull_request.head.repo.fork && format('type=registry,ref={0},mode=max', steps.cache.outputs.ref) || '' }} run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 if: ${{ !cancelled() }} @@ -519,8 +545,24 @@ jobs: run: pnpm exec playwright install chromium --only-shell if: ${{ !cancelled() }} + - name: Setup Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Resolve build cache + id: cache + run: | + ref=ghcr.io/immich-app/immich-server-build-cache:e2e-main-${{ runner.arch }} + echo "ref=$ref" >> "$GITHUB_OUTPUT" + if docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then + echo "from=type=registry,ref=$ref" >> "$GITHUB_OUTPUT" + else + echo "from=" >> "$GITHUB_OUTPUT" + fi + - name: Docker build id: docker + env: + E2E_CACHE_FROM: ${{ steps.cache.outputs.from }} run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 if: ${{ !cancelled() }} diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 7c03cea653..bf9c62087f 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -16,8 +16,9 @@ services: context: ../ dockerfile: server/Dockerfile cache_from: - - type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main - - type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main + - ${E2E_CACHE_FROM:-} + cache_to: + - ${E2E_CACHE_TO:-} args: - BUILD_ID=1234567890 - BUILD_IMAGE=e2e diff --git a/server/Dockerfile b/server/Dockerfile index 5d604f6d23..419c650653 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -9,17 +9,16 @@ RUN npm install --global corepack@latest && \ corepack enable pnpm && \ pnpm config set store-dir "$PNPM_HOME" +WORKDIR /usr/src/app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cjs ./ + FROM builder AS server -WORKDIR /usr/src/app COPY ./server ./server/ COPY ./packages/sdk ./packages/sdk/ COPY ./packages/plugin-sdk ./packages/plugin-sdk/ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \ pnpm --filter immich --prod --no-optional deploy /output/server-pruned && \ SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --dir /output/server-pruned/node_modules/sharp exec npm run build @@ -29,26 +28,18 @@ FROM builder AS web ARG BUILD_ID ENV IMMICH_BUILD=${BUILD_ID} -WORKDIR /usr/src/app COPY ./web ./web/ COPY ./i18n ./i18n/ -COPY ./packages ./packages/ +COPY ./packages/sdk ./packages/sdk/ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \ + pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile && \ pnpm --filter @immich/sdk --filter immich-web build FROM builder AS cli -COPY ./packages ./packages/ +COPY ./packages/sdk ./packages/sdk/ +COPY ./packages/cli ./packages/cli/ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && \ pnpm --filter @immich/sdk --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned @@ -59,11 +50,10 @@ ARG TARGETPLATFORM COPY --from=ghcr.io/jdx/mise:2026.8.8@sha256:638270210fb690efc0a8492b2c173d51bee6cb33f26381f05790d90c8e646a80 /usr/local/bin/mise /usr/local/bin/mise -WORKDIR /app COPY ./mise.toml ./mise.toml COPY ./mise.lock ./mise.lock COPY ./packages/plugin-core/mise.toml ./packages/plugin-core/ -ENV MISE_TRUSTED_CONFIG_PATHS=/app/mise.toml +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/mise.toml ENV MISE_DATA_DIR=/buildcache/mise ENV MISE_DISABLE_TOOLS=flutter RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ @@ -75,10 +65,6 @@ COPY ./packages/plugin-sdk ./packages/plugin-sdk/ # Build plugins RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise //:plugins @@ -92,8 +78,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli -COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist -COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json +COPY --from=plugins /usr/src/app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist +COPY --from=plugins /usr/src/app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE From a066b5f301bd17972db47b478f4bf2fc8c7a4992 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 20 Aug 2026 13:52:33 +0600 Subject: [PATCH 61/78] fix(mobile): order album/place/person timelines by local date (#29338) * fix(mobile): order album/place/person timelines by local date * fold the timeline date ordering into one helper and tighten the tests * drop the redundant none guard from the place asset query * format --- .../repositories/timeline.repository.dart | 43 +++++++--- .../timeline_repository_test.dart | 79 +++++++++++++++++++ mobile/test/medium/repository_context.dart | 3 +- 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 083c88955f..824449e939 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -185,7 +185,8 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => ( bucketSource: () => _watchRemoteAlbumBucket(albumId, groupBy: groupBy), - assetSource: (offset, count) => _getRemoteAlbumBucketAssets(albumId, offset: offset, count: count), + assetSource: (offset, count) => + _getRemoteAlbumBucketAssets(albumId, groupBy: groupBy, offset: offset, count: count), origin: TimelineOrigin.remoteAlbum, ); @@ -239,7 +240,12 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor .handleError((error) => const []); } - Future> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async { + Future> _getRemoteAlbumBucketAssets( + String albumId, { + required int offset, + required int count, + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) async { final albumData = await (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId))).getSingleOrNull(); // If album doesn't exist (was deleted), return empty list @@ -266,11 +272,9 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor ), ])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId)); - if (isAscending) { - query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); - } else { - query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]); - } + query.orderBy( + _assetDateOrder(groupBy, ascending: isAscending).map((order) => order(_db.remoteAssetEntity)).toList(), + ); query.limit(count, offset: offset); @@ -377,13 +381,14 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor TimelineQuery place(String place, GroupAssetsBy groupBy) => ( bucketSource: () => _watchPlaceBucket(place, groupBy: groupBy), - assetSource: (offset, count) => _getPlaceBucketAssets(place, offset: offset, count: count), + assetSource: (offset, count) => _getPlaceBucketAssets(place, groupBy: groupBy, offset: offset, count: count), origin: TimelineOrigin.place, ); TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => ( bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy), - assetSource: (offset, count) => _getPersonBucketAssets(userId, personId, offset: offset, count: count), + assetSource: (offset, count) => + _getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count), origin: TimelineOrigin.person, ); @@ -420,7 +425,12 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor }).watch(); } - Future> _getPlaceBucketAssets(String place, {required int offset, required int count}) { + Future> _getPlaceBucketAssets( + String place, { + required int offset, + required int count, + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { final query = _db.remoteAssetEntity.select().join([ innerJoin( @@ -434,7 +444,7 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _db.remoteExifEntity.city.equals(place), ) - ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) + ..orderBy(_assetDateOrder(groupBy).map((order) => order(_db.remoteAssetEntity)).toList()) ..limit(count, offset: offset); return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } @@ -490,6 +500,7 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor String personId, { required int offset, required int count, + GroupAssetsBy groupBy = GroupAssetsBy.day, }) { final idQuery = _db.assetFaceEntity.selectOnly() ..addColumns([_db.assetFaceEntity.assetId]) @@ -507,7 +518,7 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.timeline), ) - ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) + ..orderBy(_assetDateOrder(groupBy)) ..limit(count, offset: offset); return query.map((row) => row.toDto()).get(); @@ -717,6 +728,14 @@ class TimelineRepository extends DatabaseAccessor with $TimelineRepositor List _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)]; +List _assetDateOrder(GroupAssetsBy groupBy, {bool ascending = false}) { + OrderingTerm order(Expression exp) => ascending ? OrderingTerm.asc(exp) : OrderingTerm.desc(exp); + return [ + if (groupBy != GroupAssetsBy.none) (row) => order(row.effectiveCreatedAt(groupBy)), + (row) => order(row.createdAt), + ]; +} + extension on Expression { Expression dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) { // DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart index bc572b0e2f..3de31e9665 100644 --- a/mobile/test/medium/repositories/timeline_repository_test.dart +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -46,6 +46,60 @@ void main() { expect((assets.first as RemoteAsset).id, remoteAsset.id); expect([localAsset1.id, localAsset2.id], contains((assets.first as RemoteAsset).localId)); }); + + test('orders shifted album assets in both directions and keeps normal asset order (#28852)', () async { + final user = await ctx.newUser(); + final descendingAlbum = await ctx.newRemoteAlbum(ownerId: user.id, order: .desc); + final ascendingAlbum = await ctx.newRemoteAlbum(ownerId: user.id, order: .asc); + final shiftedLater = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 2, 12), + localDateTime: DateTime.utc(2024, 9, 3, 12), + ); + final shiftedEarlier = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 3, 12), + localDateTime: DateTime.utc(2024, 9, 2, 12), + ); + final normalLater = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 4, 14), + localDateTime: DateTime.utc(2024, 9, 4, 14), + ); + final normalEarlier = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 4, 12), + localDateTime: DateTime.utc(2024, 9, 4, 12), + ); + final seeded = [shiftedLater, shiftedEarlier, normalLater, normalEarlier]; + for (final asset in seeded) { + await ctx.newRemoteAlbumAsset(albumId: descendingAlbum.id, assetId: asset.id); + await ctx.newRemoteAlbumAsset(albumId: ascendingAlbum.id, assetId: asset.id); + } + + final descending = sut.remoteAlbum(descendingAlbum.id, .day); + final ascending = sut.remoteAlbum(ascendingAlbum.id, .day); + + final buckets = await descending.bucketSource().first; + expect(buckets, hasLength(3)); + expect(buckets.map((bucket) => bucket.assetCount), [2, 1, 1]); + + final descendingAssets = await descending.assetSource(0, 10); + expect(descendingAssets.map((asset) => (asset as RemoteAsset).id), [ + normalLater.id, + normalEarlier.id, + shiftedLater.id, + shiftedEarlier.id, + ]); + + final ascendingAssets = await ascending.assetSource(0, 10); + expect(ascendingAssets.map((asset) => (asset as RemoteAsset).id), [ + shiftedEarlier.id, + shiftedLater.id, + normalEarlier.id, + normalLater.id, + ]); + }); }); group('person assets', () { @@ -69,6 +123,31 @@ void main() { expect(assets, hasLength(1)); expect((assets.first as RemoteAsset).id, asset.id); }); + + test('orders shifted person assets by effective date (#28852)', () async { + final user = await ctx.newUser(); + final person = await ctx.newPerson(ownerId: user.id); + final shiftedLater = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 2, 12), + localDateTime: DateTime.utc(2024, 9, 3, 12), + ); + final shiftedEarlier = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 3, 12), + localDateTime: DateTime.utc(2024, 9, 2, 12), + ); + await ctx.newFace(assetId: shiftedLater.id, personId: person.id); + await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id); + + final query = sut.person(user.id, person.id, .day); + + final buckets = await query.bucketSource().first; + expect(buckets, hasLength(2)); + + final assets = await query.assetSource(0, 10); + expect(assets.map((asset) => (asset as RemoteAsset).id), [shiftedLater.id, shiftedEarlier.id]); + }); }); group('live photos', () { diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 09b8e2c7eb..c2d7a1f380 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -121,6 +121,7 @@ class MediumRepositoryContext { String? stackId, String? thumbHash, String? libraryId, + DateTime? localDateTime, }) async { id ??= TestUtils.uuid(); createdAt ??= TestUtils.date(); @@ -144,7 +145,7 @@ class MediumRepositoryContext { isEdited: .new(isEdited ?? false), livePhotoVideoId: .new(livePhotoVideoId), stackId: .new(stackId), - localDateTime: .new(createdAt.toLocal()), + localDateTime: .new(localDateTime ?? createdAt.toLocal()), thumbHash: .new(TestUtils.uuid(thumbHash)), libraryId: .new(TestUtils.uuid(libraryId)), ), From 47c5a3dbf6d3eef7fa9284c5cd1b2d4b5ae31276 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 20 Aug 2026 11:23:12 -0400 Subject: [PATCH 62/78] refactor: api key response dto (#30887) --- e2e/src/specs/server/api/api-key.e2e-spec.ts | 51 +++++++++++--------- e2e/src/specs/server/cli/login.e2e-spec.ts | 8 +-- e2e/src/utils.ts | 6 +-- open-api/immich-openapi-specs.json | 51 +++++++++++++++++++- packages/sdk/src/fetch-client.ts | 10 ++++ server/src/dtos/api-key.dto.ts | 4 +- server/src/services/api-key.service.ts | 7 +-- web/src/lib/services/api-key.service.ts | 4 +- 8 files changed, 104 insertions(+), 37 deletions(-) diff --git a/e2e/src/specs/server/api/api-key.e2e-spec.ts b/e2e/src/specs/server/api/api-key.e2e-spec.ts index 8ffcf1f525..ec481cee01 100644 --- a/e2e/src/specs/server/api/api-key.e2e-spec.ts +++ b/e2e/src/specs/server/api/api-key.e2e-spec.ts @@ -34,14 +34,14 @@ describe('/api-keys', () => { permissions: [Permission.ApiKeyRead], }); expect(body).toEqual({ + id: expect.any(String), + name: 'API Key', + permissions: [Permission.ApiKeyRead], + createdAt: expect.any(String), + updatedAt: expect.any(String), secret: expect.any(String), - apiKey: { - id: expect.any(String), - name: 'API Key', - permissions: [Permission.ApiKeyRead], - createdAt: expect.any(String), - updatedAt: expect.any(String), - }, + // TODO: remove in v4 + apiKey: expect.any(Object), }); expect(status).toBe(201); }); @@ -72,14 +72,14 @@ describe('/api-keys', () => { .send({ name: 'API Key', permissions: [Permission.All] }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual({ - apiKey: { - id: expect.any(String), - name: 'API Key', - permissions: [Permission.All], - createdAt: expect.any(String), - updatedAt: expect.any(String), - }, + id: expect.any(String), + name: 'API Key', + permissions: [Permission.All], + createdAt: expect.any(String), + updatedAt: expect.any(String), secret: expect.any(String), + // TODO: remove in v4 + apiKey: expect.any(Object), }); expect(status).toEqual(201); }); @@ -93,23 +93,30 @@ describe('/api-keys', () => { }); it('should return a list of api keys', async () => { - const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([ + const [apiKey1, apiKey2, apiKey3] = await Promise.all([ create(admin.accessToken, [Permission.All]), create(admin.accessToken, [Permission.All]), create(admin.accessToken, [Permission.All]), ]); + const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toHaveLength(3); - expect(body).toEqual(expect.arrayContaining([apiKey1, apiKey2, apiKey3])); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: apiKey1.id }), + expect.objectContaining({ id: apiKey2.id }), + expect.objectContaining({ id: apiKey3.id }), + ]), + ); expect(status).toEqual(200); }); }); describe('GET /api-keys/:id', () => { it('should get api key details', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) - .get(`/api-keys/${apiKey.id}`) + .get(`/api-keys/${id}`) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ @@ -124,9 +131,9 @@ describe('/api-keys', () => { describe('PUT /api-keys/:id', () => { it('should update api key details', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) - .put(`/api-keys/${apiKey.id}`) + .put(`/api-keys/${id}`) .send({ name: 'new name', permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate], @@ -145,9 +152,9 @@ describe('/api-keys', () => { describe('DELETE /api-keys/:id', () => { it('should delete an api key', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status } = await request(app) - .delete(`/api-keys/${apiKey.id}`) + .delete(`/api-keys/${id}`) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(204); }); diff --git a/e2e/src/specs/server/cli/login.e2e-spec.ts b/e2e/src/specs/server/cli/login.e2e-spec.ts index caf5550b6e..98a28be448 100644 --- a/e2e/src/specs/server/cli/login.e2e-spec.ts +++ b/e2e/src/specs/server/cli/login.e2e-spec.ts @@ -30,8 +30,8 @@ describe(`immich login`, () => { it('should login and save auth.yml with 600', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken, [Permission.All]); - const { stdout, stderr, exitCode } = await immichCli(['login', app, key.secret]); + const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]); + const { stdout, stderr, exitCode } = await immichCli(['login', app, apiKey.secret]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2285/api', 'Logged in as admin@immich.cloud', @@ -47,8 +47,8 @@ describe(`immich login`, () => { it('should login without /api in the url', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken, [Permission.All]); - const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), key.secret]); + const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]); + const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), apiKey.secret]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2285', 'Discovered API at http://127.0.0.1:2285/api', diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3124dd0609..6490806bff 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -675,9 +675,9 @@ export const utils = { }, cliLogin: async (accessToken: string) => { - const key = await utils.createApiKey(accessToken, [Permission.All]); - await immichCli(['login', app, key.secret]); - return key.secret; + const { secret } = await utils.createApiKey(accessToken, [Permission.All]); + await immichCli(['login', app, secret]); + return secret; }, scan: async (accessToken: string, id: string) => { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 60623a4a8d..8b6727168c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16979,16 +16979,63 @@ "ApiKeyCreateResponseDto": { "properties": { "apiKey": { - "$ref": "#/components/schemas/ApiKeyResponseDto" + "$ref": "#/components/schemas/ApiKeyResponseDto", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "API key ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "name": { + "description": "API key name", + "type": "string" + }, + "permissions": { + "description": "List of permissions", + "items": { + "$ref": "#/components/schemas/Permission" + }, + "type": "array" }, "secret": { "description": "API key secret (only shown once)", "type": "string" + }, + "updatedAt": { + "description": "Last update date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" } }, "required": [ "apiKey", - "secret" + "createdAt", + "id", + "name", + "permissions", + "secret", + "updatedAt" ], "type": "object" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 9a1f93cb30..7e0fb963f7 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -631,8 +631,18 @@ export type ApiKeyCreateDto = { }; export type ApiKeyCreateResponseDto = { apiKey: ApiKeyResponseDto; + /** Creation date */ + createdAt: string; + /** API key ID */ + id: string; + /** API key name */ + name: string; + /** List of permissions */ + permissions: Permission[]; /** API key secret (only shown once) */ secret: string; + /** Last update date */ + updatedAt: string; }; export type ApiKeyUpdateDto = { /** API key name */ diff --git a/server/src/dtos/api-key.dto.ts b/server/src/dtos/api-key.dto.ts index 470808e430..8c933e06bb 100644 --- a/server/src/dtos/api-key.dto.ts +++ b/server/src/dtos/api-key.dto.ts @@ -1,4 +1,5 @@ import { createZodDto } from 'nestjs-zod'; +import { HistoryBuilder } from 'src/decorators'; import { Permission } from 'src/enum'; import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; @@ -31,8 +32,9 @@ const ApiKeyResponseSchema = z const ApiKeyCreateResponseSchema = z .object({ + ...ApiKeyResponseSchema.shape, secret: z.string().describe('API key secret (only shown once)'), - apiKey: ApiKeyResponseSchema, + apiKey: ApiKeyResponseSchema.meta({ ...new HistoryBuilder().added('v1').deprecated('v3.2.0').getExtensions() }), }) .meta({ id: 'ApiKeyCreateResponseDto' }); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 2b9e1a814c..d65ebc4d96 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -23,8 +23,9 @@ export class ApiKeyService extends BaseService { userId: auth.user.id, permissions: dto.permissions, }); + const apiKey = this.map(entity); - return { secret: token, apiKey: this.map(entity) }; + return { ...apiKey, secret: token, apiKey }; } async update(auth: AuthDto, id: string, dto: ApiKeyUpdateDto): Promise { @@ -58,10 +59,10 @@ export class ApiKeyService extends BaseService { const token = this.cryptoRepository.randomBytesAsText(32); const hashed = this.cryptoRepository.hashSha256(token); - const newKey = await this.apiKeyRepository.update(auth.user.id, id, { key: hashed }); + const apiKey = this.map(newKey); - return { secret: token, apiKey: this.map(newKey) }; + return { ...apiKey, secret: token, apiKey }; } async delete(auth: AuthDto, id: string): Promise { diff --git a/web/src/lib/services/api-key.service.ts b/web/src/lib/services/api-key.service.ts index e5346627ff..5dc990af96 100644 --- a/web/src/lib/services/api-key.service.ts +++ b/web/src/lib/services/api-key.service.ts @@ -64,7 +64,7 @@ export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => { } const response = await createApiKey({ apiKeyCreateDto: dto }); - eventManager.emit('ApiKeyCreate', response.apiKey); + eventManager.emit('ApiKeyCreate', response); return response; } catch (error) { @@ -105,7 +105,7 @@ export const handleRotateApiKey = async (apiKey: ApiKeyResponseDto) => { try { const response = await rotateApiKey({ id: apiKey.id }); - eventManager.emit('ApiKeyUpdate', response.apiKey); + eventManager.emit('ApiKeyUpdate', response); await modalManager.show(ApiKeySecretModal, { secret: response.secret }); } catch (error) { handleError(error, $t('errors.something_went_wrong')); From e529557160d8d54bc6abad9a0f4d66ae35a1cd8e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 20 Aug 2026 11:46:17 -0400 Subject: [PATCH 63/78] feat: new config endpoints (#30881) * refactor: config endpoints * chore: pr feedback --- e2e/src/specs/server/api/jobs.e2e-spec.ts | 2 +- e2e/src/specs/server/api/oauth.e2e-spec.ts | 6 +- e2e/src/utils.ts | 2 +- open-api/immich-openapi-specs.json | 3237 ++++++++++------- packages/sdk/src/fetch-client.ts | 1133 +++--- server/src/config.ts | 449 --- server/src/constants.ts | 3 + .../controllers/config-admin.controller.ts | 46 + .../controllers/config-public.controller.ts | 35 + .../src/controllers/config-user.controller.ts | 35 + .../src/controllers/config.controller.spec.ts | 116 + server/src/controllers/index.ts | 6 + .../notification-admin.controller.ts | 2 +- server/src/controllers/server.controller.ts | 12 +- .../system-config.controller.spec.ts | 2 +- .../controllers/system-config.controller.ts | 34 +- server/src/dtos/config.dto.spec.ts | 68 + server/src/dtos/config.dto.ts | 773 ++++ server/src/dtos/model-config.dto.ts | 57 - server/src/dtos/system-config.dto.ts | 444 --- server/src/enum.ts | 21 + server/src/repositories/event.repository.ts | 2 +- .../machine-learning.repository.ts | 5 +- .../repositories/server-info.repository.ts | 2 +- server/src/services/base.service.ts | 2 +- .../services/database-backup.service.spec.ts | 2 +- server/src/services/hls.service.ts | 4 +- server/src/services/library.service.spec.ts | 2 +- server/src/services/media.service.spec.ts | 2 +- server/src/services/media.service.ts | 11 +- server/src/services/metadata.service.spec.ts | 2 +- .../notification-admin.service.spec.ts | 2 +- .../services/notification-admin.service.ts | 2 +- .../src/services/notification.service.spec.ts | 5 +- server/src/services/notification.service.ts | 2 +- server/src/services/queue.service.spec.ts | 2 +- server/src/services/queue.service.ts | 2 +- .../src/services/smart-info.service.spec.ts | 2 +- server/src/services/smart-info.service.ts | 2 +- .../services/storage-template.service.spec.ts | 2 +- .../src/services/storage-template.service.ts | 4 +- .../services/system-config.service.spec.ts | 44 +- server/src/services/system-config.service.ts | 41 +- server/src/services/version.service.spec.ts | 5 +- server/src/services/version.service.ts | 12 +- server/src/types.ts | 10 +- server/src/utils/config.ts | 5 +- server/src/utils/media.ts | 14 +- server/src/utils/misc.ts | 2 +- server/src/utils/profile-image.ts | 2 +- server/test/fixtures/system-config.stub.ts | 2 +- server/test/medium.factory.ts | 2 +- server/test/utils.ts | 6 +- .../settings/SystemConfigButtonRow.svelte | 6 +- web/src/lib/managers/event-manager.svelte.ts | 4 +- .../managers/system-config-manager.svelte.ts | 8 +- web/src/lib/services/system-config.service.ts | 12 +- .../admin/system-settings/JobSettings.svelte | 4 +- .../NotificationSettings.svelte | 2 +- .../system-settings/TemplateSettings.svelte | 6 +- 60 files changed, 3974 insertions(+), 2755 deletions(-) delete mode 100644 server/src/config.ts create mode 100644 server/src/controllers/config-admin.controller.ts create mode 100644 server/src/controllers/config-public.controller.ts create mode 100644 server/src/controllers/config-user.controller.ts create mode 100644 server/src/controllers/config.controller.spec.ts create mode 100644 server/src/dtos/config.dto.spec.ts create mode 100644 server/src/dtos/config.dto.ts delete mode 100644 server/src/dtos/model-config.dto.ts delete mode 100644 server/src/dtos/system-config.dto.ts diff --git a/e2e/src/specs/server/api/jobs.e2e-spec.ts b/e2e/src/specs/server/api/jobs.e2e-spec.ts index f9d8b75c46..b4228eeb91 100644 --- a/e2e/src/specs/server/api/jobs.e2e-spec.ts +++ b/e2e/src/specs/server/api/jobs.e2e-spec.ts @@ -45,7 +45,7 @@ describe('/jobs', () => { config.machineLearning.enabled = false; config.metadata.faces.import = false; config.machineLearning.clip.enabled = false; - await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); + await updateConfig({ adminConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); }); it('should queue metadata extraction for missing assets', async () => { diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index 3b85e9e4c8..10d3343a78 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -1,7 +1,7 @@ import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server'; import { + AdminConfigOAuthDto, LoginResponseDto, - SystemConfigOAuthDto, getConfigDefaults, getMyUser, getSessions, @@ -70,7 +70,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => return { url: redirectUrl, state, codeVerifier }; }; -const setupOAuth = async (token: string, dto: Partial) => { +const setupOAuth = async (token: string, dto: Partial) => { const options = { headers: asBearerAuth(token) }; const defaults = await getConfigDefaults(options); const merged = { @@ -80,7 +80,7 @@ const setupOAuth = async (token: string, dto: Partial) => allowInsecureRequests: true, ...dto, }; - await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options); + await updateConfig({ adminConfigDto: { ...defaults, oauth: merged } }, options); }; describe(`/oauth`, () => { diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 6490806bff..e4216ae57b 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -647,7 +647,7 @@ export const utils = { resetAdminConfig: async (accessToken: string) => { const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) }); - await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); + await updateConfig({ adminConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); }, isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8b6727168c..4c63584728 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -333,6 +333,155 @@ "x-immich-state": "Stable" } }, + "/admin/config": { + "get": { + "description": "Retrieve admin configuration.", + "operationId": "getAdminConfig", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the admin configuration", + "tags": [ + "Config (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "adminConfig.read", + "x-immich-state": "Alpha" + }, + "put": { + "description": "Update the system configuration with a new system configuration.", + "operationId": "updateAdminConfig", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update the system configuration", + "tags": [ + "Config (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "adminConfig.update", + "x-immich-state": "Alpha" + } + }, + "/admin/config/defaults": { + "get": { + "description": "Retrieve the default value of every system configuration property.", + "operationId": "getAdminConfigDefaults", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the system configuration defaults", + "tags": [ + "Config (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "adminConfig.read", + "x-immich-state": "Alpha" + } + }, "/admin/database-backups": { "delete": { "description": "Delete a backup by its filename", @@ -1169,7 +1318,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" + "$ref": "#/components/schemas/AdminConfigSmtpDto" } } }, @@ -5712,6 +5861,98 @@ "x-immich-state": "Stable" } }, + "/config": { + "get": { + "description": "Retrieve the system configuration properties that are visible to logged in users.", + "operationId": "getUserConfig", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the configuration with user visibility", + "tags": [ + "Config (user)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "userConfig.read", + "x-immich-state": "Alpha" + } + }, + "/config/defaults": { + "get": { + "description": "Retrieve the default value of the configuration properties that are visible to logged in users.", + "operationId": "getUserConfigDefaults", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the default configuration with user visibility", + "tags": [ + "Config (user)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "userConfig.read", + "x-immich-state": "Alpha" + } + }, "/download/archive": { "post": { "description": "Download a ZIP archive containing the specified assets. The assets must have been previously requested via the \"getDownloadInfo\" endpoint.", @@ -9807,6 +10048,74 @@ "x-immich-permission": "plugin.read" } }, + "/public/config": { + "get": { + "description": "Retrieve the system configuration properties that are visible to everyone.", + "operationId": "getPublicConfig", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicConfigDto" + } + } + }, + "description": "" + } + }, + "summary": "Get the public configuration", + "tags": [ + "Config (public)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-state": "Alpha" + } + }, + "/public/config/defaults": { + "get": { + "description": "Retrieve the default value of the configuration properties that are visible to everyone.", + "operationId": "getPublicConfigDefaults", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicConfigDto" + } + } + }, + "description": "" + } + }, + "summary": "Get the public configuration defaults", + "tags": [ + "Config (public)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-state": "Alpha" + } + }, "/queues": { "get": { "description": "Retrieves a list of queues.", @@ -11243,6 +11552,7 @@ }, "/server/config": { "get": { + "deprecated": true, "description": "Retrieve the current server configuration.", "operationId": "getServerConfig", "parameters": [], @@ -11260,7 +11570,8 @@ }, "summary": "Get config", "tags": [ - "Server" + "Server", + "Deprecated" ], "x-immich-history": [ { @@ -11274,13 +11585,19 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getPublicConfig" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/server/features": { "get": { + "deprecated": true, "description": "Retrieve available features supported by this server.", "operationId": "getServerFeatures", "parameters": [], @@ -11298,7 +11615,8 @@ }, "summary": "Get features", "tags": [ - "Server" + "Server", + "Deprecated" ], "x-immich-history": [ { @@ -11312,9 +11630,14 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getPublicConfig" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/server/license": { @@ -13357,6 +13680,7 @@ }, "/system-config": { "get": { + "deprecated": true, "description": "Retrieve the current system configuration.", "operationId": "getConfig", "parameters": [], @@ -13365,7 +13689,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13385,7 +13709,8 @@ ], "summary": "Get system configuration", "tags": [ - "System config" + "System config", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -13400,12 +13725,18 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getAdminConfig" } ], "x-immich-permission": "systemConfig.read", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "put": { + "deprecated": true, "description": "Update the system configuration with a new system configuration.", "operationId": "updateConfig", "parameters": [], @@ -13413,7 +13744,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13424,7 +13755,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13444,7 +13775,8 @@ ], "summary": "Update system configuration", "tags": [ - "System config" + "System config", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -13459,14 +13791,20 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "updateAdminConfig" } ], "x-immich-permission": "systemConfig.update", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/system-config/defaults": { "get": { + "deprecated": true, "description": "Retrieve the default values for the system configuration.", "operationId": "getConfigDefaults", "parameters": [], @@ -13475,7 +13813,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13495,7 +13833,8 @@ ], "summary": "Get system configuration defaults", "tags": [ - "System config" + "System config", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -13510,10 +13849,15 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getAdminConfigDefaults" } ], "x-immich-permission": "systemConfig.read", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/system-config/storage-template-options": { @@ -16420,6 +16764,18 @@ "name": "Authentication (admin)", "description": "Administrative endpoints related to authentication." }, + { + "name": "Config (user)", + "description": "The system configuration properties that are visible to logged in users." + }, + { + "name": "Config (admin)", + "description": "Endpoints to view and modify the full system configuration." + }, + { + "name": "Config (public)", + "description": "The system configuration properties that are visible to everyone." + }, { "name": "Database Backups (admin)", "description": "Manage backups of the Immich database." @@ -16683,6 +17039,1211 @@ ], "type": "object" }, + "AdminConfigBackupsDto": { + "properties": { + "database": { + "$ref": "#/components/schemas/AdminConfigDatabaseBackupDto" + } + }, + "required": [ + "database" + ], + "type": "object" + }, + "AdminConfigClipDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "modelName": { + "description": "Name of the model to use", + "type": "string" + } + }, + "required": [ + "enabled", + "modelName" + ], + "type": "object" + }, + "AdminConfigDatabaseBackupDto": { + "properties": { + "cronExpression": { + "description": "Cron expression", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "keepLastAmount": { + "description": "Keep last amount", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "cronExpression", + "enabled", + "keepLastAmount" + ], + "type": "object" + }, + "AdminConfigDto": { + "description": "Configuration properties that are visible to the admin", + "properties": { + "backup": { + "$ref": "#/components/schemas/AdminConfigBackupsDto" + }, + "ffmpeg": { + "$ref": "#/components/schemas/AdminConfigFFmpegDto" + }, + "image": { + "$ref": "#/components/schemas/AdminConfigImageDto" + }, + "integrityChecks": { + "$ref": "#/components/schemas/AdminConfigIntegrityChecksDto" + }, + "job": { + "$ref": "#/components/schemas/AdminConfigJobDto" + }, + "library": { + "$ref": "#/components/schemas/AdminConfigLibraryDto" + }, + "logging": { + "$ref": "#/components/schemas/AdminConfigLoggingDto" + }, + "machineLearning": { + "$ref": "#/components/schemas/AdminConfigMachineLearningDto" + }, + "map": { + "$ref": "#/components/schemas/AdminConfigMapDto" + }, + "metadata": { + "$ref": "#/components/schemas/AdminConfigMetadataDto" + }, + "newVersionCheck": { + "$ref": "#/components/schemas/AdminConfigNewVersionCheckDto" + }, + "nightlyTasks": { + "$ref": "#/components/schemas/AdminConfigNightlyTasksDto" + }, + "notifications": { + "$ref": "#/components/schemas/AdminConfigNotificationsDto" + }, + "oauth": { + "$ref": "#/components/schemas/AdminConfigOAuthDto" + }, + "passwordLogin": { + "$ref": "#/components/schemas/AdminConfigPasswordLoginDto" + }, + "reverseGeocoding": { + "$ref": "#/components/schemas/AdminConfigReverseGeocodingDto" + }, + "server": { + "$ref": "#/components/schemas/AdminConfigServerDto" + }, + "storageTemplate": { + "$ref": "#/components/schemas/AdminConfigStorageTemplateDto" + }, + "templates": { + "$ref": "#/components/schemas/AdminConfigTemplatesDto" + }, + "theme": { + "$ref": "#/components/schemas/AdminConfigThemeDto" + }, + "trash": { + "$ref": "#/components/schemas/AdminConfigTrashDto" + }, + "user": { + "$ref": "#/components/schemas/AdminConfigUserDto" + } + }, + "required": [ + "backup", + "ffmpeg", + "image", + "integrityChecks", + "job", + "library", + "logging", + "machineLearning", + "map", + "metadata", + "newVersionCheck", + "nightlyTasks", + "notifications", + "oauth", + "passwordLogin", + "reverseGeocoding", + "server", + "storageTemplate", + "templates", + "theme", + "trash", + "user" + ], + "type": "object" + }, + "AdminConfigDuplicateDetectionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "maxDistance": { + "description": "Maximum distance threshold for duplicate detection", + "format": "double", + "maximum": 0.1, + "minimum": 0.001, + "type": "number" + } + }, + "required": [ + "enabled", + "maxDistance" + ], + "type": "object" + }, + "AdminConfigFFmpegDto": { + "properties": { + "accel": { + "$ref": "#/components/schemas/TranscodeHWAccel" + }, + "accelDecode": { + "description": "Accelerated decode", + "type": "boolean" + }, + "acceptedAudioCodecs": { + "description": "Accepted audio codecs", + "items": { + "$ref": "#/components/schemas/AudioCodec" + }, + "type": "array" + }, + "acceptedContainers": { + "description": "Accepted containers", + "items": { + "$ref": "#/components/schemas/VideoContainer" + }, + "type": "array" + }, + "acceptedVideoCodecs": { + "description": "Accepted video codecs", + "items": { + "$ref": "#/components/schemas/VideoCodec" + }, + "type": "array" + }, + "bframes": { + "description": "B-frames", + "maximum": 16, + "minimum": -1, + "type": "integer" + }, + "cqMode": { + "$ref": "#/components/schemas/CQMode" + }, + "crf": { + "description": "CRF", + "maximum": 51, + "minimum": 0, + "type": "integer" + }, + "gopSize": { + "description": "GOP size", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "maxBitrate": { + "description": "Max bitrate", + "type": "string" + }, + "preferredHwDevice": { + "description": "Preferred hardware device", + "type": "string" + }, + "preset": { + "description": "Preset", + "type": "string" + }, + "realtime": { + "$ref": "#/components/schemas/AdminConfigFFmpegRealtimeDto" + }, + "refs": { + "description": "References", + "maximum": 6, + "minimum": 0, + "type": "integer" + }, + "targetAudioCodec": { + "$ref": "#/components/schemas/AudioCodec" + }, + "targetResolution": { + "description": "Target resolution", + "type": "string" + }, + "targetVideoCodec": { + "$ref": "#/components/schemas/VideoCodec" + }, + "temporalAQ": { + "description": "Temporal AQ", + "type": "boolean" + }, + "threads": { + "description": "Threads", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "tonemap": { + "$ref": "#/components/schemas/ToneMapping" + }, + "transcode": { + "$ref": "#/components/schemas/TranscodePolicy" + }, + "twoPass": { + "description": "Two pass", + "type": "boolean" + } + }, + "required": [ + "accel", + "accelDecode", + "acceptedAudioCodecs", + "acceptedContainers", + "acceptedVideoCodecs", + "bframes", + "cqMode", + "crf", + "gopSize", + "maxBitrate", + "preferredHwDevice", + "preset", + "realtime", + "refs", + "targetAudioCodec", + "targetResolution", + "targetVideoCodec", + "temporalAQ", + "threads", + "tonemap", + "transcode", + "twoPass" + ], + "type": "object" + }, + "AdminConfigFFmpegRealtimeDto": { + "properties": { + "enabled": { + "description": "Enable real-time HLS transcoding (alpha)", + "type": "boolean" + }, + "resolutions": { + "description": "Resolutions to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/HlsVideoResolution" + }, + "type": "array" + }, + "videoCodecs": { + "description": "Video codecs to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/VideoCodec" + }, + "type": "array" + } + }, + "required": [ + "enabled", + "resolutions", + "videoCodecs" + ], + "type": "object" + }, + "AdminConfigFacesDto": { + "properties": { + "import": { + "description": "Import", + "type": "boolean" + } + }, + "required": [ + "import" + ], + "type": "object" + }, + "AdminConfigFacialRecognitionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "maxDistance": { + "description": "Maximum distance threshold for face recognition", + "format": "double", + "maximum": 2, + "minimum": 0.1, + "type": "number" + }, + "minFaces": { + "description": "Minimum number of faces required for recognition", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "minScore": { + "description": "Minimum confidence score for face detection", + "format": "double", + "maximum": 1, + "minimum": 0.1, + "type": "number" + }, + "modelName": { + "description": "Name of the model to use", + "type": "string" + } + }, + "required": [ + "enabled", + "maxDistance", + "minFaces", + "minScore", + "modelName" + ], + "type": "object" + }, + "AdminConfigGeneratedFullsizeImageDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "progressive": { + "description": "Progressive", + "type": "boolean" + }, + "quality": { + "description": "Quality", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "enabled", + "format", + "quality" + ], + "type": "object" + }, + "AdminConfigGeneratedImageDto": { + "properties": { + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "progressive": { + "description": "Progressive", + "type": "boolean" + }, + "quality": { + "description": "Quality", + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "size": { + "description": "Size", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "format", + "quality", + "size" + ], + "type": "object" + }, + "AdminConfigImageDto": { + "properties": { + "colorspace": { + "$ref": "#/components/schemas/Colorspace" + }, + "extractEmbedded": { + "description": "Extract embedded", + "type": "boolean" + }, + "fullsize": { + "$ref": "#/components/schemas/AdminConfigGeneratedFullsizeImageDto" + }, + "preview": { + "$ref": "#/components/schemas/AdminConfigGeneratedImageDto" + }, + "thumbnail": { + "$ref": "#/components/schemas/AdminConfigGeneratedImageDto" + } + }, + "required": [ + "colorspace", + "extractEmbedded", + "fullsize", + "preview", + "thumbnail" + ], + "type": "object" + }, + "AdminConfigIntegrityChecksDto": { + "description": "Integrity checks config", + "properties": { + "checksumFiles": { + "$ref": "#/components/schemas/AdminConfigIntegrityChecksumJobDto" + }, + "missingFiles": { + "$ref": "#/components/schemas/AdminConfigIntegrityJobDto" + }, + "untrackedFiles": { + "$ref": "#/components/schemas/AdminConfigIntegrityJobDto" + } + }, + "required": [ + "checksumFiles", + "missingFiles", + "untrackedFiles" + ], + "type": "object" + }, + "AdminConfigIntegrityChecksumJobDto": { + "description": "Integrity checksum job config", + "properties": { + "cronExpression": { + "description": "Cron expression for when the integrity check should run", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "percentageLimit": { + "description": "Percentage limit of the integrity checksum job", + "format": "double", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "timeLimit": { + "description": "How long the integrity checksum job may run for", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cronExpression", + "enabled", + "percentageLimit", + "timeLimit" + ], + "type": "object" + }, + "AdminConfigIntegrityJobDto": { + "description": "Integrity job config", + "properties": { + "cronExpression": { + "description": "Cron expression for when the integrity check should run", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "cronExpression", + "enabled" + ], + "type": "object" + }, + "AdminConfigJobDto": { + "properties": { + "backgroundTask": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "editor": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "faceDetection": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "integrityCheck": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "library": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "metadataExtraction": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "migration": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "notifications": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "ocr": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "search": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "sidecar": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "smartSearch": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "thumbnailGeneration": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "videoConversion": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "workflow": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + } + }, + "required": [ + "backgroundTask", + "editor", + "faceDetection", + "integrityCheck", + "library", + "metadataExtraction", + "migration", + "notifications", + "ocr", + "search", + "sidecar", + "smartSearch", + "thumbnailGeneration", + "videoConversion", + "workflow" + ], + "type": "object" + }, + "AdminConfigJobSettingsDto": { + "properties": { + "concurrency": { + "description": "Concurrency", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "concurrency" + ], + "type": "object" + }, + "AdminConfigLibraryDto": { + "properties": { + "scan": { + "$ref": "#/components/schemas/AdminConfigLibraryScanDto" + }, + "watch": { + "$ref": "#/components/schemas/AdminConfigLibraryWatchDto" + } + }, + "required": [ + "scan", + "watch" + ], + "type": "object" + }, + "AdminConfigLibraryScanDto": { + "properties": { + "cronExpression": { + "description": "Cron expression", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "cronExpression", + "enabled" + ], + "type": "object" + }, + "AdminConfigLibraryWatchDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "AdminConfigLoggingDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "level": { + "$ref": "#/components/schemas/LogLevel" + } + }, + "required": [ + "enabled", + "level" + ], + "type": "object" + }, + "AdminConfigMachineLearningAvailabilityChecksDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "interval": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "timeout": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "enabled", + "interval", + "timeout" + ], + "type": "object" + }, + "AdminConfigMachineLearningDto": { + "properties": { + "availabilityChecks": { + "$ref": "#/components/schemas/AdminConfigMachineLearningAvailabilityChecksDto" + }, + "clip": { + "$ref": "#/components/schemas/AdminConfigClipDto" + }, + "duplicateDetection": { + "$ref": "#/components/schemas/AdminConfigDuplicateDetectionDto" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "facialRecognition": { + "$ref": "#/components/schemas/AdminConfigFacialRecognitionDto" + }, + "ocr": { + "$ref": "#/components/schemas/AdminConfigOcrDto" + }, + "urls": { + "description": "ML service URLs", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "availabilityChecks", + "clip", + "duplicateDetection", + "enabled", + "facialRecognition", + "ocr", + "urls" + ], + "type": "object" + }, + "AdminConfigMapDto": { + "properties": { + "darkStyle": { + "description": "Dark map style URL", + "format": "uri", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "lightStyle": { + "description": "Light map style URL", + "format": "uri", + "type": "string" + } + }, + "required": [ + "darkStyle", + "enabled", + "lightStyle" + ], + "type": "object" + }, + "AdminConfigMetadataDto": { + "properties": { + "faces": { + "$ref": "#/components/schemas/AdminConfigFacesDto" + } + }, + "required": [ + "faces" + ], + "type": "object" + }, + "AdminConfigNewVersionCheckDto": { + "properties": { + "channel": { + "$ref": "#/components/schemas/ReleaseChannel" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "channel", + "enabled" + ], + "type": "object" + }, + "AdminConfigNightlyTasksDto": { + "properties": { + "clusterNewFaces": { + "description": "Cluster new faces", + "type": "boolean" + }, + "databaseCleanup": { + "description": "Database cleanup", + "type": "boolean" + }, + "generateMemories": { + "description": "Generate memories", + "type": "boolean" + }, + "missingThumbnails": { + "description": "Missing thumbnails", + "type": "boolean" + }, + "startTime": { + "description": "Start time (HH:MM)", + "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$", + "type": "string" + }, + "syncQuotaUsage": { + "description": "Sync quota usage", + "type": "boolean" + } + }, + "required": [ + "clusterNewFaces", + "databaseCleanup", + "generateMemories", + "missingThumbnails", + "startTime", + "syncQuotaUsage" + ], + "type": "object" + }, + "AdminConfigNotificationsDto": { + "properties": { + "smtp": { + "$ref": "#/components/schemas/AdminConfigSmtpDto" + } + }, + "required": [ + "smtp" + ], + "type": "object" + }, + "AdminConfigOAuthDto": { + "properties": { + "allowInsecureRequests": { + "description": "Allow insecure requests", + "type": "boolean" + }, + "autoLaunch": { + "description": "Auto launch", + "type": "boolean" + }, + "autoRegister": { + "description": "Auto register", + "type": "boolean" + }, + "buttonText": { + "description": "Button text", + "type": "string" + }, + "clientId": { + "description": "Client ID", + "type": "string" + }, + "clientSecret": { + "description": "Client secret", + "type": "string" + }, + "defaultStorageQuota": { + "description": "Default storage quota", + "maximum": 9007199254740991, + "minimum": 0, + "nullable": true, + "type": "integer" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "endSessionEndpoint": { + "description": "End session endpoint", + "type": "string" + }, + "issuerUrl": { + "description": "Issuer URL", + "type": "string" + }, + "mobileOverrideEnabled": { + "description": "Mobile override enabled", + "type": "boolean" + }, + "mobileRedirectUri": { + "description": "Mobile redirect URI (set to empty string to disable)", + "type": "string" + }, + "profileSigningAlgorithm": { + "description": "Profile signing algorithm", + "type": "string" + }, + "prompt": { + "description": "OAuth prompt parameter (e.g. select_account, login, consent)", + "type": "string" + }, + "roleClaim": { + "description": "Role claim", + "type": "string" + }, + "scope": { + "description": "Scope", + "type": "string" + }, + "signingAlgorithm": { + "description": "Signing algorithm", + "type": "string" + }, + "storageLabelClaim": { + "description": "Storage label claim", + "type": "string" + }, + "storageQuotaClaim": { + "description": "Storage quota claim", + "type": "string" + }, + "timeout": { + "description": "Timeout", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "tokenEndpointAuthMethod": { + "$ref": "#/components/schemas/OAuthTokenEndpointAuthMethod" + } + }, + "required": [ + "allowInsecureRequests", + "autoLaunch", + "autoRegister", + "buttonText", + "clientId", + "clientSecret", + "defaultStorageQuota", + "enabled", + "endSessionEndpoint", + "issuerUrl", + "mobileOverrideEnabled", + "mobileRedirectUri", + "profileSigningAlgorithm", + "prompt", + "roleClaim", + "scope", + "signingAlgorithm", + "storageLabelClaim", + "storageQuotaClaim", + "timeout", + "tokenEndpointAuthMethod" + ], + "type": "object" + }, + "AdminConfigOcrDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "maxResolution": { + "description": "Maximum resolution for OCR processing", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "minDetectionScore": { + "description": "Minimum confidence score for text detection", + "format": "double", + "maximum": 1, + "minimum": 0.1, + "type": "number" + }, + "minRecognitionScore": { + "description": "Minimum confidence score for text recognition", + "format": "double", + "maximum": 1, + "minimum": 0.1, + "type": "number" + }, + "modelName": { + "description": "Name of the model to use", + "type": "string" + } + }, + "required": [ + "enabled", + "maxResolution", + "minDetectionScore", + "minRecognitionScore", + "modelName" + ], + "type": "object" + }, + "AdminConfigPasswordLoginDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "AdminConfigReverseGeocodingDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "AdminConfigServerDto": { + "properties": { + "externalDomain": { + "description": "External domain", + "type": "string" + }, + "loginPageMessage": { + "description": "Login page message", + "type": "string" + }, + "publicUsers": { + "description": "Public users", + "type": "boolean" + } + }, + "required": [ + "externalDomain", + "loginPageMessage", + "publicUsers" + ], + "type": "object" + }, + "AdminConfigSmtpDto": { + "properties": { + "enabled": { + "description": "Whether SMTP email notifications are enabled", + "type": "boolean" + }, + "from": { + "description": "Email address to send from", + "type": "string" + }, + "replyTo": { + "description": "Email address for replies", + "type": "string" + }, + "transport": { + "$ref": "#/components/schemas/AdminConfigSmtpTransportDto" + } + }, + "required": [ + "enabled", + "from", + "replyTo", + "transport" + ], + "type": "object" + }, + "AdminConfigSmtpTransportDto": { + "properties": { + "host": { + "description": "SMTP server hostname", + "type": "string" + }, + "ignoreCert": { + "description": "Whether to ignore SSL certificate errors", + "type": "boolean" + }, + "password": { + "description": "SMTP password", + "type": "string" + }, + "port": { + "description": "SMTP server port", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "secure": { + "description": "Whether to use secure connection (TLS/SSL)", + "type": "boolean" + }, + "username": { + "description": "SMTP username", + "type": "string" + } + }, + "required": [ + "host", + "ignoreCert", + "password", + "port", + "secure", + "username" + ], + "type": "object" + }, + "AdminConfigStorageTemplateDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "hashVerificationEnabled": { + "description": "Hash verification enabled", + "type": "boolean" + }, + "template": { + "description": "Template", + "type": "string" + } + }, + "required": [ + "enabled", + "hashVerificationEnabled", + "template" + ], + "type": "object" + }, + "AdminConfigTemplateEmailsDto": { + "properties": { + "albumInviteTemplate": { + "description": "Album invite template", + "type": "string" + }, + "albumUpdateTemplate": { + "description": "Album update template", + "type": "string" + }, + "welcomeTemplate": { + "description": "Welcome template", + "type": "string" + } + }, + "required": [ + "albumInviteTemplate", + "albumUpdateTemplate", + "welcomeTemplate" + ], + "type": "object" + }, + "AdminConfigTemplatesDto": { + "properties": { + "email": { + "$ref": "#/components/schemas/AdminConfigTemplateEmailsDto" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "AdminConfigThemeDto": { + "properties": { + "customCss": { + "description": "Custom CSS for theming", + "type": "string" + } + }, + "required": [ + "customCss" + ], + "type": "object" + }, + "AdminConfigTrashDto": { + "properties": { + "days": { + "description": "Days", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "days", + "enabled" + ], + "type": "object" + }, + "AdminConfigUserDto": { + "properties": { + "deleteDelay": { + "description": "Delete delay", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "deleteDelay" + ], + "type": "object" + }, "AdminOnboardingUpdateDto": { "properties": { "isOnboarded": { @@ -18468,23 +20029,6 @@ ], "type": "object" }, - "CLIPConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "modelName": { - "description": "Name of the model to use", - "type": "string" - } - }, - "required": [ - "enabled", - "modelName" - ], - "type": "object" - }, "CQMode": { "description": "CQ mode", "enum": [ @@ -18781,30 +20325,6 @@ ], "type": "object" }, - "DatabaseBackupConfig": { - "properties": { - "cronExpression": { - "description": "Cron expression", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "keepLastAmount": { - "description": "Keep last amount", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "cronExpression", - "enabled", - "keepLastAmount" - ], - "type": "object" - }, "DatabaseBackupDeleteDto": { "properties": { "backups": { @@ -19002,26 +20522,6 @@ }, "type": "object" }, - "DuplicateDetectionConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "maxDistance": { - "description": "Maximum distance threshold for duplicate detection", - "format": "double", - "maximum": 0.1, - "minimum": 0.001, - "type": "number" - } - }, - "required": [ - "enabled", - "maxDistance" - ], - "type": "object" - }, "DuplicateResolveDto": { "properties": { "groups": { @@ -19306,46 +20806,6 @@ ], "type": "object" }, - "FacialRecognitionConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "maxDistance": { - "description": "Maximum distance threshold for face recognition", - "format": "double", - "maximum": 2, - "minimum": 0.1, - "type": "number" - }, - "minFaces": { - "description": "Minimum number of faces required for recognition", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "minScore": { - "description": "Minimum confidence score for face detection", - "format": "double", - "maximum": 1, - "minimum": 0.1, - "type": "number" - }, - "modelName": { - "description": "Name of the model to use", - "type": "string" - } - }, - "required": [ - "enabled", - "maxDistance", - "minFaces", - "minScore", - "modelName" - ], - "type": "object" - }, "FoldersResponse": { "properties": { "enabled": { @@ -19549,20 +21009,6 @@ ], "type": "string" }, - "JobSettingsDto": { - "properties": { - "concurrency": { - "description": "Concurrency", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "concurrency" - ], - "type": "object" - }, "LibraryResponseDto": { "properties": { "assetCount": { @@ -19794,30 +21240,6 @@ ], "type": "object" }, - "MachineLearningAvailabilityChecksDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "interval": { - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "timeout": { - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - } - }, - "required": [ - "enabled", - "interval", - "timeout" - ], - "type": "object" - }, "MaintenanceAction": { "description": "Maintenance action", "enum": [ @@ -20799,46 +22221,6 @@ ], "type": "string" }, - "OcrConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "maxResolution": { - "description": "Maximum resolution for OCR processing", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "minDetectionScore": { - "description": "Minimum confidence score for text detection", - "format": "double", - "maximum": 1, - "minimum": 0.1, - "type": "number" - }, - "minRecognitionScore": { - "description": "Minimum confidence score for text recognition", - "format": "double", - "maximum": 1, - "minimum": 0.1, - "type": "number" - }, - "modelName": { - "description": "Name of the model to use", - "type": "string" - } - }, - "required": [ - "enabled", - "maxResolution", - "minDetectionScore", - "minRecognitionScore", - "modelName" - ], - "type": "object" - }, "OnThisDayDto": { "properties": { "year": { @@ -21148,6 +22530,9 @@ "backup.download", "backup.upload", "backup.delete", + "adminConfig.read", + "adminConfig.update", + "userConfig.read", "duplicate.read", "duplicate.delete", "face.create", @@ -21683,6 +23068,88 @@ ], "type": "object" }, + "PublicConfigDto": { + "description": "Configuration properties that are visible to everyone", + "properties": { + "oauth": { + "$ref": "#/components/schemas/PublicConfigOAuthDto" + }, + "passwordLogin": { + "$ref": "#/components/schemas/PublicConfigPasswordLoginDto" + }, + "server": { + "$ref": "#/components/schemas/PublicConfigServerDto" + }, + "theme": { + "$ref": "#/components/schemas/PublicConfigThemeDto" + } + }, + "required": [ + "oauth", + "passwordLogin", + "server", + "theme" + ], + "type": "object" + }, + "PublicConfigOAuthDto": { + "properties": { + "autoLaunch": { + "description": "Auto launch", + "type": "boolean" + }, + "buttonText": { + "description": "Button text", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "autoLaunch", + "buttonText", + "enabled" + ], + "type": "object" + }, + "PublicConfigPasswordLoginDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "PublicConfigServerDto": { + "properties": { + "loginPageMessage": { + "description": "Login page message", + "type": "string" + } + }, + "required": [ + "loginPageMessage" + ], + "type": "object" + }, + "PublicConfigThemeDto": { + "properties": { + "customCss": { + "description": "Custom CSS for theming", + "type": "string" + } + }, + "required": [ + "customCss" + ], + "type": "object" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { @@ -25782,976 +27249,6 @@ ], "type": "object" }, - "SystemConfigBackupsDto": { - "properties": { - "database": { - "$ref": "#/components/schemas/DatabaseBackupConfig" - } - }, - "required": [ - "database" - ], - "type": "object" - }, - "SystemConfigDto": { - "description": "System configuration", - "properties": { - "backup": { - "$ref": "#/components/schemas/SystemConfigBackupsDto" - }, - "ffmpeg": { - "$ref": "#/components/schemas/SystemConfigFFmpegDto" - }, - "image": { - "$ref": "#/components/schemas/SystemConfigImageDto" - }, - "integrityChecks": { - "$ref": "#/components/schemas/SystemConfigIntegrityChecks" - }, - "job": { - "$ref": "#/components/schemas/SystemConfigJobDto" - }, - "library": { - "$ref": "#/components/schemas/SystemConfigLibraryDto" - }, - "logging": { - "$ref": "#/components/schemas/SystemConfigLoggingDto" - }, - "machineLearning": { - "$ref": "#/components/schemas/SystemConfigMachineLearningDto" - }, - "map": { - "$ref": "#/components/schemas/SystemConfigMapDto" - }, - "metadata": { - "$ref": "#/components/schemas/SystemConfigMetadataDto" - }, - "newVersionCheck": { - "$ref": "#/components/schemas/SystemConfigNewVersionCheckDto" - }, - "nightlyTasks": { - "$ref": "#/components/schemas/SystemConfigNightlyTasksDto" - }, - "notifications": { - "$ref": "#/components/schemas/SystemConfigNotificationsDto" - }, - "oauth": { - "$ref": "#/components/schemas/SystemConfigOAuthDto" - }, - "passwordLogin": { - "$ref": "#/components/schemas/SystemConfigPasswordLoginDto" - }, - "reverseGeocoding": { - "$ref": "#/components/schemas/SystemConfigReverseGeocodingDto" - }, - "server": { - "$ref": "#/components/schemas/SystemConfigServerDto" - }, - "storageTemplate": { - "$ref": "#/components/schemas/SystemConfigStorageTemplateDto" - }, - "templates": { - "$ref": "#/components/schemas/SystemConfigTemplatesDto" - }, - "theme": { - "$ref": "#/components/schemas/SystemConfigThemeDto" - }, - "trash": { - "$ref": "#/components/schemas/SystemConfigTrashDto" - }, - "user": { - "$ref": "#/components/schemas/SystemConfigUserDto" - } - }, - "required": [ - "backup", - "ffmpeg", - "image", - "integrityChecks", - "job", - "library", - "logging", - "machineLearning", - "map", - "metadata", - "newVersionCheck", - "nightlyTasks", - "notifications", - "oauth", - "passwordLogin", - "reverseGeocoding", - "server", - "storageTemplate", - "templates", - "theme", - "trash", - "user" - ], - "type": "object" - }, - "SystemConfigFFmpegDto": { - "properties": { - "accel": { - "$ref": "#/components/schemas/TranscodeHWAccel" - }, - "accelDecode": { - "description": "Accelerated decode", - "type": "boolean" - }, - "acceptedAudioCodecs": { - "description": "Accepted audio codecs", - "items": { - "$ref": "#/components/schemas/AudioCodec" - }, - "type": "array" - }, - "acceptedContainers": { - "description": "Accepted containers", - "items": { - "$ref": "#/components/schemas/VideoContainer" - }, - "type": "array" - }, - "acceptedVideoCodecs": { - "description": "Accepted video codecs", - "items": { - "$ref": "#/components/schemas/VideoCodec" - }, - "type": "array" - }, - "bframes": { - "description": "B-frames", - "maximum": 16, - "minimum": -1, - "type": "integer" - }, - "cqMode": { - "$ref": "#/components/schemas/CQMode" - }, - "crf": { - "description": "CRF", - "maximum": 51, - "minimum": 0, - "type": "integer" - }, - "gopSize": { - "description": "GOP size", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "maxBitrate": { - "description": "Max bitrate", - "type": "string" - }, - "preferredHwDevice": { - "description": "Preferred hardware device", - "type": "string" - }, - "preset": { - "description": "Preset", - "type": "string" - }, - "realtime": { - "$ref": "#/components/schemas/SystemConfigFFmpegRealtimeDto" - }, - "refs": { - "description": "References", - "maximum": 6, - "minimum": 0, - "type": "integer" - }, - "targetAudioCodec": { - "$ref": "#/components/schemas/AudioCodec" - }, - "targetResolution": { - "description": "Target resolution", - "type": "string" - }, - "targetVideoCodec": { - "$ref": "#/components/schemas/VideoCodec" - }, - "temporalAQ": { - "description": "Temporal AQ", - "type": "boolean" - }, - "threads": { - "description": "Threads", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "tonemap": { - "$ref": "#/components/schemas/ToneMapping" - }, - "transcode": { - "$ref": "#/components/schemas/TranscodePolicy" - }, - "twoPass": { - "description": "Two pass", - "type": "boolean" - } - }, - "required": [ - "accel", - "accelDecode", - "acceptedAudioCodecs", - "acceptedContainers", - "acceptedVideoCodecs", - "bframes", - "cqMode", - "crf", - "gopSize", - "maxBitrate", - "preferredHwDevice", - "preset", - "realtime", - "refs", - "targetAudioCodec", - "targetResolution", - "targetVideoCodec", - "temporalAQ", - "threads", - "tonemap", - "transcode", - "twoPass" - ], - "type": "object" - }, - "SystemConfigFFmpegRealtimeDto": { - "properties": { - "enabled": { - "description": "Enable real-time HLS transcoding (alpha)", - "type": "boolean" - }, - "resolutions": { - "description": "Resolutions to use for real-time HLS transcoding", - "items": { - "$ref": "#/components/schemas/HlsVideoResolution" - }, - "type": "array" - }, - "videoCodecs": { - "description": "Video codecs to use for real-time HLS transcoding", - "items": { - "$ref": "#/components/schemas/VideoCodec" - }, - "type": "array" - } - }, - "required": [ - "enabled", - "resolutions", - "videoCodecs" - ], - "type": "object" - }, - "SystemConfigFacesDto": { - "properties": { - "import": { - "description": "Import", - "type": "boolean" - } - }, - "required": [ - "import" - ], - "type": "object" - }, - "SystemConfigGeneratedFullsizeImageDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "format": { - "$ref": "#/components/schemas/ImageFormat" - }, - "progressive": { - "description": "Progressive", - "type": "boolean" - }, - "quality": { - "description": "Quality", - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "enabled", - "format", - "quality" - ], - "type": "object" - }, - "SystemConfigGeneratedImageDto": { - "properties": { - "format": { - "$ref": "#/components/schemas/ImageFormat" - }, - "progressive": { - "description": "Progressive", - "type": "boolean" - }, - "quality": { - "description": "Quality", - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "size": { - "description": "Size", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "format", - "quality", - "size" - ], - "type": "object" - }, - "SystemConfigImageDto": { - "properties": { - "colorspace": { - "$ref": "#/components/schemas/Colorspace" - }, - "extractEmbedded": { - "description": "Extract embedded", - "type": "boolean" - }, - "fullsize": { - "$ref": "#/components/schemas/SystemConfigGeneratedFullsizeImageDto" - }, - "preview": { - "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" - }, - "thumbnail": { - "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" - } - }, - "required": [ - "colorspace", - "extractEmbedded", - "fullsize", - "preview", - "thumbnail" - ], - "type": "object" - }, - "SystemConfigIntegrityChecks": { - "description": "Integrity checks config", - "properties": { - "checksumFiles": { - "$ref": "#/components/schemas/SystemConfigIntegrityChecksumJob" - }, - "missingFiles": { - "$ref": "#/components/schemas/SystemConfigIntegrityJob" - }, - "untrackedFiles": { - "$ref": "#/components/schemas/SystemConfigIntegrityJob" - } - }, - "required": [ - "checksumFiles", - "missingFiles", - "untrackedFiles" - ], - "type": "object" - }, - "SystemConfigIntegrityChecksumJob": { - "description": "Integrity checksum job config", - "properties": { - "cronExpression": { - "description": "Cron expression for when the integrity check should run", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "percentageLimit": { - "description": "Percentage limit of the integrity checksum job", - "format": "double", - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "timeLimit": { - "description": "How long the integrity checksum job may run for", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "cronExpression", - "enabled", - "percentageLimit", - "timeLimit" - ], - "type": "object" - }, - "SystemConfigIntegrityJob": { - "description": "Integrity job config", - "properties": { - "cronExpression": { - "description": "Cron expression for when the integrity check should run", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "cronExpression", - "enabled" - ], - "type": "object" - }, - "SystemConfigJobDto": { - "properties": { - "backgroundTask": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "editor": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "faceDetection": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "integrityCheck": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "library": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "metadataExtraction": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "migration": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "notifications": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "ocr": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "search": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "sidecar": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "smartSearch": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "thumbnailGeneration": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "videoConversion": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "workflow": { - "$ref": "#/components/schemas/JobSettingsDto" - } - }, - "required": [ - "backgroundTask", - "editor", - "faceDetection", - "integrityCheck", - "library", - "metadataExtraction", - "migration", - "notifications", - "ocr", - "search", - "sidecar", - "smartSearch", - "thumbnailGeneration", - "videoConversion", - "workflow" - ], - "type": "object" - }, - "SystemConfigLibraryDto": { - "properties": { - "scan": { - "$ref": "#/components/schemas/SystemConfigLibraryScanDto" - }, - "watch": { - "$ref": "#/components/schemas/SystemConfigLibraryWatchDto" - } - }, - "required": [ - "scan", - "watch" - ], - "type": "object" - }, - "SystemConfigLibraryScanDto": { - "properties": { - "cronExpression": { - "description": "Cron expression", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "cronExpression", - "enabled" - ], - "type": "object" - }, - "SystemConfigLibraryWatchDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "SystemConfigLoggingDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "level": { - "$ref": "#/components/schemas/LogLevel" - } - }, - "required": [ - "enabled", - "level" - ], - "type": "object" - }, - "SystemConfigMachineLearningDto": { - "properties": { - "availabilityChecks": { - "$ref": "#/components/schemas/MachineLearningAvailabilityChecksDto" - }, - "clip": { - "$ref": "#/components/schemas/CLIPConfig" - }, - "duplicateDetection": { - "$ref": "#/components/schemas/DuplicateDetectionConfig" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "facialRecognition": { - "$ref": "#/components/schemas/FacialRecognitionConfig" - }, - "ocr": { - "$ref": "#/components/schemas/OcrConfig" - }, - "urls": { - "description": "ML service URLs", - "items": { - "type": "string" - }, - "minItems": 1, - "type": "array" - } - }, - "required": [ - "availabilityChecks", - "clip", - "duplicateDetection", - "enabled", - "facialRecognition", - "ocr", - "urls" - ], - "type": "object" - }, - "SystemConfigMapDto": { - "properties": { - "darkStyle": { - "description": "Dark map style URL", - "format": "uri", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "lightStyle": { - "description": "Light map style URL", - "format": "uri", - "type": "string" - } - }, - "required": [ - "darkStyle", - "enabled", - "lightStyle" - ], - "type": "object" - }, - "SystemConfigMetadataDto": { - "properties": { - "faces": { - "$ref": "#/components/schemas/SystemConfigFacesDto" - } - }, - "required": [ - "faces" - ], - "type": "object" - }, - "SystemConfigNewVersionCheckDto": { - "properties": { - "channel": { - "$ref": "#/components/schemas/ReleaseChannel" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "channel", - "enabled" - ], - "type": "object" - }, - "SystemConfigNightlyTasksDto": { - "properties": { - "clusterNewFaces": { - "description": "Cluster new faces", - "type": "boolean" - }, - "databaseCleanup": { - "description": "Database cleanup", - "type": "boolean" - }, - "generateMemories": { - "description": "Generate memories", - "type": "boolean" - }, - "missingThumbnails": { - "description": "Missing thumbnails", - "type": "boolean" - }, - "startTime": { - "description": "Start time (HH:MM)", - "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$", - "type": "string" - }, - "syncQuotaUsage": { - "description": "Sync quota usage", - "type": "boolean" - } - }, - "required": [ - "clusterNewFaces", - "databaseCleanup", - "generateMemories", - "missingThumbnails", - "startTime", - "syncQuotaUsage" - ], - "type": "object" - }, - "SystemConfigNotificationsDto": { - "properties": { - "smtp": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" - } - }, - "required": [ - "smtp" - ], - "type": "object" - }, - "SystemConfigOAuthDto": { - "properties": { - "allowInsecureRequests": { - "description": "Allow insecure requests", - "type": "boolean" - }, - "autoLaunch": { - "description": "Auto launch", - "type": "boolean" - }, - "autoRegister": { - "description": "Auto register", - "type": "boolean" - }, - "buttonText": { - "description": "Button text", - "type": "string" - }, - "clientId": { - "description": "Client ID", - "type": "string" - }, - "clientSecret": { - "description": "Client secret", - "type": "string" - }, - "defaultStorageQuota": { - "description": "Default storage quota", - "maximum": 9007199254740991, - "minimum": 0, - "nullable": true, - "type": "integer" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "endSessionEndpoint": { - "description": "End session endpoint", - "type": "string" - }, - "issuerUrl": { - "description": "Issuer URL", - "type": "string" - }, - "mobileOverrideEnabled": { - "description": "Mobile override enabled", - "type": "boolean" - }, - "mobileRedirectUri": { - "description": "Mobile redirect URI (set to empty string to disable)", - "type": "string" - }, - "profileSigningAlgorithm": { - "description": "Profile signing algorithm", - "type": "string" - }, - "prompt": { - "description": "OAuth prompt parameter (e.g. select_account, login, consent)", - "type": "string" - }, - "roleClaim": { - "description": "Role claim", - "type": "string" - }, - "scope": { - "description": "Scope", - "type": "string" - }, - "signingAlgorithm": { - "description": "Signing algorithm", - "type": "string" - }, - "storageLabelClaim": { - "description": "Storage label claim", - "type": "string" - }, - "storageQuotaClaim": { - "description": "Storage quota claim", - "type": "string" - }, - "timeout": { - "description": "Timeout", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "tokenEndpointAuthMethod": { - "$ref": "#/components/schemas/OAuthTokenEndpointAuthMethod" - } - }, - "required": [ - "allowInsecureRequests", - "autoLaunch", - "autoRegister", - "buttonText", - "clientId", - "clientSecret", - "defaultStorageQuota", - "enabled", - "endSessionEndpoint", - "issuerUrl", - "mobileOverrideEnabled", - "mobileRedirectUri", - "profileSigningAlgorithm", - "prompt", - "roleClaim", - "scope", - "signingAlgorithm", - "storageLabelClaim", - "storageQuotaClaim", - "timeout", - "tokenEndpointAuthMethod" - ], - "type": "object" - }, - "SystemConfigPasswordLoginDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "SystemConfigReverseGeocodingDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "SystemConfigServerDto": { - "properties": { - "externalDomain": { - "description": "External domain", - "type": "string" - }, - "loginPageMessage": { - "description": "Login page message", - "type": "string" - }, - "publicUsers": { - "description": "Public users", - "type": "boolean" - } - }, - "required": [ - "externalDomain", - "loginPageMessage", - "publicUsers" - ], - "type": "object" - }, - "SystemConfigSmtpDto": { - "properties": { - "enabled": { - "description": "Whether SMTP email notifications are enabled", - "type": "boolean" - }, - "from": { - "description": "Email address to send from", - "type": "string" - }, - "replyTo": { - "description": "Email address for replies", - "type": "string" - }, - "transport": { - "$ref": "#/components/schemas/SystemConfigSmtpTransportDto" - } - }, - "required": [ - "enabled", - "from", - "replyTo", - "transport" - ], - "type": "object" - }, - "SystemConfigSmtpTransportDto": { - "properties": { - "host": { - "description": "SMTP server hostname", - "type": "string" - }, - "ignoreCert": { - "description": "Whether to ignore SSL certificate errors", - "type": "boolean" - }, - "password": { - "description": "SMTP password", - "type": "string" - }, - "port": { - "description": "SMTP server port", - "maximum": 65535, - "minimum": 0, - "type": "integer" - }, - "secure": { - "description": "Whether to use secure connection (TLS/SSL)", - "type": "boolean" - }, - "username": { - "description": "SMTP username", - "type": "string" - } - }, - "required": [ - "host", - "ignoreCert", - "password", - "port", - "secure", - "username" - ], - "type": "object" - }, - "SystemConfigStorageTemplateDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "hashVerificationEnabled": { - "description": "Hash verification enabled", - "type": "boolean" - }, - "template": { - "description": "Template", - "type": "string" - } - }, - "required": [ - "enabled", - "hashVerificationEnabled", - "template" - ], - "type": "object" - }, - "SystemConfigTemplateEmailsDto": { - "properties": { - "albumInviteTemplate": { - "description": "Album invite template", - "type": "string" - }, - "albumUpdateTemplate": { - "description": "Album update template", - "type": "string" - }, - "welcomeTemplate": { - "description": "Welcome template", - "type": "string" - } - }, - "required": [ - "albumInviteTemplate", - "albumUpdateTemplate", - "welcomeTemplate" - ], - "type": "object" - }, "SystemConfigTemplateStorageOptionDto": { "properties": { "dayOptions": { @@ -26823,62 +27320,6 @@ ], "type": "object" }, - "SystemConfigTemplatesDto": { - "properties": { - "email": { - "$ref": "#/components/schemas/SystemConfigTemplateEmailsDto" - } - }, - "required": [ - "email" - ], - "type": "object" - }, - "SystemConfigThemeDto": { - "properties": { - "customCss": { - "description": "Custom CSS for theming", - "type": "string" - } - }, - "required": [ - "customCss" - ], - "type": "object" - }, - "SystemConfigTrashDto": { - "properties": { - "days": { - "description": "Days", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "days", - "enabled" - ], - "type": "object" - }, - "SystemConfigUserDto": { - "properties": { - "deleteDelay": { - "description": "Delete delay", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "deleteDelay" - ], - "type": "object" - }, "TagBulkAssetsDto": { "properties": { "assetIds": { @@ -27780,6 +28221,362 @@ ], "type": "string" }, + "UserConfigClipDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigDto": { + "description": "Configuration properties that are visible to a logged user", + "properties": { + "ffmpeg": { + "$ref": "#/components/schemas/UserConfigFFmpegDto" + }, + "image": { + "$ref": "#/components/schemas/UserConfigImageDto" + }, + "machineLearning": { + "$ref": "#/components/schemas/UserConfigMachineLearningDto" + }, + "map": { + "$ref": "#/components/schemas/UserConfigMapDto" + }, + "oauth": { + "$ref": "#/components/schemas/UserConfigOAuthDto" + }, + "passwordLogin": { + "$ref": "#/components/schemas/UserConfigPasswordLoginDto" + }, + "reverseGeocoding": { + "$ref": "#/components/schemas/UserConfigReverseGeocodingDto" + }, + "server": { + "$ref": "#/components/schemas/UserConfigServerDto" + }, + "theme": { + "$ref": "#/components/schemas/UserConfigThemeDto" + }, + "trash": { + "$ref": "#/components/schemas/UserConfigTrashDto" + }, + "user": { + "$ref": "#/components/schemas/UserConfigUserDto" + } + }, + "required": [ + "ffmpeg", + "image", + "machineLearning", + "map", + "oauth", + "passwordLogin", + "reverseGeocoding", + "server", + "theme", + "trash", + "user" + ], + "type": "object" + }, + "UserConfigDuplicateDetectionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigFFmpegDto": { + "properties": { + "realtime": { + "$ref": "#/components/schemas/UserConfigFFmpegRealtimeDto" + } + }, + "required": [ + "realtime" + ], + "type": "object" + }, + "UserConfigFFmpegRealtimeDto": { + "properties": { + "enabled": { + "description": "Enable real-time HLS transcoding (alpha)", + "type": "boolean" + }, + "resolutions": { + "description": "Resolutions to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/HlsVideoResolution" + }, + "type": "array" + }, + "videoCodecs": { + "description": "Video codecs to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/VideoCodec" + }, + "type": "array" + } + }, + "required": [ + "enabled", + "resolutions", + "videoCodecs" + ], + "type": "object" + }, + "UserConfigFacialRecognitionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "minFaces": { + "description": "Minimum number of faces required for recognition", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "enabled", + "minFaces" + ], + "type": "object" + }, + "UserConfigGeneratedFullsizeImageDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigGeneratedImageDto": { + "properties": { + "size": { + "description": "Size", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "size" + ], + "type": "object" + }, + "UserConfigImageDto": { + "properties": { + "fullsize": { + "$ref": "#/components/schemas/UserConfigGeneratedFullsizeImageDto" + }, + "preview": { + "$ref": "#/components/schemas/UserConfigGeneratedImageDto" + }, + "thumbnail": { + "$ref": "#/components/schemas/UserConfigGeneratedImageDto" + } + }, + "required": [ + "fullsize", + "preview", + "thumbnail" + ], + "type": "object" + }, + "UserConfigMachineLearningDto": { + "properties": { + "clip": { + "$ref": "#/components/schemas/UserConfigClipDto" + }, + "duplicateDetection": { + "$ref": "#/components/schemas/UserConfigDuplicateDetectionDto" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "facialRecognition": { + "$ref": "#/components/schemas/UserConfigFacialRecognitionDto" + }, + "ocr": { + "$ref": "#/components/schemas/UserConfigOcrDto" + } + }, + "required": [ + "clip", + "duplicateDetection", + "enabled", + "facialRecognition", + "ocr" + ], + "type": "object" + }, + "UserConfigMapDto": { + "properties": { + "darkStyle": { + "description": "Dark map style URL", + "format": "uri", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "lightStyle": { + "description": "Light map style URL", + "format": "uri", + "type": "string" + } + }, + "required": [ + "darkStyle", + "enabled", + "lightStyle" + ], + "type": "object" + }, + "UserConfigOAuthDto": { + "properties": { + "autoLaunch": { + "description": "Auto launch", + "type": "boolean" + }, + "buttonText": { + "description": "Button text", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "autoLaunch", + "buttonText", + "enabled" + ], + "type": "object" + }, + "UserConfigOcrDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigPasswordLoginDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigReverseGeocodingDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigServerDto": { + "properties": { + "externalDomain": { + "description": "External domain", + "type": "string" + }, + "loginPageMessage": { + "description": "Login page message", + "type": "string" + }, + "publicUsers": { + "description": "Public users", + "type": "boolean" + } + }, + "required": [ + "externalDomain", + "loginPageMessage", + "publicUsers" + ], + "type": "object" + }, + "UserConfigThemeDto": { + "properties": { + "customCss": { + "description": "Custom CSS for theming", + "type": "string" + } + }, + "required": [ + "customCss" + ], + "type": "object" + }, + "UserConfigTrashDto": { + "properties": { + "days": { + "description": "Days", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "days", + "enabled" + ], + "type": "object" + }, + "UserConfigUserDto": { + "properties": { + "deleteDelay": { + "description": "Delete delay", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "deleteDelay" + ], + "type": "object" + }, "UserLicense": { "properties": { "activatedAt": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 7e0fb963f7..2d20f9c5d2 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -54,6 +54,380 @@ export type ActivityStatisticsResponseDto = { /** Number of likes */ likes: number; }; +export type AdminConfigDatabaseBackupDto = { + /** Cron expression */ + cronExpression: string; + /** Enabled */ + enabled: boolean; + /** Keep last amount */ + keepLastAmount: number; +}; +export type AdminConfigBackupsDto = { + database: AdminConfigDatabaseBackupDto; +}; +export type AdminConfigFFmpegRealtimeDto = { + /** Enable real-time HLS transcoding (alpha) */ + enabled: boolean; + /** Resolutions to use for real-time HLS transcoding */ + resolutions: HlsVideoResolution[]; + /** Video codecs to use for real-time HLS transcoding */ + videoCodecs: VideoCodec[]; +}; +export type AdminConfigFFmpegDto = { + accel: TranscodeHWAccel; + /** Accelerated decode */ + accelDecode: boolean; + /** Accepted audio codecs */ + acceptedAudioCodecs: AudioCodec[]; + /** Accepted containers */ + acceptedContainers: VideoContainer[]; + /** Accepted video codecs */ + acceptedVideoCodecs: VideoCodec[]; + /** B-frames */ + bframes: number; + cqMode: CQMode; + /** CRF */ + crf: number; + /** GOP size */ + gopSize: number; + /** Max bitrate */ + maxBitrate: string; + /** Preferred hardware device */ + preferredHwDevice: string; + /** Preset */ + preset: string; + realtime: AdminConfigFFmpegRealtimeDto; + /** References */ + refs: number; + targetAudioCodec: AudioCodec; + /** Target resolution */ + targetResolution: string; + targetVideoCodec: VideoCodec; + /** Temporal AQ */ + temporalAQ: boolean; + /** Threads */ + threads: number; + tonemap: ToneMapping; + transcode: TranscodePolicy; + /** Two pass */ + twoPass: boolean; +}; +export type AdminConfigGeneratedFullsizeImageDto = { + /** Enabled */ + enabled: boolean; + format: ImageFormat; + /** Progressive */ + progressive?: boolean; + /** Quality */ + quality: number; +}; +export type AdminConfigGeneratedImageDto = { + format: ImageFormat; + /** Progressive */ + progressive?: boolean; + /** Quality */ + quality: number; + /** Size */ + size: number; +}; +export type AdminConfigImageDto = { + colorspace: Colorspace; + /** Extract embedded */ + extractEmbedded: boolean; + fullsize: AdminConfigGeneratedFullsizeImageDto; + preview: AdminConfigGeneratedImageDto; + thumbnail: AdminConfigGeneratedImageDto; +}; +export type AdminConfigIntegrityChecksumJobDto = { + /** Cron expression for when the integrity check should run */ + cronExpression: string; + /** Enabled */ + enabled: boolean; + /** Percentage limit of the integrity checksum job */ + percentageLimit: number; + /** How long the integrity checksum job may run for */ + timeLimit: number; +}; +export type AdminConfigIntegrityJobDto = { + /** Cron expression for when the integrity check should run */ + cronExpression: string; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigIntegrityChecksDto = { + checksumFiles: AdminConfigIntegrityChecksumJobDto; + missingFiles: AdminConfigIntegrityJobDto; + untrackedFiles: AdminConfigIntegrityJobDto; +}; +export type AdminConfigJobSettingsDto = { + /** Concurrency */ + concurrency: number; +}; +export type AdminConfigJobDto = { + backgroundTask: AdminConfigJobSettingsDto; + editor: AdminConfigJobSettingsDto; + faceDetection: AdminConfigJobSettingsDto; + integrityCheck: AdminConfigJobSettingsDto; + library: AdminConfigJobSettingsDto; + metadataExtraction: AdminConfigJobSettingsDto; + migration: AdminConfigJobSettingsDto; + notifications: AdminConfigJobSettingsDto; + ocr: AdminConfigJobSettingsDto; + search: AdminConfigJobSettingsDto; + sidecar: AdminConfigJobSettingsDto; + smartSearch: AdminConfigJobSettingsDto; + thumbnailGeneration: AdminConfigJobSettingsDto; + videoConversion: AdminConfigJobSettingsDto; + workflow: AdminConfigJobSettingsDto; +}; +export type AdminConfigLibraryScanDto = { + /** Cron expression */ + cronExpression: string; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigLibraryWatchDto = { + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigLibraryDto = { + scan: AdminConfigLibraryScanDto; + watch: AdminConfigLibraryWatchDto; +}; +export type AdminConfigLoggingDto = { + /** Enabled */ + enabled: boolean; + level: LogLevel; +}; +export type AdminConfigMachineLearningAvailabilityChecksDto = { + /** Enabled */ + enabled: boolean; + interval: number; + timeout: number; +}; +export type AdminConfigClipDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Name of the model to use */ + modelName: string; +}; +export type AdminConfigDuplicateDetectionDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Maximum distance threshold for duplicate detection */ + maxDistance: number; +}; +export type AdminConfigFacialRecognitionDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Maximum distance threshold for face recognition */ + maxDistance: number; + /** Minimum number of faces required for recognition */ + minFaces: number; + /** Minimum confidence score for face detection */ + minScore: number; + /** Name of the model to use */ + modelName: string; +}; +export type AdminConfigOcrDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Maximum resolution for OCR processing */ + maxResolution: number; + /** Minimum confidence score for text detection */ + minDetectionScore: number; + /** Minimum confidence score for text recognition */ + minRecognitionScore: number; + /** Name of the model to use */ + modelName: string; +}; +export type AdminConfigMachineLearningDto = { + availabilityChecks: AdminConfigMachineLearningAvailabilityChecksDto; + clip: AdminConfigClipDto; + duplicateDetection: AdminConfigDuplicateDetectionDto; + /** Enabled */ + enabled: boolean; + facialRecognition: AdminConfigFacialRecognitionDto; + ocr: AdminConfigOcrDto; + /** ML service URLs */ + urls: string[]; +}; +export type AdminConfigMapDto = { + /** Dark map style URL */ + darkStyle: string; + /** Enabled */ + enabled: boolean; + /** Light map style URL */ + lightStyle: string; +}; +export type AdminConfigFacesDto = { + /** Import */ + "import": boolean; +}; +export type AdminConfigMetadataDto = { + faces: AdminConfigFacesDto; +}; +export type AdminConfigNewVersionCheckDto = { + channel: ReleaseChannel; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigNightlyTasksDto = { + /** Cluster new faces */ + clusterNewFaces: boolean; + /** Database cleanup */ + databaseCleanup: boolean; + /** Generate memories */ + generateMemories: boolean; + /** Missing thumbnails */ + missingThumbnails: boolean; + /** Start time (HH:MM) */ + startTime: string; + /** Sync quota usage */ + syncQuotaUsage: boolean; +}; +export type AdminConfigSmtpTransportDto = { + /** SMTP server hostname */ + host: string; + /** Whether to ignore SSL certificate errors */ + ignoreCert: boolean; + /** SMTP password */ + password: string; + /** SMTP server port */ + port: number; + /** Whether to use secure connection (TLS/SSL) */ + secure: boolean; + /** SMTP username */ + username: string; +}; +export type AdminConfigSmtpDto = { + /** Whether SMTP email notifications are enabled */ + enabled: boolean; + /** Email address to send from */ + "from": string; + /** Email address for replies */ + replyTo: string; + transport: AdminConfigSmtpTransportDto; +}; +export type AdminConfigNotificationsDto = { + smtp: AdminConfigSmtpDto; +}; +export type AdminConfigOAuthDto = { + /** Allow insecure requests */ + allowInsecureRequests: boolean; + /** Auto launch */ + autoLaunch: boolean; + /** Auto register */ + autoRegister: boolean; + /** Button text */ + buttonText: string; + /** Client ID */ + clientId: string; + /** Client secret */ + clientSecret: string; + /** Default storage quota */ + defaultStorageQuota: number | null; + /** Enabled */ + enabled: boolean; + /** End session endpoint */ + endSessionEndpoint: string; + /** Issuer URL */ + issuerUrl: string; + /** Mobile override enabled */ + mobileOverrideEnabled: boolean; + /** Mobile redirect URI (set to empty string to disable) */ + mobileRedirectUri: string; + /** Profile signing algorithm */ + profileSigningAlgorithm: string; + /** OAuth prompt parameter (e.g. select_account, login, consent) */ + prompt: string; + /** Role claim */ + roleClaim: string; + /** Scope */ + scope: string; + /** Signing algorithm */ + signingAlgorithm: string; + /** Storage label claim */ + storageLabelClaim: string; + /** Storage quota claim */ + storageQuotaClaim: string; + /** Timeout */ + timeout: number; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; +}; +export type AdminConfigPasswordLoginDto = { + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigReverseGeocodingDto = { + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigServerDto = { + /** External domain */ + externalDomain: string; + /** Login page message */ + loginPageMessage: string; + /** Public users */ + publicUsers: boolean; +}; +export type AdminConfigStorageTemplateDto = { + /** Enabled */ + enabled: boolean; + /** Hash verification enabled */ + hashVerificationEnabled: boolean; + /** Template */ + template: string; +}; +export type AdminConfigTemplateEmailsDto = { + /** Album invite template */ + albumInviteTemplate: string; + /** Album update template */ + albumUpdateTemplate: string; + /** Welcome template */ + welcomeTemplate: string; +}; +export type AdminConfigTemplatesDto = { + email: AdminConfigTemplateEmailsDto; +}; +export type AdminConfigThemeDto = { + /** Custom CSS for theming */ + customCss: string; +}; +export type AdminConfigTrashDto = { + /** Days */ + days: number; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigUserDto = { + /** Delete delay */ + deleteDelay: number; +}; +export type AdminConfigDto = { + backup: AdminConfigBackupsDto; + ffmpeg: AdminConfigFFmpegDto; + image: AdminConfigImageDto; + integrityChecks: AdminConfigIntegrityChecksDto; + job: AdminConfigJobDto; + library: AdminConfigLibraryDto; + logging: AdminConfigLoggingDto; + machineLearning: AdminConfigMachineLearningDto; + map: AdminConfigMapDto; + metadata: AdminConfigMetadataDto; + newVersionCheck: AdminConfigNewVersionCheckDto; + nightlyTasks: AdminConfigNightlyTasksDto; + notifications: AdminConfigNotificationsDto; + oauth: AdminConfigOAuthDto; + passwordLogin: AdminConfigPasswordLoginDto; + reverseGeocoding: AdminConfigReverseGeocodingDto; + server: AdminConfigServerDto; + storageTemplate: AdminConfigStorageTemplateDto; + templates: AdminConfigTemplatesDto; + theme: AdminConfigThemeDto; + trash: AdminConfigTrashDto; + user: AdminConfigUserDto; +}; export type DatabaseBackupDeleteDto = { /** Backup filenames to delete */ backups: string[]; @@ -165,29 +539,6 @@ export type TemplateResponseDto = { /** Template name */ name: string; }; -export type SystemConfigSmtpTransportDto = { - /** SMTP server hostname */ - host: string; - /** Whether to ignore SSL certificate errors */ - ignoreCert: boolean; - /** SMTP password */ - password: string; - /** SMTP server port */ - port: number; - /** Whether to use secure connection (TLS/SSL) */ - secure: boolean; - /** SMTP username */ - username: string; -}; -export type SystemConfigSmtpDto = { - /** Whether SMTP email notifications are enabled */ - enabled: boolean; - /** Email address to send from */ - "from": string; - /** Email address for replies */ - replyTo: string; - transport: SystemConfigSmtpTransportDto; -}; export type TestEmailResponseDto = { /** Email message ID */ messageId: string; @@ -1123,6 +1474,115 @@ export type ValidateAccessTokenResponseDto = { /** Authentication status */ authStatus: boolean; }; +export type UserConfigFFmpegRealtimeDto = { + /** Enable real-time HLS transcoding (alpha) */ + enabled: boolean; + /** Resolutions to use for real-time HLS transcoding */ + resolutions: HlsVideoResolution[]; + /** Video codecs to use for real-time HLS transcoding */ + videoCodecs: VideoCodec[]; +}; +export type UserConfigFFmpegDto = { + realtime: UserConfigFFmpegRealtimeDto; +}; +export type UserConfigGeneratedFullsizeImageDto = { + /** Enabled */ + enabled: boolean; +}; +export type UserConfigGeneratedImageDto = { + /** Size */ + size: number; +}; +export type UserConfigImageDto = { + fullsize: UserConfigGeneratedFullsizeImageDto; + preview: UserConfigGeneratedImageDto; + thumbnail: UserConfigGeneratedImageDto; +}; +export type UserConfigClipDto = { + /** Whether the task is enabled */ + enabled: boolean; +}; +export type UserConfigDuplicateDetectionDto = { + /** Whether the task is enabled */ + enabled: boolean; +}; +export type UserConfigFacialRecognitionDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Minimum number of faces required for recognition */ + minFaces: number; +}; +export type UserConfigOcrDto = { + /** Whether the task is enabled */ + enabled: boolean; +}; +export type UserConfigMachineLearningDto = { + clip: UserConfigClipDto; + duplicateDetection: UserConfigDuplicateDetectionDto; + /** Enabled */ + enabled: boolean; + facialRecognition: UserConfigFacialRecognitionDto; + ocr: UserConfigOcrDto; +}; +export type UserConfigMapDto = { + /** Dark map style URL */ + darkStyle: string; + /** Enabled */ + enabled: boolean; + /** Light map style URL */ + lightStyle: string; +}; +export type UserConfigOAuthDto = { + /** Auto launch */ + autoLaunch: boolean; + /** Button text */ + buttonText: string; + /** Enabled */ + enabled: boolean; +}; +export type UserConfigPasswordLoginDto = { + /** Enabled */ + enabled: boolean; +}; +export type UserConfigReverseGeocodingDto = { + /** Enabled */ + enabled: boolean; +}; +export type UserConfigServerDto = { + /** External domain */ + externalDomain: string; + /** Login page message */ + loginPageMessage: string; + /** Public users */ + publicUsers: boolean; +}; +export type UserConfigThemeDto = { + /** Custom CSS for theming */ + customCss: string; +}; +export type UserConfigTrashDto = { + /** Days */ + days: number; + /** Enabled */ + enabled: boolean; +}; +export type UserConfigUserDto = { + /** Delete delay */ + deleteDelay: number; +}; +export type UserConfigDto = { + ffmpeg: UserConfigFFmpegDto; + image: UserConfigImageDto; + machineLearning: UserConfigMachineLearningDto; + map: UserConfigMapDto; + oauth: UserConfigOAuthDto; + passwordLogin: UserConfigPasswordLoginDto; + reverseGeocoding: UserConfigReverseGeocodingDto; + server: UserConfigServerDto; + theme: UserConfigThemeDto; + trash: UserConfigTrashDto; + user: UserConfigUserDto; +}; export type DownloadArchiveDto = { /** Asset IDs */ assetIds: string[]; @@ -1592,6 +2052,32 @@ export type PluginTemplateResponseDto = { /** Ui hints, for example "smart-album" */ uiHints: string[]; }; +export type PublicConfigOAuthDto = { + /** Auto launch */ + autoLaunch: boolean; + /** Button text */ + buttonText: string; + /** Enabled */ + enabled: boolean; +}; +export type PublicConfigPasswordLoginDto = { + /** Enabled */ + enabled: boolean; +}; +export type PublicConfigServerDto = { + /** Login page message */ + loginPageMessage: string; +}; +export type PublicConfigThemeDto = { + /** Custom CSS for theming */ + customCss: string; +}; +export type PublicConfigDto = { + oauth: PublicConfigOAuthDto; + passwordLogin: PublicConfigPasswordLoginDto; + server: PublicConfigServerDto; + theme: PublicConfigThemeDto; +}; export type QueueResponseDto = { /** Whether the queue is paused */ isPaused: boolean; @@ -2298,357 +2784,6 @@ export type SyncStreamDto = { /** Sync request types */ types: SyncRequestType[]; }; -export type DatabaseBackupConfig = { - /** Cron expression */ - cronExpression: string; - /** Enabled */ - enabled: boolean; - /** Keep last amount */ - keepLastAmount: number; -}; -export type SystemConfigBackupsDto = { - database: DatabaseBackupConfig; -}; -export type SystemConfigFFmpegRealtimeDto = { - /** Enable real-time HLS transcoding (alpha) */ - enabled: boolean; - /** Resolutions to use for real-time HLS transcoding */ - resolutions: HlsVideoResolution[]; - /** Video codecs to use for real-time HLS transcoding */ - videoCodecs: VideoCodec[]; -}; -export type SystemConfigFFmpegDto = { - accel: TranscodeHWAccel; - /** Accelerated decode */ - accelDecode: boolean; - /** Accepted audio codecs */ - acceptedAudioCodecs: AudioCodec[]; - /** Accepted containers */ - acceptedContainers: VideoContainer[]; - /** Accepted video codecs */ - acceptedVideoCodecs: VideoCodec[]; - /** B-frames */ - bframes: number; - cqMode: CQMode; - /** CRF */ - crf: number; - /** GOP size */ - gopSize: number; - /** Max bitrate */ - maxBitrate: string; - /** Preferred hardware device */ - preferredHwDevice: string; - /** Preset */ - preset: string; - realtime: SystemConfigFFmpegRealtimeDto; - /** References */ - refs: number; - targetAudioCodec: AudioCodec; - /** Target resolution */ - targetResolution: string; - targetVideoCodec: VideoCodec; - /** Temporal AQ */ - temporalAQ: boolean; - /** Threads */ - threads: number; - tonemap: ToneMapping; - transcode: TranscodePolicy; - /** Two pass */ - twoPass: boolean; -}; -export type SystemConfigGeneratedFullsizeImageDto = { - /** Enabled */ - enabled: boolean; - format: ImageFormat; - /** Progressive */ - progressive?: boolean; - /** Quality */ - quality: number; -}; -export type SystemConfigGeneratedImageDto = { - format: ImageFormat; - /** Progressive */ - progressive?: boolean; - /** Quality */ - quality: number; - /** Size */ - size: number; -}; -export type SystemConfigImageDto = { - colorspace: Colorspace; - /** Extract embedded */ - extractEmbedded: boolean; - fullsize: SystemConfigGeneratedFullsizeImageDto; - preview: SystemConfigGeneratedImageDto; - thumbnail: SystemConfigGeneratedImageDto; -}; -export type SystemConfigIntegrityChecksumJob = { - /** Cron expression for when the integrity check should run */ - cronExpression: string; - /** Enabled */ - enabled: boolean; - /** Percentage limit of the integrity checksum job */ - percentageLimit: number; - /** How long the integrity checksum job may run for */ - timeLimit: number; -}; -export type SystemConfigIntegrityJob = { - /** Cron expression for when the integrity check should run */ - cronExpression: string; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigIntegrityChecks = { - checksumFiles: SystemConfigIntegrityChecksumJob; - missingFiles: SystemConfigIntegrityJob; - untrackedFiles: SystemConfigIntegrityJob; -}; -export type JobSettingsDto = { - /** Concurrency */ - concurrency: number; -}; -export type SystemConfigJobDto = { - backgroundTask: JobSettingsDto; - editor: JobSettingsDto; - faceDetection: JobSettingsDto; - integrityCheck: JobSettingsDto; - library: JobSettingsDto; - metadataExtraction: JobSettingsDto; - migration: JobSettingsDto; - notifications: JobSettingsDto; - ocr: JobSettingsDto; - search: JobSettingsDto; - sidecar: JobSettingsDto; - smartSearch: JobSettingsDto; - thumbnailGeneration: JobSettingsDto; - videoConversion: JobSettingsDto; - workflow: JobSettingsDto; -}; -export type SystemConfigLibraryScanDto = { - /** Cron expression */ - cronExpression: string; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigLibraryWatchDto = { - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigLibraryDto = { - scan: SystemConfigLibraryScanDto; - watch: SystemConfigLibraryWatchDto; -}; -export type SystemConfigLoggingDto = { - /** Enabled */ - enabled: boolean; - level: LogLevel; -}; -export type MachineLearningAvailabilityChecksDto = { - /** Enabled */ - enabled: boolean; - interval: number; - timeout: number; -}; -export type ClipConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Name of the model to use */ - modelName: string; -}; -export type DuplicateDetectionConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Maximum distance threshold for duplicate detection */ - maxDistance: number; -}; -export type FacialRecognitionConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Maximum distance threshold for face recognition */ - maxDistance: number; - /** Minimum number of faces required for recognition */ - minFaces: number; - /** Minimum confidence score for face detection */ - minScore: number; - /** Name of the model to use */ - modelName: string; -}; -export type OcrConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Maximum resolution for OCR processing */ - maxResolution: number; - /** Minimum confidence score for text detection */ - minDetectionScore: number; - /** Minimum confidence score for text recognition */ - minRecognitionScore: number; - /** Name of the model to use */ - modelName: string; -}; -export type SystemConfigMachineLearningDto = { - availabilityChecks: MachineLearningAvailabilityChecksDto; - clip: ClipConfig; - duplicateDetection: DuplicateDetectionConfig; - /** Enabled */ - enabled: boolean; - facialRecognition: FacialRecognitionConfig; - ocr: OcrConfig; - /** ML service URLs */ - urls: string[]; -}; -export type SystemConfigMapDto = { - /** Dark map style URL */ - darkStyle: string; - /** Enabled */ - enabled: boolean; - /** Light map style URL */ - lightStyle: string; -}; -export type SystemConfigFacesDto = { - /** Import */ - "import": boolean; -}; -export type SystemConfigMetadataDto = { - faces: SystemConfigFacesDto; -}; -export type SystemConfigNewVersionCheckDto = { - channel: ReleaseChannel; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigNightlyTasksDto = { - /** Cluster new faces */ - clusterNewFaces: boolean; - /** Database cleanup */ - databaseCleanup: boolean; - /** Generate memories */ - generateMemories: boolean; - /** Missing thumbnails */ - missingThumbnails: boolean; - /** Start time (HH:MM) */ - startTime: string; - /** Sync quota usage */ - syncQuotaUsage: boolean; -}; -export type SystemConfigNotificationsDto = { - smtp: SystemConfigSmtpDto; -}; -export type SystemConfigOAuthDto = { - /** Allow insecure requests */ - allowInsecureRequests: boolean; - /** Auto launch */ - autoLaunch: boolean; - /** Auto register */ - autoRegister: boolean; - /** Button text */ - buttonText: string; - /** Client ID */ - clientId: string; - /** Client secret */ - clientSecret: string; - /** Default storage quota */ - defaultStorageQuota: number | null; - /** Enabled */ - enabled: boolean; - /** End session endpoint */ - endSessionEndpoint: string; - /** Issuer URL */ - issuerUrl: string; - /** Mobile override enabled */ - mobileOverrideEnabled: boolean; - /** Mobile redirect URI (set to empty string to disable) */ - mobileRedirectUri: string; - /** Profile signing algorithm */ - profileSigningAlgorithm: string; - /** OAuth prompt parameter (e.g. select_account, login, consent) */ - prompt: string; - /** Role claim */ - roleClaim: string; - /** Scope */ - scope: string; - /** Signing algorithm */ - signingAlgorithm: string; - /** Storage label claim */ - storageLabelClaim: string; - /** Storage quota claim */ - storageQuotaClaim: string; - /** Timeout */ - timeout: number; - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; -}; -export type SystemConfigPasswordLoginDto = { - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigReverseGeocodingDto = { - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigServerDto = { - /** External domain */ - externalDomain: string; - /** Login page message */ - loginPageMessage: string; - /** Public users */ - publicUsers: boolean; -}; -export type SystemConfigStorageTemplateDto = { - /** Enabled */ - enabled: boolean; - /** Hash verification enabled */ - hashVerificationEnabled: boolean; - /** Template */ - template: string; -}; -export type SystemConfigTemplateEmailsDto = { - /** Album invite template */ - albumInviteTemplate: string; - /** Album update template */ - albumUpdateTemplate: string; - /** Welcome template */ - welcomeTemplate: string; -}; -export type SystemConfigTemplatesDto = { - email: SystemConfigTemplateEmailsDto; -}; -export type SystemConfigThemeDto = { - /** Custom CSS for theming */ - customCss: string; -}; -export type SystemConfigTrashDto = { - /** Days */ - days: number; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigUserDto = { - /** Delete delay */ - deleteDelay: number; -}; -export type SystemConfigDto = { - backup: SystemConfigBackupsDto; - ffmpeg: SystemConfigFFmpegDto; - image: SystemConfigImageDto; - integrityChecks: SystemConfigIntegrityChecks; - job: SystemConfigJobDto; - library: SystemConfigLibraryDto; - logging: SystemConfigLoggingDto; - machineLearning: SystemConfigMachineLearningDto; - map: SystemConfigMapDto; - metadata: SystemConfigMetadataDto; - newVersionCheck: SystemConfigNewVersionCheckDto; - nightlyTasks: SystemConfigNightlyTasksDto; - notifications: SystemConfigNotificationsDto; - oauth: SystemConfigOAuthDto; - passwordLogin: SystemConfigPasswordLoginDto; - reverseGeocoding: SystemConfigReverseGeocodingDto; - server: SystemConfigServerDto; - storageTemplate: SystemConfigStorageTemplateDto; - templates: SystemConfigTemplatesDto; - theme: SystemConfigThemeDto; - trash: SystemConfigTrashDto; - user: SystemConfigUserDto; -}; export type SystemConfigTemplateStorageOptionDto = { /** Available day format options for storage template */ dayOptions: string[]; @@ -3452,6 +3587,43 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +/** + * Get the admin configuration + */ +export function getAdminConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminConfigDto; + }>("/admin/config", { + ...opts + })); +} +/** + * Update the system configuration + */ +export function updateAdminConfig({ adminConfigDto }: { + adminConfigDto: AdminConfigDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminConfigDto; + }>("/admin/config", oazapfts.json({ + ...opts, + method: "PUT", + body: adminConfigDto + }))); +} +/** + * Get the system configuration defaults + */ +export function getAdminConfigDefaults(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminConfigDto; + }>("/admin/config/defaults", { + ...opts + })); +} /** * Delete database backup */ @@ -3659,8 +3831,8 @@ export function getNotificationTemplateAdmin({ name, templateDto }: { /** * Send test email */ -export function sendTestEmailAdmin({ systemConfigSmtpDto }: { - systemConfigSmtpDto: SystemConfigSmtpDto; +export function sendTestEmailAdmin({ adminConfigSmtpDto }: { + adminConfigSmtpDto: AdminConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3668,7 +3840,7 @@ export function sendTestEmailAdmin({ systemConfigSmtpDto }: { }>("/admin/notifications/test-email", oazapfts.json({ ...opts, method: "POST", - body: systemConfigSmtpDto + body: adminConfigSmtpDto }))); } /** @@ -4695,6 +4867,28 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +/** + * Get the configuration with user visibility + */ +export function getUserConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserConfigDto; + }>("/config", { + ...opts + })); +} +/** + * Get the default configuration with user visibility + */ +export function getUserConfigDefaults(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserConfigDto; + }>("/config/defaults", { + ...opts + })); +} /** * Download asset archive */ @@ -5640,6 +5834,28 @@ export function getPlugin({ id }: { ...opts })); } +/** + * Get the public configuration + */ +export function getPublicConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PublicConfigDto; + }>("/public/config", { + ...opts + })); +} +/** + * Get the public configuration defaults + */ +export function getPublicConfigDefaults(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PublicConfigDto; + }>("/public/config/defaults", { + ...opts + })); +} /** * List all queues */ @@ -6450,7 +6666,7 @@ export function getSyncStream({ syncStreamDto }: { export function getConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SystemConfigDto; + data: AdminConfigDto; }>("/system-config", { ...opts })); @@ -6458,16 +6674,16 @@ export function getConfig(opts?: Oazapfts.RequestOpts) { /** * Update system configuration */ -export function updateConfig({ systemConfigDto }: { - systemConfigDto: SystemConfigDto; +export function updateConfig({ adminConfigDto }: { + adminConfigDto: AdminConfigDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SystemConfigDto; + data: AdminConfigDto; }>("/system-config", oazapfts.json({ ...opts, method: "PUT", - body: systemConfigDto + body: adminConfigDto }))); } /** @@ -6476,7 +6692,7 @@ export function updateConfig({ systemConfigDto }: { export function getConfigDefaults(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SystemConfigDto; + data: AdminConfigDto; }>("/system-config/defaults", { ...opts })); @@ -7164,6 +7380,80 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum TranscodeHWAccel { + Nvenc = "nvenc", + Qsv = "qsv", + Vaapi = "vaapi", + Rkmpp = "rkmpp", + Disabled = "disabled" +} +export enum AudioCodec { + Mp3 = "mp3", + Aac = "aac", + Opus = "opus", + PcmS16Le = "pcm_s16le" +} +export enum VideoContainer { + Mov = "mov", + Mp4 = "mp4", + Ogg = "ogg", + Webm = "webm" +} +export enum VideoCodec { + H264 = "h264", + Hevc = "hevc", + Vp9 = "vp9", + Av1 = "av1" +} +export enum CQMode { + Auto = "auto", + Cqp = "cqp", + Icq = "icq" +} +export enum HlsVideoResolution { + $480 = 480, + $720 = 720, + $1080 = 1080, + $1440 = 1440, + $2160 = 2160 +} +export enum ToneMapping { + Hable = "hable", + Mobius = "mobius", + Reinhard = "reinhard", + Disabled = "disabled" +} +export enum TranscodePolicy { + All = "all", + Optimal = "optimal", + Bitrate = "bitrate", + Required = "required", + Disabled = "disabled" +} +export enum Colorspace { + Srgb = "srgb", + P3 = "p3" +} +export enum ImageFormat { + Jpeg = "jpeg", + Webp = "webp" +} +export enum LogLevel { + Verbose = "verbose", + Debug = "debug", + Log = "log", + Warn = "warn", + Error = "error", + Fatal = "fatal" +} +export enum ReleaseChannel { + Stable = "stable", + ReleaseCandidate = "releaseCandidate" +} +export enum OAuthTokenEndpointAuthMethod { + ClientSecretPost = "client_secret_post", + ClientSecretBasic = "client_secret_basic" +} export enum IntegrityReport { UntrackedFile = "untracked_file", MissingFile = "missing_file", @@ -7272,6 +7562,9 @@ export enum Permission { BackupDownload = "backup.download", BackupUpload = "backup.upload", BackupDelete = "backup.delete", + AdminConfigRead = "adminConfig.read", + AdminConfigUpdate = "adminConfig.update", + UserConfigRead = "userConfig.read", DuplicateRead = "duplicate.read", DuplicateDelete = "duplicate.delete", FaceCreate = "face.create", @@ -7679,80 +7972,6 @@ export enum SyncRequestType { AssetFacesV2 = "AssetFacesV2", UserMetadataV1 = "UserMetadataV1" } -export enum TranscodeHWAccel { - Nvenc = "nvenc", - Qsv = "qsv", - Vaapi = "vaapi", - Rkmpp = "rkmpp", - Disabled = "disabled" -} -export enum AudioCodec { - Mp3 = "mp3", - Aac = "aac", - Opus = "opus", - PcmS16Le = "pcm_s16le" -} -export enum VideoContainer { - Mov = "mov", - Mp4 = "mp4", - Ogg = "ogg", - Webm = "webm" -} -export enum VideoCodec { - H264 = "h264", - Hevc = "hevc", - Vp9 = "vp9", - Av1 = "av1" -} -export enum CQMode { - Auto = "auto", - Cqp = "cqp", - Icq = "icq" -} -export enum HlsVideoResolution { - $480 = 480, - $720 = 720, - $1080 = 1080, - $1440 = 1440, - $2160 = 2160 -} -export enum ToneMapping { - Hable = "hable", - Mobius = "mobius", - Reinhard = "reinhard", - Disabled = "disabled" -} -export enum TranscodePolicy { - All = "all", - Optimal = "optimal", - Bitrate = "bitrate", - Required = "required", - Disabled = "disabled" -} -export enum Colorspace { - Srgb = "srgb", - P3 = "p3" -} -export enum ImageFormat { - Jpeg = "jpeg", - Webp = "webp" -} -export enum LogLevel { - Verbose = "verbose", - Debug = "debug", - Log = "log", - Warn = "warn", - Error = "error", - Fatal = "fatal" -} -export enum ReleaseChannel { - Stable = "stable", - ReleaseCandidate = "releaseCandidate" -} -export enum OAuthTokenEndpointAuthMethod { - ClientSecretPost = "client_secret_post", - ClientSecretBasic = "client_secret_basic" -} export enum AssetOrderBy { TakenAt = "takenAt", CreatedAt = "createdAt" diff --git a/server/src/config.ts b/server/src/config.ts deleted file mode 100644 index 55304080a3..0000000000 --- a/server/src/config.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { CronExpression } from '@nestjs/schedule'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; -import { - AudioCodec, - Colorspace, - CQMode, - HlsVideoResolution, - ImageFormat, - LogLevel, - OAuthTokenEndpointAuthMethod, - QueueName, - ToneMapping, - TranscodeHardwareAcceleration, - TranscodePolicy, - VideoCodec, - VideoContainer, -} from 'src/enum'; -import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types'; - -export type SystemConfig = { - backup: { - database: { - enabled: boolean; - cronExpression: string; - keepLastAmount: number; - }; - }; - ffmpeg: { - crf: number; - threads: number; - preset: string; - targetVideoCodec: VideoCodec; - acceptedVideoCodecs: VideoCodec[]; - targetAudioCodec: AudioCodec; - acceptedAudioCodecs: AudioCodec[]; - acceptedContainers: VideoContainer[]; - targetResolution: string; - maxBitrate: string; - bframes: number; - refs: number; - gopSize: number; - temporalAQ: boolean; - cqMode: CQMode; - twoPass: boolean; - preferredHwDevice: string; - transcode: TranscodePolicy; - accel: TranscodeHardwareAcceleration; - accelDecode: boolean; - tonemap: ToneMapping; - realtime: { - enabled: boolean; - videoCodecs: VideoCodec[]; - resolutions: HlsVideoResolution[]; - }; - }; - integrityChecks: { - missingFiles: { - enabled: boolean; - cronExpression: string; - }; - untrackedFiles: { - enabled: boolean; - cronExpression: string; - }; - checksumFiles: { - enabled: boolean; - cronExpression: string; - timeLimit: number; - percentageLimit: number; - }; - }; - job: Record; - logging: { - enabled: boolean; - level: LogLevel; - }; - machineLearning: { - enabled: boolean; - urls: string[]; - availabilityChecks: { - enabled: boolean; - timeout: number; - interval: number; - }; - clip: { - enabled: boolean; - modelName: string; - }; - duplicateDetection: { - enabled: boolean; - maxDistance: number; - }; - facialRecognition: { - enabled: boolean; - modelName: string; - minScore: number; - minFaces: number; - maxDistance: number; - }; - ocr: { - enabled: boolean; - modelName: string; - minDetectionScore: number; - minRecognitionScore: number; - maxResolution: number; - }; - }; - map: { - enabled: boolean; - lightStyle: string; - darkStyle: string; - }; - reverseGeocoding: { - enabled: boolean; - }; - metadata: { - faces: { - import: boolean; - }; - }; - oauth: { - autoLaunch: boolean; - autoRegister: boolean; - buttonText: string; - clientId: string; - clientSecret: string; - defaultStorageQuota: number | null; - enabled: boolean; - issuerUrl: string; - endSessionEndpoint: string; - mobileOverrideEnabled: boolean; - mobileRedirectUri: string; - prompt: string; - scope: string; - signingAlgorithm: string; - profileSigningAlgorithm: string; - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; - timeout: number; - allowInsecureRequests: boolean; - storageLabelClaim: string; - storageQuotaClaim: string; - roleClaim: string; - }; - passwordLogin: { - enabled: boolean; - }; - storageTemplate: { - enabled: boolean; - hashVerificationEnabled: boolean; - template: string; - }; - image: { - thumbnail: ImageOptions; - preview: ImageOptions; - colorspace: Colorspace; - extractEmbedded: boolean; - fullsize: FullsizeImageOptions; - }; - newVersionCheck: { - enabled: boolean; - channel: ReleaseChannel; - }; - nightlyTasks: { - startTime: string; - databaseCleanup: boolean; - missingThumbnails: boolean; - clusterNewFaces: boolean; - generateMemories: boolean; - syncQuotaUsage: boolean; - }; - trash: { - enabled: boolean; - days: number; - }; - theme: { - customCss: string; - }; - library: { - scan: { - enabled: boolean; - cronExpression: string; - }; - watch: { - enabled: boolean; - }; - }; - notifications: { - smtp: { - enabled: boolean; - from: string; - replyTo: string; - transport: { - ignoreCert: boolean; - host: string; - port: number; - secure: boolean; - username: string; - password: string; - }; - }; - }; - templates: { - email: { - welcomeTemplate: string; - albumInviteTemplate: string; - albumUpdateTemplate: string; - }; - }; - server: { - externalDomain: string; - loginPageMessage: string; - publicUsers: boolean; - }; - user: { - deleteDelay: number; - }; -}; - -export type MachineLearningConfig = SystemConfig['machineLearning']; - -export const defaults = Object.freeze({ - backup: { - database: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_2AM, - keepLastAmount: 14, - }, - }, - ffmpeg: { - crf: 23, - threads: 0, - preset: 'ultrafast', - targetVideoCodec: VideoCodec.H264, - acceptedVideoCodecs: [VideoCodec.H264], - targetAudioCodec: AudioCodec.Aac, - acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], - acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], - targetResolution: '720', - maxBitrate: '0', - bframes: -1, - refs: 0, - gopSize: 0, - temporalAQ: false, - cqMode: CQMode.Auto, - twoPass: false, - preferredHwDevice: 'auto', - transcode: TranscodePolicy.Required, - tonemap: ToneMapping.Hable, - accel: TranscodeHardwareAcceleration.Disabled, - accelDecode: true, - realtime: { - enabled: false, - videoCodecs: [VideoCodec.H264, VideoCodec.Hevc], - resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080], - }, - }, - integrityChecks: { - missingFiles: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_3AM, - }, - untrackedFiles: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_3AM, - }, - checksumFiles: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_3AM, - timeLimit: 60 * 60 * 1000, // 1 hour - percentageLimit: 1, // 100% of assets - }, - }, - job: { - [QueueName.BackgroundTask]: { concurrency: 5 }, - [QueueName.SmartSearch]: { concurrency: 2 }, - [QueueName.MetadataExtraction]: { concurrency: 5 }, - [QueueName.FaceDetection]: { concurrency: 2 }, - [QueueName.Search]: { concurrency: 5 }, - [QueueName.Sidecar]: { concurrency: 5 }, - [QueueName.Library]: { concurrency: 5 }, - [QueueName.Migration]: { concurrency: 5 }, - [QueueName.ThumbnailGeneration]: { concurrency: 3 }, - [QueueName.VideoConversion]: { concurrency: 1 }, - [QueueName.Notification]: { concurrency: 5 }, - [QueueName.Ocr]: { concurrency: 1 }, - [QueueName.Workflow]: { concurrency: 5 }, - [QueueName.IntegrityCheck]: { concurrency: 1 }, - [QueueName.Editor]: { concurrency: 2 }, - }, - logging: { - enabled: true, - level: LogLevel.Log, - }, - machineLearning: { - enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', - urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], - availabilityChecks: { - enabled: true, - timeout: 2000, - interval: 30_000, - }, - clip: { - enabled: true, - modelName: 'ViT-B-32__openai', - }, - duplicateDetection: { - enabled: true, - maxDistance: 0.01, - }, - facialRecognition: { - enabled: true, - modelName: 'buffalo_l', - minScore: 0.7, - maxDistance: 0.5, - minFaces: 3, - }, - ocr: { - enabled: true, - modelName: 'PP-OCRv5_mobile', - minDetectionScore: 0.5, - minRecognitionScore: 0.8, - maxResolution: 736, - }, - }, - map: { - enabled: true, - lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', - darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', - }, - reverseGeocoding: { - enabled: true, - }, - metadata: { - faces: { - import: false, - }, - }, - oauth: { - autoLaunch: false, - autoRegister: true, - buttonText: 'Login with OAuth', - clientId: '', - clientSecret: '', - defaultStorageQuota: null, - enabled: false, - issuerUrl: '', - endSessionEndpoint: '', - mobileOverrideEnabled: false, - mobileRedirectUri: '', - prompt: '', - scope: 'openid email profile', - signingAlgorithm: 'RS256', - profileSigningAlgorithm: 'none', - storageLabelClaim: 'preferred_username', - storageQuotaClaim: 'immich_quota', - roleClaim: 'immich_role', - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost, - timeout: 30_000, - allowInsecureRequests: false, - }, - passwordLogin: { - enabled: true, - }, - storageTemplate: { - enabled: false, - hashVerificationEnabled: true, - template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', - }, - image: { - thumbnail: { - format: ImageFormat.Webp, - size: 250, - quality: 80, - progressive: false, - }, - preview: { - format: ImageFormat.Jpeg, - size: 1440, - quality: 80, - progressive: false, - }, - colorspace: Colorspace.P3, - extractEmbedded: false, - fullsize: { - enabled: false, - format: ImageFormat.Jpeg, - quality: 80, - progressive: false, - }, - }, - newVersionCheck: { - enabled: true, - channel: ReleaseChannel.Stable, - }, - nightlyTasks: { - startTime: '00:00', - databaseCleanup: true, - generateMemories: true, - syncQuotaUsage: true, - missingThumbnails: true, - clusterNewFaces: true, - }, - trash: { - enabled: true, - days: 30, - }, - theme: { - customCss: '', - }, - library: { - scan: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT, - }, - watch: { - enabled: false, - }, - }, - server: { - externalDomain: '', - loginPageMessage: '', - publicUsers: true, - }, - notifications: { - smtp: { - enabled: false, - from: '', - replyTo: '', - transport: { - ignoreCert: false, - host: '', - port: 587, - secure: false, - username: '', - password: '', - }, - }, - }, - templates: { - email: { - welcomeTemplate: '', - albumInviteTemplate: '', - albumUpdateTemplate: '', - }, - }, - user: { - deleteDelay: 7, - }, -}); diff --git a/server/src/constants.ts b/server/src/constants.ts index 815a400ed8..a8b2216b32 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -150,6 +150,9 @@ export const endpointTags: Record = { [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', + [ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.', + [ApiTag.ConfigAdmin]: 'Endpoints to view and modify the full system configuration.', + [ApiTag.ConfigPublic]: 'The system configuration properties that are visible to everyone.', [ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.', [ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.', [ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.', diff --git a/server/src/controllers/config-admin.controller.ts b/server/src/controllers/config-admin.controller.ts new file mode 100644 index 0000000000..b135b0e62e --- /dev/null +++ b/server/src/controllers/config-admin.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AdminConfigDto } from 'src/dtos/config.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemConfigService } from 'src/services/system-config.service'; + +@ApiTags(ApiTag.ConfigAdmin) +@Controller('admin/config') +export class ConfigAdminController { + constructor(private service: SystemConfigService) {} + + @Get() + @Authenticated({ permission: Permission.AdminConfigRead, admin: true }) + @Endpoint({ + summary: 'Get the admin configuration', + description: 'Retrieve admin configuration.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAdminConfig(): Promise { + return this.service.getAdminConfig(); + } + + @Get('defaults') + @Authenticated({ permission: Permission.AdminConfigRead, admin: true }) + @Endpoint({ + summary: 'Get the system configuration defaults', + description: 'Retrieve the default value of every system configuration property.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAdminConfigDefaults(): AdminConfigDto { + return this.service.getAdminConfigDefaults(); + } + + @Put() + @Authenticated({ permission: Permission.AdminConfigUpdate, admin: true }) + @Endpoint({ + summary: 'Update the system configuration', + description: 'Update the system configuration with a new system configuration.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + updateAdminConfig(@Body() dto: AdminConfigDto): Promise { + return this.service.updateAdminConfig(dto); + } +} diff --git a/server/src/controllers/config-public.controller.ts b/server/src/controllers/config-public.controller.ts new file mode 100644 index 0000000000..87f7bb14c8 --- /dev/null +++ b/server/src/controllers/config-public.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { PublicConfigDto } from 'src/dtos/config.dto'; +import { ApiTag } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemConfigService } from 'src/services/system-config.service'; + +@ApiTags(ApiTag.ConfigPublic) +@Controller('public/config') +export class ConfigPublicController { + constructor(private service: SystemConfigService) {} + + @Get() + @Authenticated({ public: true }) + @Endpoint({ + summary: 'Get the public configuration', + description: 'Retrieve the system configuration properties that are visible to everyone.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getPublicConfig(): Promise { + return this.service.getPublicConfig(); + } + + @Get('defaults') + @Authenticated({ public: true }) + @Endpoint({ + summary: 'Get the public configuration defaults', + description: 'Retrieve the default value of the configuration properties that are visible to everyone.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getPublicConfigDefaults(): PublicConfigDto { + return this.service.getPublicConfigDefaults(); + } +} diff --git a/server/src/controllers/config-user.controller.ts b/server/src/controllers/config-user.controller.ts new file mode 100644 index 0000000000..8f49d119fd --- /dev/null +++ b/server/src/controllers/config-user.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { UserConfigDto } from 'src/dtos/config.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemConfigService } from 'src/services/system-config.service'; + +@ApiTags(ApiTag.ConfigUser) +@Controller('config') +export class ConfigUserController { + constructor(private service: SystemConfigService) {} + + @Get() + @Authenticated({ permission: Permission.UserConfigRead }) + @Endpoint({ + summary: 'Get the configuration with user visibility', + description: 'Retrieve the system configuration properties that are visible to logged in users.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getUserConfig(): Promise { + return this.service.getUserConfig(); + } + + @Get('defaults') + @Authenticated({ permission: Permission.UserConfigRead }) + @Endpoint({ + summary: 'Get the default configuration with user visibility', + description: 'Retrieve the default value of the configuration properties that are visible to logged in users.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getUserConfigDefaults(): UserConfigDto { + return this.service.getUserConfigDefaults(); + } +} diff --git a/server/src/controllers/config.controller.spec.ts b/server/src/controllers/config.controller.spec.ts new file mode 100644 index 0000000000..fdd6a76e74 --- /dev/null +++ b/server/src/controllers/config.controller.spec.ts @@ -0,0 +1,116 @@ +import _ from 'lodash'; +import { ConfigAdminController } from 'src/controllers/config-admin.controller'; +import { ConfigPublicController } from 'src/controllers/config-public.controller'; +import { ConfigUserController } from 'src/controllers/config-user.controller'; +import { defaults, mapPublicConfig, mapUserConfig } from 'src/dtos/config.dto'; +import { SystemConfigService } from 'src/services/system-config.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +/** Returns a full config that passes Zod validation (required URLs and min lengths). */ +function validConfig() { + const config = _.cloneDeep(defaults) as typeof defaults & { + oauth: { mobileRedirectUri: string }; + notifications: { smtp: { from: string; transport: { host: string } } }; + server: { externalDomain: string }; + }; + config.oauth.mobileRedirectUri ||= 'https://example.com'; + config.server.externalDomain ||= 'https://example.com'; + config.notifications.smtp.from ||= 'noreply@example.com'; + config.notifications.smtp.transport.host ||= 'localhost'; + return config; +} + +describe('config controllers', () => { + let ctx: ControllerContext; + const service = mockBaseService(SystemConfigService); + + beforeAll(async () => { + ctx = await controllerSetup( + [ConfigAdminController, ConfigUserController, ConfigPublicController], + [{ provide: SystemConfigService, useValue: service }], + ); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /admin/config', () => { + it('should return the full config', async () => { + service.getAdminConfig.mockResolvedValue(validConfig()); + + const { status, body } = await request(ctx.getHttpServer()).get('/admin/config'); + + expect(status).toBe(200); + expect(body.oauth.clientSecret).toBeDefined(); + expect(body.job.thumbnailGeneration).toBeDefined(); + }); + }); + + describe('PUT /admin/config', () => { + it('should accept a valid config', async () => { + service.updateAdminConfig.mockImplementation((dto) => Promise.resolve(dto)); + + const { status } = await request(ctx.getHttpServer()).put('/admin/config').send(validConfig()); + + expect(status).toBe(200); + }); + + it('should reject an invalid config', async () => { + const config = validConfig(); + config.nightlyTasks.startTime = 'invalid'; + + const { status, body } = await request(ctx.getHttpServer()).put('/admin/config').send(config); + + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { + path: ['nightlyTasks', 'startTime'], + message: 'Invalid input: expected string in HH:MM format, received string', + }, + ]), + ); + expect(service.updateAdminConfig).not.toHaveBeenCalled(); + }); + }); + + describe('GET /config', () => { + it('should return the properties visible to logged in users', async () => { + service.getUserConfig.mockResolvedValue(mapUserConfig(validConfig())); + + const { status, body } = await request(ctx.getHttpServer()).get('/config'); + + expect(status).toBe(200); + expect(body.image).toEqual({ + thumbnail: { size: defaults.image.thumbnail.size }, + preview: { size: defaults.image.preview.size }, + fullsize: { enabled: defaults.image.fullsize.enabled }, + }); + expect(body.oauth.clientSecret).toBeUndefined(); + expect(body.job).toBeUndefined(); + }); + }); + + describe('GET /public/config', () => { + it('should return the properties visible to everyone', async () => { + service.getPublicConfig.mockResolvedValue(mapPublicConfig(validConfig())); + + const { status, body } = await request(ctx.getHttpServer()).get('/public/config'); + + expect(status).toBe(200); + expect(body.server).toEqual({ loginPageMessage: defaults.server.loginPageMessage }); + expect(body.oauth).toEqual({ + autoLaunch: defaults.oauth.autoLaunch, + buttonText: defaults.oauth.buttonText, + enabled: defaults.oauth.enabled, + }); + expect(body.image).toBeUndefined(); + expect(body.trash).toBeUndefined(); + }); + }); +}); diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e7a01643ab..f6ea6d4248 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -6,6 +6,9 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller'; import { AuthController } from 'src/controllers/auth.controller'; +import { ConfigAdminController } from 'src/controllers/config-admin.controller'; +import { ConfigPublicController } from 'src/controllers/config-public.controller'; +import { ConfigUserController } from 'src/controllers/config-user.controller'; import { DatabaseBackupController } from 'src/controllers/database-backup.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; @@ -49,6 +52,9 @@ export const controllers = [ AssetMediaController, AuthController, AuthAdminController, + ConfigUserController, + ConfigAdminController, + ConfigPublicController, DatabaseBackupController, DownloadController, DuplicateController, diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts index c322c5a2b6..7a5585fad4 100644 --- a/server/src/controllers/notification-admin.controller.ts +++ b/server/src/controllers/notification-admin.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/com import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/config.dto'; import { NotificationCreateDto, NotificationDto, @@ -9,7 +10,6 @@ import { TemplateResponseDto, TestEmailResponseDto, } from 'src/dtos/notification.dto'; -import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { ApiTag } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { EmailTemplate } from 'src/repositories/email.repository'; diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 6407155492..ef9f408e8c 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -101,7 +101,11 @@ export class ServerController { @Endpoint({ summary: 'Get features', description: 'Retrieve available features supported by this server.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getPublicConfig' }), }) getServerFeatures(): Promise { return this.service.getFeatures(); @@ -112,7 +116,11 @@ export class ServerController { @Endpoint({ summary: 'Get config', description: 'Retrieve the current server configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getPublicConfig' }), }) getServerConfig(): Promise { return this.service.getSystemConfig(); diff --git a/server/src/controllers/system-config.controller.spec.ts b/server/src/controllers/system-config.controller.spec.ts index 7d40f12583..0ae8741c61 100644 --- a/server/src/controllers/system-config.controller.spec.ts +++ b/server/src/controllers/system-config.controller.spec.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { defaults } from 'src/config'; import { SystemConfigController } from 'src/controllers/system-config.controller'; +import { defaults } from 'src/dtos/config.dto'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { SystemConfigService } from 'src/services/system-config.service'; import request from 'supertest'; diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 6b79b38d98..b7c5f00996 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; +import { AdminConfigDto, ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto'; import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { StorageTemplateService } from 'src/services/storage-template.service'; @@ -20,10 +20,14 @@ export class SystemConfigController { @Endpoint({ summary: 'Get system configuration', description: 'Retrieve the current system configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getAdminConfig' }), }) - getConfig(): Promise { - return this.service.getSystemConfig(); + getConfig(): Promise { + return this.service.getAdminConfig(); } @Get('defaults') @@ -31,10 +35,14 @@ export class SystemConfigController { @Endpoint({ summary: 'Get system configuration defaults', description: 'Retrieve the default values for the system configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getAdminConfigDefaults' }), }) - getConfigDefaults(): SystemConfigDto { - return this.service.getDefaults(); + getConfigDefaults(): AdminConfigDto { + return this.service.getAdminConfigDefaults(); } @Put() @@ -42,10 +50,14 @@ export class SystemConfigController { @Endpoint({ summary: 'Update system configuration', description: 'Update the system configuration with a new system configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'updateAdminConfig' }), }) - updateConfig(@Body() dto: SystemConfigDto): Promise { - return this.service.updateSystemConfig(dto); + updateConfig(@Body() dto: AdminConfigDto): Promise { + return this.service.updateAdminConfig(dto); } @Get('storage-template-options') @@ -55,7 +67,7 @@ export class SystemConfigController { description: 'Retrieve exemplary storage template options.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { + getStorageTemplateOptions(): ConfigTemplateStorageOptionDto { return this.storageTemplateService.getStorageTemplateOptions(); } } diff --git a/server/src/dtos/config.dto.spec.ts b/server/src/dtos/config.dto.spec.ts new file mode 100644 index 0000000000..ce4f5a5d06 --- /dev/null +++ b/server/src/dtos/config.dto.spec.ts @@ -0,0 +1,68 @@ +import { + AdminConfigDto, + defaults, + mapPublicConfig, + mapUserConfig, + PublicConfigDto, + UserConfigDto, +} from 'src/dtos/config.dto'; +import { getKeysDeep } from 'src/utils/misc'; +import z from 'zod'; + +const PUBLIC_PROPERTIES = [ + 'oauth.autoLaunch', + 'oauth.buttonText', + 'oauth.enabled', + 'passwordLogin.enabled', + 'server.loginPageMessage', + 'theme.customCss', +]; + +describe('config visibility', () => { + it('should expose every property to admins', () => { + const paths = getKeysDeep(defaults); + + expect(paths).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES)); + expect(paths).toContain('oauth.clientSecret'); + expect(paths.length).toBeGreaterThan(100); + }); + + it('should expose the public properties to everyone', () => { + expect(getKeysDeep(mapPublicConfig(defaults)).sort()).toEqual(PUBLIC_PROPERTIES); + }); + + it('should expose everything public to logged in users as well', () => { + expect(getKeysDeep(mapUserConfig(defaults))).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES)); + }); + + it('should accept the defaults with the admin schema', () => { + expect(AdminConfigDto.schema.safeParse(defaults)).toEqual(expect.objectContaining({ success: true })); + }); + + it('should map the defaults onto the user and public schemas', () => { + expect(UserConfigDto.schema.safeParse(mapUserConfig(defaults))).toEqual(expect.objectContaining({ success: true })); + expect(PublicConfigDto.schema.safeParse(mapPublicConfig(defaults))).toEqual( + expect.objectContaining({ success: true }), + ); + }); + + it('should not leak admin properties into the public config', () => { + const config = mapPublicConfig(defaults) as Record; + + expect(config.oauth).toEqual({ + autoLaunch: defaults.oauth.autoLaunch, + buttonText: defaults.oauth.buttonText, + enabled: defaults.oauth.enabled, + }); + expect(config.job).toBeUndefined(); + expect(config.image).toBeUndefined(); + expect(config.notifications).toBeUndefined(); + }); + + it('should keep the visibility metadata out of the schemas', () => { + for (const schema of [AdminConfigDto.schema, UserConfigDto.schema, PublicConfigDto.schema]) { + const json = JSON.stringify(z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' })); + expect(json).not.toContain('visibility'); + } + }); +}); diff --git a/server/src/dtos/config.dto.ts b/server/src/dtos/config.dto.ts new file mode 100644 index 0000000000..a7dbaf0cc6 --- /dev/null +++ b/server/src/dtos/config.dto.ts @@ -0,0 +1,773 @@ +import { CronExpression } from '@nestjs/schedule'; +import { validateCronExpression } from 'cron'; +import { createZodDto } from 'nestjs-zod'; +import { + AudioCodec, + AudioCodecSchema, + Colorspace, + ColorspaceSchema, + ConfigVisibility, + CQMode, + CQModeSchema, + HlsVideoResolution, + HlsVideoResolutionSchema, + ImageFormat, + ImageFormatSchema, + LogLevel, + LogLevelSchema, + OAuthTokenEndpointAuthMethod, + OAuthTokenEndpointAuthMethodSchema, + ReleaseChannel, + ReleaseChannelSchema, + ToneMapping, + ToneMappingSchema, + TranscodeHardwareAcceleration, + TranscodeHardwareAccelerationSchema, + TranscodePolicy, + TranscodePolicySchema, + VideoCodec, + VideoCodecSchema, + VideoContainer, + VideoContainerSchema, +} from 'src/enum'; +import { DeepPartial } from 'src/types'; +import z from 'zod'; + +const { Admin, User, Public } = ConfigVisibility; + +const configBool = z + .preprocess((val) => { + if (val === 'true') { + return true; + } + if (val === 'false') { + return false; + } + return val; + }, z.boolean()) + .meta({ type: 'boolean' }); + +const cronExpressionSchema = z + .string() + .superRefine((value, ctx) => { + const validated = validateCronExpression(value); + if (!validated.valid) { + ctx.addIssue({ + code: 'custom', + message: `Invalid cron expression. ${validated.error?.message ?? ''}`, + input: value, + }); + } + }) + .describe('Cron expression'); + +const emptyOrUrl = (error: string) => + z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error }); + +const AdminConfigIntegrityJobSchema = z + .object({ + enabled: z.boolean().describe('Enabled'), + cronExpression: cronExpressionSchema.describe('Cron expression for when the integrity check should run'), + }) + .describe('Integrity job config') + .meta({ id: 'AdminConfigIntegrityJobDto' }); + +const AdminConfigJobSettingsSchema = z + .object({ concurrency: z.int().min(1).describe('Concurrency') }) + .meta({ id: 'AdminConfigJobSettingsDto' }); + +const AdminConfigMachineLearningTaskSchema = z.object({ + enabled: z.boolean().describe('Whether the task is enabled').meta({ visibility: User }), +}); + +const AdminConfigMachineLearningModelSchema = AdminConfigMachineLearningTaskSchema.extend({ + modelName: z.string().describe('Name of the model to use'), +}); + +const AdminConfigGeneratedImageSchema = z + .object({ + format: ImageFormatSchema, + quality: z.int().min(1).max(100).describe('Quality'), + size: z.int().min(1).describe('Size').meta({ visibility: User }), + progressive: configBool.default(false).optional().describe('Progressive'), + }) + .meta({ id: 'AdminConfigGeneratedImageDto' }); + +const AdminConfigFFmpegSchema = z + .object({ + crf: z.coerce.number().int().min(0).max(51).describe('CRF'), + threads: z.coerce.number().int().min(0).describe('Threads'), + preset: z.string().describe('Preset'), + targetVideoCodec: VideoCodecSchema, + acceptedVideoCodecs: z.array(VideoCodecSchema).describe('Accepted video codecs'), + targetAudioCodec: AudioCodecSchema, + acceptedAudioCodecs: z.array(AudioCodecSchema).describe('Accepted audio codecs'), + acceptedContainers: z.array(VideoContainerSchema).describe('Accepted containers'), + targetResolution: z.string().describe('Target resolution'), + maxBitrate: z.string().describe('Max bitrate'), + bframes: z.coerce.number().int().min(-1).max(16).describe('B-frames'), + refs: z.coerce.number().int().min(0).max(6).describe('References'), + gopSize: z.coerce.number().int().min(0).describe('GOP size'), + temporalAQ: configBool.describe('Temporal AQ'), + cqMode: CQModeSchema, + twoPass: configBool.describe('Two pass'), + preferredHwDevice: z.string().describe('Preferred hardware device'), + transcode: TranscodePolicySchema, + accel: TranscodeHardwareAccelerationSchema, + accelDecode: configBool.describe('Accelerated decode'), + tonemap: ToneMappingSchema, + realtime: z + .object({ + enabled: configBool.describe('Enable real-time HLS transcoding (alpha)').meta({ visibility: User }), + videoCodecs: z + .array(VideoCodecSchema) + .describe('Video codecs to use for real-time HLS transcoding') + .meta({ visibility: User }), + resolutions: z + .array(HlsVideoResolutionSchema) + .describe('Resolutions to use for real-time HLS transcoding') + .meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigFFmpegRealtimeDto' }), + }) + .meta({ id: 'AdminConfigFFmpegDto' }); + +const AdminConfigSmtpSchema = z + .object({ + enabled: configBool.describe('Whether SMTP email notifications are enabled'), + from: z.string().describe('Email address to send from'), + replyTo: z.string().describe('Email address for replies'), + transport: z + .object({ + ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'), + host: z.string().describe('SMTP server hostname'), + port: z.int().min(0).max(65_535).describe('SMTP server port'), + secure: configBool.describe('Whether to use secure connection (TLS/SSL)'), + username: z.string().describe('SMTP username'), + password: z.string().describe('SMTP password'), + }) + .meta({ id: 'AdminConfigSmtpTransportDto' }), + }) + .meta({ id: 'AdminConfigSmtpDto' }); + +const AdminConfigSchemaWithVisibility = z + .object({ + backup: z + .object({ + database: z + .object({ + enabled: configBool.describe('Enabled'), + cronExpression: cronExpressionSchema, + keepLastAmount: z.int().min(1).describe('Keep last amount'), + }) + .meta({ id: 'AdminConfigDatabaseBackupDto' }), + }) + .meta({ id: 'AdminConfigBackupsDto' }), + ffmpeg: AdminConfigFFmpegSchema, + integrityChecks: z + .object({ + missingFiles: AdminConfigIntegrityJobSchema, + untrackedFiles: AdminConfigIntegrityJobSchema, + checksumFiles: AdminConfigIntegrityJobSchema.extend({ + timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'), + percentageLimit: z + .float32() + .nonnegative() + .max(1) + .describe('Percentage limit of the integrity checksum job') + .meta({ format: 'double' }), + }) + .describe('Integrity checksum job config') + .meta({ id: 'AdminConfigIntegrityChecksumJobDto' }), + }) + .describe('Integrity checks config') + .meta({ id: 'AdminConfigIntegrityChecksDto' }), + job: z + .object({ + thumbnailGeneration: AdminConfigJobSettingsSchema, + metadataExtraction: AdminConfigJobSettingsSchema, + videoConversion: AdminConfigJobSettingsSchema, + faceDetection: AdminConfigJobSettingsSchema, + smartSearch: AdminConfigJobSettingsSchema, + backgroundTask: AdminConfigJobSettingsSchema, + migration: AdminConfigJobSettingsSchema, + search: AdminConfigJobSettingsSchema, + sidecar: AdminConfigJobSettingsSchema, + library: AdminConfigJobSettingsSchema, + notifications: AdminConfigJobSettingsSchema, + ocr: AdminConfigJobSettingsSchema, + workflow: AdminConfigJobSettingsSchema, + editor: AdminConfigJobSettingsSchema, + integrityCheck: AdminConfigJobSettingsSchema, + }) + .meta({ id: 'AdminConfigJobDto' }), + logging: z + .object({ + enabled: configBool.describe('Enabled'), + level: LogLevelSchema, + }) + .meta({ id: 'AdminConfigLoggingDto' }), + machineLearning: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + urls: z.array(z.string()).min(1).describe('ML service URLs'), + availabilityChecks: z + .object({ + enabled: configBool.describe('Enabled'), + timeout: z.int(), + interval: z.int(), + }) + .meta({ id: 'AdminConfigMachineLearningAvailabilityChecksDto' }), + clip: AdminConfigMachineLearningModelSchema.meta({ id: 'AdminConfigClipDto' }), + duplicateDetection: AdminConfigMachineLearningTaskSchema.extend({ + maxDistance: z + .number() + .min(0.001) + .max(0.1) + .describe('Maximum distance threshold for duplicate detection') + .meta({ format: 'double' }), + }).meta({ id: 'AdminConfigDuplicateDetectionDto' }), + facialRecognition: AdminConfigMachineLearningModelSchema.extend({ + minScore: z + .number() + .min(0.1) + .max(1) + .describe('Minimum confidence score for face detection') + .meta({ format: 'double' }), + maxDistance: z + .number() + .min(0.1) + .max(2) + .describe('Maximum distance threshold for face recognition') + .meta({ format: 'double' }), + minFaces: z + .int() + .min(1) + .describe('Minimum number of faces required for recognition') + .meta({ visibility: User }), + }).meta({ id: 'AdminConfigFacialRecognitionDto' }), + ocr: AdminConfigMachineLearningModelSchema.extend({ + maxResolution: z.int().min(1).describe('Maximum resolution for OCR processing'), + minDetectionScore: z + .number() + .min(0.1) + .max(1) + .describe('Minimum confidence score for text detection') + .meta({ format: 'double' }), + minRecognitionScore: z + .number() + .min(0.1) + .max(1) + .describe('Minimum confidence score for text recognition') + .meta({ format: 'double' }), + }).meta({ id: 'AdminConfigOcrDto' }), + }) + .meta({ id: 'AdminConfigMachineLearningDto' }), + map: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + lightStyle: z.url().describe('Light map style URL').meta({ visibility: User }), + darkStyle: z.url().describe('Dark map style URL').meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigMapDto' }), + reverseGeocoding: z + .object({ enabled: configBool.describe('Enabled').meta({ visibility: User }) }) + .meta({ id: 'AdminConfigReverseGeocodingDto' }), + metadata: z + .object({ + faces: z.object({ import: configBool.describe('Import') }).meta({ id: 'AdminConfigFacesDto' }), + }) + .meta({ id: 'AdminConfigMetadataDto' }), + oauth: z + .object({ + autoLaunch: configBool.describe('Auto launch').meta({ visibility: Public }), + autoRegister: configBool.describe('Auto register'), + buttonText: z.string().describe('Button text').meta({ visibility: Public }), + clientId: z.string().describe('Client ID'), + clientSecret: z.string().describe('Client secret'), + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema, + timeout: z.int().min(1).describe('Timeout'), + allowInsecureRequests: configBool.describe('Allow insecure requests'), + defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'), + enabled: configBool.describe('Enabled').meta({ visibility: Public }), + issuerUrl: emptyOrUrl('Issuer URL must be an empty string or a valid URL').describe('Issuer URL'), + scope: z.string().describe('Scope'), + prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'), + endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe( + 'End session endpoint', + ), + signingAlgorithm: z.string().describe('Signing algorithm'), + profileSigningAlgorithm: z.string().describe('Profile signing algorithm'), + storageLabelClaim: z.string().describe('Storage label claim'), + storageQuotaClaim: z.string().describe('Storage quota claim'), + roleClaim: z.string().describe('Role claim'), + mobileOverrideEnabled: configBool.describe('Mobile override enabled'), + mobileRedirectUri: z.string().describe('Mobile redirect URI (set to empty string to disable)'), + }) + .transform((value, ctx) => { + if (!value.mobileOverrideEnabled || value.mobileRedirectUri === '') { + return value; + } + + if (!z.url().safeParse(value.mobileRedirectUri).success) { + ctx.issues.push({ + code: 'custom', + message: 'Mobile redirect URI must be an empty string or a valid URL', + input: value.mobileRedirectUri, + }); + return z.NEVER; + } + + return value; + }) + .meta({ id: 'AdminConfigOAuthDto' }), + passwordLogin: z + .object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) }) + .meta({ id: 'AdminConfigPasswordLoginDto' }), + storageTemplate: z + .object({ + enabled: configBool.describe('Enabled'), + hashVerificationEnabled: configBool.describe('Hash verification enabled'), + template: z.string().describe('Template'), + }) + .meta({ id: 'AdminConfigStorageTemplateDto' }), + image: z + .object({ + thumbnail: AdminConfigGeneratedImageSchema, + preview: AdminConfigGeneratedImageSchema, + fullsize: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + format: ImageFormatSchema, + quality: z.int().min(1).max(100).describe('Quality'), + progressive: configBool.default(false).optional().describe('Progressive'), + }) + .meta({ id: 'AdminConfigGeneratedFullsizeImageDto' }), + colorspace: ColorspaceSchema, + extractEmbedded: configBool.describe('Extract embedded'), + }) + .meta({ id: 'AdminConfigImageDto' }), + newVersionCheck: z + .object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema }) + .meta({ id: 'AdminConfigNewVersionCheckDto' }), + nightlyTasks: z + .object({ + startTime: z.iso + .time({ + precision: -1, + error: (iss) => `Invalid input: expected string in HH:MM format, received ${typeof iss.input}`, + }) + .describe('Start time (HH:MM)'), + databaseCleanup: configBool.describe('Database cleanup'), + missingThumbnails: configBool.describe('Missing thumbnails'), + clusterNewFaces: configBool.describe('Cluster new faces'), + generateMemories: configBool.describe('Generate memories'), + syncQuotaUsage: configBool.describe('Sync quota usage'), + }) + .meta({ id: 'AdminConfigNightlyTasksDto' }), + trash: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + days: z.int().min(0).describe('Days').meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigTrashDto' }), + theme: z + .object({ customCss: z.string().describe('Custom CSS for theming').meta({ visibility: Public }) }) + .meta({ id: 'AdminConfigThemeDto' }), + library: z + .object({ + scan: z + .object({ + enabled: configBool.describe('Enabled'), + cronExpression: cronExpressionSchema, + }) + .meta({ id: 'AdminConfigLibraryScanDto' }), + watch: z.object({ enabled: configBool.describe('Enabled') }).meta({ id: 'AdminConfigLibraryWatchDto' }), + }) + .meta({ id: 'AdminConfigLibraryDto' }), + notifications: z.object({ smtp: AdminConfigSmtpSchema }).meta({ id: 'AdminConfigNotificationsDto' }), + templates: z + .object({ + email: z + .object({ + welcomeTemplate: z.string().describe('Welcome template'), + albumInviteTemplate: z.string().describe('Album invite template'), + albumUpdateTemplate: z.string().describe('Album update template'), + }) + .meta({ id: 'AdminConfigTemplateEmailsDto' }), + }) + .meta({ id: 'AdminConfigTemplatesDto' }), + server: z + .object({ + externalDomain: emptyOrUrl('External domain must be an empty string or a valid URL') + .describe('External domain') + .meta({ visibility: User }), + loginPageMessage: z.string().describe('Login page message').meta({ visibility: Public }), + publicUsers: configBool.describe('Public users').meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigServerDto' }), + user: z + .object({ deleteDelay: z.int().min(1).describe('Delete delay').meta({ visibility: User }) }) + .meta({ id: 'AdminConfigUserDto' }), + }) + .describe('Configuration properties that are visible to the admin') + .meta({ id: 'AdminConfigDto' }); + +export type SystemConfig = z.infer; +export type MachineLearningConfig = SystemConfig['machineLearning']; + +const visibilities = [Public, User, Admin]; + +const isVisible = (property: ConfigVisibility, visibility: ConfigVisibility) => + visibilities.indexOf(property) <= visibilities.indexOf(visibility); + +const getMeta = (schema: z.ZodType) => + (z.globalRegistry.get(schema) ?? {}) as { id?: string; description?: string; visibility?: ConfigVisibility }; + +const unwrap = (schema: z.ZodType) => (schema instanceof z.ZodPipe ? (schema.def.in as z.ZodType) : schema); + +const visibleSchemas = new Map>(); + +const applyVisibility = (visibility: ConfigVisibility): z.ZodType | undefined => { + const map: Record = { + [Admin]: 'Configuration properties that are visible to the admin', + [User]: 'Configuration properties that are visible to a logged user', + [Public]: 'Configuration properties that are visible to everyone', + }; + + return applyVisibilityRecursive(AdminConfigSchemaWithVisibility, visibility, map[visibility]); +}; + +const applyVisibilityRecursive = ( + schema: z.ZodType, + visibility: ConfigVisibility, + override?: string, +): z.ZodType | undefined => { + const object = unwrap(schema); + const { id, description, visibility: property } = getMeta(schema); + + if (!(object instanceof z.ZodObject)) { + return isVisible(property ?? Admin, visibility) ? schema : undefined; + } + + let cache = visibleSchemas.get(schema); + if (!cache) { + cache = new Map(); + visibleSchemas.set(schema, cache); + } + + if (cache.has(visibility)) { + return cache.get(visibility); + } + + const shape: Record = {}; + for (const [key, value] of Object.entries(object.shape as Record)) { + const visible = applyVisibilityRecursive(value, visibility); + if (visible) { + shape[key] = visible; + } + } + + let visible: z.ZodType | undefined; + if (Object.keys(shape).length > 0) { + visible = z.object(shape).meta({ + ...(id && { id: `${visibility}${id.slice(Admin.length)}` }), + ...((override ?? description) && { description: override ?? description }), + }); + } + + cache.set(visibility, visible); + + return visible; +}; + +const stripVisibilityMetadata = (schema: T): T => { + const object = unwrap(schema); + if (object instanceof z.ZodObject) { + for (const value of Object.values(object.shape as Record)) { + stripVisibilityMetadata(value); + } + + return schema; + } + + const { visibility, ...meta } = getMeta(schema); + if (visibility) { + z.globalRegistry.add(schema, meta); + } + + return schema; +}; + +const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType; +const UserConfigSchema = applyVisibility(User)! as z.ZodType>; +const PublicConfigSchema = applyVisibility(Public)! as z.ZodType>; + +// prevent visibility metadata from leaking to openapi spec +// eslint-disable-next-line unicorn/no-top-level-side-effects +stripVisibilityMetadata(AdminConfigSchemaWithVisibility); + +const ConfigTemplateStorageOptionSchema = z + .object({ + yearOptions: z.array(z.string()).describe('Available year format options for storage template'), + monthOptions: z.array(z.string()).describe('Available month format options for storage template'), + weekOptions: z.array(z.string()).describe('Available week format options for storage template'), + dayOptions: z.array(z.string()).describe('Available day format options for storage template'), + hourOptions: z.array(z.string()).describe('Available hour format options for storage template'), + minuteOptions: z.array(z.string()).describe('Available minute format options for storage template'), + secondOptions: z.array(z.string()).describe('Available second format options for storage template'), + presetOptions: z.array(z.string()).describe('Available preset template options'), + }) + .meta({ id: 'SystemConfigTemplateStorageOptionDto' }); + +export class AdminConfigDto extends createZodDto(AdminConfigSchema) {} +export class UserConfigDto extends createZodDto(UserConfigSchema) {} +export class PublicConfigDto extends createZodDto(PublicConfigSchema) {} +export class ConfigFFmpegDto extends createZodDto(AdminConfigFFmpegSchema) {} +export class ConfigSmtpDto extends createZodDto(AdminConfigSmtpSchema) {} +export class ConfigTemplateStorageOptionDto extends createZodDto(ConfigTemplateStorageOptionSchema) {} + +/** @deprecated the `/system-config` endpoints these are named after are on their way out */ +export { AdminConfigDto as SystemConfigDto, ConfigSmtpDto as SystemConfigSmtpDto }; + +export function mapAdminConfig(config: SystemConfig): AdminConfigDto { + return config; +} + +export function mapUserConfig(config: SystemConfig): UserConfigDto { + return UserConfigSchema.parse(config); +} + +export function mapPublicConfig(config: SystemConfig): PublicConfigDto { + return PublicConfigSchema.parse(config); +} + +export const defaults = Object.freeze({ + backup: { + database: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_2AM, + keepLastAmount: 14, + }, + }, + ffmpeg: { + crf: 23, + threads: 0, + preset: 'ultrafast', + targetVideoCodec: VideoCodec.H264, + acceptedVideoCodecs: [VideoCodec.H264], + targetAudioCodec: AudioCodec.Aac, + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], + acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], + targetResolution: '720', + maxBitrate: '0', + bframes: -1, + refs: 0, + gopSize: 0, + temporalAQ: false, + cqMode: CQMode.Auto, + twoPass: false, + preferredHwDevice: 'auto', + transcode: TranscodePolicy.Required, + tonemap: ToneMapping.Hable, + accel: TranscodeHardwareAcceleration.Disabled, + accelDecode: true, + realtime: { + enabled: false, + videoCodecs: [VideoCodec.H264, VideoCodec.Hevc], + resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080], + }, + }, + integrityChecks: { + missingFiles: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_3AM, + }, + untrackedFiles: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_3AM, + }, + checksumFiles: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_3AM, + timeLimit: 60 * 60 * 1000, // 1 hour + percentageLimit: 1, // 100% of assets + }, + }, + job: { + thumbnailGeneration: { concurrency: 3 }, + metadataExtraction: { concurrency: 5 }, + videoConversion: { concurrency: 1 }, + faceDetection: { concurrency: 2 }, + smartSearch: { concurrency: 2 }, + backgroundTask: { concurrency: 5 }, + migration: { concurrency: 5 }, + search: { concurrency: 5 }, + sidecar: { concurrency: 5 }, + library: { concurrency: 5 }, + notifications: { concurrency: 5 }, + ocr: { concurrency: 1 }, + workflow: { concurrency: 5 }, + editor: { concurrency: 2 }, + integrityCheck: { concurrency: 1 }, + }, + logging: { + enabled: true, + level: LogLevel.Log, + }, + machineLearning: { + enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', + urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], + availabilityChecks: { + enabled: true, + timeout: 2000, + interval: 30_000, + }, + clip: { + enabled: true, + modelName: 'ViT-B-32__openai', + }, + duplicateDetection: { + enabled: true, + maxDistance: 0.01, + }, + facialRecognition: { + enabled: true, + modelName: 'buffalo_l', + minScore: 0.7, + maxDistance: 0.5, + minFaces: 3, + }, + ocr: { + enabled: true, + modelName: 'PP-OCRv5_mobile', + minDetectionScore: 0.5, + minRecognitionScore: 0.8, + maxResolution: 736, + }, + }, + map: { + enabled: true, + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', + }, + reverseGeocoding: { + enabled: true, + }, + metadata: { + faces: { + import: false, + }, + }, + oauth: { + autoLaunch: false, + autoRegister: true, + buttonText: 'Login with OAuth', + clientId: '', + clientSecret: '', + defaultStorageQuota: null, + enabled: false, + issuerUrl: '', + endSessionEndpoint: '', + mobileOverrideEnabled: false, + mobileRedirectUri: '', + prompt: '', + scope: 'openid email profile', + signingAlgorithm: 'RS256', + profileSigningAlgorithm: 'none', + storageLabelClaim: 'preferred_username', + storageQuotaClaim: 'immich_quota', + roleClaim: 'immich_role', + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost, + timeout: 30_000, + allowInsecureRequests: false, + }, + passwordLogin: { + enabled: true, + }, + storageTemplate: { + enabled: false, + hashVerificationEnabled: true, + template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + }, + image: { + thumbnail: { + format: ImageFormat.Webp, + size: 250, + quality: 80, + progressive: false, + }, + preview: { + format: ImageFormat.Jpeg, + size: 1440, + quality: 80, + progressive: false, + }, + colorspace: Colorspace.P3, + extractEmbedded: false, + fullsize: { + enabled: false, + format: ImageFormat.Jpeg, + quality: 80, + progressive: false, + }, + }, + newVersionCheck: { + enabled: true, + channel: ReleaseChannel.Stable, + }, + nightlyTasks: { + startTime: '00:00', + databaseCleanup: true, + generateMemories: true, + syncQuotaUsage: true, + missingThumbnails: true, + clusterNewFaces: true, + }, + trash: { + enabled: true, + days: 30, + }, + theme: { + customCss: '', + }, + library: { + scan: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT, + }, + watch: { + enabled: false, + }, + }, + server: { + externalDomain: '', + loginPageMessage: '', + publicUsers: true, + }, + notifications: { + smtp: { + enabled: false, + from: '', + replyTo: '', + transport: { + ignoreCert: false, + host: '', + port: 587, + secure: false, + username: '', + password: '', + }, + }, + }, + templates: { + email: { + welcomeTemplate: '', + albumInviteTemplate: '', + albumUpdateTemplate: '', + }, + }, + user: { + deleteDelay: 7, + }, +}); diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts deleted file mode 100644 index 2ba6f0c365..0000000000 --- a/server/src/dtos/model-config.dto.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createZodDto } from 'nestjs-zod'; -import z from 'zod'; - -const TaskConfigSchema = z - .object({ - enabled: z.boolean().describe('Whether the task is enabled'), - }) - .meta({ id: 'TaskConfig' }); - -const ModelConfigSchema = TaskConfigSchema.extend({ - modelName: z.string().describe('Name of the model to use'), -}); - -export const CLIPConfigSchema = ModelConfigSchema.meta({ id: 'CLIPConfig' }); - -export const DuplicateDetectionConfigSchema = TaskConfigSchema.extend({ - maxDistance: z - .number() - .meta({ format: 'double' }) - .min(0.001) - .max(0.1) - .describe('Maximum distance threshold for duplicate detection'), -}).meta({ id: 'DuplicateDetectionConfig' }); - -export const FacialRecognitionConfigSchema = ModelConfigSchema.extend({ - minScore: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(1) - .describe('Minimum confidence score for face detection'), - maxDistance: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(2) - .describe('Maximum distance threshold for face recognition'), - minFaces: z.int().min(1).describe('Minimum number of faces required for recognition'), -}).meta({ id: 'FacialRecognitionConfig' }); - -export const OcrConfigSchema = ModelConfigSchema.extend({ - maxResolution: z.int().min(1).describe('Maximum resolution for OCR processing'), - minDetectionScore: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(1) - .describe('Minimum confidence score for text detection'), - minRecognitionScore: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(1) - .describe('Minimum confidence score for text recognition'), -}).meta({ id: 'OcrConfig' }); - -export class CLIPConfig extends createZodDto(CLIPConfigSchema) {} diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts deleted file mode 100644 index a50b7abe87..0000000000 --- a/server/src/dtos/system-config.dto.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { validateCronExpression } from 'cron'; -import { createZodDto } from 'nestjs-zod'; -import { SystemConfig } from 'src/config'; -import { - CLIPConfigSchema, - DuplicateDetectionConfigSchema, - FacialRecognitionConfigSchema, - OcrConfigSchema, -} from 'src/dtos/model-config.dto'; -import { - AudioCodecSchema, - ColorspaceSchema, - CQModeSchema, - HlsVideoResolutionSchema, - ImageFormatSchema, - LogLevelSchema, - OAuthTokenEndpointAuthMethodSchema, - ToneMappingSchema, - TranscodeHardwareAccelerationSchema, - TranscodePolicySchema, - VideoCodecSchema, - VideoContainerSchema, -} from 'src/enum'; -import z from 'zod'; - -/** Coerces 'true'/'false' strings to boolean, but also allows booleans. */ -const configBool = z - .preprocess((val) => { - if (val === 'true') { - return true; - } - if (val === 'false') { - return false; - } - return val; - }, z.boolean()) - .meta({ type: 'boolean' }); - -const JobSettingsSchema = z - .object({ - concurrency: z.int().min(1).describe('Concurrency'), - }) - .meta({ id: 'JobSettingsDto' }); - -const cronExpressionSchema = z - .string() - .superRefine((value, ctx) => { - const validated = validateCronExpression(value); - if (!validated.valid) { - ctx.addIssue({ - code: 'custom', - message: `Invalid cron expression. ${validated.error?.message ?? ''}`, - input: value, - }); - } - }) - .describe('Cron expression'); - -const DatabaseBackupSchema = z - .object({ - enabled: configBool.describe('Enabled'), - cronExpression: cronExpressionSchema, - keepLastAmount: z.int().min(1).describe('Keep last amount'), - }) - .meta({ id: 'DatabaseBackupConfig' }); - -const SystemConfigIntegrityJobSchema = z - .object({ - enabled: z.boolean().describe('Enabled'), - cronExpression: cronExpressionSchema.describe('Cron expression for when the integrity check should run'), - }) - .describe('Integrity job config') - .meta({ id: 'SystemConfigIntegrityJob' }); - -const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({ - timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'), - percentageLimit: z - .float32() - .nonnegative() - .max(1) - .describe('Percentage limit of the integrity checksum job') - .meta({ format: 'double' }), -}) - .describe('Integrity checksum job config') - .meta({ id: 'SystemConfigIntegrityChecksumJob' }); - -const SystemConfigIntegrityChecksSchema = z - .object({ - missingFiles: SystemConfigIntegrityJobSchema, - untrackedFiles: SystemConfigIntegrityJobSchema, - checksumFiles: SystemConfigIntegrityChecksumJobSchema, - }) - .describe('Integrity checks config') - .meta({ id: 'SystemConfigIntegrityChecks' }); - -const SystemConfigBackupsSchema = z.object({ database: DatabaseBackupSchema }).meta({ id: 'SystemConfigBackupsDto' }); - -const SystemConfigFFmpegSchema = z - .object({ - crf: z.coerce.number().int().min(0).max(51).describe('CRF'), - threads: z.coerce.number().int().min(0).describe('Threads'), - preset: z.string().describe('Preset'), - targetVideoCodec: VideoCodecSchema, - acceptedVideoCodecs: z.array(VideoCodecSchema).describe('Accepted video codecs'), - targetAudioCodec: AudioCodecSchema, - acceptedAudioCodecs: z.array(AudioCodecSchema).describe('Accepted audio codecs'), - acceptedContainers: z.array(VideoContainerSchema).describe('Accepted containers'), - targetResolution: z.string().describe('Target resolution'), - maxBitrate: z.string().describe('Max bitrate'), - bframes: z.coerce.number().int().min(-1).max(16).describe('B-frames'), - refs: z.coerce.number().int().min(0).max(6).describe('References'), - gopSize: z.coerce.number().int().min(0).describe('GOP size'), - temporalAQ: configBool.describe('Temporal AQ'), - cqMode: CQModeSchema, - twoPass: configBool.describe('Two pass'), - preferredHwDevice: z.string().describe('Preferred hardware device'), - transcode: TranscodePolicySchema, - accel: TranscodeHardwareAccelerationSchema, - accelDecode: configBool.describe('Accelerated decode'), - tonemap: ToneMappingSchema, - realtime: z - .object({ - enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'), - videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'), - resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'), - }) - .meta({ id: 'SystemConfigFFmpegRealtimeDto' }), - }) - .meta({ id: 'SystemConfigFFmpegDto' }); - -const SystemConfigJobSchema = z - .object({ - thumbnailGeneration: JobSettingsSchema, - metadataExtraction: JobSettingsSchema, - videoConversion: JobSettingsSchema, - faceDetection: JobSettingsSchema, - smartSearch: JobSettingsSchema, - backgroundTask: JobSettingsSchema, - migration: JobSettingsSchema, - search: JobSettingsSchema, - sidecar: JobSettingsSchema, - library: JobSettingsSchema, - notifications: JobSettingsSchema, - ocr: JobSettingsSchema, - workflow: JobSettingsSchema, - editor: JobSettingsSchema, - integrityCheck: JobSettingsSchema, - }) - .meta({ id: 'SystemConfigJobDto' }); - -const SystemConfigLibraryScanSchema = z - .object({ - enabled: configBool.describe('Enabled'), - cronExpression: cronExpressionSchema, - }) - .meta({ id: 'SystemConfigLibraryScanDto' }); - -const SystemConfigLibraryWatchSchema = z - .object({ enabled: configBool.describe('Enabled') }) - .meta({ id: 'SystemConfigLibraryWatchDto' }); - -const SystemConfigLibrarySchema = z - .object({ scan: SystemConfigLibraryScanSchema, watch: SystemConfigLibraryWatchSchema }) - .meta({ id: 'SystemConfigLibraryDto' }); - -const SystemConfigLoggingSchema = z - .object({ - enabled: configBool.describe('Enabled'), - level: LogLevelSchema, - }) - .meta({ id: 'SystemConfigLoggingDto' }); - -const MachineLearningAvailabilityChecksSchema = z - .object({ - enabled: configBool.describe('Enabled'), - timeout: z.int(), - interval: z.int(), - }) - .meta({ id: 'MachineLearningAvailabilityChecksDto' }); - -const SystemConfigMachineLearningSchema = z - .object({ - enabled: configBool.describe('Enabled'), - urls: z.array(z.string()).min(1).describe('ML service URLs'), - availabilityChecks: MachineLearningAvailabilityChecksSchema, - clip: CLIPConfigSchema, - duplicateDetection: DuplicateDetectionConfigSchema, - facialRecognition: FacialRecognitionConfigSchema, - ocr: OcrConfigSchema, - }) - .meta({ id: 'SystemConfigMachineLearningDto' }); - -const SystemConfigMapSchema = z - .object({ - enabled: configBool.describe('Enabled'), - lightStyle: z.url().describe('Light map style URL'), - darkStyle: z.url().describe('Dark map style URL'), - }) - .meta({ id: 'SystemConfigMapDto' }); - -export enum ReleaseChannel { - Stable = 'stable', - ReleaseCandidate = 'releaseCandidate', -} - -const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' }); - -const SystemConfigNewVersionCheckSchema = z - .object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema }) - .meta({ id: 'SystemConfigNewVersionCheckDto' }); - -const SystemConfigNightlyTasksSchema = z - .object({ - startTime: z.iso - .time({ - precision: -1, - error: (iss) => `Invalid input: expected string in HH:MM format, received ${typeof iss.input}`, - }) - .describe('Start time (HH:MM)'), - databaseCleanup: configBool.describe('Database cleanup'), - missingThumbnails: configBool.describe('Missing thumbnails'), - clusterNewFaces: configBool.describe('Cluster new faces'), - generateMemories: configBool.describe('Generate memories'), - syncQuotaUsage: configBool.describe('Sync quota usage'), - }) - .meta({ id: 'SystemConfigNightlyTasksDto' }); - -const SystemConfigOAuthSchema = z - .object({ - autoLaunch: configBool.describe('Auto launch'), - autoRegister: configBool.describe('Auto register'), - buttonText: z.string().describe('Button text'), - clientId: z.string().describe('Client ID'), - clientSecret: z.string().describe('Client secret'), - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema, - timeout: z.int().min(1).describe('Timeout'), - allowInsecureRequests: configBool.describe('Allow insecure requests'), - defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'), - enabled: configBool.describe('Enabled'), - issuerUrl: z - .string() - .refine((url) => url.length === 0 || z.url().safeParse(url).success, { - error: 'Issuer URL must be an empty string or a valid URL', - }) - .describe('Issuer URL'), - scope: z.string().describe('Scope'), - prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'), - endSessionEndpoint: z - .string() - .refine((url) => url.length === 0 || z.url().safeParse(url).success, { - error: 'endSessionEndpoint must be an empty string or a valid URL', - }) - .describe('End session endpoint'), - signingAlgorithm: z.string().describe('Signing algorithm'), - profileSigningAlgorithm: z.string().describe('Profile signing algorithm'), - storageLabelClaim: z.string().describe('Storage label claim'), - storageQuotaClaim: z.string().describe('Storage quota claim'), - roleClaim: z.string().describe('Role claim'), - mobileOverrideEnabled: configBool.describe('Mobile override enabled'), - mobileRedirectUri: z.string().describe('Mobile redirect URI (set to empty string to disable)'), - }) - .transform((value, ctx) => { - if (!value.mobileOverrideEnabled || value.mobileRedirectUri === '') { - return value; - } - - if (!z.url().safeParse(value.mobileRedirectUri).success) { - ctx.issues.push({ - code: 'custom', - message: 'Mobile redirect URI must be an empty string or a valid URL', - input: value.mobileRedirectUri, - }); - return z.NEVER; - } - - return value; - }) - .meta({ - id: 'SystemConfigOAuthDto', - }); - -const SystemConfigPasswordLoginSchema = z - .object({ enabled: configBool.describe('Enabled') }) - .meta({ id: 'SystemConfigPasswordLoginDto' }); - -const SystemConfigReverseGeocodingSchema = z - .object({ enabled: configBool.describe('Enabled') }) - .meta({ id: 'SystemConfigReverseGeocodingDto' }); - -const SystemConfigFacesSchema = z - .object({ import: configBool.describe('Import') }) - .meta({ id: 'SystemConfigFacesDto' }); -const SystemConfigMetadataSchema = z.object({ faces: SystemConfigFacesSchema }).meta({ id: 'SystemConfigMetadataDto' }); - -const SystemConfigServerSchema = z - .object({ - externalDomain: z - .string() - .refine((url) => url.length === 0 || z.url().safeParse(url).success, { - error: 'External domain must be an empty string or a valid URL', - }) - .describe('External domain'), - loginPageMessage: z.string().describe('Login page message'), - publicUsers: configBool.describe('Public users'), - }) - .meta({ id: 'SystemConfigServerDto' }); - -const SystemConfigSmtpTransportSchema = z - .object({ - ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'), - host: z.string().describe('SMTP server hostname'), - port: z.int().min(0).max(65_535).describe('SMTP server port'), - secure: configBool.describe('Whether to use secure connection (TLS/SSL)'), - username: z.string().describe('SMTP username'), - password: z.string().describe('SMTP password'), - }) - .meta({ id: 'SystemConfigSmtpTransportDto' }); - -const SystemConfigSmtpSchema = z - .object({ - enabled: configBool.describe('Whether SMTP email notifications are enabled'), - from: z.string().describe('Email address to send from'), - replyTo: z.string().describe('Email address for replies'), - transport: SystemConfigSmtpTransportSchema, - }) - .meta({ id: 'SystemConfigSmtpDto' }); - -const SystemConfigNotificationsSchema = z - .object({ smtp: SystemConfigSmtpSchema }) - .meta({ id: 'SystemConfigNotificationsDto' }); - -const SystemConfigTemplateEmailsSchema = z - .object({ - albumInviteTemplate: z.string().describe('Album invite template'), - welcomeTemplate: z.string().describe('Welcome template'), - albumUpdateTemplate: z.string().describe('Album update template'), - }) - .meta({ id: 'SystemConfigTemplateEmailsDto' }); -const SystemConfigTemplatesSchema = z - .object({ email: SystemConfigTemplateEmailsSchema }) - .meta({ id: 'SystemConfigTemplatesDto' }); - -const SystemConfigStorageTemplateSchema = z - .object({ - enabled: configBool.describe('Enabled'), - hashVerificationEnabled: configBool.describe('Hash verification enabled'), - template: z.string().describe('Template'), - }) - .meta({ id: 'SystemConfigStorageTemplateDto' }); - -const SystemConfigTemplateStorageOptionSchema = z - .object({ - yearOptions: z.array(z.string()).describe('Available year format options for storage template'), - monthOptions: z.array(z.string()).describe('Available month format options for storage template'), - weekOptions: z.array(z.string()).describe('Available week format options for storage template'), - dayOptions: z.array(z.string()).describe('Available day format options for storage template'), - hourOptions: z.array(z.string()).describe('Available hour format options for storage template'), - minuteOptions: z.array(z.string()).describe('Available minute format options for storage template'), - secondOptions: z.array(z.string()).describe('Available second format options for storage template'), - presetOptions: z.array(z.string()).describe('Available preset template options'), - }) - .meta({ id: 'SystemConfigTemplateStorageOptionDto' }); - -const SystemConfigThemeSchema = z - .object({ customCss: z.string().describe('Custom CSS for theming') }) - .meta({ id: 'SystemConfigThemeDto' }); - -const SystemConfigGeneratedImageSchema = z - .object({ - format: ImageFormatSchema, - quality: z.int().min(1).max(100).describe('Quality'), - size: z.int().min(1).describe('Size'), - progressive: configBool.default(false).optional().describe('Progressive'), - }) - .meta({ id: 'SystemConfigGeneratedImageDto' }); - -const SystemConfigGeneratedFullsizeImageSchema = z - .object({ - enabled: configBool.describe('Enabled'), - format: ImageFormatSchema, - quality: z.int().min(1).max(100).describe('Quality'), - progressive: configBool.default(false).optional().describe('Progressive'), - }) - .meta({ id: 'SystemConfigGeneratedFullsizeImageDto' }); - -const SystemConfigImageSchema = z - .object({ - thumbnail: SystemConfigGeneratedImageSchema, - preview: SystemConfigGeneratedImageSchema, - fullsize: SystemConfigGeneratedFullsizeImageSchema, - colorspace: ColorspaceSchema, - extractEmbedded: configBool.describe('Extract embedded'), - }) - .meta({ id: 'SystemConfigImageDto' }); - -const SystemConfigTrashSchema = z - .object({ - enabled: configBool.describe('Enabled'), - days: z.int().min(0).describe('Days'), - }) - .meta({ id: 'SystemConfigTrashDto' }); - -const SystemConfigUserSchema = z - .object({ - deleteDelay: z.int().min(1).describe('Delete delay'), - }) - .meta({ id: 'SystemConfigUserDto' }); - -export const SystemConfigSchema = z - .object({ - backup: SystemConfigBackupsSchema, - ffmpeg: SystemConfigFFmpegSchema, - logging: SystemConfigLoggingSchema, - machineLearning: SystemConfigMachineLearningSchema, - map: SystemConfigMapSchema, - newVersionCheck: SystemConfigNewVersionCheckSchema, - nightlyTasks: SystemConfigNightlyTasksSchema, - oauth: SystemConfigOAuthSchema, - passwordLogin: SystemConfigPasswordLoginSchema, - reverseGeocoding: SystemConfigReverseGeocodingSchema, - metadata: SystemConfigMetadataSchema, - storageTemplate: SystemConfigStorageTemplateSchema, - job: SystemConfigJobSchema, - image: SystemConfigImageSchema, - trash: SystemConfigTrashSchema, - theme: SystemConfigThemeSchema, - library: SystemConfigLibrarySchema, - notifications: SystemConfigNotificationsSchema, - templates: SystemConfigTemplatesSchema, - server: SystemConfigServerSchema, - user: SystemConfigUserSchema, - integrityChecks: SystemConfigIntegrityChecksSchema, - }) - .describe('System configuration') - .meta({ id: 'SystemConfigDto' }); - -export class SystemConfigFFmpegDto extends createZodDto(SystemConfigFFmpegSchema) {} -export class SystemConfigSmtpDto extends createZodDto(SystemConfigSmtpSchema) {} -export class SystemConfigTemplateStorageOptionDto extends createZodDto(SystemConfigTemplateStorageOptionSchema) {} -export class SystemConfigDto extends createZodDto(SystemConfigSchema) {} - -export function mapConfig(config: SystemConfig): SystemConfigDto { - return config; -} diff --git a/server/src/enum.ts b/server/src/enum.ts index b6e2f6e468..3402afbc08 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -161,6 +161,11 @@ export enum Permission { BackupUpload = 'backup.upload', BackupDelete = 'backup.delete', + AdminConfigRead = 'adminConfig.read', + AdminConfigUpdate = 'adminConfig.update', + + UserConfigRead = 'userConfig.read', + DuplicateRead = 'duplicate.read', DuplicateDelete = 'duplicate.delete', @@ -1165,12 +1170,25 @@ export const AssetVisibilitySchema = z .describe('Asset visibility') .meta({ id: 'AssetVisibility' }); +export enum ReleaseChannel { + Stable = 'stable', + ReleaseCandidate = 'releaseCandidate', +} + +export const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' }); + export enum CronJob { LibraryScan = 'LibraryScan', NightlyJobs = 'NightlyJobs', VersionCheck = 'VersionCheck', } +export enum ConfigVisibility { + Public = 'Public', + User = 'User', + Admin = 'Admin', +} + export enum ApiTag { Activities = 'Activities', Albums = 'Albums', @@ -1178,6 +1196,9 @@ export enum ApiTag { Authentication = 'Authentication', AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', + ConfigUser = 'Config (user)', + ConfigAdmin = 'Config (admin)', + ConfigPublic = 'Config (public)', DatabaseBackups = 'Database Backups (admin)', Deprecated = 'Deprecated', Download = 'Download', diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 7fedc4eb3a..d377215e5b 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; import { Socket } from 'socket.io'; -import { SystemConfig } from 'src/config'; import { Asset } from 'src/database'; import { EventConfig } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index a05ddbc866..c1b14fae0a 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import { readFile } from 'node:fs/promises'; -import { MachineLearningConfig } from 'src/config'; -import { CLIPConfig } from 'src/dtos/model-config.dto'; +import { MachineLearningConfig } from 'src/dtos/config.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; export interface BoundingBox { @@ -205,7 +204,7 @@ export class MachineLearningRepository { }; } - async encodeImage(imagePath: string, { modelName }: CLIPConfig) { + async encodeImage(imagePath: string, { modelName }: MachineLearningConfig['clip']) { const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } }; const response = await this.predict({ imagePath }, request); return response[ModelTask.SEARCH]; diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index 5cfded148d..4b0e13356d 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -4,7 +4,7 @@ import { exec as execCallback } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import sharp from 'sharp'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; +import { ReleaseChannel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6ed8ce925a..9204ccea42 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -1,10 +1,10 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { Insertable } from 'kysely'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { UserAdmin } from 'src/database'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index 5026386252..ac808af8ae 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -1,8 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { DateTime } from 'luxon'; import { PassThrough, Readable } from 'node:stream'; -import { defaults, SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository'; import { DatabaseBackupService } from 'src/services/database-backup.service'; diff --git a/server/src/services/hls.service.ts b/server/src/services/hls.service.ts index 94a8a46ace..d2f7133c0d 100644 --- a/server/src/services/hls.service.ts +++ b/server/src/services/hls.service.ts @@ -5,7 +5,7 @@ import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VER import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { ConfigFFmpegDto } from 'src/dtos/config.dto'; import { CacheControl, ImmichWorker, Permission } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -124,7 +124,7 @@ export class HlsService extends BaseService { this.websocketRepository.serverSend('HlsSessionEnd', { sessionId }); } - private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) { + private generateMainPlaylist(sessionId: string, ffmpeg: ConfigFFmpegDto, asset: AssetWithStreamInfo) { const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration; const roundedFps = fps.toFixed(3); const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index ae06232605..575de87598 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { defaults, SystemConfig } from 'src/config'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d64e0088eb..c9cd395ccc 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,7 +1,7 @@ import { ShallowDehydrateObject } from 'kysely'; import { OutputInfo } from 'sharp'; -import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 19b1688e88..0839eba6ad 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,11 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; +import { ConfigFFmpegDto, SystemConfig } from 'src/dtos/config.dto'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetFileType, AssetType, @@ -626,7 +625,7 @@ export class MediaService extends BaseService { } private getTranscodeTarget( - config: SystemConfigFFmpegDto, + config: ConfigFFmpegDto, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo, ): TranscodeTarget { @@ -648,7 +647,7 @@ export class MediaService extends BaseService { return TranscodeTarget.None; } - private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean { + private isAudioTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream?: AudioStreamInfo): boolean { if (!stream) { return false; } @@ -671,7 +670,7 @@ export class MediaService extends BaseService { } } - private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean { + private isVideoTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream: VideoStreamInfo): boolean { const isScalingEnabled = ffmpegConfig.targetResolution !== 'original'; const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = isScalingEnabled && Math.min(stream.height, stream.width) > targetRes; @@ -703,7 +702,7 @@ export class MediaService extends BaseService { } } - private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { + private isRemuxRequired(ffmpegConfig: ConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { if (ffmpegConfig.transcode === TranscodePolicy.Disabled) { return false; } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e..bcd347c560 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -2,7 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; -import { defaults } from 'src/config'; +import { defaults } from 'src/dtos/config.dto'; import { AssetFileType, AssetType, diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts index c200897719..12fb3e71fb 100644 --- a/server/src/services/notification-admin.service.spec.ts +++ b/server/src/services/notification-admin.service.spec.ts @@ -1,4 +1,4 @@ -import { defaults, SystemConfig } from 'src/config'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { EmailTemplate } from 'src/repositories/email.repository'; import { NotificationService } from 'src/services/notification.service'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/services/notification-admin.service.ts b/server/src/services/notification-admin.service.ts index 2fc4584dca..63c1da0d2e 100644 --- a/server/src/services/notification-admin.service.ts +++ b/server/src/services/notification-admin.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/config.dto'; import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto'; -import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { NotificationLevel, NotificationType } from 'src/enum'; import { EmailTemplate } from 'src/repositories/email.repository'; import { BaseService } from 'src/services/base.service'; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index d6fcefbe6a..47b9b7257a 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,5 +1,4 @@ -import { defaults, SystemConfig } from 'src/config'; -import { SystemConfigDto } from 'src/dtos/system-config.dto'; +import { AdminConfigDto, defaults, SystemConfig } from 'src/dtos/config.dto'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { NotificationService } from 'src/services/notification.service'; import { AlbumFactory } from 'test/factories/album.factory'; @@ -100,7 +99,7 @@ describe(NotificationService.name, () => { it('skips smtp validation with DTO when there are no changes', async () => { const oldConfig = { ...configs.smtpEnabled }; - const newConfig = configs.smtpEnabled as SystemConfigDto; + const newConfig = configs.smtpEnabled as AdminConfigDto; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(mocks.email.verifySmtp).not.toHaveBeenCalled(); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index a650b466ba..a341468bb4 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -3,6 +3,7 @@ import { OnEvent, OnJob } from 'src/decorators'; import { MapAlbumDto } from 'src/dtos/album.dto'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/config.dto'; import { mapNotification, NotificationDeleteAllDto, @@ -11,7 +12,6 @@ import { NotificationUpdateAllDto, NotificationUpdateDto, } from 'src/dtos/notification.dto'; -import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AssetFileType, JobName, diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 5643c5eced..3490c634e6 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { defaults, SystemConfig } from 'src/config'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum'; import { QueueService } from 'src/services/queue.service'; import { factory } from 'test/small.factory'; diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts index 1f0bf6c76b..7130146c15 100644 --- a/server/src/services/queue.service.ts +++ b/server/src/services/queue.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { mapQueueLegacy, mapQueuesLegacy, diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 6bd0a3c9b2..354672a6e5 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,4 +1,4 @@ -import { SystemConfig } from 'src/config'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 19a1774492..e147d3da7f 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; +import { SystemConfig } from 'src/dtos/config.dto'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 8f11a1dfa2..72d8802e1b 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,5 +1,5 @@ import { Stats } from 'node:fs'; -import { defaults, SystemConfig } from 'src/config'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { AssetPathType, AssetType, JobStatus } from 'src/enum'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { AlbumFactory } from 'test/factories/album.factory'; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index de731f46c6..c2aaf986eb 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; -import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; +import { ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto'; import { AssetFileType, AssetPathType, @@ -129,7 +129,7 @@ export class StorageTemplateService extends BaseService { } } - getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { + getStorageTemplateOptions(): ConfigTemplateStorageOptionDto { return { ...storageTokens, presetOptions: storagePresets }; } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 08851da96a..90d91ccf48 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -1,6 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { defaults, SystemConfig } from 'src/config'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { AudioCodec, Colorspace, @@ -10,6 +9,7 @@ import { LogLevel, OAuthTokenEndpointAuthMethod, QueueName, + ReleaseChannel, ToneMapping, TranscodeHardwareAcceleration, TranscodePolicy, @@ -269,7 +269,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', () => { mocks.systemMetadata.get.mockResolvedValue(partialConfig); - expect(sut.getDefaults()).toEqual(defaults); + expect(sut.getAdminConfigDefaults()).toEqual(defaults); expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); }); @@ -278,7 +278,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', async () => { mocks.systemMetadata.get.mockResolvedValue({}); - await expect(sut.getSystemConfig()).resolves.toEqual(defaults); + await expect(sut.getAdminConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { @@ -289,14 +289,14 @@ describe(SystemConfigService.name, () => { user: { deleteDelay: 15 }, }); - await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -305,7 +305,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); - await expect(sut.getSystemConfig()).resolves.toMatchObject({ + await expect(sut.getAdminConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ twoPass: false }), }); }); @@ -314,7 +314,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); - await expect(sut.getSystemConfig()).resolves.toMatchObject({ + await expect(sut.getAdminConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ threads: 42 }), }); }); @@ -325,7 +325,7 @@ describe(SystemConfigService.name, () => { JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }), ); - await expect(sut.getSystemConfig()).resolves.toMatchObject({ + await expect(sut.getAdminConfig()).resolves.toMatchObject({ library: { scan: { enabled: true, @@ -339,7 +339,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ oauth: { issuerUrl: 'accounts.google.com' } })); - await expect(sut.getSystemConfig()).rejects.toThrow( + await expect(sut.getAdminConfig()).rejects.toThrow( '[oauth.issuerUrl] Issuer URL must be an empty string or a valid URL', ); }); @@ -348,7 +348,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); - await expect(sut.getSystemConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression'); + await expect(sut.getAdminConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression'); }); it('should log errors with the config file', async () => { @@ -356,7 +356,7 @@ describe(SystemConfigService.name, () => { mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); - await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getAdminConfig()).rejects.toBeInstanceOf(Error); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(mocks.logger.error).toHaveBeenCalledTimes(2); @@ -380,7 +380,7 @@ describe(SystemConfigService.name, () => { `; mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); - await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); @@ -389,7 +389,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.getSystemConfig()).resolves.toEqual(defaults); + await expect(sut.getAdminConfig()).resolves.toEqual(defaults); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -399,7 +399,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getSystemConfig(); + const config = await sut.getAdminConfig(); expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); }); @@ -420,7 +420,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { server: { externalDomain } }; mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getSystemConfig(); + const config = await sut.getAdminConfig(); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); }); } @@ -432,7 +432,7 @@ describe(SystemConfigService.name, () => { `; mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); - await sut.getSystemConfig(); + await sut.getAdminConfig(); expect(mocks.logger.warn).toHaveBeenCalled(); }); @@ -467,12 +467,12 @@ describe(SystemConfigService.name, () => { mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.throws) { - await expect(sut.getSystemConfig()).rejects.toThrow(test.throws); + await expect(sut.getAdminConfig()).rejects.toThrow(test.throws); } else if (test.warn) { - await sut.getSystemConfig(); + await sut.getAdminConfig(); expect(mocks.logger.warn).toHaveBeenCalled(); } else { - const config = await sut.getSystemConfig(); + const config = await sut.getAdminConfig(); test.check!(config); } }); @@ -482,7 +482,7 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit an event', async () => { mocks.systemMetadata.get.mockResolvedValue(partialConfig); - await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); + await expect(sut.updateAdminConfig(updatedConfig)).resolves.toEqual(updatedConfig); expect(mocks.event.emit).toHaveBeenCalledWith( 'ConfigUpdate', expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), @@ -492,7 +492,7 @@ describe(SystemConfigService.name, () => { it('should throw an error if a config file is in use', async () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.updateAdminConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index faa4f8d423..055a21f1c5 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,8 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import _ from 'lodash'; -import { defaults } from 'src/config'; import { OnEvent } from 'src/decorators'; -import { mapConfig, SystemConfigDto } from 'src/dtos/system-config.dto'; +import { + AdminConfigDto, + defaults, + mapAdminConfig, + mapPublicConfig, + mapUserConfig, + PublicConfigDto, + UserConfigDto, +} from 'src/dtos/config.dto'; import { BootstrapEventPriority } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -22,13 +29,31 @@ export class SystemConfigService extends BaseService { this.machineLearningRepository.teardown(); } - async getSystemConfig(): Promise { + async getAdminConfig(): Promise { const config = await this.getConfig({ withCache: false }); - return mapConfig(config); + return mapAdminConfig(config); } - getDefaults(): SystemConfigDto { - return mapConfig(defaults); + getAdminConfigDefaults(): AdminConfigDto { + return mapAdminConfig(defaults); + } + + async getUserConfig(): Promise { + const config = await this.getConfig({ withCache: false }); + return mapUserConfig(config); + } + + getUserConfigDefaults(): UserConfigDto { + return mapUserConfig(defaults); + } + + async getPublicConfig(): Promise { + const config = await this.getConfig({ withCache: false }); + return mapPublicConfig(config); + } + + getPublicConfigDefaults(): PublicConfigDto { + return mapPublicConfig(defaults); } @OnEvent({ name: 'ConfigInit', priority: -100 }) @@ -56,7 +81,7 @@ export class SystemConfigService extends BaseService { } } - async updateSystemConfig(dto: SystemConfigDto): Promise { + async updateAdminConfig(dto: AdminConfigDto): Promise { const { configFile } = this.configRepository.getEnv(); if (configFile) { throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); @@ -75,7 +100,7 @@ export class SystemConfigService extends BaseService { await this.eventRepository.emit('ConfigUpdate', { newConfig, oldConfig }); - return mapConfig(newConfig); + return mapAdminConfig(newConfig); } async getCustomCss(): Promise { diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 0044730cee..b5fc6764f2 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,8 +1,7 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; -import { defaults } from 'src/config'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; -import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; +import { defaults } from 'src/dtos/config.dto'; +import { CronJob, JobName, JobStatus, ReleaseChannel, SystemMetadataKey } from 'src/enum'; import { VersionService } from 'src/services/version.service'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index f1abeedb30..85bf579fbd 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -4,8 +4,16 @@ import semver, { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; -import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; +import { + CronJob, + DatabaseLock, + ImmichWorker, + JobName, + JobStatus, + QueueName, + ReleaseChannel, + SystemMetadataKey, +} from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { VersionCheckMetadata } from 'src/types'; diff --git a/server/src/types.ts b/server/src/types.ts index d31841ffd3..3b5a937245 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,9 +1,9 @@ import { ShallowDehydrateObject } from 'kysely'; -import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { @@ -34,10 +34,10 @@ import { Mocked } from 'vitest'; export type DeepPartial = T extends Date ? T - : T extends Record - ? { [K in keyof T]?: DeepPartial } - : T extends Array - ? DeepPartial[] + : T extends Array + ? DeepPartial[] + : T extends object + ? { [K in keyof T]?: DeepPartial } : T; export type RepositoryInterface = Pick; diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index a6073471d1..38e5facb03 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -1,8 +1,7 @@ import AsyncLock from 'async-lock'; import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigSchema } from 'src/dtos/system-config.dto'; +import { AdminConfigDto, SystemConfig, defaults } from 'src/dtos/config.dto'; import { DatabaseLock, SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -100,7 +99,7 @@ const buildConfig = async (repos: RepoDeps) => { } // validate with Zod schema - const result = SystemConfigSchema.safeParse(rawConfig); + const result = AdminConfigDto.schema.safeParse(rawConfig); if (!result.success) { const messages = ['Invalid system config: ']; for (const issue of result.error.issues) { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 656e5bf441..49877904ec 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,5 +1,5 @@ import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { ConfigFFmpegDto } from 'src/dtos/config.dto'; import { ColorMatrix, ColorPrimaries, @@ -62,18 +62,18 @@ export const getCodecString = (codec: VideoCodec, width: number, height: number, export class BaseConfig implements VideoCodecSWConfig { readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; protected constructor( - protected config: SystemConfigFFmpegDto, + protected config: ConfigFFmpegDto, protected tune: VideoTuning = { strictGop: false, lowLatency: false }, ) {} - static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { + static create(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { if (config.accel === TranscodeHardwareAcceleration.Disabled) { return BaseConfig.getSWCodecConfig(config, tune); } return BaseConfig.getHWCodecConfig(config, interfaces, tune); } - private static getSWCodecConfig(config: SystemConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig { + private static getSWCodecConfig(config: ConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig { switch (config.targetVideoCodec) { case VideoCodec.H264: { return new H264Config(config, tune); @@ -93,7 +93,7 @@ export class BaseConfig implements VideoCodecSWConfig { } } - private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { + private static getHWCodecConfig(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { if (!SUPPORTED_HWA_CODECS[config.accel].includes(config.targetVideoCodec)) { throw new Error( `${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${SUPPORTED_HWA_CODECS[config.accel]}`, @@ -424,7 +424,7 @@ export class BaseHWConfig extends BaseConfig { protected device: string; constructor( - protected config: SystemConfigFFmpegDto, + protected config: ConfigFFmpegDto, protected interfaces: VideoInterfaces, tune?: VideoTuning, ) { @@ -471,7 +471,7 @@ export class BaseHWConfig extends BaseConfig { } export class ThumbnailConfig extends BaseConfig { - static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig { + static create(config: ConfigFFmpegDto): VideoCodecSWConfig { return new ThumbnailConfig(config); } diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index a514007987..84ebc7e980 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -13,9 +13,9 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; import picomatch from 'picomatch'; import parse from 'picomatch/lib/parse'; -import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants'; import { extraModels } from 'src/decorators'; +import { SystemConfig } from 'src/dtos/config.dto'; import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; diff --git a/server/src/utils/profile-image.ts b/server/src/utils/profile-image.ts index ee94dd8986..ea2c0d3b6b 100644 --- a/server/src/utils/profile-image.ts +++ b/server/src/utils/profile-image.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; -import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; +import { SystemConfig } from 'src/dtos/config.dto'; import { StorageFolder } from 'src/enum'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { MediaRepository } from 'src/repositories/media.repository'; diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index 355f2cc1a3..6ba6556921 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -1,4 +1,4 @@ -import { SystemConfig } from 'src/config'; +import { SystemConfig } from 'src/dtos/config.dto'; import { DeepPartial } from 'src/types'; export const systemConfigStub = { diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 1fbe41d964..09cef8947c 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -4,9 +4,9 @@ import { createHash, randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { resolve } from 'node:path'; import { Writable } from 'node:stream'; -import { SystemConfig } from 'src/config'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto'; import { AlbumUserRole, diff --git a/server/test/utils.ts b/server/test/utils.ts index 3bdbe1f5d5..f8d3c15e22 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -95,7 +95,9 @@ export type ControllerContext = { close: () => Promise; }; -export const controllerSetup = async (controller: new (...args: any[]) => unknown, providers: Provider[]) => { +type ControllerClass = new (...args: any[]) => unknown; + +export const controllerSetup = async (controller: ControllerClass | ControllerClass[], providers: Provider[]) => { const noopInterceptor = { intercept: (ctx: never, next: CallHandler) => next.handle() }; const upload = multer({ storage: multer.memoryStorage() }); const memoryFileInterceptor = { @@ -117,7 +119,7 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow }, }; const moduleRef = await Test.createTestingModule({ - controllers: [controller], + controllers: Array.isArray(controller) ? controller : [controller], providers: [ { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useClass: ZodValidationPipe }, diff --git a/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte b/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte index da58d779e9..3948b6bbc2 100644 --- a/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte +++ b/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte @@ -1,15 +1,15 @@ -{#if !authManager.isSharedLink && isOwner} +{#if !authManager.isSharedLink}
-
- {$t('people')} -
- {#if people.some((person) => person.isHidden)} - assetViewerManager.toggleHiddenPeople()} - /> - {/if} - assetViewerManager.toggleFaceEditMode()} - /> + {#if isOwner || visiblePeople.length > 0} +
+ {$t('people')} +
+ {#if isOwner} + {#if people.some((person) => person.isHidden)} + assetViewerManager.toggleHiddenPeople()} + /> + {/if} + assetViewerManager.toggleFaceEditMode()} + /> - {#if faceManager.data.length > 0} - assetViewerManager.openEditFacesPanel()} - /> - {/if} + {#if faceManager.data.length > 0} + assetViewerManager.openEditFacesPanel()} + /> + {/if} + {/if} +
-
+ {/if}
{#each visiblePeople as person (person.id)} diff --git a/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte b/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte index 79cfff858a..4e6929a87e 100644 --- a/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte @@ -2,6 +2,8 @@ import { goto } from '$app/navigation'; import { focusTrap } from '$lib/actions/focus-trap'; import NotificationItem from '$lib/components/shared-components/navigation-bar/NotificationItem.svelte'; + import { OpenQueryParam } from '$lib/constants'; + import { Route } from '$lib/route'; import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { NotificationType, type NotificationDto } from '@immich/sdk'; @@ -50,6 +52,11 @@ break; } + case NotificationType.ClusterGroupRequest: { + await goto(Route.userSettings({ isOpen: OpenQueryParam.SHARING })); + break; + } + default: { break; } diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 802f3bc63b..530db1e8d5 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -67,6 +67,7 @@ export enum OpenQueryParam { STORAGE_TEMPLATE = 'storage-template', NOTIFICATIONS = 'notifications', PURCHASE_SETTINGS = 'user-purchase-settings', + SHARING = 'sharing', } export const maximumLengthSearchPeople = 100; diff --git a/web/src/lib/modals/ClusterGroupUserSelectionModal.svelte b/web/src/lib/modals/ClusterGroupUserSelectionModal.svelte new file mode 100644 index 0000000000..7ee46adfbe --- /dev/null +++ b/web/src/lib/modals/ClusterGroupUserSelectionModal.svelte @@ -0,0 +1,60 @@ + + + + + {#await loadUsers()} +
+ +
+ {:then _} + {#if availableUsers.length > 0} +
+ {#each availableUsers as user (user.id)} + selectUser(user)} selected={selectedUsers.some(({ id }) => id === user.id)}> + +
+ {user.name} + {user.email} +
+
+ {/each} +
+ + + + + {:else} + {$t('partner_page_no_more_users')} + {/if} + {/await} +
+
diff --git a/web/src/lib/modals/ClusterGroupUsersModal.svelte b/web/src/lib/modals/ClusterGroupUsersModal.svelte new file mode 100644 index 0000000000..2bfb61257c --- /dev/null +++ b/web/src/lib/modals/ClusterGroupUsersModal.svelte @@ -0,0 +1,54 @@ + + + + + {#await loadUsers()} +
+ +
+ {:then _} + {$t('cluster_group_invite_description')} + +
+ {$t('users')} +
+ +
+ {#each users as user (user.id)} +
+ +
+ {user.name} + {user.email} +
+
+ {/each} +
+ {/await} +
+ + + + + + + +
diff --git a/web/src/routes/(user)/user-settings/PartnerSettings.svelte b/web/src/routes/(user)/user-settings/PartnerSettings.svelte deleted file mode 100644 index af1eff38bf..0000000000 --- a/web/src/routes/(user)/user-settings/PartnerSettings.svelte +++ /dev/null @@ -1,194 +0,0 @@ - - -
- {#if partners.length > 0} - {#each partners as partner (partner.user.id)} -
-
-
- -
-

- {partner.user.name} -

-

- {partner.user.email} -

-
-
- - {#if partner.sharedByMe} - handleRemovePartner(partner.user)} - icon={mdiClose} - size="small" - aria-label={$t('stop_sharing_photos_with_user')} - /> - {/if} -
- -
- - {#if partner.sharedByMe} -
- - {$t('shared_with_partner', { values: { partner: partner.user.name } })} - - {$t('partner_can_access', { values: { partner: partner.user.name } })} -
    -
  • - - {$t('partner_can_access_assets')} -
  • -
  • - - {$t('partner_can_access_location')} -
  • -
- {/if} - - - {#if partner.sharedWithMe} -
- - {$t('shared_from_partner', { values: { partner: partner.user.name } })} - - - handleShowOnTimelineChanged(partner, isChecked)} - /> - {/if} -
-
- {/each} - {/if} - -
- -
-
diff --git a/web/src/routes/(user)/user-settings/SharingSettings.svelte b/web/src/routes/(user)/user-settings/SharingSettings.svelte new file mode 100644 index 0000000000..8e0ea59938 --- /dev/null +++ b/web/src/routes/(user)/user-settings/SharingSettings.svelte @@ -0,0 +1,379 @@ + + +
+ {$t('cluster_group')} + {$t('cluster_group_description')} + + + + {#each users as user, index (user.id)} +
0}> +
+ +
+

+ {user.name} + {#if user.id === authManager.user.id} + ({$t('you')}) + {/if} +

+

{user.email}

+
+
+ + {#if user.id === authManager.user.id && canLeave} + + {/if} +
+ {/each} +
+
+ + {#if sentRequests.length > 0 || receivedRequests.length > 0} +
+ {$t('pending')} +
+ + + + {#each receivedRequests as request, index (request.id)} +
0}> + {$t('request_received_description')} +
+ +
+
+ {/each} + + {#each sentRequests as request, index (request.id)} + {@const user = candidates[request.userId]} +
0 || receivedRequests.length > 0}> +
+ {#if user} + + {/if} +
+

{user?.name ?? request.userId}

+

{user?.email ?? ''}

+
+
+ + +
+ {/each} +
+
+ {/if} + +
+ +
+
+ +
+ {$t('partners')} + + {#if partners.length > 0} + {#each partners as partner (partner.user.id)} +
+
+
+ +
+

+ {partner.user.name} +

+

+ {partner.user.email} +

+
+
+ + {#if partner.sharedByMe} + handleRemovePartner(partner.user)} + icon={mdiClose} + size="small" + aria-label={$t('stop_sharing_photos_with_user')} + /> + {/if} +
+ +
+ + {#if partner.sharedByMe} +
+ + {$t('shared_with_partner', { values: { partner: partner.user.name } })} + + {$t('partner_can_access', { values: { partner: partner.user.name } })} +
    +
  • + + {$t('partner_can_access_assets')} +
  • +
  • + + {$t('partner_can_access_location')} +
  • +
+ {/if} + + + {#if partner.sharedWithMe} +
+ + {$t('shared_from_partner', { values: { partner: partner.user.name } })} + + + handleShowOnTimelineChanged(partner, isChecked)} + /> + {/if} +
+
+ {/each} + {/if} + +
+ +
+
diff --git a/web/src/routes/(user)/user-settings/UserSettingsList.svelte b/web/src/routes/(user)/user-settings/UserSettingsList.svelte index 91c32560d8..b6595ed60f 100644 --- a/web/src/routes/(user)/user-settings/UserSettingsList.svelte +++ b/web/src/routes/(user)/user-settings/UserSettingsList.svelte @@ -31,7 +31,7 @@ import ChangePasswordSettings from './ChangePasswordSettings.svelte'; import DeviceList from './DeviceList.svelte'; import OauthSettings from './OauthSettings.svelte'; - import PartnerSettings from './PartnerSettings.svelte'; + import SharingSettings from './SharingSettings.svelte'; import UserApiKeyList from './UserApiKeyList.svelte'; import UserProfileSettings from './UserProfileSettings.svelte'; @@ -129,15 +129,6 @@ - - - - + + + + diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 7b56c275e2..edc1ead1ce 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -4,6 +4,7 @@ import { Sync } from 'factory.ts'; export const userAdminFactory = Sync.makeFactory({ id: Sync.each(() => faker.string.uuid()), + clusterGroupId: Sync.each(() => faker.string.uuid()), email: Sync.each(() => faker.internet.email()), name: Sync.each(() => faker.person.fullName()), profileImagePath: '', From c8199ef32fe68fa481b557624f32c021b26dd9f3 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Thu, 20 Aug 2026 11:17:05 -0700 Subject: [PATCH 67/78] chore(mobile): update flutter-maplibre-gl to 0.27.0 (#30892) --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- mobile/pubspec.lock | 12 ++++++------ mobile/pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index da0f59f87d..4cf0fac890 100644 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "84a79bc375a301169390ac110c868f06c857b83f", - "version" : "6.27.0" + "revision" : "5ee345ca5d65238a6fce29bba87816204be7df20", + "version" : "6.28.0" } }, { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 34676069dd..65cd03c3ae 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1072,26 +1072,26 @@ packages: dependency: "direct main" description: name: maplibre_gl - sha256: b676f124a2fcf88c4dafedc7b462155e6396d61ce7af46354b4de11b45c9812f + sha256: "8b24dce6a050ba44779154c550e0318654ee12c572fa5fd1ad0c12e90d061356" url: "https://pub.dev" source: hosted - version: "0.26.2" + version: "0.27.0" maplibre_gl_platform_interface: dependency: transitive description: name: maplibre_gl_platform_interface - sha256: "1f0ca8a99f03fa9434618ee21f4e42dd615830a7dd973e632b11c73ffec993a8" + sha256: "8a1952b77ce841162fcd2601dc857f5b3f8eea46d34025b901ef803bb15d2adc" url: "https://pub.dev" source: hosted - version: "0.26.2" + version: "0.27.0" maplibre_gl_web: dependency: transitive description: name: maplibre_gl_web - sha256: bbf022f29ceef26d73f63e584819fbd02fbaf4eb1facf5234206c78936cf8f1c + sha256: c9c7bb1183cc9c6baafcef0b5b741420db869ca45e474ca80edf732168a286ee url: "https://pub.dev" source: hosted - version: "0.26.2" + version: "0.27.0" matcher: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5a83ad7556..9bda1b20ff 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: intl: ^0.20.2 local_auth: ^2.3.0 logging: ^1.3.0 - maplibre_gl: ^0.26.0 + maplibre_gl: ^0.27.0 native_video_player: git: url: https://github.com/immich-app/native_video_player From 24532c4d8216c228d6204d9076e83b6aa2dc6391 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 20 Aug 2026 16:37:43 -0400 Subject: [PATCH 68/78] fix(ci): docker caching (#30894) --- .github/workflows/test.yml | 42 ------------------- e2e/docker-compose.yml | 6 +-- packages/e2e-auth-server/Dockerfile | 12 ++++-- server/Dockerfile | 62 +++++++++++++++++++---------- 4 files changed, 52 insertions(+), 70 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfb31f064a..8a4ecceb14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -397,7 +397,6 @@ jobs: runs-on: ${{ matrix.runner }} permissions: contents: read - packages: write defaults: run: working-directory: ./e2e @@ -441,33 +440,8 @@ jobs: run: pnpm install --frozen-lockfile if: ${{ !cancelled() }} - - name: Setup Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Login to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - if: ${{ github.event_name == 'push' && !github.event.pull_request.head.repo.fork }} - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Resolve build cache - id: cache - run: | - ref=ghcr.io/immich-app/immich-server-build-cache:e2e-main-${{ runner.arch }} - echo "ref=$ref" >> "$GITHUB_OUTPUT" - if docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then - echo "from=type=registry,ref=$ref" >> "$GITHUB_OUTPUT" - else - echo "from=" >> "$GITHUB_OUTPUT" - fi - - name: Start Docker Compose id: docker - env: - E2E_CACHE_FROM: ${{ steps.cache.outputs.from }} - E2E_CACHE_TO: ${{ github.event_name == 'push' && !github.event.pull_request.head.repo.fork && format('type=registry,ref={0},mode=max', steps.cache.outputs.ref) || '' }} run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 if: ${{ !cancelled() }} @@ -545,24 +519,8 @@ jobs: run: pnpm exec playwright install chromium --only-shell if: ${{ !cancelled() }} - - name: Setup Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Resolve build cache - id: cache - run: | - ref=ghcr.io/immich-app/immich-server-build-cache:e2e-main-${{ runner.arch }} - echo "ref=$ref" >> "$GITHUB_OUTPUT" - if docker buildx imagetools inspect "$ref" > /dev/null 2>&1; then - echo "from=type=registry,ref=$ref" >> "$GITHUB_OUTPUT" - else - echo "from=" >> "$GITHUB_OUTPUT" - fi - - name: Docker build id: docker - env: - E2E_CACHE_FROM: ${{ steps.cache.outputs.from }} run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300 if: ${{ !cancelled() }} diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index bf9c62087f..dd7f52e0f3 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -16,9 +16,9 @@ services: context: ../ dockerfile: server/Dockerfile cache_from: - - ${E2E_CACHE_FROM:-} - cache_to: - - ${E2E_CACHE_TO:-} + # hash is the sha256 of the build-args in docker.yml + - type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main + - type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main args: - BUILD_ID=1234567890 - BUILD_IMAGE=e2e diff --git a/packages/e2e-auth-server/Dockerfile b/packages/e2e-auth-server/Dockerfile index 087fa45d23..183b4ef096 100644 --- a/packages/e2e-auth-server/Dockerfile +++ b/packages/e2e-auth-server/Dockerfile @@ -1,9 +1,15 @@ FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d WORKDIR /usr/src/app -COPY package* pnpm* .pnpmfile.cjs ./ -COPY ./packages ./packages/ -WORKDIR /usr/src/app/packages/e2e-auth-server + RUN corepack enable + +COPY package* pnpm* .pnpmfile.cjs ./ +COPY ./packages/e2e-auth-server/package.json ./packages/e2e-auth-server/ + +WORKDIR /usr/src/app/packages/e2e-auth-server RUN pnpm install --frozen-lockfile + +COPY ./packages/e2e-auth-server ./ + EXPOSE 2286 CMD ["pnpm", "run", "start"] diff --git a/server/Dockerfile b/server/Dockerfile index 419c650653..95f842e658 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -13,38 +13,58 @@ WORKDIR /usr/src/app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cjs ./ -FROM builder AS server +FROM builder AS sdk + +COPY ./packages/sdk/package.json ./packages/sdk/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/sdk install --frozen-lockfile + +COPY ./packages/sdk ./packages/sdk/ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/sdk build + +FROM sdk AS plugin-sdk + +COPY ./packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/plugin-sdk install --frozen-lockfile + +COPY ./packages/plugin-sdk ./packages/plugin-sdk/ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/plugin-sdk build + +FROM plugin-sdk AS server + +COPY ./server/package.json ./server/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter immich install --frozen-lockfile COPY ./server ./server/ -COPY ./packages/sdk ./packages/sdk/ -COPY ./packages/plugin-sdk ./packages/plugin-sdk/ -RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ - pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \ - pnpm --filter immich --prod --no-optional deploy /output/server-pruned && \ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ + pnpm --filter immich build && \ + pnpm --filter immich --prod --no-optional deploy /output/server-pruned +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --dir /output/server-pruned/node_modules/sharp exec npm run build -FROM builder AS web +FROM sdk AS web + +COPY ./web/package.json ./web/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter immich-web install --frozen-lockfile + +COPY ./i18n ./i18n/ +COPY ./web ./web/ ARG BUILD_ID ENV IMMICH_BUILD=${BUILD_ID} -COPY ./web ./web/ -COPY ./i18n ./i18n/ -COPY ./packages/sdk ./packages/sdk/ -RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \ - pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile && \ - pnpm --filter @immich/sdk --filter immich-web build +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter immich-web build -FROM builder AS cli +FROM sdk AS cli + +COPY ./packages/cli/package.json ./packages/cli/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/cli install --frozen-lockfile -COPY ./packages/sdk ./packages/sdk/ COPY ./packages/cli ./packages/cli/ -RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ - pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && \ - pnpm --filter @immich/sdk --filter @immich/cli build && \ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ + pnpm --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned -FROM builder AS plugins +FROM plugin-sdk AS plugins ARG TARGETPLATFORM @@ -59,12 +79,10 @@ ENV MISE_DISABLE_TOOLS=flutter RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise install --locked -COPY ./packages/sdk ./packages/sdk/ COPY ./packages/plugin-core ./packages/plugin-core/ -COPY ./packages/plugin-sdk ./packages/plugin-sdk/ # Build plugins -RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise //:plugins From c7f6197e75560e2f8c81a8cd404da445edab341a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sacha=20Brout=C3=A9?= <3247529+sacha-c@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:31:34 +0200 Subject: [PATCH 69/78] feat: add oauth account management url (#30873) --- e2e/src/specs/server/api/oauth.e2e-spec.ts | 1 + e2e/src/specs/server/api/server.e2e-spec.ts | 1 + i18n/en.json | 3 +++ open-api/immich-openapi-specs.json | 10 ++++++++++ packages/sdk/src/fetch-client.ts | 4 ++++ server/src/dtos/config.dto.ts | 5 +++++ server/src/dtos/server.dto.ts | 1 + server/src/repositories/oauth.repository.ts | 1 + server/src/services/server.service.spec.ts | 1 + server/src/services/server.service.ts | 1 + server/src/services/system-config.service.spec.ts | 1 + .../routes/(user)/user-settings/OauthSettings.svelte | 8 +++++++- .../routes/admin/system-settings/AuthSettings.svelte | 10 ++++++++++ 13 files changed, 46 insertions(+), 1 deletion(-) diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index 10d3343a78..5bb569d5d2 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -77,6 +77,7 @@ const setupOAuth = async (token: string, dto: Partial) => { ...defaults.oauth, buttonText: 'Login with Immich', issuerUrl: `${authServer.internal}/.well-known/openid-configuration`, + accountManagementUrl: authServer.internal, allowInsecureRequests: true, ...dto, }; diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index c6ab9a6704..ea410186c9 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -121,6 +121,7 @@ describe('/server', () => { expect(body).toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', + oauthAccountManagementUrl: '', trashDays: 30, userDeleteDelay: 7, isInitialized: true, diff --git a/i18n/en.json b/i18n/en.json index 92bc2d1318..57a8ae9503 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -294,6 +294,8 @@ "notification_enable_email_notifications": "Enable email notifications", "notification_settings": "Notification Settings", "notification_settings_description": "Manage notification settings, including email", + "oauth_account_management_url": "Account Management URL", + "oauth_account_management_url_description": "Location in the external identity provider where a user can manage their settings or profile.", "oauth_allow_insecure_requests": "Allow insecure requests", "oauth_allow_insecure_requests_description": "WARNING: This disables TLS certificate validation for OAuth requests and may expose you to MITM attacks.", "oauth_auto_launch": "Auto launch", @@ -1379,6 +1381,7 @@ "manage_media_access_settings": "Open settings", "manage_media_access_subtitle": "Allow the Immich app to manage and move media files.", "manage_media_access_title": "Media Management Access", + "manage_oauth_account": "Manage OAuth Account", "manage_sharing_with_other_users": "Manage sharing with other users", "manage_sharing_with_partners": "Manage sharing with partners", "manage_the_app_settings": "Manage the app settings", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 584510e364..2d471be154 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -18232,6 +18232,11 @@ }, "AdminConfigOAuthDto": { "properties": { + "accountManagementUrl": { + "default": "", + "description": "Account management URL", + "type": "string" + }, "allowInsecureRequests": { "description": "Allow insecure requests", "type": "boolean" @@ -24593,6 +24598,11 @@ "minimum": -9007199254740991, "type": "integer" }, + "oauthAccountManagementUrl": { + "default": "", + "description": "OAuth account management URL", + "type": "string" + }, "oauthButtonText": { "description": "OAuth button text", "type": "string" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index f0ee028324..e511b4d589 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -313,6 +313,8 @@ export type AdminConfigNotificationsDto = { smtp: AdminConfigSmtpDto; }; export type AdminConfigOAuthDto = { + /** Account management URL */ + accountManagementUrl?: string; /** Allow insecure requests */ allowInsecureRequests: boolean; /** Auto launch */ @@ -2521,6 +2523,8 @@ export type ServerConfigDto = { mapLightStyleUrl: string; /** People min faces server default */ minFaces: number; + /** OAuth account management URL */ + oauthAccountManagementUrl?: string; /** OAuth button text */ oauthButtonText: string; /** Whether public user registration is enabled */ diff --git a/server/src/dtos/config.dto.ts b/server/src/dtos/config.dto.ts index a7dbaf0cc6..b0f99996eb 100644 --- a/server/src/dtos/config.dto.ts +++ b/server/src/dtos/config.dto.ts @@ -291,6 +291,10 @@ const AdminConfigSchemaWithVisibility = z defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'), enabled: configBool.describe('Enabled').meta({ visibility: Public }), issuerUrl: emptyOrUrl('Issuer URL must be an empty string or a valid URL').describe('Issuer URL'), + accountManagementUrl: emptyOrUrl('Account management URL must be an empty string or a valid URL') + .describe('Account management URL') + .optional() + .default(''), scope: z.string().describe('Scope'), prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'), endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe( @@ -668,6 +672,7 @@ export const defaults = Object.freeze({ defaultStorageQuota: null, enabled: false, issuerUrl: '', + accountManagementUrl: '', endSessionEndpoint: '', mobileOverrideEnabled: false, mobileRedirectUri: '', diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 0215558aab..f2aaa11d93 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -114,6 +114,7 @@ const ServerMediaTypesResponseSchema = z const ServerConfigSchema = z .object({ oauthButtonText: z.string().describe('OAuth button text'), + oauthAccountManagementUrl: z.string().describe('OAuth account management URL').optional().default(''), loginPageMessage: z.string().describe('Login page message'), trashDays: z.int().describe('Number of days before trashed assets are permanently deleted'), userDeleteDelay: z.int().describe('Delay in days before deleted users are permanently removed'), diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index 1a09fff70d..0d56343f53 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -22,6 +22,7 @@ export type OAuthConfig = { clientId: string; clientSecret?: string; issuerUrl: string; + accountManagementUrl: string; endSessionEndpoint: string; mobileOverrideEnabled: boolean; mobileRedirectUri: string; diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index b4b1af35d6..82f53bcf38 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -160,6 +160,7 @@ describe(ServerService.name, () => { await expect(sut.getSystemConfig()).resolves.toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', + oauthAccountManagementUrl: '', trashDays: 30, userDeleteDelay: 7, isInitialized: false, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 57342f9509..6732099fab 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -120,6 +120,7 @@ export class ServerService extends BaseService { trashDays: config.trash.days, userDeleteDelay: config.user.deleteDelay, oauthButtonText: config.oauth.buttonText, + oauthAccountManagementUrl: config.oauth.accountManagementUrl, isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 90d91ccf48..e6ada2f0b0 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -154,6 +154,7 @@ const updatedConfig = Object.freeze({ enabled: true, }, oauth: { + accountManagementUrl: '', autoLaunch: true, autoRegister: true, buttonText: 'Login with OAuth', diff --git a/web/src/routes/(user)/user-settings/OauthSettings.svelte b/web/src/routes/(user)/user-settings/OauthSettings.svelte index 0da42b2b2d..2b6e20cf21 100644 --- a/web/src/routes/(user)/user-settings/OauthSettings.svelte +++ b/web/src/routes/(user)/user-settings/OauthSettings.svelte @@ -1,5 +1,6 @@ {#if downloadManager.isDownloading}
- {$t('downloading')} +
+ {$t('prepared_archives')} + +
- {#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)} - {@const download = downloadManager.assets[downloadKey]} -
-
+ {#each downloadManager.assets as [downloadKey, download] (downloadKey)} +
+

{downloadKey}

{#if download.total}

{getByteUnitString(download.total, $locale)}

{/if}
-
-
-
-
-

- - {(download.percentage / 100).toLocaleString($locale, { style: 'percent' })} - -

-
-
+
abort(downloadKey, download)} + aria-label={$t(download.downloaded ? 'retry' : 'download')} + onclick={() => startDownload(downloadKey, download)} size="tiny" - icon={mdiClose} + icon={download.downloaded ? mdiReload : mdiDownload} />
From 8ce6b18563cf3361ff598ee93862894ab021c9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20G=C4=99borys?= <118941925+PAtreju@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:28:12 +0200 Subject: [PATCH 72/78] fix(server): library exclusion patterns can soft-delete assets outside their directory (#30850) Co-authored-by: Daniel Dietzler --- server/src/queries/asset.repository.sql | 2 +- server/src/repositories/asset.repository.ts | 6 ++-- server/src/utils/misc.spec.ts | 32 +++++++++++------- server/src/utils/misc.ts | 36 +++------------------ 4 files changed, 28 insertions(+), 48 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index b790705cc8..dbdae444db 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -531,7 +531,7 @@ where and "libraryId" = $5::uuid and ( not "originalPath" like $6 - or "originalPath" like $7 + or "originalPath" ~ $7 ) -- AssetRepository.filterNewExternalAssetPaths diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 9427bfca6a..189c5c621f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -54,7 +54,7 @@ import { withTagId, withTags, } from 'src/utils/database'; -import { globToSqlPattern } from 'src/utils/misc'; +import { globToPostgresRegex } from 'src/utils/misc'; export type AssetStats = Record; @@ -1086,7 +1086,7 @@ export class AssetRepository { exclusionPatterns: string[], ): Promise { const paths = importPaths.map((importPath) => `${importPath}%`); - const exclusions = exclusionPatterns.map((pattern) => globToSqlPattern(pattern)); + const exclusions = exclusionPatterns.map((pattern) => globToPostgresRegex(pattern)); return this.db .updateTable('asset') @@ -1100,7 +1100,7 @@ export class AssetRepository { .where((eb) => eb.or([ eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))), - eb.or(exclusions.map((path) => eb('originalPath', 'like', path))), + eb.or(exclusions.map((pattern) => eb('originalPath', '~', pattern))), ]), ) .executeTakeFirstOrThrow(); diff --git a/server/src/utils/misc.spec.ts b/server/src/utils/misc.spec.ts index 3c45482938..e6b36d4a0d 100644 --- a/server/src/utils/misc.spec.ts +++ b/server/src/utils/misc.spec.ts @@ -1,4 +1,4 @@ -import { getKeysDeep, globToSqlPattern, unsetDeep } from 'src/utils/misc'; +import { getKeysDeep, globToPostgresRegex, unsetDeep } from 'src/utils/misc'; import { describe, expect, it } from 'vitest'; describe('getKeysDeep', () => { @@ -52,18 +52,26 @@ describe('unsetDeep', () => { }); }); -describe('globToSqlPattern', () => { - const testCases = [ - ['**/Raw/**', '%/Raw/%'], - ['**/abc/*.tif', '%/abc/%.tif'], - ['**/*.tif', '%/%.tif'], - ['**/*.jp?', '%/%.jp_'], - ['**/@eaDir/**', '%/@eaDir/%'], - ['**/._*', `%/._%`], - ['/absolute/path/**', `/absolute/path/%`], +const matches = (glob: string, value: string) => new RegExp(globToPostgresRegex(glob)).test(value); + +describe('globToPostgresRegex', () => { + const testCases: [string, string, boolean][] = [ + ['**/Raw/**', '/foo/Raw/bar.jpg', true], + ['**/Raw/**', '/foo/bar.jpg', false], + ['**/abc/*.tif', '/foo/abc/scan.tif', true], + ['**/abc/*.tif', '/foo/abc/sub/scan.tif', false], + ['**/*.tif', '/foo/bar.tif', true], + ['**/*.jp?', '/foo/bar.jpg', true], + ['**/@eaDir/**', '/foo/@eaDir/thumb.jpg', true], + ['**/._*', '/foo/._resource', true], + ['/absolute/path/**', '/absolute/path/photo.jpg', true], + ['/absolute/path/**', '/other/path/photo.jpg', false], + // a bare `*` must not cross a path separator, unlike SQL `LIKE`'s `%` + ['/path/*.*', '/path/photo.jpg', true], + ['/path/*.*', '/path/2020/photo.jpg', false], ]; - it.each(testCases)('should convert %s to %s', (input, expected) => { - expect(globToSqlPattern(input)).toEqual(expected); + it.each(testCases)('should match %s against %s as %s', (glob, value, expected) => { + expect(matches(glob, value)).toEqual(expected); }); }); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 84ebc7e980..7727a8eb85 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -12,7 +12,6 @@ import { cleanupOpenApiDoc } from 'nestjs-zod'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import picomatch from 'picomatch'; -import parse from 'picomatch/lib/parse'; import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants'; import { extraModels } from 'src/decorators'; import { SystemConfig } from 'src/dtos/config.dto'; @@ -338,37 +337,10 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) } }; -const convertTokenToSqlPattern = (token: parse.Token): string => { - switch (token.type) { - case 'slash': { - return '/'; - } - case 'text': { - return token.value; - } - case 'globstar': - case 'star': { - return '%'; - } - case 'underscore': { - return String.raw`\_`; - } - case 'qmark': { - return '_'; - } - case 'dot': { - return '.'; - } - default: { - return ''; - } - } -}; - -export const globToSqlPattern = (glob: string) => { - const tokens = picomatch.parse(glob).tokens; - return tokens.map((token) => convertTokenToSqlPattern(token)).join(''); -}; +// Compiles a glob to the equivalent Postgres regex (Postgres's Advanced Regular Expression +// dialect is a superset of what picomatch emits, so the two stay in sync with `picomatch.isMatch`, +// including which paths a lone `*` may cross vs `/`). +export const globToPostgresRegex = (glob: string) => picomatch.makeRe(glob).source; export function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); From 4c26a7ca761c8facc1d1e506880f5e5c88c05b26 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 21 Aug 2026 06:57:50 -0400 Subject: [PATCH 73/78] refactor: activity e2e (#30896) --- e2e/src/specs/server/api/activity.e2e-spec.ts | 378 ------------------ server/test/medium.factory.ts | 11 + .../specs/services/activity.service.spec.ts | 310 ++++++++++++++ 3 files changed, 321 insertions(+), 378 deletions(-) delete mode 100644 e2e/src/specs/server/api/activity.e2e-spec.ts create mode 100644 server/test/medium/specs/services/activity.service.spec.ts diff --git a/e2e/src/specs/server/api/activity.e2e-spec.ts b/e2e/src/specs/server/api/activity.e2e-spec.ts deleted file mode 100644 index 9ce9b4b916..0000000000 --- a/e2e/src/specs/server/api/activity.e2e-spec.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { - ActivityCreateDto, - AlbumResponseDto, - AlbumUserRole, - AssetMediaResponseDto, - LoginResponseDto, - ReactionType, - createActivity as create, - createAlbum, - removeAssetFromAlbum, -} from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -describe('/activities', () => { - let admin: LoginResponseDto; - let nonOwner: LoginResponseDto; - let asset: AssetMediaResponseDto; - let album: AlbumResponseDto; - - const createActivity = (dto: ActivityCreateDto, accessToken?: string) => - create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) }); - - beforeAll(async () => { - await utils.resetDatabase(); - - admin = await utils.adminSetup(); - nonOwner = await utils.userSetup(admin.accessToken, createUserDto.user1); - asset = await utils.createAsset(admin.accessToken); - album = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 1', - assetIds: [asset.id], - albumUsers: [{ userId: nonOwner.userId, role: AlbumUserRole.Editor }], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - }); - - beforeEach(async () => { - await utils.resetDatabase(['activity']); - }); - - describe('GET /activities', () => { - it('should start off empty', async () => { - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toEqual([]); - expect(status).toEqual(200); - }); - - it('should filter by album id', async () => { - const album2 = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 2', - assetIds: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - const [reaction] = await Promise.all([ - createActivity({ albumId: album.id, type: ReactionType.Like }), - createActivity({ albumId: album2.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by type=comment', async () => { - const [reaction] = await Promise.all([ - createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'comment', - }), - createActivity({ albumId: album.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, type: 'comment' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by type=like', async () => { - const [reaction] = await Promise.all([ - createActivity({ albumId: album.id, type: ReactionType.Like }), - createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'comment', - }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, type: 'like' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by userId', async () => { - const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); - - const response1 = await request(app) - .get('/activities') - .query({ albumId: album.id, userId: uuidDto.notFound }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(response1.status).toEqual(200); - expect(response1.body.length).toBe(0); - - const response2 = await request(app) - .get('/activities') - .query({ albumId: album.id, userId: admin.userId }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(response2.status).toEqual(200); - expect(response2.body.length).toBe(1); - expect(response2.body[0]).toEqual(reaction); - }); - - it('should filter by assetId', async () => { - const [reaction] = await Promise.all([ - createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }), - createActivity({ albumId: album.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, assetId: asset.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - }); - - describe('POST /activities', () => { - it('should add a comment to an album', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - albumId: album.id, - type: 'comment', - comment: 'This is my first comment', - }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: null, - createdAt: expect.any(String), - type: 'comment', - comment: 'This is my first comment', - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should add a like to an album', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: null, - createdAt: expect.any(String), - type: 'like', - comment: null, - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should return a 200 for a duplicate like on the album', async () => { - const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(200); - expect(body).toEqual(reaction); - }); - - it('should not confuse an album like with an asset like', async () => { - const reaction = await createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }); - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(201); - expect(body.id).not.toEqual(reaction.id); - }); - - it('should add a comment to an asset', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - albumId: album.id, - assetId: asset.id, - type: 'comment', - comment: 'This is my first comment', - }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: asset.id, - createdAt: expect.any(String), - type: 'comment', - comment: 'This is my first comment', - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should add a like to an asset', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, assetId: asset.id, type: 'like' }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: asset.id, - createdAt: expect.any(String), - type: 'like', - comment: null, - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should return a 200 for a duplicate like on an asset', async () => { - const reaction = await createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }); - - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, assetId: asset.id, type: 'like' }); - expect(status).toEqual(200); - expect(body).toEqual(reaction); - }); - }); - - describe('DELETE /activities/:id', () => { - it('should remove a comment from an album', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(204); - }); - - it('should remove a like from an album', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Like, - }); - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(204); - }); - - it('should let the owner remove a comment by another user', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toEqual(204); - }); - - it('should not let a user remove a comment by another user', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - - const { status, body } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${nonOwner.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access')); - }); - - it('should let a non-owner remove their own comment', async () => { - const reaction = await createActivity( - { - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }, - nonOwner.accessToken, - ); - - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${nonOwner.accessToken}`); - - expect(status).toBe(204); - }); - - it('should return empty list when asset is removed', async () => { - const album3 = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 3', - assetIds: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like }); - - await removeAssetFromAlbum( - { - id: album3.id, - bulkIdsDto: { - ids: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body).toEqual([]); - }); - }); -}); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index c66e9d4a79..a754d7bc28 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -253,6 +253,17 @@ export class MediumTestContext = return { albumUser: { albumId, userId, role }, result }; } + /** An album owned by one user, containing one asset, shared with a second user */ + async newSharedAlbum(dto: { role?: AlbumUserRole } = {}) { + const { user: owner } = await this.newUser(); + const { user: sharedWith } = await this.newUser(); + const { asset } = await this.newAsset({ ownerId: owner.id }); + const { album } = await this.newAlbum({ ownerId: owner.id }, [asset.id]); + await this.newAlbumUser({ albumId: album.id, userId: sharedWith.id, role: dto.role ?? AlbumUserRole.Editor }); + + return { album, asset, owner, sharedWith }; + } + async softDeleteAsset(assetId: string) { await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute(); } diff --git a/server/test/medium/specs/services/activity.service.spec.ts b/server/test/medium/specs/services/activity.service.spec.ts new file mode 100644 index 0000000000..c568f05a26 --- /dev/null +++ b/server/test/medium/specs/services/activity.service.spec.ts @@ -0,0 +1,310 @@ +import { Kysely } from 'kysely'; +import { ReactionType } from 'src/dtos/activity.dto'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { ActivityService } from 'src/services/activity.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory, newUuid } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(ActivityService, { + database: db || defaultDatabase, + real: [AccessRepository, ActivityRepository, AlbumRepository, AlbumUserRepository, AssetRepository, UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(ActivityService.name, () => { + describe('getAll', () => { + it('should start off empty', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + + await expect(sut.getAll(factory.auth({ user: owner }), { albumId: album.id })).resolves.toEqual([]); + }); + + it('should filter by album id', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { album: other } = await ctx.newAlbum({ ownerId: owner.id }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + await sut.create(auth, { albumId: other.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([value]); + }); + + it('should filter by type=comment', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'comment', + }); + await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, type: ReactionType.COMMENT })).resolves.toEqual([value]); + }); + + it('should filter by type=like', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + await sut.create(auth, { albumId: album.id, type: ReactionType.COMMENT, comment: 'comment' }); + + await expect(sut.getAll(auth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual([value]); + }); + + it('should filter by userId', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, userId: newUuid() })).resolves.toEqual([]); + await expect(sut.getAll(auth, { albumId: album.id, userId: owner.id })).resolves.toEqual([value]); + }); + + it('should filter by assetId', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, assetId: asset.id })).resolves.toEqual([value]); + }); + }); + + describe('create', () => { + it('should add a comment to an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + + await expect( + sut.create(auth, { albumId: album.id, type: ReactionType.COMMENT, comment: 'This is my first comment' }), + ).resolves.toEqual({ + duplicate: false, + value: { + id: expect.any(String), + assetId: null, + createdAt: expect.any(Date), + type: ReactionType.COMMENT, + comment: 'This is my first comment', + user: expect.objectContaining({ id: owner.id }), + }, + }); + }); + + it('should add a like to an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + + await expect(sut.create(auth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual({ + duplicate: false, + value: { + id: expect.any(String), + assetId: null, + createdAt: expect.any(Date), + type: ReactionType.LIKE, + comment: null, + user: expect.objectContaining({ id: owner.id }), + }, + }); + }); + + it('should report a duplicate like on an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const ownerAuth = factory.auth({ user: owner }); + const { value } = await sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual({ + duplicate: true, + value, + }); + }); + + it('should not confuse an album like with an asset like', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const ownerAuth = factory.auth({ user: owner }); + const { value } = await sut.create(ownerAuth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + + const result = await sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE }); + + expect(result.duplicate).toBe(false); + expect(result.value.id).not.toEqual(value.id); + }); + + it('should add a comment to an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + + await expect( + sut.create(factory.auth({ user: owner }), { + albumId: album.id, + assetId: asset.id, + type: ReactionType.COMMENT, + comment: 'This is my first comment', + }), + ).resolves.toEqual( + expect.objectContaining({ + duplicate: false, + value: expect.objectContaining({ assetId: asset.id, comment: 'This is my first comment' }), + }), + ); + }); + + it('should add a like to an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + + await expect( + sut.create(factory.auth({ user: owner }), { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }), + ).resolves.toEqual( + expect.objectContaining({ + duplicate: false, + value: expect.objectContaining({ assetId: asset.id, type: ReactionType.LIKE, comment: null }), + }), + ); + }); + + it('should report a duplicate like on an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + + await expect( + sut.create(auth, { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }), + ).resolves.toEqual({ duplicate: true, value }); + }); + + it('should not let a user comment on an album they cannot access', async () => { + const { sut, ctx } = setup(); + const { album } = await ctx.newSharedAlbum(); + const { user: outsider } = await ctx.newUser(); + + await expect( + sut.create(factory.auth({ user: outsider }), { albumId: album.id, type: ReactionType.LIKE }), + ).rejects.toThrow('Not found or no activity.create access'); + }); + }); + + describe('delete', () => { + it('should remove a comment from an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should remove a like from an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should let the album owner remove a comment by another user', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(sharedWithAuth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should not let a user remove a comment by another user', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(sharedWithAuth, value.id)).rejects.toThrow('Not found or no activity.delete access'); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([value]); + }); + + it('should let a non-owner remove their own comment', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(sharedWithAuth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(sharedWithAuth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should drop activities when the asset is removed from the album', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + await sut.create(auth, { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }); + + await ctx.database + .deleteFrom('album_asset') + .where('albumId', '=', album.id) + .where('assetId', '=', asset.id) + .execute(); + + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + }); +}); From a490cea1c49a575e54493242172ce420337bfdf0 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Fri, 21 Aug 2026 17:13:48 +0600 Subject: [PATCH 74/78] fix(mobile): dedupe stale remote_asset rows on sync (#28445) * fix(mobile): dedupe stale remote_asset rows on sync queue a pre-delete on the matching partial-index tuple before each upsert in updateAssetsV1/V2 so the second insert does not crash on SqliteException(2067) when the server re-issues a new id for the same (ownerId, checksum). closes #22522 #27186. * run the dedupe deletes before the inserts and keep only the last duplicate per payload * switch the asset upsert to insert or replace * trim the dedupe tests to the essentials --- .../repositories/sync_stream.repository.dart | 2 + .../sync_stream_repository_test.dart | 129 +++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index d118b51c62..ce246b97ab 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -227,6 +227,7 @@ class SyncStreamRepository extends DatabaseAccessor with $SyncStreamRepos batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } @@ -266,6 +267,7 @@ class SyncStreamRepository extends DatabaseAccessor with $SyncStreamRepos batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index bd47f63da5..13da161149 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -3,6 +3,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -25,17 +26,18 @@ SyncAssetV1 _createAsset({ required String id, required String checksum, required String fileName, - String ownerId = 'user-1', int? width, int? height, + String? libraryId, + bool isFavorite = false, }) { return SyncAssetV1( id: id, checksum: checksum, originalFileName: fileName, type: AssetTypeEnum.IMAGE, - ownerId: ownerId, - isFavorite: false, + ownerId: 'user-1', + isFavorite: isFavorite, fileCreatedAt: DateTime(2024, 1, 1), fileModifiedAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), @@ -45,6 +47,31 @@ SyncAssetV1 _createAsset({ height: height, deletedAt: null, duration: null, + libraryId: libraryId, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, + isEdited: false, + ); +} + +SyncAssetV2 _createAssetV2({required String id, required String checksum, required String fileName}) { + return SyncAssetV2( + id: id, + checksum: checksum, + originalFileName: fileName, + type: AssetTypeEnum.IMAGE, + ownerId: 'user-1', + isFavorite: false, + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024, 1, 1), + localDateTime: DateTime(2024, 1, 1), + visibility: AssetVisibility.timeline, + width: null, + height: null, + deletedAt: null, + duration: 0, libraryId: null, livePhotoVideoId: null, stackId: null, @@ -240,4 +267,100 @@ void main() { expect(after.backupSelection, equals(BackupSelection.none)); }); }); + + group('SyncStreamRepository - updateAssets upsert dedupe (#22522 #27186)', () { + Future seedExif(String assetId) => + db.remoteExifEntity.insertOne(RemoteExifEntityCompanion.insert(assetId: assetId)); + + Future exifExists(String assetId) async { + final rows = await (db.remoteExifEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + return rows.isNotEmpty; + } + + test('same-id update keeps the child row and updates fields', () async { + await sut.updateUsersV1([_createUser()]); + final asset = _createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([asset]); + await seedExif(asset.id); + + final renamed = _createAsset(id: asset.id, checksum: asset.checksum, fileName: 'renamed.jpg', isFavorite: true); + await sut.updateAssetsV1([renamed]); + + expect(await exifExists(asset.id), isTrue, reason: 'DO UPDATE keeps the row, the child survives'); + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle(); + expect(row.name, renamed.originalFileName); + expect(row.isFavorite, isTrue); + }); + + test('reupload with a new id replaces the stale row and cascades its child', () async { + await sut.updateUsersV1([_createUser()]); + final stale = _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([stale]); + await seedExif(stale.id); + + final fresh = _createAsset(id: 'fresh', checksum: stale.checksum, fileName: stale.originalFileName); + await sut.updateAssetsV1([fresh]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1), reason: 'no 2067, stale row replaced away'); + expect(rows.single.id, fresh.id); + expect(await exifExists(stale.id), isFalse, reason: 'the stale child cascades with the replaced row'); + + // same scenario through V2 + final staleV2 = _createAssetV2(id: 'stale2', checksum: 'BBB', fileName: 'photo2.jpg'); + await sut.updateAssetsV2([staleV2]); + await seedExif(staleV2.id); + final freshV2 = _createAssetV2(id: 'fresh2', checksum: staleV2.checksum, fileName: staleV2.originalFileName); + await sut.updateAssetsV2([freshV2]); + + final rows2 = await db.remoteAssetEntity.select().get(); + expect(rows2.map((r) => r.id), containsAll([fresh.id, freshV2.id])); + expect(await exifExists(staleV2.id), isFalse); + }); + + test('library variant replaces only the matching library row', () async { + await sut.updateUsersV1([_createUser()]); + final staleLib = _createAsset(id: 'stale-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'); + final keepNull = _createAsset(id: 'keep-null', checksum: staleLib.checksum, fileName: staleLib.originalFileName); + await sut.updateAssetsV1([staleLib, keepNull]); + + final freshLib = _createAsset( + id: 'fresh-lib', + checksum: staleLib.checksum, + fileName: staleLib.originalFileName, + libraryId: staleLib.libraryId, + ); + await sut.updateAssetsV1([freshLib]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows.map((r) => r.id).toSet(), { + freshLib.id, + keepNull.id, + }, reason: 'library NULL and NOT NULL match different partial indexes'); + }); + + test('batch-internal duplicates keep the last payload asset', () async { + await sut.updateUsersV1([_createUser()]); + final first = _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'); + final last = _createAsset(id: 'last-id', checksum: first.checksum, fileName: first.originalFileName); + final firstLib = _createAsset( + id: 'first-lib', + checksum: 'BBB', + fileName: first.originalFileName, + libraryId: 'lib-1', + ); + final lastLib = _createAsset( + id: 'last-lib', + checksum: firstLib.checksum, + fileName: firstLib.originalFileName, + libraryId: firstLib.libraryId, + ); + + await sut.updateAssetsV1([first, last, firstLib, lastLib]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(2), reason: 'REPLACE makes batch-internal duplicates last-wins, no crash'); + expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id}); + }); + }); } From 57ce9b2221e35d859caa7c639b887b7328e2645f Mon Sep 17 00:00:00 2001 From: Holegots Date: Fri, 21 Aug 2026 19:32:17 +0800 Subject: [PATCH 75/78] fix(server): correct asset dimensions from exif metadata (#29244) Co-authored-by: Daniel Dietzler --- .../src/repositories/metadata.repository.ts | 14 ++++- .../specs/services/metadata.service.spec.ts | 53 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 1d504f6c71..c4a3b4f89e 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -92,8 +92,18 @@ export class MetadataRepository { /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ geoTz: (lat, lon) => geotz.find(lat, lon)[0], geolocation: true, - // Enable exiftool LFS to parse metadata for files larger than 2GB. - readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'], + readArgs: [ + // Enable exiftool LFS to parse metadata for files larger than 2GB. + '-api', + 'largefilesupport=1', + '--ICC_Profile:DeviceManufacturer', + '--ICC_Profile:DeviceModelName', + // Ignore embedded thumbnail dimensions/orientation for the main asset. + '--IFD1:Orientation', + '--MWG:Orientation', + '--IFD1:ImageWidth', + '--IFD1:ImageHeight', + ], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], taskTimeoutMillis: 2 * 60 * 1000, }); diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 37603520f7..c3f111e5aa 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -151,6 +151,59 @@ describe(MetadataService.name, () => { // note that this date is technically wrong. it does not throw though and should get the user's attention either way. ).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') }); }); + + it('should ignore IFD1 thumbnail orientation when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ 'IFD1:Orientation#': 6 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('orientation') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ orientation: null }); + }); + + it('should ignore IFD1 thumbnail dimensions when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ 'IFD1:ImageWidth#': 160, 'IFD1:ImageHeight#': 120 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ width: 1, height: 1 }), + ); + }); + + it('should keep IFD0 orientation when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ 'IFD0:Orientation#': 6 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('orientation') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ orientation: '6' }); + }); }); it('should handle float lens models (#30492)', async () => { From 47c0dea07fa683e875ac622223a43644e1bef7aa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:50:10 +0200 Subject: [PATCH 76/78] chore(deps): update grafana/grafana docker tag to v12.4.8-ubuntu (#30835) --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 8e981c5824..c071ae282b 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -97,7 +97,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.4.7-ubuntu@sha256:d327f509400334fbea74a282d287f20c3187222741ec99e76f253cac16c90129 + image: grafana/grafana:12.4.8-ubuntu@sha256:473cfc1712694135a5d9dc012dc573f2a48e51efb9ac0da50418918f3cbd8ad3 volumes: - grafana-data:/var/lib/grafana From 8ae98f2a046a650238ccc3ab9d649e9a1d744e5c Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:11:20 +0200 Subject: [PATCH 77/78] fix: quote database owner in restore (#30905) --- server/src/services/database-backup.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index 6e1da7a1b7..d99d0433e5 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -584,7 +584,7 @@ function createSqlOwnerTransformStream(databaseUsername: string) { const DATA_MARKER_START = new TextEncoder().encode('FROM stdin'); const LINE_END = new TextEncoder().encode(';'); - const owner = new TextEncoder().encode(databaseUsername); + const owner = new TextEncoder().encode(`"${databaseUsername}"`); let ownerSequenceIndex = 0; From caea849fee509909d42220e327c855cc7de21b2a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 21 Aug 2026 09:55:25 -0400 Subject: [PATCH 78/78] feat: asset file apis (#25900) * feat: asset file endpoints * Update server/src/controllers/asset-file.controller.ts Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * Update server/src/controllers/asset-file.controller.ts Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- open-api/immich-openapi-specs.json | 332 ++++++++++++++++++ packages/sdk/src/fetch-client.ts | 87 +++++ server/src/constants.ts | 1 + .../src/controllers/asset-file.controller.ts | 72 ++++ server/src/controllers/index.ts | 2 + server/src/dtos/asset-file.dto.ts | 45 +++ server/src/enum.ts | 7 + server/src/queries/access.repository.sql | 11 + server/src/repositories/access.repository.ts | 24 ++ .../src/repositories/asset-file.repository.ts | 31 ++ server/src/repositories/index.ts | 2 + server/src/services/asset-file.service.ts | 48 +++ server/src/services/base.service.ts | 4 + server/src/services/index.ts | 2 + server/src/utils/access.ts | 9 + server/test/medium.factory.ts | 2 + .../repositories/access.repository.mock.ts | 4 + server/test/utils.ts | 4 + 18 files changed, 687 insertions(+) create mode 100644 server/src/controllers/asset-file.controller.ts create mode 100644 server/src/dtos/asset-file.dto.ts create mode 100644 server/src/repositories/asset-file.repository.ts create mode 100644 server/src/services/asset-file.service.ts diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4322e843df..0b832e3c08 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3465,6 +3465,266 @@ "x-immich-permission": "apiKey.rotate" } }, + "/asset-files": { + "get": { + "description": "Returns all matching asset files.", + "operationId": "searchAssetFiles", + "parameters": [ + { + "name": "assetId", + "required": true, + "in": "query", + "description": "Asset ID to filter files by", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "isEdited", + "required": false, + "in": "query", + "description": "The file was generated from an edit", + "schema": { + "type": "boolean" + } + }, + { + "name": "isProgressive", + "required": false, + "in": "query", + "description": "The file is a progressively encoded JPEG", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTransparent", + "required": false, + "in": "query", + "description": "The file is transparent", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "description": "Filter by type of file", + "schema": { + "$ref": "#/components/schemas/AssetFileType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetFileResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Search asset files", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}": { + "delete": { + "description": "Delete a file and remove it from the database.", + "operationId": "deleteAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Returns metadata about a specific asset file.", + "operationId": "getAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFileResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}/download": { + "get": { + "description": "Serve the contents of a specific asset file.", + "operationId": "downloadAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.download", + "x-immich-state": "Alpha" + } + }, "/assets": { "delete": { "description": "Deletes multiple assets at the same time.", @@ -17105,6 +17365,10 @@ "name": "Assets", "description": "An asset is an image or video that has been uploaded to Immich." }, + { + "name": "Asset files", + "description": "An asset file is a file associated with an asset, including edited versions, thumbnails, etc." + }, { "name": "Authentication", "description": "Endpoints related to user authentication, including OAuth." @@ -19529,6 +19793,71 @@ ], "type": "object" }, + "AssetFileResponseDto": { + "properties": { + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Asset file ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "isEdited": { + "description": "The file was generated from an edit", + "type": "boolean" + }, + "isProgressive": { + "description": "The file is a progressively encoded JPEG", + "type": "boolean" + }, + "isTransparent": { + "description": "The file is transparent", + "type": "boolean" + }, + "path": { + "description": "File path", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetFileType" + }, + "updatedAt": { + "description": "Update date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "createdAt", + "id", + "isEdited", + "isProgressive", + "isTransparent", + "path", + "type", + "updatedAt" + ], + "type": "object" + }, + "AssetFileType": { + "description": "Type of file", + "enum": [ + "fullsize", + "preview", + "thumbnail", + "sidecar", + "encoded_video" + ], + "type": "string" + }, "AssetIdErrorReason": { "description": "Error reason if failed", "enum": [ @@ -22921,6 +23250,9 @@ "asset.upload", "asset.copy", "asset.derive", + "assetFile.read", + "assetFile.delete", + "assetFile.download", "asset.edit.get", "asset.edit.create", "asset.edit.delete", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index d562f6877c..2834c63669 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1005,6 +1005,23 @@ export type ApiKeyUpdateDto = { /** List of permissions */ permissions?: Permission[]; }; +export type AssetFileResponseDto = { + /** Creation date */ + createdAt: string; + /** Asset file ID */ + id: string; + /** The file was generated from an edit */ + isEdited: boolean; + /** The file is a progressively encoded JPEG */ + isProgressive: boolean; + /** The file is transparent */ + isTransparent: boolean; + /** File path */ + path: string; + "type": AssetFileType; + /** Update date */ + updatedAt: string; +}; export type AssetBulkDeleteDto = { /** Force delete even if in use */ force?: boolean; @@ -4330,6 +4347,66 @@ export function rotateApiKey({ id }: { method: "POST" })); } +/** + * Search asset files + */ +export function searchAssetFiles({ assetId, isEdited, isProgressive, isTransparent, $type }: { + assetId: string; + isEdited?: boolean; + isProgressive?: boolean; + isTransparent?: boolean; + $type?: AssetFileType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto[]; + }>(`/asset-files${QS.query(QS.explode({ + assetId, + isEdited, + isProgressive, + isTransparent, + "type": $type + }))}`, { + ...opts + })); +} +/** + * Delete an asset file + */ +export function deleteAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/asset-files/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve an asset file + */ +export function getAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto; + }>(`/asset-files/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Download an asset file + */ +export function downloadAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/asset-files/${encodeURIComponent(id)}/download`, { + ...opts + })); +} /** * Delete assets */ @@ -7649,6 +7726,9 @@ export enum Permission { AssetUpload = "asset.upload", AssetCopy = "asset.copy", AssetDerive = "asset.derive", + AssetFileRead = "assetFile.read", + AssetFileDelete = "assetFile.delete", + AssetFileDownload = "assetFile.download", AssetEditGet = "asset.edit.get", AssetEditCreate = "asset.edit.create", AssetEditDelete = "asset.edit.delete", @@ -7794,6 +7874,13 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } +export enum AssetFileType { + Fullsize = "fullsize", + Preview = "preview", + Thumbnail = "thumbnail", + Sidecar = "sidecar", + EncodedVideo = "encoded_video" +} export enum AssetMediaStatus { Created = "created", Duplicate = "duplicate" diff --git a/server/src/constants.ts b/server/src/constants.ts index 0ab0838547..856a6ffc57 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -148,6 +148,7 @@ export const endpointTags: Record = { [ApiTag.Albums]: 'An album is a collection of assets that can be shared with other users or via shared links.', [ApiTag.ApiKeys]: 'An api key can be used to programmatically access the Immich API.', [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', + [ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', [ApiTag.ClusterGroups]: diff --git a/server/src/controllers/asset-file.controller.ts b/server/src/controllers/asset-file.controller.ts new file mode 100644 index 0000000000..5d50f54eea --- /dev/null +++ b/server/src/controllers/asset-file.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Delete, Get, HttpCode, HttpStatus, Next, Param, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AssetFileResponseDto, AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetFileService } from 'src/services/asset-file.service'; +import { sendFile } from 'src/utils/file'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags(ApiTag.AssetFiles) +@Controller('asset-files') +export class AssetFilesController { + constructor( + private service: AssetFileService, + private logger: LoggingRepository, + ) {} + + @Get() + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Search asset files', + description: 'Returns all matching asset files.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + searchAssetFiles(@Auth() auth: AuthDto, @Query() dto: AssetFileSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Retrieve an asset file', + description: 'Returns metadata about a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Delete(':id') + @Authenticated({ permission: Permission.AssetFileDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an asset file', + description: 'Delete a file and remove it from the database.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + deleteAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } + + @Get(':id/download') + @FileResponse() + @Authenticated({ permission: Permission.AssetFileDownload }) + @Endpoint({ + summary: 'Download an asset file', + description: 'Serve the contents of a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + async downloadAssetFile( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.download(auth, id), this.logger); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e840654152..a50ab050e5 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -2,6 +2,7 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; +import { AssetFilesController } from 'src/controllers/asset-file.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller'; @@ -50,6 +51,7 @@ export const controllers = [ AlbumController, AppController, AssetController, + AssetFilesController, AssetMediaController, AuthController, AuthAdminController, diff --git a/server/src/dtos/asset-file.dto.ts b/server/src/dtos/asset-file.dto.ts new file mode 100644 index 0000000000..54a772cd88 --- /dev/null +++ b/server/src/dtos/asset-file.dto.ts @@ -0,0 +1,45 @@ +import { Selectable } from 'kysely'; +import { createZodDto } from 'nestjs-zod'; +import { AssetFileTypeSchema } from 'src/enum'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { isoDatetimeToDate, stringToBool } from 'src/validation'; +import z from 'zod'; + +const AssetFileSearchSchema = z + .object({ + assetId: z.uuidv4().describe('Asset ID to filter files by'), + type: AssetFileTypeSchema.optional().describe('Filter by type of file'), + isEdited: stringToBool.optional().describe('The file was generated from an edit'), + isProgressive: stringToBool.optional().describe('The file is a progressively encoded JPEG'), + isTransparent: stringToBool.optional().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileSearchDto' }); + +const AssetFileResponseSchema = z + .object({ + id: z.uuidv4().describe('Asset file ID'), + createdAt: isoDatetimeToDate.describe('Creation date'), + updatedAt: isoDatetimeToDate.describe('Update date'), + type: AssetFileTypeSchema.describe('Type of file'), + path: z.string().describe('File path'), + isEdited: z.boolean().describe('The file was generated from an edit'), + isProgressive: z.boolean().describe('The file is a progressively encoded JPEG'), + isTransparent: z.boolean().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileResponseDto' }); + +export class AssetFileSearchDto extends createZodDto(AssetFileSearchSchema) {} +export class AssetFileResponseDto extends createZodDto(AssetFileResponseSchema) {} + +export const mapAssetFile = (file: Selectable): AssetFileResponseDto => { + return { + id: file.id, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + type: file.type, + path: file.path, + isEdited: file.isEdited, + isProgressive: file.isProgressive, + isTransparent: file.isTransparent, + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index 2b61999a4f..4be515a9e1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -62,6 +62,8 @@ export enum AssetFileType { EncodedVideo = 'encoded_video', } +export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Type of file').meta({ id: 'AssetFileType' }); + export enum AlbumUserRole { Editor = 'editor', Owner = 'owner', @@ -131,6 +133,10 @@ export enum Permission { AssetCopy = 'asset.copy', AssetDerive = 'asset.derive', + AssetFileRead = 'assetFile.read', + AssetFileDelete = 'assetFile.delete', + AssetFileDownload = 'assetFile.download', + AssetEditGet = 'asset.edit.get', AssetEditCreate = 'asset.edit.create', AssetEditDelete = 'asset.edit.delete', @@ -1203,6 +1209,7 @@ export enum ApiTag { Authentication = 'Authentication', AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', + AssetFiles = 'Asset files', ConfigUser = 'Config (user)', ConfigAdmin = 'Config (admin)', ConfigPublic = 'Config (public)', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 270bc915e4..d21941f126 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -149,6 +149,17 @@ where "albumAssets"."livePhotoVideoId" ] && array[$2]::uuid[] +-- AccessRepository.assetFile.checkOwnerAccess +select + "asset_file"."id" +from + "asset_file" + inner join "asset" on "asset"."id" = "asset_file"."assetId" +where + "asset"."visibility" != $1 + and "asset"."ownerId" = $2 + and "asset_file"."id" in ($3) + -- AccessRepository.authDevice.checkOwnerAccess select "session"."id" diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 62f2fcf6e9..9acb8c3fcb 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -278,6 +278,28 @@ class AssetAccess { } } +class AssetFileAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, fileIds: Set, hasElevatedPermission: boolean | undefined) { + if (fileIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('asset_file') + .select('asset_file.id') + .innerJoin('asset', 'asset.id', 'asset_file.assetId') + .$if(!hasElevatedPermission, (eb) => eb.where('asset.visibility', '!=', AssetVisibility.Locked)) + .where('asset.ownerId', '=', userId) + .where('asset_file.id', 'in', [...fileIds]) + .execute() + .then((files) => new Set(files.map(({ id }) => id))); + } +} + class AuthDeviceAccess { constructor(private db: Kysely) {} @@ -596,6 +618,7 @@ export class AccessRepository { activity: ActivityAccess; album: AlbumAccess; asset: AssetAccess; + assetFile: AssetFileAccess; authDevice: AuthDeviceAccess; duplicate: DuplicateAccess; memory: MemoryAccess; @@ -614,6 +637,7 @@ export class AccessRepository { this.activity = new ActivityAccess(db); this.album = new AlbumAccess(db); this.asset = new AssetAccess(db); + this.assetFile = new AssetFileAccess(db); this.authDevice = new AuthDeviceAccess(db); this.duplicate = new DuplicateAccess(db); this.memory = new MemoryAccess(db); diff --git a/server/src/repositories/asset-file.repository.ts b/server/src/repositories/asset-file.repository.ts new file mode 100644 index 0000000000..ac0f51e60a --- /dev/null +++ b/server/src/repositories/asset-file.repository.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class AssetFileRepository { + constructor(@InjectKysely() private db: Kysely) {} + + get(id: string) { + return this.db.selectFrom('asset_file').where('id', '=', id).selectAll().executeTakeFirst(); + } + + search(dto: AssetFileSearchDto) { + return this.db + .selectFrom('asset_file') + .where('assetId', '=', dto.assetId) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.isEdited !== undefined, (qb) => qb.where('isEdited', '=', dto.isEdited!)) + .$if(dto.isProgressive !== undefined, (qb) => qb.where('isProgressive', '=', dto.isProgressive!)) + .$if(dto.isTransparent !== undefined, (qb) => qb.where('isTransparent', '=', dto.isTransparent!)) + .selectAll() + .execute(); + } + + async delete(id: string): Promise { + const { numDeletedRows } = await this.db.deleteFrom('asset_file').where('id', '=', id).executeTakeFirst(); + return Number(numDeletedRows) === 1; + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 15ec7c353a..606f3b8b57 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,6 +5,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -62,6 +63,7 @@ export const repositories = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ConfigRepository, CronRepository, diff --git a/server/src/services/asset-file.service.ts b/server/src/services/asset-file.service.ts new file mode 100644 index 0000000000..155d2d407c --- /dev/null +++ b/server/src/services/asset-file.service.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AssetFileResponseDto, AssetFileSearchDto, mapAssetFile } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetFileType, CacheControl, JobName, Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; +import { mimeTypes } from 'src/utils/mime-types'; +import { findOrFail } from 'src/utils/misc'; + +@Injectable() +export class AssetFileService extends BaseService { + async search(auth: AuthDto, dto: AssetFileSearchDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }); + const files = await this.assetFileRepository.search(dto); + return files.map((file) => mapAssetFile(file)); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetFileRead, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + return mapAssetFile(file); + } + + async download(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDownload, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + + return new ImmichFileResponse({ + path: file.path, + fileName: getFileNameWithoutExtension(file.path) + getFilenameExtension(file.path), + contentType: mimeTypes.lookup(file.path), + cacheControl: CacheControl.PrivateWithCache, + }); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDelete, ids: [id] }); + + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + // TODO consider implications of allowing sidecar files to be deleted + if (file.type === AssetFileType.Sidecar) { + throw new BadRequestException('Sidecar files cannot be deleted'); + } + + await this.assetFileRepository.delete(id); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.path] } }); + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6c3d5e3df3..3c5dab1ed2 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -12,6 +12,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -74,6 +75,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ClusterGroupRepository, ConfigRepository, @@ -135,6 +137,7 @@ export class BaseService { protected appRepository: AppRepository, protected assetRepository: AssetRepository, protected assetEditRepository: AssetEditRepository, + protected assetFileRepository: AssetFileRepository, protected assetJobRepository: AssetJobRepository, protected clusterGroupRepository: ClusterGroupRepository, protected configRepository: ConfigRepository, @@ -205,6 +208,7 @@ export class BaseService { ctx.appRepository, ctx.assetRepository, ctx.assetEditRepository, + ctx.assetFileRepository, ctx.assetJobRepository, ctx.clusterGroupRepository, ctx.configRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index d8bb618b01..2a45d0308b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,6 +2,7 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; import { ApiKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; +import { AssetFileService } from 'src/services/asset-file.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuthAdminService } from 'src/services/auth-admin.service'; @@ -56,6 +57,7 @@ export const services = [ ActivityService, AlbumService, ApiService, + AssetFileService, AssetMediaService, AssetService, AuthService, diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 97c1e8e0c5..dc3c635ba7 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -126,6 +126,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return setUnion(isOwner, isPartner); } + case Permission.AssetFileDownload: { + return access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AssetView: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); @@ -164,6 +168,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetFileRead: + case Permission.AssetFileDelete: { + return await access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index a754d7bc28..f6a2497e7e 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -24,6 +24,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -467,6 +468,7 @@ const newRealRepository = (key: T, db: Kysely case ApiKeyRepository: case AssetRepository: case AssetEditRepository: + case AssetFileRepository: case AssetJobRepository: case ClusterGroupRepository: case DuplicateRepository: diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index a928b4796d..90b9f64e0b 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -33,6 +33,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()), }, + assetFile: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + album: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkSharedAlbumAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/utils.ts b/server/test/utils.ts index e7392949ad..e273d72a36 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -24,6 +24,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; @@ -239,6 +240,7 @@ export type ServiceOverrides = { app: AppRepository; asset: AssetRepository; assetEdit: AssetEditRepository; + assetFile: AssetFileRepository; assetJob: AssetJobRepository; clusterGroup: ClusterGroupRepository; config: ConfigRepository; @@ -323,6 +325,7 @@ export const getMocks = () => { albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), assetEdit: automock(AssetEditRepository), + assetFile: automock(AssetFileRepository), assetJob: automock(AssetJobRepository), clusterGroup: automock(ClusterGroupRepository), app: automock(AppRepository, { strict: false }), @@ -397,6 +400,7 @@ export const newTestService = ( overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), overrides.assetEdit || (mocks.assetEdit as As), + overrides.assetFile || (mocks.assetFile as As), overrides.assetJob || (mocks.assetJob as As), overrides.clusterGroup || (mocks.clusterGroup as As), overrides.config || (mocks.config as As as ConfigRepository),